mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-20 07:38:34 +02:00
chore: prepare the repository for public release
Removes operator-private material from the shipped tree, corrects documentation
that had drifted from the code, deletes dead code, and applies a consistent
prose style.
Private material moved out of the public tree (docs/private/, git-ignored):
- the wallet keyfile recovery runbook and its helper scripts, which together
published the mainnet wallet file path, the service account, backup snapshot
filenames, and the wallet RPC listener state. The helper duplicated the same
constants, so relocating the document alone would not have removed them.
- the stagenet parallel-candidate runbook and its fixtures.
- the dated production-state record, the internal remediation plan, and the
TODO list. None are reference documentation for an outside reader.
Redacted or genericised:
- operator home-directory paths in shipped scripts and units. The asset build
script now takes KENNEY_ASSETS_DIR instead of a hard-coded path.
- private LAN addresses in tests, env examples and deploy docs, replaced with
RFC 5737 documentation addresses.
- the default alert sender and the notification hub example.
- the Monero wallet unit now matches the Wownero one: a dedicated service
account and a config file, instead of the operator's login account with the
wallet password in argv where any local user can read it from /proc.
Ignore rules now cover every .env variant rather than the bare name, plus local
agent state. The deployment docs instruct operators to create .env.mainnet and
.env.stagenet, which the exact-name rule would have allowed to be committed.
Documentation corrected against the code: per-chain dungeon level counts, the
premium unlock rule, Tavern chat scope, backup retention, the chain-profile
fallback, the environment template location, and several sections describing
shipped work as pending.
Dead code removed: configPersistence, rpccalls, a duplicate vendored ROT.js
bundle, the unused tile-picker overlay, the unreachable legacy game-size path
and its vestigial parameter, spectator stubs that shadowed the real
SpectatorManager, and commented-out debugging. nip98 now imports the shared
nostr loader rather than duplicating it.
Prose style: em-dashes removed throughout in favour of colons, semicolons and
commas; comments restated as present-tense descriptions of the code rather than
narration of past changes.
127 suites / 1015 tests pass.
This commit is contained in:
+17
-3
@@ -12,8 +12,13 @@ logs/
|
||||
*.pid
|
||||
*.gz
|
||||
|
||||
# Environment variables
|
||||
# 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/
|
||||
@@ -37,7 +42,7 @@ __pycache__/
|
||||
Thumbs.db
|
||||
TEMP_TODO.md
|
||||
|
||||
# 3D models / generation source artifacts (produced by the sprite pipeline — never commit;
|
||||
# 3D models / generation source artifacts (produced by the sprite pipeline; never commit:
|
||||
# see ~/src/docs/animated-sprite-pipeline.md)
|
||||
*.glb
|
||||
*.fbx
|
||||
@@ -46,5 +51,14 @@ TEMP_TODO.md
|
||||
*.obj
|
||||
*.stl
|
||||
|
||||
# AI-generated / heavy premium assets — hosted & lazy-loaded, not in the code tree
|
||||
# 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/
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Wownerogue
|
||||
|
||||
A browser-based roguelike synchronized with Monero (XMR) and Wownero (WOW) block timing. The same codebase runs the original solo dungeon, the social Tavern, and operator-selected PvP/race rulesets, with separate free and paid leaderboards.
|
||||
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
|
||||
|
||||
@@ -11,30 +14,37 @@ Such Software (`apps@such.software`) operates only these two public products:
|
||||
| `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 remains generally 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.
|
||||
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 commitments and per-depth fingerprints
|
||||
- **Live spectator mode** - watch active games in real-time
|
||||
- **Persistent chat** with 30-day history
|
||||
- **Transaction history** - view payment and payout records
|
||||
- **Multiple game modes**: Free play, per-game payments, or credit bundles
|
||||
- **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 locally served 3D techniques across the dungeon
|
||||
and Tavern
|
||||
- **Real-time multiplayer races**: free, prestige-credit, or crypto winner-take-pot races every block
|
||||
- Configurable difficulty with house edge tuning
|
||||
- Automatic wallet RPC integration for payments and payouts
|
||||
- **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 <repository-url>
|
||||
git clone https://github.com/Such-Software/wownerogue.git
|
||||
cd wownerogue/src
|
||||
npm install
|
||||
cp .env.example .env # Edit with your database/wallet settings
|
||||
@@ -42,47 +52,55 @@ npm run db:create # Initialize PostgreSQL
|
||||
npm run dev # Start development server
|
||||
```
|
||||
|
||||
Open http://localhost:3000 to play.
|
||||
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 |
|
||||
| `FREE` | None | None | Pleb |
|
||||
| `PAID_SINGLE` | Per-run chain payment | Profile-controlled | Paid prestige |
|
||||
| `PAID_CREDITS` | Purchased credit | Profile-controlled | Paid prestige |
|
||||
|
||||
The exact 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
|
||||
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.
|
||||
Generic self-hosts leave that variable unset and are not constrained by those product decisions.
|
||||
|
||||
## Match Mode (Multiplayer Races)
|
||||
|
||||
Optional real-time multiplayer races are available when `MATCH_ENABLED=true`. A new race starts every
|
||||
crypto block as long as at least two players are queued. Supported economies:
|
||||
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 |
|
||||
| Free | None | None | Pleb |
|
||||
| Prestige Credits | Credits | None | Prestige |
|
||||
| Crypto Race | Race-entry ticket | Winner-take-pot minus house fee | Hall of Champions |
|
||||
|
||||
See `docs/MATCH_MODE.md` for full architecture, configuration, and API details.
|
||||
`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`.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Difficulty & Pacing
|
||||
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 has two levers, both keyed to the crypto network so `cryptoType` actually shapes the
|
||||
game (it used to be a dead parameter).
|
||||
## Difficulty and Pacing
|
||||
|
||||
**Presets** (`DIFFICULTY_PRESET`) — the per-level shape:
|
||||
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 |
|
||||
|--------|--------------|---------|------------------|
|
||||
@@ -91,60 +109,110 @@ game (it used to be a dead parameter).
|
||||
| `hard` | 55×28 | high | ~65% |
|
||||
| `casino` | 70×35 | very high | ~70% |
|
||||
|
||||
Defaults to `normal` for free play, `casino` for paid. Every knob is `env`-overridable
|
||||
(`DUNGEON_WIDTH`, `MONSTER_SPEED`, …).
|
||||
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`) — the pacing lever, ∝ block time.** A run *descends* N levels (each a
|
||||
preset-sized dungeon with a fair monster). Reaching a non-final exit takes the stairs down; only the
|
||||
final exit escapes; the treasure sits in the vault (final level). Because total run length scales
|
||||
with the chain's block time, the "race the block" **timer** provides the house edge — no giant map,
|
||||
no cheating-fast monster.
|
||||
**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 | Block time | Levels |
|
||||
|-------|-----------|--------|
|
||||
| Chain | Mean block time | Levels |
|
||||
|-------|-----------------|--------|
|
||||
| GRIN | ~1 min | 1 |
|
||||
| XMR | ~2 min | 2 |
|
||||
| XMR | ~2 min | 1 |
|
||||
| LTC | ~2.5 min | 2 |
|
||||
| WOW | ~5 min (measured) | 4 |
|
||||
| BTC | ~10 min | 8 |
|
||||
| WOW | ~5 min | 2 |
|
||||
| BTC | ~10 min | 3 |
|
||||
|
||||
Levels live in `NETWORK_TUNING` (`src/game/difficultyConfig.js`); override with `DUNGEON_LEVELS`,
|
||||
disable with `NETWORK_TUNING_DISABLED=true`. The counts are sim-derived starting points — see
|
||||
[`docs/BALANCE_SIM.md`](docs/BALANCE_SIM.md) for how the balance is measured and calibrated.
|
||||
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`.
|
||||
|
||||
> **Note:** Wownero's block time is **~5 min** (measured on the live daemon), not 2 min as older
|
||||
> docs/comments claimed.
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
## Chain Profiles
|
||||
|
||||
### Provably Fair Gaming
|
||||
`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.
|
||||
|
||||
1. The server publishes a one-use, socket-bound SHA-256 commitment before paid entry.
|
||||
2. The browser adds an independent WebCrypto client seed; neither side alone selects the final seed.
|
||||
3. The effective seed deterministically generates each dungeon depth and its audit fingerprint.
|
||||
4. After completion, the server seed and depth manifest are revealed at `/verify/:gameId` and
|
||||
`/api/verify/:gameId`; active-game server seeds remain private.
|
||||
| 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` |
|
||||
|
||||
### Spectator Mode
|
||||
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.
|
||||
|
||||
Click **"👁️ Watch Games"** to see active games. Spectators receive real-time updates via Socket.IO rooms. Press ESC to exit.
|
||||
Note that WOW uses 11 decimals, not Monero's 12: 1 WOW = 10^11 atomic units.
|
||||
|
||||
### Transaction History
|
||||
---
|
||||
|
||||
Click **"📜 History"** to view your payment and payout records. Shows:
|
||||
- Total received from payouts
|
||||
- Payment history with status and credits received
|
||||
- Payout history with multipliers and transaction hashes
|
||||
## Provably Fair Gaming
|
||||
|
||||
### Session Persistence
|
||||
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.
|
||||
|
||||
Sessions, including anonymous Tavern entry, use an anonymous token stored in localStorage:
|
||||
- ✅ Persists across refreshes, browser closes, server restarts
|
||||
- ⚠️ Lost when clearing cookies/localStorage, using incognito mode, or switching browsers/devices
|
||||
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.
|
||||
|
||||
A warning appears if localStorage is unavailable.
|
||||
## 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).
|
||||
|
||||
---
|
||||
|
||||
@@ -158,25 +226,48 @@ A warning appears if localStorage is unavailable.
|
||||
| `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/leaderboard?board=pleb\|champions\|prestige&period=all\|week\|month` | One explicitly separated score board; an omitted `board` means `pleb`, while legacy `all`, blank, and unknown board values return 400 |
|
||||
| `GET /api/user/:socketId/credits` | User credit balance |
|
||||
| `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 `X-Admin-Key` header matching `ADMIN_API_KEY` env variable.
|
||||
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**
|
||||
|
||||
**Example: Refund a payment**
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/admin/refund/payment \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -184,7 +275,8 @@ curl -X POST http://localhost:3000/api/admin/refund/payment \
|
||||
-d '{"paymentId": 123, "reason": "Customer request", "sendFunds": true}'
|
||||
```
|
||||
|
||||
**Example: Adjust credits**
|
||||
**Example: adjust credits**
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/admin/credits/adjust \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -192,48 +284,92 @@ curl -X POST http://localhost:3000/api/admin/credits/adjust \
|
||||
-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 → Server**
|
||||
| Event | Description |
|
||||
|-------|-------------|
|
||||
| `auto_start` | Start game (uses credits if available) |
|
||||
| `player_move` | Move player (direction: 0-3) |
|
||||
| `request_payment` | Request payment address |
|
||||
| `get_active_games` | List games for spectating |
|
||||
| `spectate_game` | Start spectating a game |
|
||||
**Client to server**
|
||||
|
||||
**Server → Client**
|
||||
| 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 (incl. `depth`/`maxDepth` for multi-level descent) |
|
||||
| `game_event` | Discrete event: `treasure_found`, `descend` (took the stairs down), `escaped` |
|
||||
| `game_over` | Game ended |
|
||||
| `payment_created` | Payment address ready |
|
||||
| `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 |
|
||||
| `spectator_update` | Spectated game state |
|
||||
| `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 (not an operated production profile; see
|
||||
`.env.example` for the full list):
|
||||
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":"500000000000","bonus":0}]'
|
||||
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
|
||||
|
||||
@@ -244,6 +380,7 @@ 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
|
||||
@@ -251,33 +388,49 @@ DB_PASSWORD=your_password
|
||||
# Admin API (generate with: openssl rand -hex 32)
|
||||
ADMIN_API_KEY=your-secure-key
|
||||
|
||||
# Network (Monero only - Wownero only has mainnet)
|
||||
# Optional modes
|
||||
TAVERN_ENABLED=true
|
||||
MATCH_ENABLED=false
|
||||
|
||||
# Network selection (Monero only; Wownero has mainnet only)
|
||||
MONERO_NETWORK=mainnet
|
||||
```
|
||||
|
||||
Amounts use atomic units: 1 WOW = 10^11 atomic units.
|
||||
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 detailed production deployment instructions including:
|
||||
- systemd service configuration
|
||||
- Nginx/reverse proxy setup with WebSocket support
|
||||
- Database permissions and security hardening
|
||||
- Multi-instance deployment (running Wownero and Monero on the same server)
|
||||
See [`docs/DEPLOY.md`](docs/DEPLOY.md) for production deployment, including:
|
||||
|
||||
The dated [`play.wowne.ro` production record](docs/PRODUCTION_STATE_2026-07-25.md) distinguishes the
|
||||
live schema-neutral renderer/Tavern overlay from the still-gated full mainline migration cutover.
|
||||
- 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
|
||||
- PostgreSQL automated backups
|
||||
- Backup verification procedures
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
@@ -288,7 +441,8 @@ cd src
|
||||
npm test
|
||||
```
|
||||
|
||||
Tests cover payment handlers, wallet RPC, security rules, movement logic, and integration flows.
|
||||
Tests cover payment handlers, wallet RPC, security rules, movement logic, fairness verification, and
|
||||
integration flows.
|
||||
|
||||
---
|
||||
|
||||
@@ -296,29 +450,47 @@ Tests cover payment handlers, wallet RPC, security rules, movement logic, and in
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.js # Entry point
|
||||
├── config/ # Payment config, validation
|
||||
├── db/ # PostgreSQL layer
|
||||
├── game/ # Dungeon, player, monster logic
|
||||
├── network/ # Socket handlers, chat, queue, spectator
|
||||
├── payments/ # Wallet RPC, QR codes
|
||||
└── utils/ # Errors, memory management
|
||||
├── 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
|
||||
├── admin.html # Admin dashboard
|
||||
└── js/ # Frontend modules
|
||||
├── 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
|
||||
└── *.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/or 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.
|
||||
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
|
||||
|
||||
+104
-52
@@ -1,76 +1,128 @@
|
||||
# Architecture
|
||||
|
||||
Wowngeon is a provably-fair, block-timed roguelike that doubles as a **multi-chain on-ramp for the
|
||||
Smirk wallet**. This is the index for the architecture docs; each subsystem below has its own page.
|
||||
Wowngeon is a provably fair, block-timed roguelike with crypto entry and payouts. A solo run is a
|
||||
race against a random block deadline; multiplayer matches run the same engine under a ruleset. The
|
||||
game also acts as a multi-chain on-ramp for the Smirk wallet.
|
||||
|
||||
## The map
|
||||
This page is the index for the architecture docs. Each subsystem has its own page.
|
||||
|
||||
## System map
|
||||
|
||||
```
|
||||
┌──────────── players (browser) ────────────┐
|
||||
│ index.html · tavern.html · render kit │
|
||||
┌──────────── players (browser) ─────────────┐
|
||||
│ index.html · tavern.html · match.html │
|
||||
│ html/js/render (render kit) │
|
||||
└───────────────┬───────────────┬────────────┘
|
||||
Socket.IO │ │ REST (auth, status)
|
||||
Socket.IO │ │ REST (auth, payments, status)
|
||||
┌────────────────▼───────────────▼────────────┐
|
||||
│ src/network/* (socket handlers, chat, │
|
||||
│ tavern, match, payments) │
|
||||
├──────────────────────────────────────────────┤
|
||||
game core │ src/game/* dungeon · rulesets · modes │ → RULESETS.md
|
||||
money │ src/payments/* providers · wallet-rpc │ → PAYMENTS.md
|
||||
cosmetics │ src/multiplayer/entitlements + catalog │ → MONETIZATION.md
|
||||
chains │ src/chain/chainProfile.js │ → chain profiles (below)
|
||||
chat │ src/network/chat/* local + nostr │ → CHAT_AND_NOSTR.md
|
||||
└──────────────────────────────────────────────┘
|
||||
│ src/network/* socket handlers, chat, │
|
||||
│ tavern, match, payments │
|
||||
├─────────────────────────────────────────────┤
|
||||
game core │ src/game/* dungeon · rulesets · modes │ → RULESETS.md
|
||||
money │ src/payments/* providers · wallet-rpc │ → PAYMENTS.md
|
||||
cosmetics │ src/multiplayer/entitlements + catalog │ → MONETIZATION.md
|
||||
chains │ src/chain/chainProfile.js │ → chain profiles, below
|
||||
chat │ src/network/chat/* local + nostr │ → CHAT_AND_NOSTR.md
|
||||
└─────────────────────────────────────────────┘
|
||||
│ │
|
||||
PostgreSQL Wownero/Monero wallet-RPC + BTCPay/checkout relays
|
||||
PostgreSQL Wownero/Monero wallet-RPC, BTCPay/checkout gateways
|
||||
```
|
||||
|
||||
## The five pillars (2026-07 "10x" build)
|
||||
`src/index.js` is the composition root: it builds the Express app and Socket.IO server, wires the
|
||||
database manager, wallet RPC service, payment config, game mode manager and socket handlers, and
|
||||
mounts the REST routes in `src/routes/`.
|
||||
|
||||
| Pillar | What | Doc |
|
||||
|--------|------|-----|
|
||||
| **P1 — Credits & catalog** | Operator-owned cosmetic catalog + tiered unlock rule (free / grant / spend-threshold / tier) | [MONETIZATION.md](MONETIZATION.md) |
|
||||
| **P2 — Chain profiles** | One registry for the 5 Smirk chains (decimals, block time, adapter family, URI scheme) | see *Chain profiles* below |
|
||||
| **P3 — Modular payments** | Pluggable payment providers: BTCPay + xmr/wowcheckout (Greenfield) + native Monero, behind one registry | [PAYMENTS.md](PAYMENTS.md) |
|
||||
| **P4 — Ruleset / PvP** | Gameplay as a data object; race / last-alive / score / co-op are rulesets on the match engine | [RULESETS.md](RULESETS.md) |
|
||||
| **P5 — Nostr chat** | Global cross-server chat over `relay.smirk.cash`; bridge → per-player Smirk-npub identity | [CHAT_AND_NOSTR.md](CHAT_AND_NOSTR.md) |
|
||||
## Subsystem guide
|
||||
|
||||
All five are **behavior-preserving and env-gated** — each stays dormant (native/local defaults) until
|
||||
its config is set, so `main` is always deployable.
|
||||
| Area | Where | Doc |
|
||||
|------|-------|-----|
|
||||
| Credits, cosmetic catalog, entitlements | `src/multiplayer/entitlements.js`, `src/services/catalogService.js` | [MONETIZATION.md](MONETIZATION.md) |
|
||||
| Chain constants for the five Smirk chains | `src/chain/chainProfile.js` | below |
|
||||
| Payment providers and payouts | `src/payments/` | [PAYMENTS.md](PAYMENTS.md) |
|
||||
| Rulesets and match engine | `src/game/rulesets/`, `src/multiplayer/MatchEngine.js` | [RULESETS.md](RULESETS.md), [MATCH_MODE.md](MATCH_MODE.md) |
|
||||
| Tavern lobby and spectating | `src/network/tavernManager.js`, `src/multiplayer/Room.js` | [TAVERN_AND_MULTIPLAYER.md](TAVERN_AND_MULTIPLAYER.md) |
|
||||
| Chat, local and nostr | `src/network/chat/` | [CHAT_AND_NOSTR.md](CHAT_AND_NOSTR.md) |
|
||||
| Client render kit and art packs | `html/js/render/` | [RENDER_PACKS.md](RENDER_PACKS.md) |
|
||||
| Balance and pacing | `src/sim/`, `src/game/difficultyConfig.js` | [BALANCE_SIM.md](BALANCE_SIM.md), [MULTI_LEVEL.md](MULTI_LEVEL.md) |
|
||||
| Deployment and operations | `scripts/deploy/` | [DEPLOY.md](DEPLOY.md), [DEPLOY_INSTANCES.md](DEPLOY_INSTANCES.md), [LOGS_AND_BACKUP.md](LOGS_AND_BACKUP.md) |
|
||||
| Financial reconciliation and exports | `src/services/` | [FINANCIAL_RECONCILIATION.md](FINANCIAL_RECONCILIATION.md), [FINANCIAL_EVENT_EXPORT.md](FINANCIAL_EVENT_EXPORT.md) |
|
||||
|
||||
## Chain profiles (P2)
|
||||
Optional subsystems are environment-gated and dormant by default: payment gateways register only
|
||||
when their `*_URL` / `*_STORE_ID` / `*_API_KEY` trio is set, global nostr chat requires
|
||||
`NOSTR_CHAT_ENABLED=true`, and match rooms require `MATCH_ENABLED=true`. With none of those set the
|
||||
server runs the native wallet-RPC payment path, local socket chat, and solo play only. The full
|
||||
environment template is [`src/.env.example`](../src/.env.example).
|
||||
|
||||
## Chain profiles
|
||||
|
||||
`src/chain/chainProfile.js` is the single source of truth for per-chain constants across BTC, LTC,
|
||||
XMR, WOW, GRIN: `decimalsFor`, `meanBlockTimeMsFor`, `familyFor` (monero / utxo / mimblewimble),
|
||||
`uriSchemeFor`, `atomicDivisor` (BigInt). Unknown chains fall back to a WOW-shaped default. Money math
|
||||
stays exact via `src/money/atomic.js` (BigInt, decimals-parameterized) — never floats.
|
||||
XMR, WOW and GRIN: `decimalsFor`, `meanBlockTimeMsFor`, `familyFor` (`monero` / `utxo` /
|
||||
`mimblewimble`), `uriSchemeFor` and `atomicDivisor` (BigInt).
|
||||
|
||||
> **Resolved (2026-07):** `cryptoType` now keys difficulty off block time via `NETWORK_TUNING` in
|
||||
> `difficultyConfig.js` — the pacing lever is **multi-level depth** (levels ∝ block time), measured
|
||||
> and calibrated with a headless balance simulator. Also corrected: **WOW is ~5 min/block**, not 2
|
||||
> (measured on the live daemon). See [BALANCE_SIM.md](BALANCE_SIM.md) and [MULTI_LEVEL.md](MULTI_LEVEL.md).
|
||||
| Chain | Decimals | Mean block time | Family | URI scheme |
|
||||
|-------|----------|-----------------|--------|------------|
|
||||
| WOW | 11 | 5 min | monero | `wownero` |
|
||||
| XMR | 12 | 2 min | monero | `monero` |
|
||||
| BTC | 8 | 10 min | utxo | `bitcoin` |
|
||||
| LTC | 8 | 2.5 min | utxo | `litecoin` |
|
||||
| GRIN | 9 | 1 min | mimblewimble | `grin` |
|
||||
|
||||
An unrecognized chain id resolves to a default profile shaped like XMR: 12 decimals, 120000 ms mean
|
||||
block time, `monero` family, `monero` URI scheme. Callers therefore never crash on a mis-set
|
||||
`CRYPTO_TYPE`. Adding a chain is one entry in the registry plus a family adapter.
|
||||
|
||||
Money math is exact: `src/money/atomic.js` operates on BigInt atomic units parameterized by
|
||||
decimals, never floats.
|
||||
|
||||
## Game balance and pacing
|
||||
|
||||
A solo run is bounded by a random deadline drawn from an exponential distribution with mean equal to
|
||||
the chain's block time, so the house edge is a convolution of the run-completion distribution with
|
||||
block cadence rather than a number that can be set by hand. `src/sim/` drives the real engine with
|
||||
headless bots and integrates the deadline out analytically, reporting a measured house win rate per
|
||||
preset and per network.
|
||||
|
||||
`NETWORK_TUNING` in `src/game/difficultyConfig.js` scales dungeon depth to block time. Dungeon size
|
||||
and monster behaviour come from the difficulty preset; the per-network entry overrides only the
|
||||
level count:
|
||||
|
||||
| Chain | Levels |
|
||||
|-------|--------|
|
||||
| GRIN | 1 |
|
||||
| XMR | 1 |
|
||||
| LTC | 2 |
|
||||
| WOW | 2 |
|
||||
| BTC | 3 |
|
||||
|
||||
`NETWORK_TUNING_DISABLED=true` bypasses the override and leaves the preset's own level count in
|
||||
place. Descending emits a `game_event`, which the client handles in
|
||||
`html/js/network/socketHandlers.js` to surface level progress. See [BALANCE_SIM.md](BALANCE_SIM.md)
|
||||
and [MULTI_LEVEL.md](MULTI_LEVEL.md).
|
||||
|
||||
## Client render kit
|
||||
|
||||
`html/js/render/` (`RK`) draws the shared `Scene` in multiple techniques (Tiled / ASCII / Iso / 3D)
|
||||
and interchangeable art **packs**, with a shared pure-canvas FX layer (torch/hearth fire, hazard
|
||||
tiles), zoom, and an operator-gated cosmetic catalog. See [RENDER_PACKS.md](RENDER_PACKS.md).
|
||||
`html/js/render/` (exposed as `RK`) draws a shared `Scene` model in four techniques: Tiled, ASCII,
|
||||
Iso and 3D. Art packs are interchangeable data, a pure-canvas FX layer supplies torch and hazard
|
||||
effects, and the pack and technique available to a player come from the operator-owned cosmetic
|
||||
catalog. Tiled and ASCII are free; Iso and 3D are premium. See [RENDER_PACKS.md](RENDER_PACKS.md).
|
||||
|
||||
## Game balance & pacing
|
||||
## Provable fairness
|
||||
|
||||
Difficulty is **measured, not guessed**: single-player runs are bounded by a random (exponential)
|
||||
block deadline, so the house edge is a simulation problem. `src/sim/` drives the real engine with
|
||||
headless bots; `NETWORK_TUNING` + multi-level depth are the calibrated levers. See
|
||||
[BALANCE_SIM.md](BALANCE_SIM.md) and [MULTI_LEVEL.md](MULTI_LEVEL.md).
|
||||
`src/game/provablyFair.js` derives each dungeon from a committed seed, `src/game/fairnessVerifier.js`
|
||||
exposes verification endpoints, and `src/views/verifyPage.js` renders the public verification page so
|
||||
a player can reproduce a dungeon from its published proof.
|
||||
|
||||
The code is heavily JSDoc-commented, while the hand-authored pages here explain subsystem seams and
|
||||
operator intent. A docs-only generator is intentionally not part of the production dependency lock;
|
||||
add one in a dedicated documentation toolchain if generated API pages become a release artifact.
|
||||
## Conventions
|
||||
|
||||
## Conventions worth knowing
|
||||
- **Game id** is a UUID stored in `games.dungeon_seed`; `games.id` is a serial integer. Match game
|
||||
objects with `WHERE dungeon_seed = $N`, never `WHERE id = $N`.
|
||||
- **Socket ids are volatile** and change on refresh. Stable identity is the database `user_id` or the
|
||||
session, never `socket_id`.
|
||||
- **WOW has 11 decimals** (atomic divisor 1e11), unlike Monero's 12.
|
||||
- **Cosmetic entitlements are per-pack**, resolved by tier and by lifetime credits purchased in
|
||||
`src/multiplayer/entitlements.js`. Buying credits does not by itself unlock premium packs.
|
||||
- **Migrations** live in `src/migrations/NNN_*.sql` and run in numeric order.
|
||||
- **Tests**: `cd src && npm test` (Jest, `--runInBand`).
|
||||
|
||||
- **Game id** is a UUID stored in `games.dungeon_seed`; `games.id` is a serial int. Match game objects
|
||||
with `WHERE dungeon_seed = $N`, never `WHERE id = $N`.
|
||||
- **Socket ids are volatile** (change on refresh). For stable identity use the DB `user_id` / session,
|
||||
never `socket_id`.
|
||||
- **WOW has 11 decimals** (atomic divisor 1e11), not Monero's 12.
|
||||
- **Tests**: `cd src && npm test` (jest, `--runInBand`). Migrations live in `src/migrations/NNN_*.sql`.
|
||||
Source files carry JSDoc for API-level detail; these pages cover the seams between subsystems and
|
||||
the operator-facing intent behind them.
|
||||
|
||||
+93
-53
@@ -1,43 +1,57 @@
|
||||
# Balance simulation & per-network calibration
|
||||
# Balance simulation and per-network calibration
|
||||
|
||||
Wownerogue is a **block-timed** roguelike: a run is bounded by the blockchain, and the house edge
|
||||
comes from whether you can clear the dungeon before the block lands. That makes the balance
|
||||
un-eyeball-able — the deadline is a *random* variable and every chain has a different block time —
|
||||
so difficulty is **measured**, not guessed. This doc covers the simulator that measures it and the
|
||||
per-network tuning it produces.
|
||||
Wownerogue is a block-timed roguelike: a single-player run is bounded by the blockchain, and the
|
||||
house edge comes from whether the player clears the dungeon before the next block lands. The
|
||||
deadline is a random variable and every supported chain has a different block time, so difficulty is
|
||||
measured rather than estimated. This document covers the simulator that measures it and the
|
||||
per-network tuning it feeds.
|
||||
|
||||
## The timing model (why this is subtle)
|
||||
## Timing model
|
||||
|
||||
- **Single-player runs are bounded by one block.** You enter on block *N* and die if you haven't
|
||||
escaped before *N+1* (`queueManager.js`, `socketHandlers.js checkGamesTimeout`). Movement is
|
||||
real-time within that window (~100 ms/move floor).
|
||||
- **Block arrival is a Poisson process**, so the run window is **exponentially distributed** with
|
||||
mean = the network's block time. Enter just after a block → a near-full interval; just before →
|
||||
seconds. High variance is the core mechanic, intended.
|
||||
- Therefore, for a run that escapes at wall-clock time `T`:
|
||||
A run starts on block *N* and ends as a loss if the player has not escaped by the time block *N+1*
|
||||
arrives (`src/network/socketHandlers.js`, `checkGamesTimeout`). Movement is real time within that
|
||||
window, with a 100 ms per-move cooldown (`src/game/movementManager.js`). A grace period,
|
||||
`GAME_START_GRACE_MS` (default 2000 ms, `0` disables), suppresses only the degenerate case where a
|
||||
block lands in the same instant the game starts; random block timing is otherwise preserved intact.
|
||||
|
||||
```
|
||||
P(survive) = P(block lands after T) = exp(-T / meanBlockTime)
|
||||
house_win(run) = caught|stuck ? 1 : (1 - exp(-T / meanBlockTime))
|
||||
```
|
||||
Block arrival is a Poisson process, so the run window is exponentially distributed with mean equal
|
||||
to the network's block time. Entering just after a block yields a near-full interval; entering just
|
||||
before yields seconds. That variance is the core mechanic.
|
||||
|
||||
The random deadline is integrated out analytically — no Monte-Carlo over deadlines needed.
|
||||
For a run that escapes at wall-clock time `T`:
|
||||
|
||||
Block times (`src/chain/chainProfile.js`, `meanBlockTimeMs`): GRIN ~1 min · XMR ~2 · LTC ~2.5 ·
|
||||
**WOW ~5 (measured on the live daemon)** · BTC ~10.
|
||||
```
|
||||
P(survive) = P(block lands after T) = exp(-T / meanBlockTime)
|
||||
house_win(run) = caught or stuck ? 1 : (1 - exp(-T / meanBlockTime))
|
||||
```
|
||||
|
||||
The random deadline is integrated out analytically, so no Monte Carlo over deadlines is required.
|
||||
|
||||
Mean block times come from `src/chain/chainProfile.js` (`meanBlockTimeMs`):
|
||||
|
||||
| Chain | Mean block time |
|
||||
|-------|-----------------|
|
||||
| GRIN | 1 min |
|
||||
| XMR | 2 min |
|
||||
| LTC | 2.5 min |
|
||||
| WOW | 5 min |
|
||||
| BTC | 10 min |
|
||||
|
||||
Unknown or unset chain ids resolve to a fallback profile of 12 decimals and a 120000 ms block time,
|
||||
so a mis-set `CRYPTO_TYPE` degrades to XMR-shaped timing instead of crashing.
|
||||
|
||||
## The simulator (`src/sim/`)
|
||||
|
||||
Drives the **real engine** (`src/game/game.js` — same dungeon generator, monster AI, movement as
|
||||
production) with headless bots to terminal outcomes, then reports the measured distribution. It
|
||||
never reimplements game rules — it's a thin layer over `Game`.
|
||||
The harness drives the real engine (`src/game/game.js`: same dungeon generator, monster AI and
|
||||
movement as production) with headless bots until each run reaches a terminal outcome, then reports
|
||||
the measured distribution. It never reimplements game rules; it is a thin layer over `Game`.
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `simulate.js` | Harness + CLI. Per preset: escape/treasure/caught/stuck rates, completion-move/-time percentiles, and **measured house-win per network** via the identity above. |
|
||||
| `simBots.js` | Bot policies. `omniscient-*` (knows the full map, beelines) and `explorer-*` (fog-of-war, realistic). Neither *evades* the monster, so both **over-count catches** → true house-win is likely below the reported band. Read them as an upper band, not a point estimate. |
|
||||
| `pathfind.js` | Dependency-free BFS (keeps the sim auditable). |
|
||||
| `calibrate.js`| Solves tuning per network to a target house-win. |
|
||||
| `simulate.js` | Harness and CLI. Per preset: escape, treasure, caught and stuck rates; completion-move and completion-time percentiles; measured house-win per network via the identity above. |
|
||||
| `simBots.js` | Bot policies. `omniscient-*` know the full map and take BFS-optimal paths; `explorer-*` play under fog of war and reset their knowledge on descent. Neither policy evades the monster. |
|
||||
| `pathfind.js` | Dependency-free 4-connected BFS distance field and downhill step, so navigation stays auditable and separate from game rules. |
|
||||
| `calibrate.js`| Per-network solver: scales dungeon size by `√(blockTime / 120000)` for pacing, then binary-searches monster speed to a target house-win. |
|
||||
|
||||
Run:
|
||||
|
||||
@@ -46,37 +60,63 @@ node src/sim/simulate.js --runs 200 --bot explorer-greedy --presets normal,casin
|
||||
node src/sim/calibrate.js --target 0.70 --preset casino
|
||||
```
|
||||
|
||||
`--cadence` (ms/move, default 320) is the moves→seconds assumption the block-time math hinges on —
|
||||
sweep it when calibrating for real players. `test/sim_ultra.test.js` guards the harness structurally
|
||||
(it is **not** a balance assertion — those numbers are meant to move).
|
||||
`simulate.js` accepts `--runs`, `--bot`, `--presets`, `--nets`, `--cadence`, `--movecap` and
|
||||
`--json`. Bots are `omniscient-escape`, `omniscient-greedy`, `explorer-escape` and
|
||||
`explorer-greedy` (default).
|
||||
|
||||
## What the sim proved (and the design it forced)
|
||||
`--cadence` (ms per move, default 320) is the moves-to-seconds assumption the block-time math rests
|
||||
on; sweep it when calibrating against real player pacing.
|
||||
|
||||
1. **Presets undershot their declared targets**, and the edge swung ~15 pts across chains from
|
||||
*identical* dungeons — because `cryptoType` never sized anything.
|
||||
2. **Dungeon size is not a clean edge lever.** A bigger map lengthens the run (timer edge ↑) but
|
||||
gives the player more room to dodge (monster edge ↓); house-win self-cancels around ~60% until
|
||||
the map becomes an unplayable slog.
|
||||
3. **A fast monster is both distasteful and ineffective on slow chains** — WOW (5-min blocks) caps
|
||||
~64% house-win even at a 2.2× monster, because a single dungeon clears in ~1 min ≪ the block, so
|
||||
Two properties of the harness matter when reading its output:
|
||||
|
||||
- Because neither bot evades the monster, both over-count catches, so reported house-win is an upper
|
||||
band rather than a point estimate. A skilled human wins more often.
|
||||
- One simulated dungeon configuration is scored against every network in `--nets`. The dungeon
|
||||
itself is built from `DIFFICULTY_PRESET` and the process `CRYPTO_TYPE` (default `WOW`), so the
|
||||
per-network columns differ only in block time, not in level count. Set `CRYPTO_TYPE` explicitly to
|
||||
simulate a specific chain's level count.
|
||||
|
||||
`test/sim_ultra.test.js` guards the harness structurally: it asserts that bots reach terminal
|
||||
outcomes and that rates are well formed and partition the runs. It asserts nothing about balance
|
||||
values, which are expected to move.
|
||||
|
||||
## Findings
|
||||
|
||||
1. Presets declare a `targetHouseWinRate` but measured rates land below it, and the edge varies by
|
||||
roughly 15 points across chains for an identical dungeon purely from block time.
|
||||
2. Dungeon size is not a clean edge lever. A larger map lengthens the run (timer edge up) but gives
|
||||
the player more room to dodge (monster edge down), so house-win self-cancels near 60% until the
|
||||
map becomes an unplayable slog. Size is useful as a pacing knob, not an edge knob.
|
||||
3. A fast monster is both unpleasant and ineffective on slow chains. On WOW's 5-minute blocks a
|
||||
single dungeon clears in about a minute, so even a 2.2x monster caps house-win near 64% because
|
||||
the timer barely bites.
|
||||
|
||||
**Conclusion → multi-level depth.** The fair, on-theme lever is *run length*: descend N levels
|
||||
(each preset-sized, fair monster) so the cumulative timer provides the edge. Levels ∝ block time.
|
||||
See `docs/MULTI_LEVEL.md` (mechanic) and `NETWORK_TUNING` in `src/game/difficultyConfig.js`.
|
||||
The lever that follows from this is run length: descend N preset-sized levels, each with a fair
|
||||
monster, so cumulative run time supplies the edge and levels scale with block time. `monsterSpeed`
|
||||
is deliberately not used as a per-network lever. See `docs/MULTI_LEVEL.md` for the mechanic.
|
||||
|
||||
## Per-network tuning (`NETWORK_TUNING`)
|
||||
|
||||
`difficultyConfig.js` folds the tuning onto the resolved preset. Precedence:
|
||||
**preset → network tuning → env → explicit overrides** (so operators keep `DUNGEON_*`/`MONSTER_*`;
|
||||
`NETWORK_TUNING_DISABLED=true` is the kill switch). Currently it sets **`levels`** per network
|
||||
(the pacing lever); size and monster stay at the fair preset.
|
||||
`src/game/difficultyConfig.js` folds per-network tuning onto the resolved preset. Precedence, low to
|
||||
high: preset, network tuning, environment variables, explicit overrides. Operator settings such as
|
||||
`DUNGEON_*`, `MONSTER_*` and `DUNGEON_LEVELS` therefore win over the tuning, and
|
||||
`NETWORK_TUNING_DISABLED=true` removes it entirely.
|
||||
|
||||
```
|
||||
GRIN 1 · XMR 2 · LTC 2 · WOW 4 · BTC 8
|
||||
```
|
||||
The tuning sets only `levels`. Dungeon size and monster parameters stay at the preset values.
|
||||
|
||||
**Pending:** the level counts are sim starting points. The simulator's multi-level support (bots
|
||||
reset fog-of-war on descent) is in; a full re-calibration run to lock the per-network level counts
|
||||
against a target house-win is the open follow-up. The `monsterSpeed` lever was intentionally
|
||||
**dropped** (see finding 3).
|
||||
| Chain | Levels |
|
||||
|-------|--------|
|
||||
| GRIN | 1 |
|
||||
| XMR | 1 |
|
||||
| LTC | 2 |
|
||||
| WOW | 2 |
|
||||
| BTC | 3 |
|
||||
|
||||
Level counts are conservative relative to the raw pacing formula (block time divided by roughly 75
|
||||
seconds per level), because a deep descent inside one block deadline is rarely completable and makes
|
||||
reach-the-exit-to-win unintuitive.
|
||||
|
||||
`Game` reads the resolved count as `maxDepth`. Reaching a non-final exit descends to a fresh level
|
||||
and emits a `descend` event; only the final level's exit ends the run as a win, and treasure is
|
||||
placed only on the final level. The client handles `game_event` (including `descend`) in
|
||||
`html/js/network/socketHandlers.js` and surfaces depth progress to the player.
|
||||
|
||||
+147
-87
@@ -1,131 +1,191 @@
|
||||
# Chat & Nostr (P5)
|
||||
# Chat and Nostr
|
||||
|
||||
Chat has two layers: **local delivery** (Socket.IO + Postgres history) and an optional **nostr fan-out**
|
||||
Chat has two layers: local delivery (Socket.IO plus Postgres history) and an optional nostr fan-out
|
||||
that carries the same conversation across servers and into the Smirk ecosystem. Both sit behind one
|
||||
seam so callers never change.
|
||||
seam, so callers are identical either way.
|
||||
|
||||
## The seam
|
||||
## The provider seam
|
||||
|
||||
`src/network/chat/ChatProvider.js` — the abstraction every chat caller uses:
|
||||
`src/network/chat/ChatProvider.js` is the abstraction every chat caller uses:
|
||||
|
||||
```
|
||||
publish(msg) deliver + (optionally) persist a message
|
||||
getHistory({scope, limit}) recent messages, oldest → newest
|
||||
relaySignedEvent(req) deliver a CLIENT-signed nostr event (per-player identity)
|
||||
publish(msg) deliver and (optionally) persist a message
|
||||
getHistory({scope, limit}) recent messages, oldest to newest
|
||||
relaySignedEvent(req) deliver a client-signed nostr event (per-player identity)
|
||||
initialize() / shutdown()
|
||||
```
|
||||
|
||||
Application concerns (commands, moderation, rate limiting, HTML escaping) stay in the caller;
|
||||
a provider only delivers and stores.
|
||||
|
||||
Two implementations:
|
||||
|
||||
- **`SocketChatProvider`** — the default. Global scope → persist via `ChatHistoryManager` + broadcast
|
||||
(`chat_broadcast`); room scope (tavern) → deliver to that Socket.IO room only.
|
||||
- **`NostrChatProvider`** — a **decorator** around a local provider. Everything still delivers in-game +
|
||||
persists; on top of that, global messages fan out to nostr relays, and messages arriving from a relay
|
||||
are delivered locally-only (never re-published → no echo loops).
|
||||
- `SocketChatProvider` is the default. Scope `global` persists through `ChatHistoryManager` and
|
||||
broadcasts `chat_broadcast` to every client; a room scope such as `tavern:<id>` delivers only to
|
||||
that Socket.IO room and persists nothing. Broadcasts carry a short `publicId` (the user id, or the
|
||||
first 6 characters of the socket id) rather than the full socket id.
|
||||
- `NostrChatProvider` decorates a local provider. Every message still delivers in-game and persists;
|
||||
in addition, global messages fan out to nostr relays, and messages arriving from a relay are
|
||||
delivered locally only, never re-published, so there are no echo loops.
|
||||
|
||||
`buildChatProvider({local, env})` (`src/network/chat/index.js`) picks: with `NOSTR_CHAT_ENABLED` unset it
|
||||
returns the plain local provider (behavior-preserving); enabled + `scope=global` wraps it in a
|
||||
`buildChatProvider({local, env})` in `src/network/chat/index.js` chooses between them. With
|
||||
`NOSTR_CHAT_ENABLED` unset, or with `NOSTR_CHAT_SCOPE` set to anything other than `global`, it
|
||||
returns the plain local provider unchanged; otherwise it wraps that provider in a
|
||||
`NostrChatProvider`.
|
||||
|
||||
`ChatHandler` constructs the local provider (sharing its own `ChatHistoryManager` instance so ban
|
||||
checks and stats keep working) and passes it through `buildChatProvider`.
|
||||
|
||||
## Unified global chat
|
||||
|
||||
The **tavern shares the lobby's global chat** — `TavernManager` takes the lobby's `chatProvider` and
|
||||
publishes to the `global` scope, and sends the global backlog (`chat_history`) on join. So lobby and
|
||||
tavern are one conversation with persistent history, and when nostr is on, tavern chat rides the relay
|
||||
too. Topology is always **client → server → relay**: the server stays the moderation authority (bans,
|
||||
rate-limit, history in Postgres); the relay is transport + fan-out.
|
||||
`TavernManager` accepts the lobby's provider as `globalChatProvider` and publishes tavern messages to
|
||||
the `global` scope, sending the global backlog as `chat_history` on join. Lobby and tavern are
|
||||
therefore one conversation with persistent history, and when nostr is enabled tavern chat rides the
|
||||
relay too. When no global provider is injected, the tavern falls back to its own room-scoped,
|
||||
ephemeral provider.
|
||||
|
||||
## Signer models
|
||||
Topology is always client to server to relay. The server remains the moderation authority (bans,
|
||||
rate limits, history in Postgres); the relay is transport and fan-out.
|
||||
|
||||
`NostrChatProvider` takes an injectable **signer** (`{ pubkey, sign(template) }`). That one object is the
|
||||
whole difference between the two identity models:
|
||||
## Identity models
|
||||
|
||||
### Phase 1 — bridge (shipped, live)
|
||||
The server signs every global message with one allowlisted bridge npub; the player's name rides in an
|
||||
`['n', name]` tag. Works for anyone (even anon), but on nostr it reads as one account talking for
|
||||
everyone. `createBridgeSigner(NOSTR_BRIDGE_SK)` builds it; the npub must be write-allowlisted on the relay
|
||||
(see *Relay policy*).
|
||||
`NostrChatProvider` takes an injectable signer with the contract `{ pubkey, sign(template) }`. That
|
||||
one object is the whole difference between the two identity models.
|
||||
|
||||
### Phase 2 — per-player signing (built; one real-wallet click to confirm)
|
||||
The **client** signs with the player's own Smirk npub and sends the finished event; the server verifies +
|
||||
relays it. On nostr it's genuinely *them*.
|
||||
### Server bridge signer
|
||||
|
||||
The server signs every outbound global message with a single key from `NOSTR_BRIDGE_SK`
|
||||
(`createBridgeSigner`, accepting an `nsec` or 64-hex secret). The player's display name rides in an
|
||||
`['n', name]` tag. This works for every player, including anonymous ones, but on nostr the whole
|
||||
server reads as one account. The bridge pubkey must be accepted by the relay's write policy, either
|
||||
by being a registered npub or by sitting in the relay write allowlist. With no key configured the
|
||||
signer is `null` and global chat is receive-only.
|
||||
|
||||
### Client per-player signing
|
||||
|
||||
The client signs with the player's own Smirk npub and sends the finished event; the server verifies
|
||||
it and relays it unmodified, so on nostr the author is genuinely the player.
|
||||
|
||||
```
|
||||
client server (chatHandler) relay
|
||||
│ build kind-1 event │ │
|
||||
│ window.smirk.signNostrEvent │ │
|
||||
│ ── chat_signed {event} ────► │ rate-limit │
|
||||
│ │ verifyChatEvent(event, npub) │
|
||||
│ │ sig ok? id ok? fresh? tag ok? │
|
||||
│ │ pubkey === session npub? ◄── the │
|
||||
│ │ impersonation guard │
|
||||
│ │ escape + relaySignedEvent ──────────►│ (published under the player's npub)
|
||||
│ ◄──────── chat_broadcast (in-game, all clients) ──────────────────│
|
||||
client server (ChatHandler) relay
|
||||
build kind-1 event
|
||||
window.smirk.signNostrEvent
|
||||
emit chat_signed {event} --> rate limit
|
||||
look up session npub
|
||||
verifyChatEvent(event, npub)
|
||||
signature, id, freshness, channel tag,
|
||||
pubkey equals session npub
|
||||
relaySignedEvent ----------> published under
|
||||
the player's npub
|
||||
<------------------- chat_broadcast (all in-game clients)
|
||||
```
|
||||
|
||||
- **`src/utils/verifyChatEvent.js`** — the security core. Same defenses as `verifyNip98Event` (rebuild a
|
||||
clean event, independently recompute the id, verify schnorr, freshness) **plus** `event.pubkey` must
|
||||
equal the session's authenticated npub (`users.smirk_public_key`, set at NIP-98 login). A valid signature
|
||||
by *some other* key is rejected (`pubkey-mismatch`).
|
||||
- **`chatHandler.handleSignedChatMessage`** — rate-limit → resolve session npub → verify → escape → relay.
|
||||
- **`NostrChatProvider.relaySignedEvent`** — local delivery + `transport.publish(signedEvent)` (no
|
||||
re-signing) + echo-dedupe on the event id.
|
||||
- **`html/js/smirkChatSign.js`** — feature-detected client helper. Signs via `window.smirk.signNostrEvent`
|
||||
with the Nostr-scope-grant retry (sign first; on `NOT_AUTHORIZED`, `getNostrPublicKey()` once and
|
||||
retry — same pattern as `SmirkAuth`), so a returning user gets a single approval. Falls back to the
|
||||
unsigned send when Smirk isn't connected, so wiring it into a send site is always safe. Loaded on
|
||||
`tavern.html`; the tavern send tries it first. (A real Smirk wallet click is the only step left to
|
||||
confirm end to end.)
|
||||
- `src/utils/verifyChatEvent.js` is the security core. It validates shape and types, rebuilds a clean
|
||||
event from primitives only, independently recomputes the event id, verifies the BIP-340 schnorr
|
||||
signature, enforces freshness (120 seconds by default), and requires the `t` tag to match the
|
||||
configured channel tag so the server cannot be used to relay arbitrary events. Critically,
|
||||
`event.pubkey` must equal the session's authenticated npub (`users.smirk_public_key`, set at NIP-98
|
||||
login); a valid signature by any other key is rejected with `pubkey-mismatch`. Content is capped at
|
||||
280 characters.
|
||||
- `ChatHandler.handleSignedChatMessage` runs the sequence: rate limit, resolve the session's npub
|
||||
from the database, verify, escape, relay. A session with no linked npub is told to sign in with
|
||||
Smirk. The display name comes from `display_name` or `username`, falling back to the first 8
|
||||
characters of the npub.
|
||||
- `NostrChatProvider.relaySignedEvent` performs local delivery and then `transport.publish(event)`
|
||||
with no re-signing, marking the event id as seen so the relay round-trip does not deliver it twice.
|
||||
- `html/js/smirkChatSign.js` is the client helper. It signs through `window.smirk.signNostrEvent` and
|
||||
emits `chat_signed`. Signing is attempted first; on `NOT_AUTHORIZED` it calls `getNostrPublicKey()`
|
||||
once to obtain the nostr scope grant and retries, the same pattern as `SmirkAuth`, so a returning
|
||||
user sees a single approval. It resolves `false` whenever Smirk is unavailable or declines, and the
|
||||
caller then falls back to the ordinary unsigned send. The helper is loaded on `tavern.html`, whose
|
||||
send path tries it before `tavern_chat`.
|
||||
|
||||
## The tier model (two relays)
|
||||
The server registers the `chat_signed` socket event in `src/network/socketHandlers.js`.
|
||||
|
||||
Everyone signed-in **self-signs as themselves**; the tier is only which relay accepts them, and what perks
|
||||
come with it (perks + cosmetics, never identity):
|
||||
## Relays and tiers
|
||||
|
||||
| Player | Signs | Lands on |
|
||||
|--------|-------|----------|
|
||||
| Anon | nobody | local only |
|
||||
| Free "pleb" | own npub | `relay.smirk.cash` — open signup + PoW |
|
||||
| Premium | own npub + ✦ badge | `relay.wowne.ro` — premium-post |
|
||||
The game publishes each event to every relay in `NOSTR_RELAYS` and lets each relay's policy accept or
|
||||
reject it; there is no per-player routing or allowlist in game code. Reading is the mirror: subscribe
|
||||
to all relays and dedupe by event id.
|
||||
|
||||
The game publishes each signed event to **all** `NOSTR_RELAYS` and lets each relay's policy accept or
|
||||
reject — no per-player routing or allowlist in game code. Reading is the mirror (subscribe to all, dedupe
|
||||
by event id). Premium membership also sets the player's **entitlement tier**, which unlocks cosmetics
|
||||
(see [MONETIZATION.md](MONETIZATION.md)) — one subscription, two payoffs.
|
||||
| Player | Signs as | Reaches |
|
||||
|--------|----------|---------|
|
||||
| Anonymous | not signed | in-game chat only |
|
||||
| Signed in with Smirk | own npub | in-game chat plus every relay whose write policy accepts that npub |
|
||||
|
||||
## Config (env)
|
||||
|
||||
| Var | Meaning |
|
||||
|-----|---------|
|
||||
| `NOSTR_CHAT_ENABLED` | master switch (unset ⇒ plain local chat) |
|
||||
| `NOSTR_CHAT_SCOPE` | `global` (fan out) or `local` (plain provider) |
|
||||
| `NOSTR_RELAYS` | CSV of relay URLs; transport publishes/subscribes to all |
|
||||
| `NOSTR_CHAT_TAG` | shared channel topic (default `wowngeon-global`) |
|
||||
| `NOSTR_BRIDGE_SK` | bridge secret key (nsec/hex); omit ⇒ receive-only |
|
||||
A premium subscription also sets the player's entitlement tier, which unlocks cosmetics; see
|
||||
[MONETIZATION.md](MONETIZATION.md). Entitlements are per-pack tier based
|
||||
(`src/multiplayer/entitlements.js`): buying credits maps to tier 0 and unlocks packs by lifetime
|
||||
credits purchased, while a subscription tier unlocks the packs at or below that tier.
|
||||
|
||||
## Relay policy (smirk-backend-core)
|
||||
|
||||
`relay.smirk.cash` is a nostr-rs-relay gated by the Smirk backend's admission oracle. Write policy
|
||||
`premium-post` = only premium (or write-allowlisted) npubs may publish; `RELAY_WRITE_ALLOWLIST_NPUBS`
|
||||
(comma-separated npub1…/hex) exempts specific keys — that's how the **bridge npub is registered**. PoW
|
||||
(`RELAY_INBOUND_POW_BITS`) only gates non-registered authors, so allowlisted/premium npubs post freely.
|
||||
Reading is open.
|
||||
Relay admission is configured on the relay side, in the Smirk backend that fronts nostr-rs-relay:
|
||||
|
||||
- `RELAY_WRITE_POLICY` is one of `inbox-outbox` (the default), `author-allowlist`, `open`, or
|
||||
`premium-post`. `premium-post` lets registered users publish wallet kinds and reserves general
|
||||
posting for premium members; `PREMIUM_ENABLED` requires this policy.
|
||||
- `RELAY_WRITE_ALLOWLIST_NPUBS` (comma-separated `npub1…` or hex) exempts specific keys from the
|
||||
policy. This is how a server bridge key is registered.
|
||||
- `RELAY_INBOUND_POW_BITS` applies a NIP-13 proof-of-work gate to authors that are not registered or
|
||||
allowlisted, so registered and allowlisted npubs post without PoW.
|
||||
- Reading is open under every policy.
|
||||
|
||||
## Moderation and limits
|
||||
|
||||
- Inbound chat is rate limited through the shared `RateLimiter` under the `chat:message` bucket
|
||||
(12 messages per 10 seconds), keyed by a stable session id plus client IP so reconnecting cannot
|
||||
reset it. Plain lobby broadcasts add a 2 second per-socket cooldown; tavern occupants have a
|
||||
900 ms per-occupant cooldown.
|
||||
- Lobby messages are rejected above 200 characters and HTML-escaped before delivery. Signed messages
|
||||
are escaped and capped at 200 characters for in-game rendering; the event published to nostr is the
|
||||
client's original, unmodified.
|
||||
- Chat bans are checked against `ChatHistoryManager.isUserChatBanned` before a message is published.
|
||||
The tavern applies the same rate limiter and ban check before anything reaches global chat.
|
||||
- Remote (relay-sourced) messages are escaped with `escapeChatText`, capped at 200 characters,
|
||||
attributed from the `n` tag or the first 8 characters of the author pubkey, and rate limited to
|
||||
`maxRemotePerMin` (120 per minute by default) so a hostile relay cannot flood in-game clients.
|
||||
- Event ids are deduped through a bounded FIFO of the last 1000 ids, covering both the server's own
|
||||
echoes and repeats across relays.
|
||||
- The relay subscription filters on `since = now` at startup, so connecting does not flood clients
|
||||
with history; local Postgres history covers the past.
|
||||
- `NostrTransport` degrades to a warning and a no-op if `nostr-tools` or `ws` cannot load, so
|
||||
enabling nostr chat cannot crash the server.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Meaning |
|
||||
|----------|---------|
|
||||
| `NOSTR_CHAT_ENABLED` | Master switch. Unset means plain local chat. Accepts `1`, `true`, `yes`, `on`. |
|
||||
| `NOSTR_CHAT_SCOPE` | `global` (fan out) or anything else (plain local provider). Default `global`. |
|
||||
| `NOSTR_RELAYS` | Comma-separated relay URLs. Default `wss://relay.smirk.cash`. |
|
||||
| `NOSTR_CHAT_TAG` | Shared channel topic. Default `wowngeon-global`. |
|
||||
| `NOSTR_CHAT_KIND` | Event kind. Default `1`. |
|
||||
| `NOSTR_BRIDGE_SK` | Bridge secret key (`nsec` or 64-hex). Omit for receive-only global chat. |
|
||||
| `NOSTR_SERVER_ORIGIN` | Value for the `origin` tag on bridge-signed events. Falls back to `SERVER_ID`. |
|
||||
|
||||
The full server environment template is `src/.env.example`.
|
||||
|
||||
## Loader shim
|
||||
|
||||
`nostr-tools` pulls ESM-only deps. `src/utils/nostrLoader.js` `loadNostrTools()` require()s it in prod
|
||||
Node 22 and falls back to the pre-bundled IIFE under CJS-only Jest. Chat + NIP-98 both use it.
|
||||
`nostr-tools` pulls ESM-only dependencies. `loadNostrTools()` in `src/utils/nostrLoader.js`
|
||||
`require()`s the package under Node 22, which natively requires those dependencies, and falls back to
|
||||
the pre-bundled self-contained IIFE build under a CJS-only test runtime. Chat and NIP-98 auth both
|
||||
load nostr through it.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
src/network/chat/ChatProvider.js seam (+ relaySignedEvent default)
|
||||
src/network/chat/SocketChatProvider.js local delivery + history
|
||||
src/network/chat/NostrChatProvider.js decorator: local + nostr fan-out + signed relay
|
||||
src/network/chat/index.js buildChatProvider (env → provider)
|
||||
src/network/chat/nostr/NostrTransport.js SimplePool wrapper (multi-relay, degrades to noop)
|
||||
src/network/chat/ChatProvider.js seam, including the default relaySignedEvent
|
||||
src/network/chat/SocketChatProvider.js local delivery and history
|
||||
src/network/chat/NostrChatProvider.js decorator: local plus nostr fan-out plus signed relay
|
||||
src/network/chat/index.js buildChatProvider (env to provider)
|
||||
src/network/chat/nostr/NostrTransport.js SimplePool wrapper, multi-relay, degrades to no-op
|
||||
src/network/chat/nostr/bridgeSigner.js server bridge signer
|
||||
src/network/chatHandler.js commands, moderation, signed-event handling
|
||||
src/network/tavernManager.js tavern chat routed into global chat
|
||||
src/utils/verifyChatEvent.js verify a client-signed event (impersonation guard)
|
||||
src/utils/escapeChat.js HTML escape and length cap
|
||||
src/utils/nostrLoader.js nostr-tools loader shim
|
||||
html/js/smirkChatSign.js client per-player signing helper
|
||||
```
|
||||
|
||||
+64
-34
@@ -1,31 +1,46 @@
|
||||
# Disposable clone migration gate
|
||||
|
||||
`npm run db:migrate:clone` applies the release's normal ordered migrations to an existing,
|
||||
disposable PostgreSQL clone. It is the migration step for a restored production dump before an
|
||||
immutable release is eligible for activation. It never creates, restores, drops, audits, or
|
||||
activates a database.
|
||||
`npm run db:migrate:clone` applies a release's ordered migrations to an existing, disposable
|
||||
PostgreSQL clone. It is the migration step for a restored production dump, run before an immutable
|
||||
release becomes eligible for activation. It never creates, restores, drops, audits, or activates a
|
||||
database.
|
||||
|
||||
The runner is `src/scripts/migrate-disposable-clone.js`. Its only input is the inherited process
|
||||
environment: it accepts no command-line options, so a target or credential cannot land in argv or
|
||||
shell history.
|
||||
|
||||
## Hard target contract
|
||||
|
||||
The runner refuses to open a connection unless all of these conditions hold:
|
||||
|
||||
- `DB_HOST` is explicit loopback (`127.0.0.1`, `::1`, or `localhost`) or the standard local Unix
|
||||
socket directory `/run/postgresql` or `/var/run/postgresql`;
|
||||
- `DB_NAME` starts with `monerogue_`, `wownerogue_`, or `wowngeon_` and contains a complete
|
||||
underscore-delimited `clone`, `restore`, or `canary` token;
|
||||
- the name contains no live marker beginning with `prd`, `prod`, `production`, `live`, `mainnet`,
|
||||
`master`, `primary`, `current`, or `active`;
|
||||
- `DB_HOST` is explicit loopback (`127.0.0.1`, `::1`, or `localhost`) or one of the standard local
|
||||
Unix socket directories `/run/postgresql` and `/var/run/postgresql`;
|
||||
- `DB_NAME` matches `^[a-z][a-z0-9_]{2,62}$`, its first underscore-delimited token is `monerogue`,
|
||||
`wownerogue`, or `wowngeon`, and some later token is exactly `clone`, `restore`, or `canary`;
|
||||
- no token of `DB_NAME` begins with a live marker: `active`, `current`, `live`, `mainnet`, `master`,
|
||||
`primary`, `prd`, `prod`, or `production`;
|
||||
- `CLONE_MIGRATION_EXPECT_DATABASE` exactly equals `DB_NAME`;
|
||||
- `CLONE_MIGRATION_CONFIRM` exactly equals `MIGRATE_DISPOSABLE_CLONE:<DB_NAME>`;
|
||||
- `DB_PORT` and `DB_USER` are explicit, `DATABASE_URL` is absent, and the process receives no
|
||||
command-line arguments.
|
||||
- `DB_PORT` is an explicit integer port from 1 through 65535 and `DB_USER` is an explicit simple
|
||||
role name matching `^[a-z_][a-z0-9_]{0,62}$`;
|
||||
- `DATABASE_URL` is unset or empty, and the process receives no command-line arguments.
|
||||
|
||||
After connecting, the runner checks PostgreSQL's `current_database()` before any DDL. It then calls
|
||||
the same `DatabaseManager.runMigrations()` used by application startup: lexical filename order,
|
||||
the `schema_migrations` ledger, and one transaction per newly applied file. Finally, it requires the
|
||||
ledger to equal the complete repository migration manifest, in order, with no missing or extra
|
||||
entry. The manifest is discovered from the candidate release and must be contiguous from 001; the
|
||||
current floor is migration 043. A successful current release reports:
|
||||
The migration manifest is then discovered from the running release's `src/migrations` directory.
|
||||
Every `.sql` entry must be a regular file named `NNN_name.sql`, the ordinals must be contiguous
|
||||
from `001`, and the highest ordinal must be at least `043`.
|
||||
|
||||
Only after those static gates pass does the runner connect. It opens a single-connection pool
|
||||
(`max: 1`, five-second connection and idle timeouts, `application_name`
|
||||
`wowngeon-disposable-clone-migrations`) and reads PostgreSQL's own `current_database()` before any
|
||||
DDL, requiring an exact match with `CLONE_MIGRATION_EXPECT_DATABASE`.
|
||||
|
||||
Migrations then run through the same `DatabaseManager.runMigrations()` that application startup
|
||||
uses: lexical filename order, a `schema_migrations` ledger, and one transaction per newly applied
|
||||
file that both executes the SQL and records the ledger row. Afterwards the runner requires the
|
||||
ledger to equal the repository manifest exactly: same count, same filenames, same order, ending at
|
||||
the same latest migration.
|
||||
|
||||
A successful run reports:
|
||||
|
||||
```text
|
||||
clone_migration_status=ok
|
||||
@@ -34,12 +49,16 @@ migration_count=43
|
||||
latest_migration=043_durable_solo_restart_snapshots.sql
|
||||
```
|
||||
|
||||
Any refusal or verification failure exits non-zero after printing a single line prefixed `REFUSED:`
|
||||
(target or manifest contract violation) or `FAILED:` (any other error). The `FAILED:` path prints no
|
||||
server diagnostics, connection object, or environment value.
|
||||
|
||||
## Invocation
|
||||
|
||||
Create and restore the clone with the reviewed backup procedure first. Keep the target variables in
|
||||
a protected operator-owned environment file and database credentials in a mode-0600 `PGPASSFILE`;
|
||||
do not type a password, connection URL, or target as a command argument. For example, the inherited
|
||||
non-secret target environment is:
|
||||
a protected operator-owned environment file and database credentials in a mode-0600 `PGPASSFILE`.
|
||||
Do not pass a password, connection URL, or target as a command argument. A non-secret target
|
||||
environment looks like this:
|
||||
|
||||
```dotenv
|
||||
DB_HOST=127.0.0.1
|
||||
@@ -54,26 +73,37 @@ CLONE_MIGRATION_CONFIRM=MIGRATE_DISPOSABLE_CLONE:monerogue_restore_20260721t2200
|
||||
Database names use lowercase letters, digits, and underscores only. Normalize a release ID into a
|
||||
separate safe tag rather than copying a hyphenated artifact ID into `DB_NAME`.
|
||||
|
||||
Load that protected environment through the reviewed service/wrapper, change into the extracted
|
||||
Load that protected environment through the reviewed service or wrapper, change into the extracted
|
||||
candidate's `src` directory, and run only:
|
||||
|
||||
```bash
|
||||
npm run db:migrate:clone
|
||||
```
|
||||
|
||||
Do not append `--` arguments. The runner never logs host, role, password, connection URL, or
|
||||
`PGPASSFILE` contents. `DB_PASSWORD` is supported only for an already-protected inherited service
|
||||
environment and is kept in memory; `PGPASSFILE` is preferred.
|
||||
Do not append `--` arguments. The runner logs no host, role, password, connection URL, or
|
||||
`PGPASSFILE` contents. `DB_PASSWORD` is honoured when an already-protected service environment
|
||||
supplies it and is held in memory only; `PGPASSFILE` or `PGPASSWORD` is preferred, and neither
|
||||
requires the application-specific variable.
|
||||
|
||||
## Required follow-on gates and limits
|
||||
## Required follow-on gates
|
||||
|
||||
Run `scripts/deploy/financial-audit.sql` and the restored-data constraint validation gate against
|
||||
the same clone after migration. Compare the result with the pre-migration audit, then test the
|
||||
candidate and predecessor against the migrated clone as required by the fleet runbook.
|
||||
Run these against the same clone after migration:
|
||||
|
||||
- `scripts/deploy/financial-audit.sql`, compared against the pre-migration audit of the same data;
|
||||
- `scripts/deploy/financial-constraint-validate-restore.sql`, the restored-data constraint
|
||||
validation gate described in
|
||||
[FINANCIAL_CONSTRAINT_VALIDATION.md](./FINANCIAL_CONSTRAINT_VALIDATION.md).
|
||||
|
||||
Then test both the candidate and its predecessor against the migrated clone, as described in
|
||||
[RELEASE_ARTIFACT.md](./RELEASE_ARTIFACT.md). The deploy SQL lives outside the runtime artifact and
|
||||
is run from the repository against the clone.
|
||||
|
||||
## Limits
|
||||
|
||||
This runner does not prove that a database with a disposable-looking name is operationally
|
||||
disposable, does not prevent another application process from connecting, and does not acquire an
|
||||
activation-wide drain lock. The operator must create the clone from the intended fresh dump, keep
|
||||
it outside public service configuration, and destroy it separately after the release decision. A
|
||||
successful result authorizes only the next audit/test gate; it does not authorize a symlink switch,
|
||||
wallet action, payout, or traffic restoration.
|
||||
disposable, does not prevent another process from connecting to it, and does not acquire an
|
||||
activation-wide drain lock. The operator must create the clone from the intended fresh dump, keep it
|
||||
outside public service configuration, and destroy it separately after the release decision.
|
||||
|
||||
A successful result authorizes only the next audit and test gate. It does not authorize a symlink
|
||||
switch, wallet action, payout, or traffic restoration.
|
||||
|
||||
+194
-125
@@ -2,34 +2,36 @@
|
||||
|
||||
This guide covers deploying Wownerogue to a production environment.
|
||||
|
||||
For evidence about the release currently serving `play.wowne.ro`, see the dated
|
||||
[production-state record](PRODUCTION_STATE_2026-07-25.md). That record is not a substitute for this
|
||||
runbook or authorization for another activation.
|
||||
|
||||
## Operated profiles versus independent deployments
|
||||
|
||||
Such Software (`apps@such.software`) operates only `play.wowne.ro` (Wownero mainnet
|
||||
pay-for-credits leaderboard/prestige, no prize/payout/cash-out and not marketed as gambling) and
|
||||
`monerogue.app` (Monero stagenet direct-entry-only **NO REAL VALUE** 2×/3× solo test gambling
|
||||
mechanics, with purchased-credit entry and crypto-match payouts off).
|
||||
Classification of any product under applicable law requires jurisdiction-specific advice.
|
||||
Their reviewed environment templates set `OPERATED_PRODUCT_PROFILE`, so preflight/startup rejects
|
||||
scope drift. Independent MIT deployments must leave that variable unset and identify their actual
|
||||
operator.
|
||||
Such Software (`apps@such.software`) operates only `play.wowne.ro` (Wownero mainnet free play plus
|
||||
pay-for-credits leaderboard/prestige, with no prize, payout, or cash-out, and not marketed as
|
||||
gambling) and `monerogue.app` (Monero stagenet direct entry only, **NO REAL VALUE** test coins, with
|
||||
single-player 2x/3x test gambling mechanics, purchased-credit entry disabled, and crypto match
|
||||
payouts off). Both profiles are defined in `src/config/operatedProductProfiles.js` and selected by
|
||||
`OPERATED_PRODUCT_PROFILE`; preflight and normal startup reject any network, identity, or
|
||||
economic-scope drift from the selected profile. Classification of any product under applicable law
|
||||
requires jurisdiction-specific advice.
|
||||
|
||||
MIT rights are subject to retaining the copyright and permission notice. The software is provided
|
||||
“AS IS”, without warranty, and the code/docs are not legal advice or compliance approval. Each
|
||||
third-party operator is solely responsible for its deployment, legal obligations, funds, players,
|
||||
claims, and support; Such Software neither endorses nor accepts responsibility for it.
|
||||
Independent MIT deployments leave `OPERATED_PRODUCT_PROFILE` unset and identify their actual
|
||||
operator. MIT rights are subject to retaining the copyright and permission notice. The software is
|
||||
provided "AS IS", without warranty, and the code and documentation are not legal advice or
|
||||
compliance approval. Each third-party operator is solely responsible for its deployment, legal
|
||||
obligations, funds, players, claims, and support. Such Software neither endorses nor accepts
|
||||
responsibility for it.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 22.x LTS or later
|
||||
- PostgreSQL 12+
|
||||
- Node.js 22.x LTS or later (`src/package.json` requires `node >=22`, `npm >=10`)
|
||||
- PostgreSQL 12 or later
|
||||
- Wownero or Monero wallet-rpc running and synced
|
||||
- A domain with SSL certificate (via reverse proxy)
|
||||
- A domain with a TLS certificate, terminated by a reverse proxy
|
||||
|
||||
The full environment template is `src/.env.example`. Operated profile templates are
|
||||
`src/.env.mainnet.example` and `src/.env.stagenet.example`; `src/.env.match.example` covers race
|
||||
mode. The five-line `.env.example` at the repository root is not a configuration reference.
|
||||
|
||||
---
|
||||
|
||||
@@ -39,16 +41,17 @@ claims, and support; Such Software neither endorses nor accepts responsibility f
|
||||
- [ ] Configure TLS via reverse proxy (Nginx/Caddy)
|
||||
- [ ] Set secure database credentials
|
||||
- [ ] Configure wallet-rpc with authentication
|
||||
- [ ] Set `ADMIN_API_KEY` for admin endpoints
|
||||
- [ ] Set `ADMIN_API_KEY` (production requires a non-placeholder secret of at least 32 characters)
|
||||
- [ ] Run `npm run preflight` with the final environment
|
||||
- [ ] Keep `PAYOUTS_ENABLED=false` on real-money prestige instances
|
||||
- [ ] Complete a stagenet payment and payout before enabling a payout instance
|
||||
- [ ] Firewall: allow Node 3000/3001 only from the reverse proxy; keep wallet/daemon RPC loopback-only
|
||||
- [ ] Protect `/admin.html` with basic auth
|
||||
- [ ] Firewall: allow Node 3000/3001 only from the reverse proxy; keep wallet and daemon RPC
|
||||
loopback-only
|
||||
- [ ] Protect the admin page (served at both `/admin` and `/admin.html`) with basic auth
|
||||
- [ ] Set up database backups (see [LOGS_AND_BACKUP.md](./LOGS_AND_BACKUP.md))
|
||||
- [ ] Configure log rotation
|
||||
- [ ] Run database migrations
|
||||
- [ ] If 3D is offered, provision the gitignored runtime GLBs and verify library/model requests
|
||||
- [ ] If 3D is offered, provision the gitignored runtime GLBs and verify library and model requests
|
||||
stay same-origin
|
||||
|
||||
---
|
||||
@@ -56,7 +59,7 @@ claims, and support; Such Software neither endorses nor accepts responsibility f
|
||||
## Create Dedicated User
|
||||
|
||||
Create an isolated, non-login service identity. Release directories and the `current` link remain
|
||||
root-owned; the service identity receives read/traverse access only and must never own its code:
|
||||
root-owned; the service identity receives read and traverse access only and never owns its code:
|
||||
|
||||
```bash
|
||||
sudo useradd --system --shell /usr/sbin/nologin --home-dir /var/www/wownerogue --no-create-home wownerogue
|
||||
@@ -74,16 +77,22 @@ sudo install -d -m 0750 -o wownerogue -g wownerogue /var/log/wownerogue
|
||||
|
||||
Build the runtime artifact from a clean, reviewed commit as described in
|
||||
[RELEASE_ARTIFACT.md](./RELEASE_ARTIFACT.md). The production host needs no Git checkout, deploy key,
|
||||
or write-capable source identity. The reviewed fleet
|
||||
`playbooks/wowngeon-stage-candidate.yml` role transfers exactly one tarball to a newly selected
|
||||
root-owned mode-0700 candidate inbox and compares its remote SHA-256 with the independently
|
||||
recorded literal digest. Follow the fleet Wowngeon runbook; the role is a one-shot custody boundary,
|
||||
not deployment.
|
||||
or write-capable source identity.
|
||||
|
||||
The executable extraction/activation boundary lives only in the separately reviewed fleet
|
||||
The artifact contains exactly `LICENSE`, `html`, and `src`, minus environment files, tests, the
|
||||
balance simulator, and the operator-only scripts under `src/scripts`. Documentation, service units,
|
||||
firewall and backup scripts, and root-level operator tooling are deliberately excluded: operational
|
||||
artifacts keep their own pinned provenance in the source and fleet repositories rather than becoming
|
||||
part of the application runtime because they share a Git commit.
|
||||
|
||||
The fleet `playbooks/wowngeon-stage-candidate.yml` role transfers exactly one tarball to a newly
|
||||
selected root-owned mode-0700 candidate inbox and compares its remote SHA-256 with the
|
||||
independently recorded literal digest. It is a one-shot custody boundary, not deployment.
|
||||
|
||||
The executable extraction and activation boundary lives only in the separately reviewed fleet
|
||||
repository as the default-closed `wowngeon_release_activate` role. This application checkout cannot
|
||||
attest which fleet revision is installed or authorize its execution. Follow the hash-pinned fleet
|
||||
runbook and literal receipt contract; do not manually extract beneath `releases/`, install
|
||||
runbook and literal receipt contract. Do not manually extract beneath `releases/`, install
|
||||
dependencies there, change `current`, restart an application, or run migrations merely because a
|
||||
blob has arrived.
|
||||
|
||||
@@ -103,11 +112,11 @@ node --version
|
||||
npm --version
|
||||
```
|
||||
|
||||
The reviewed fleet extraction/activation role installs exactly the locked production graph in its
|
||||
The fleet extraction and activation role installs exactly the locked production graph in its
|
||||
private, writable staging directory with
|
||||
`npm ci --omit=dev --ignore-scripts --no-audit --no-fund`, verifies it with
|
||||
`npm ls --omit=dev`, and seals the finished release root-owned and non-writable before it can be
|
||||
selected. Never make a release tree writable by `wownerogue`. Online advisory lookup is a separate
|
||||
selected. Never make a release tree writable by `wownerogue`. Online advisory lookup is a separate,
|
||||
explicitly authorized source-review operation; never run `npm audit fix` in staging or on a live
|
||||
release.
|
||||
|
||||
@@ -123,11 +132,11 @@ sudo chmod 0640 /etc/wownerogue/app.env
|
||||
```
|
||||
|
||||
Do not use recursive ownership changes on `/var/www/wownerogue`; they can hand the service process
|
||||
write access to immutable code or the rollback selector. The reviewed fleet role must verify
|
||||
every individual release's ownership and modes before activation.
|
||||
write access to immutable code or the rollback selector. The fleet activation role verifies every
|
||||
individual release's ownership and modes before activation.
|
||||
|
||||
**Database ownership** - the application currently runs its own migrations at startup, so its
|
||||
database role must own the database/schema (or otherwise retain DDL rights). Create it this way:
|
||||
**Database ownership.** The application runs its own migrations at startup, so its database role
|
||||
must own the database and schema, or otherwise retain DDL rights. Create it this way:
|
||||
|
||||
```sql
|
||||
-- Create role with minimal privileges
|
||||
@@ -136,34 +145,47 @@ CREATE DATABASE wownerogue OWNER wownerogue;
|
||||
```
|
||||
|
||||
Do not revoke schema `CREATE` from this role while application startup owns migration execution;
|
||||
that makes a fresh release fail partway through startup. A separate one-shot migrator role is the
|
||||
follow-up path if runtime DDL privileges need to be removed.
|
||||
that makes a fresh release fail partway through startup.
|
||||
|
||||
---
|
||||
|
||||
## Database Setup
|
||||
|
||||
### Run Migrations
|
||||
|
||||
Database migrations run automatically, in filename order, inside one transaction per file and are
|
||||
recorded in `schema_migrations`. Do not apply migration files directly with `psql`: bypassing the
|
||||
ledger makes startup attempt them again. Before a release, take a restricted PostgreSQL backup and
|
||||
test the complete migration set against a restored copy of each production database using the
|
||||
fail-closed [disposable clone migration gate](CLONE_MIGRATIONS.md).
|
||||
Migrations in `src/migrations` run automatically at startup in filename order. Each file is applied
|
||||
and recorded in the `schema_migrations` ledger inside one transaction, so a migration can never be
|
||||
applied but unrecorded. Do not apply migration files directly with `psql`: bypassing the ledger
|
||||
makes startup attempt them again.
|
||||
|
||||
The migration ledger does not prove that rows predating a `NOT VALID` constraint are clean. Run
|
||||
the read-only historical audit and the rollback-only native validation proof described in
|
||||
[`FINANCIAL_CONSTRAINT_VALIDATION.md`](FINANCIAL_CONSTRAINT_VALIDATION.md) for each restored
|
||||
database. The native `VALIDATE CONSTRAINT` gate deliberately refuses live database names.
|
||||
Before a release, take a restricted PostgreSQL backup and test the complete migration set against a
|
||||
restored copy of each production database using the fail-closed
|
||||
[disposable clone migration gate](CLONE_MIGRATIONS.md).
|
||||
|
||||
The migration ledger does not prove that rows predating a `NOT VALID` constraint are clean. Run the
|
||||
read-only historical audit and the rollback-only native validation proof described in
|
||||
[FINANCIAL_CONSTRAINT_VALIDATION.md](FINANCIAL_CONSTRAINT_VALIDATION.md) for each restored database.
|
||||
The native `VALIDATE CONSTRAINT` gate refuses live database names.
|
||||
|
||||
If the Wownero mainnet service will export accounting events, review and configure the durable
|
||||
[financial-event outbox](FINANCIAL_EVENT_EXPORT.md) before cutover. Monero stagenet export must
|
||||
remain unset: those no-value test events are marked ignored locally.
|
||||
[financial-event outbox](FINANCIAL_EVENT_EXPORT.md) before cutover. Monero stagenet export stays
|
||||
unset: those no-value test events are marked ignored locally.
|
||||
|
||||
Install the reviewed `wowngeon-db-backup.sh`, `.service`, and `.timer` through the hash-pinned fleet
|
||||
operations change—not from the runtime artifact—and create
|
||||
`/var/backups/wowngeon/daily` as `postgres:postgres` mode `0700`, then enable the timer. Each run
|
||||
writes custom-format dumps atomically, verifies their catalogs with `pg_restore --list`, and records
|
||||
a SHA-256 sidecar. Configure retention/remote replication according to the operator's recovery
|
||||
policy; the supplied job deliberately does not delete backups.
|
||||
### Backups
|
||||
|
||||
Install `wowngeon-db-backup.sh`, `.service`, and `.timer` through the hash-pinned fleet operations
|
||||
change rather than from the runtime artifact, create `/var/backups/wowngeon/daily` as
|
||||
`postgres:postgres` mode `0700`, then enable the timer.
|
||||
|
||||
Each run writes custom-format dumps atomically, verifies their catalogs with `pg_restore --list`,
|
||||
chmods them `0600`, and records a SHA-256 sidecar. The script refuses any backup root other than
|
||||
`/var/backups/wowngeon/daily` and refuses a missing root or a symlink.
|
||||
|
||||
Dumps and sidecars older than `BACKUP_RETENTION_DAYS` (default 14) are deleted, and interrupted
|
||||
temporary files older than one day are cleaned up. This bounds a same-disk directory so it cannot
|
||||
fill `/`; it is an operational convenience, not the retention archive. Long-horizon history belongs
|
||||
in a separate, independently encrypted offsite vault configured according to the operator's recovery
|
||||
policy.
|
||||
|
||||
### Database Reset (Development Only)
|
||||
|
||||
@@ -176,25 +198,30 @@ npm run db:create # Recreate schema
|
||||
|
||||
## systemd Service
|
||||
|
||||
Install reviewed application, wallet, backup, and firewall artifacts only through the hash-pinned
|
||||
fleet operations change; the runtime artifact intentionally has no service units. Application
|
||||
units read secrets from `/etc/wownerogue/app.env` or `/etc/monerogue/app.env` plus the narrow
|
||||
wallet-RPC environment, never from an immutable release directory. They run `npm run preflight`
|
||||
before every start and use `/var/www/<instance>/current/src` so rollback is an atomic symlink
|
||||
switch.
|
||||
Install application, wallet, backup, and firewall units only through the hash-pinned fleet
|
||||
operations change; the runtime artifact has no service units. Application units read secrets from
|
||||
`/etc/wownerogue/app.env` or `/etc/monerogue/app.env` plus the narrow wallet-RPC environment, never
|
||||
from an immutable release directory. They run `scripts/preflight.js` as `ExecStartPre` before every
|
||||
start and use `/var/www/<instance>/current/src` as the working directory, so rollback is an atomic
|
||||
symlink switch.
|
||||
|
||||
For the two Such Software services, do not replace this boundary with manual `install`, `systemctl`,
|
||||
or symlink commands. Follow the default-closed wallet candidate/promotion, candidate validation,
|
||||
or symlink commands. Follow the default-closed wallet candidate and promotion, candidate validation,
|
||||
and one-instance activation procedures in [DEPLOY_INSTANCES.md](DEPLOY_INSTANCES.md) and the
|
||||
hash-pinned `~/src/such-fleet/RUNBOOK-wowngeon.md`. Independent MIT operators must design and review
|
||||
their own equivalent control plane.
|
||||
hash-pinned `such-fleet/RUNBOOK-wowngeon.md`. Independent MIT operators design and review their own
|
||||
equivalent control plane.
|
||||
|
||||
The systemd hardening options:
|
||||
- `NoNewPrivileges` - prevents privilege escalation
|
||||
- `ProtectSystem=strict` - mounts filesystem read-only except allowed paths
|
||||
- `ProtectHome=yes` - hides /home, /root, /run/user
|
||||
- `PrivateTmp=yes` - isolates /tmp
|
||||
- `ReadWritePaths` - whitelists the wallet state directory in wallet-RPC units
|
||||
The reviewed units drop the ambient environment with a broad `UnsetEnvironment` list (loader,
|
||||
proxy, `PG*`, and `npm_config_*` variables among others) and apply these systemd hardening options:
|
||||
|
||||
- `NoNewPrivileges` prevents privilege escalation
|
||||
- `ProtectSystem=strict` mounts the filesystem read-only except allowed paths
|
||||
- `ProtectHome=yes` hides `/home`, `/root`, and `/run/user`
|
||||
- `PrivateTmp=yes` isolates `/tmp`
|
||||
- `CapabilityBoundingSet=` and `AmbientCapabilities=` remove all capabilities
|
||||
- `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6` and `SystemCallArchitectures=native` narrow the
|
||||
kernel surface
|
||||
- `ReadWritePaths` whitelists the wallet state directory in wallet-RPC units
|
||||
|
||||
---
|
||||
|
||||
@@ -205,19 +232,15 @@ If using Nginx Proxy Manager:
|
||||
1. **Add Proxy Host**
|
||||
- Domain Names: `yourdomain.com`
|
||||
- Scheme: `http`
|
||||
- Forward Hostname/IP: Your server's LAN IP (e.g., `192.168.1.100`)
|
||||
- Forward Hostname/IP: your server's LAN IP (for example `192.168.1.100`)
|
||||
- Forward Port: `3000`
|
||||
- Enable "Websockets Support" toggle
|
||||
|
||||
Record the proxy's fixed source address in `/etc/wowngeon/firewall.env` as
|
||||
`NPM_SOURCE_IPV4=<address>`. Direct access to 3000/3001 must be rejected; this also makes the
|
||||
single-hop `TRUST_PROXY_HOPS=1` setting safe.
|
||||
- Enable the "Websockets Support" toggle
|
||||
|
||||
2. **SSL Tab**
|
||||
- Request a new SSL certificate
|
||||
- Enable "Force SSL"
|
||||
|
||||
3. **Advanced Tab** - Paste this config for WebSocket support:
|
||||
3. **Advanced Tab**: paste this config for WebSocket support:
|
||||
|
||||
```nginx
|
||||
location /socket.io/ {
|
||||
@@ -232,17 +255,37 @@ location /socket.io/ {
|
||||
}
|
||||
```
|
||||
|
||||
Record the proxy's fixed source address in `/etc/wowngeon/firewall.env` as
|
||||
`NPM_SOURCE_IPV4=<address>`. `wowngeon-firewall.sh` rejects every non-loopback TCP connection to
|
||||
ports 3000 and 3001 that does not originate from that address. Because direct access is rejected,
|
||||
the single-hop `TRUST_PROXY_HOPS=1` setting is safe. The application clamps `TRUST_PROXY_HOPS` to
|
||||
the range 1 through 8.
|
||||
|
||||
---
|
||||
|
||||
## Browser Asset Origin
|
||||
|
||||
Three.js is an exact production dependency and is served from the application origin at
|
||||
`/vendor/three/<version>`, with the version embedded in the URL so an immutable cache cannot survive
|
||||
an upgrade to different bytes. `RENDERER_CDN_ENABLED` defaults to `false`, which keeps the CSP from
|
||||
granting third-party CDN scripts the same privileges as the game and payment UI. Setting it to
|
||||
`true` in production logs a warning and widens `script-src` and `connect-src` to jsDelivr.
|
||||
|
||||
Generated 3D avatar models under `html/assets/generated/` are gitignored and are not part of the
|
||||
release artifact. Provision them separately on any instance that offers the 3D renderer.
|
||||
|
||||
---
|
||||
|
||||
## Updating the Deployment
|
||||
|
||||
Run the full suite from the clean source commit, then build and verify the runtime artifact using
|
||||
[RELEASE_ARTIFACT.md](./RELEASE_ARTIFACT.md). Use the fleet blob-staging playbook, then proceed only
|
||||
through the reviewed, hash-pinned `wowngeon_release_activate` role after its clone, wallet,
|
||||
accounting, drain, predecessor, and receipt gates pass. That role—not an application service
|
||||
identity or a manual shell session—builds a new immutable
|
||||
`/var/www/<instance>/releases/<release-id>`, installs the locked graph, verifies the clone/preflight
|
||||
evidence, seals ownership, and atomically selects `current`.
|
||||
through the hash-pinned `wowngeon_release_activate` role after its clone, wallet, accounting, drain,
|
||||
predecessor, and receipt gates pass. That role, not an application service identity or a manual
|
||||
shell session, builds a new immutable `/var/www/<instance>/releases/<release-id>`, installs the
|
||||
locked graph, verifies the clone and preflight evidence, seals ownership, and atomically selects
|
||||
`current`.
|
||||
|
||||
Never run `git pull`, `npm install`, or `npm audit fix` inside the active release. Keep the previous
|
||||
release and database backup until the new version has passed public health, WebSocket,
|
||||
payment-intake, and (stagenet only) payout smoke tests.
|
||||
@@ -251,7 +294,7 @@ payment-intake, and (stagenet only) payout smoke tests.
|
||||
|
||||
## Multi-Instance Deployment
|
||||
|
||||
To run multiple instances (e.g., Wownero on port 3000 and Monero stagenet on port 3001):
|
||||
To run multiple instances, for example Wownero on port 3000 and Monero stagenet on port 3001:
|
||||
|
||||
### Directory Structure
|
||||
|
||||
@@ -275,68 +318,84 @@ To run multiple instances (e.g., Wownero on port 3000 and Monero stagenet on por
|
||||
| `PRIMARY_WALLET_ENDPOINT` | http://127.0.0.1:34570 | http://127.0.0.1:38083 |
|
||||
| `PRIMARY_RPC_ENDPOINT` | http://127.0.0.1:34568 | http://127.0.0.1:38081 |
|
||||
|
||||
### Daemon redundancy and graceful degradation
|
||||
### Separate systemd Service
|
||||
|
||||
Install the hash-pinned fleet copy of `monerogue.service`; it is absent from the runtime artifact.
|
||||
Do not hand-maintain a second, weaker unit. The reviewed template carries the same preflight,
|
||||
graceful-stop, immutable-release path, and systemd sandbox as the mainnet service.
|
||||
|
||||
---
|
||||
|
||||
## Daemon Redundancy and Graceful Degradation
|
||||
|
||||
`RPC_ENDPOINTS` takes any number of daemons, comma or whitespace separated, in **preference order**:
|
||||
|
||||
```
|
||||
RPC_ENDPOINTS=http://127.0.0.1:34568,http://10.42.1.40:34568,http://10.42.1.41:34568
|
||||
RPC_ENDPOINTS=http://127.0.0.1:34568,http://192.0.2.40:34568,http://192.0.2.41:34568
|
||||
```
|
||||
|
||||
`PRIMARY_RPC_ENDPOINT` / `FALLBACK_RPC_ENDPOINT` still work and are appended to the list. The list
|
||||
is deduplicated — note that the stock configuration sets primary and fallback to the *same* host, so
|
||||
by default there is exactly one node and no redundancy at all. Add at least one genuinely separate
|
||||
daemon before relying on failover.
|
||||
`PRIMARY_RPC_ENDPOINT` and `FALLBACK_RPC_ENDPOINT` remain supported and are appended to an explicit
|
||||
list. When no list is given, primary (or `http://127.0.0.1:34568`) and fallback form the list on
|
||||
their own. The result is deduplicated, so a stock configuration that points primary and fallback at
|
||||
the same host yields exactly one node and no redundancy. Add at least one genuinely separate daemon
|
||||
before relying on failover.
|
||||
|
||||
Behaviour:
|
||||
|
||||
- one logical RPC call tries **every** configured node before it fails;
|
||||
- each node must pass the same chain-identity check before it may answer, so failover can never
|
||||
- each node must pass the same chain-identity check before it may answer, so failover cannot
|
||||
silently serve a different chain's data;
|
||||
- after failing over, the preferred node is re-tested every `RPC_PREFERRED_RETRY_MS` (default 60s,
|
||||
minimum 1s), so recovery does not need a restart;
|
||||
- when **no** node answers, the service reports unhealthy and throws. `getBlockCountStrict()` —
|
||||
used by fairness and match seeding — never substitutes a cached height.
|
||||
- after failing over, the preferred node is re-tested every `RPC_PREFERRED_RETRY_MS` (default
|
||||
60000, minimum 1000), so recovery does not need a restart;
|
||||
- when **no** node answers, the service reports unhealthy and throws. `getBlockCountStrict()`, used
|
||||
by match fairness and seeding, never substitutes a cached height.
|
||||
|
||||
**Entries are refused while no node is reachable.** A run's lifetime is counted in blocks and its
|
||||
entry block comes from the last successful poll; admitting a game during an outage anchors it to an
|
||||
already-past block and it is killed as a timeout on the first recovered poll. The player is told
|
||||
`Can't reach a <CHAIN> node right now…` and nothing is charged. Credits are not consumed, because
|
||||
the gate runs before any spend.
|
||||
entry block comes from the last successful poll, so a game admitted during an outage is anchored to
|
||||
an already-past block and is killed as a timeout on the first recovered poll. The player receives a
|
||||
`chain_unavailable` event reading `Can't reach a <CHAIN> node right now, so entries are paused.
|
||||
Nothing was charged`, and nothing is charged: the gate runs before any spend, so credits are not
|
||||
consumed. The gate fires only when the chain health check explicitly reports false, so simulated
|
||||
blocks and runtimes without the probe are unaffected.
|
||||
|
||||
**The wallet is deliberately NOT failed over.** Two `wallet-rpc` processes serving one wallet file
|
||||
can build transactions from the same outputs and corrupt the wallet cache — a standby wallet is a
|
||||
custody decision for the operator, not something the app may do on its own. Daemons are read-only
|
||||
and interchangeable; wallets are neither. When the wallet is unreachable the server declines paid
|
||||
intake with `Payments are temporarily unavailable…`, and credits and free play keep working.
|
||||
|
||||
### Separate systemd Service
|
||||
|
||||
Install the hash-pinned fleet copy of `monerogue.service`; it is intentionally absent from the
|
||||
runtime artifact. Do not hand-maintain a second, weaker unit. The reviewed template contains the
|
||||
same preflight, graceful-stop, immutable-release path, and systemd sandbox as the mainnet service.
|
||||
**The wallet is deliberately not failed over.** Two `wallet-rpc` processes serving one wallet file
|
||||
can build transactions from the same outputs and corrupt the wallet cache, so a standby wallet is a
|
||||
custody decision for the operator rather than something the application may take on its own.
|
||||
Daemons are read-only and interchangeable; wallets are neither. When the wallet is unreachable the
|
||||
server declines paid intake with a `payment_unavailable` event reading `Payments are temporarily
|
||||
unavailable`, and credits and free play keep working.
|
||||
|
||||
---
|
||||
|
||||
## Wallet Output Management
|
||||
|
||||
Wownero (and Monero) lock change outputs for several blocks after spending. If the wallet has only one large output and multiple payouts fire in quick succession, the second payout will fail because the change output from the first is still locked.
|
||||
Wownero and Monero lock change outputs for several blocks after spending. If the wallet holds one
|
||||
large output and multiple payouts fire in quick succession, the second payout fails because the
|
||||
change output from the first is still locked.
|
||||
|
||||
**Solution**: Pre-split wallet outputs into many smaller ones. The game server includes a CLI tool for this:
|
||||
The remedy is to pre-split wallet outputs into many smaller ones. `scripts/splitOutputs.js` is an
|
||||
operator tool in the source repository, not part of the release artifact; run it from a source
|
||||
checkout whose `src/.env` points at the wallet RPC.
|
||||
|
||||
```bash
|
||||
# Preview what would happen (no transaction sent)
|
||||
node scripts/splitOutputs.js --amount 10 --count 30 --dry-run
|
||||
# Show the current output breakdown, including locked and unlocked status
|
||||
node scripts/splitOutputs.js --status
|
||||
|
||||
# Split into 30 outputs of 10 WOW each (requires ~300 WOW + fees)
|
||||
# Split into 30 outputs of 10 WOW each (requires 300+ WOW unlocked, plus fees)
|
||||
node scripts/splitOutputs.js --amount 10 --count 30
|
||||
```
|
||||
|
||||
This creates 30 independently-spendable outputs, allowing up to 30 concurrent payouts before any output locking becomes an issue.
|
||||
Defaults are 10 WOW and 20 outputs; `--endpoint` overrides `PRIMARY_WALLET_ENDPOINT`. Thirty
|
||||
independently spendable outputs allow up to thirty concurrent payouts before output locking becomes
|
||||
an issue.
|
||||
|
||||
**When to re-split**: As payouts go out, outputs get consumed and consolidated via change. Periodically check the wallet and re-split when the number of spendable outputs drops low. The server also batches payouts that happen within 5 seconds of each other into a single transaction to conserve outputs.
|
||||
**When to re-split.** Outputs are consumed and consolidated back into change as payouts go out.
|
||||
Check the wallet with `--status` periodically and re-split when the number of spendable outputs
|
||||
drops low. The server also debounces payouts by 5 seconds so wins landing close together are batched
|
||||
into one transfer, which conserves outputs. A slower interval sweep
|
||||
(`payouts.processing.batchInterval`, default 300 seconds) picks up anything left pending.
|
||||
|
||||
**Recommended setup**: After initial wallet funding, run the split script. For a game with typical traffic, 20-30 outputs of 10 WOW each provides good concurrency headroom.
|
||||
For typical traffic, 20 to 30 outputs of 10 WOW each provides good concurrency headroom.
|
||||
|
||||
---
|
||||
|
||||
@@ -349,22 +408,28 @@ systemctl status wownerogue
|
||||
journalctl -u wownerogue -f # Follow logs
|
||||
```
|
||||
|
||||
### Health Endpoint
|
||||
### Health Endpoints
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/health/live
|
||||
curl --fail http://localhost:3000/health/ready
|
||||
```
|
||||
|
||||
`/health/live` checks the process. `/health/ready` returns 503 until PostgreSQL, the chain daemon,
|
||||
and (for paid instances) wallet RPC are ready. Public probes do not expose wallet balances or RPC
|
||||
endpoints; those remain in the authenticated admin dashboard.
|
||||
`/health/live` reports process liveness only. `/health` returns the full public snapshot, and
|
||||
`/health/ready` returns the same body with HTTP 503 until PostgreSQL, the chain daemon, and (for
|
||||
paid instances) wallet RPC are ready. All three send `Cache-Control: no-store`.
|
||||
|
||||
Public probes omit wallet balances, RPC addresses, secrets, memory details, and abuse thresholds;
|
||||
those remain in the authenticated admin stats endpoints. An optional accounting sink is reported as
|
||||
sanitized informational data and never makes gameplay readiness fail.
|
||||
|
||||
### Admin Dashboard
|
||||
|
||||
Access `/admin.html` with your `ADMIN_API_KEY` for:
|
||||
The admin page is served at `/admin` and `/admin.html`. Admin API endpoints require an `X-Admin-Key`
|
||||
header matching `ADMIN_API_KEY`, and return 503 when that variable is unset. The dashboard covers:
|
||||
|
||||
- Wallet balance and connection status
|
||||
- Pending/failed payouts
|
||||
- Pending and failed payouts
|
||||
- Game statistics
|
||||
- User search
|
||||
|
||||
@@ -386,6 +451,10 @@ sudo -u wownerogue bash -c 'set -a; . /etc/wownerogue/app.env; set +a; npm run p
|
||||
psql -h localhost -U wownerogue -d wownerogue -c "SELECT 1;"
|
||||
```
|
||||
|
||||
Preflight validates operator configuration without connecting to PostgreSQL or either RPC. It
|
||||
catches missing secrets, ambiguous payout flags, simulated blocks in production, missing wallet or
|
||||
daemon endpoints, and operated-profile scope drift.
|
||||
|
||||
### Wallet RPC Issues
|
||||
|
||||
```bash
|
||||
@@ -403,4 +472,4 @@ If migrations fail, check the `schema_migrations` ledger:
|
||||
SELECT filename, applied_at FROM schema_migrations ORDER BY filename;
|
||||
```
|
||||
|
||||
Migrations are tracked by name to prevent duplicate execution.
|
||||
Migrations are tracked by filename to prevent duplicate execution.
|
||||
|
||||
+194
-192
@@ -1,117 +1,159 @@
|
||||
# Two-instance deployment runbook
|
||||
|
||||
Two independent instances of the same secured codebase, behind Nginx Proxy Manager (NPM):
|
||||
Two independent instances of the same codebase run behind Nginx Proxy Manager (NPM), each with its
|
||||
own service identity, database, environment file, wallet, and chain.
|
||||
|
||||
| Instance | Domain | Port | Network | Money | Payouts | Leaderboards |
|
||||
|----------|--------|------|---------|-------|---------|--------------|
|
||||
| **wownerogue** (existing) | play.wowne.ro | 3000 | Wownero **mainnet** | purchased service credits | **OFF, always** | Pleb + paid prestige |
|
||||
| **monerogue** (new) | monerogue.app | 3001 | Monero **stagenet only** | test coins — **NO REAL VALUE** | **ON for 2×/3× solo only (target; wallet-gated)** | Pleb + Hall of Champions |
|
||||
| Instance | Domain | Port | Chain | Entry | Payouts | Leaderboards |
|
||||
|----------|--------|------|-------|-------|---------|--------------|
|
||||
| `wownerogue` | play.wowne.ro | 3000 | Wownero mainnet | free play and purchased service credits | disabled | Pleb and paid prestige |
|
||||
| `monerogue` | monerogue.app | 3001 | Monero stagenet | free play and direct test-coin entry | enabled, direct solo only | Pleb and Hall of Champions |
|
||||
|
||||
Such Software (`apps@such.software`) operates only these two domains and these exact scopes.
|
||||
`play.wowne.ro` is pay-for-credits prestige with no prize/payout/cash-out and is not marketed as
|
||||
gambling; legal classification depends on applicable law. `monerogue.app` is a conspicuous
|
||||
no-real-value test gambling surface; never send mainnet XMR and never enable crypto-match payouts.
|
||||
Stagenet payouts are a technical test surface, not authorization to offer real-money wagering.
|
||||
`ALLOW_MAINNET_PAYOUTS=true` is only an engineering interlock; obtain jurisdiction-specific legal,
|
||||
age/access-control, responsible-play, and operational review before attaching redeemable value.
|
||||
NPM forwards both domains to these ports and terminates TLS, so this runbook contains no
|
||||
reverse-proxy configuration.
|
||||
|
||||
NPM already forwards both domains to these ports + handles TLS — no reverse-proxy work here.
|
||||
## Scope and legal boundary
|
||||
|
||||
> ⚠️ This is an operator runbook, not a record of actions already executed. Deploy only a reviewed,
|
||||
> tagged release after the full test suite and both preflights pass.
|
||||
Such Software operates only these two domains, and only within the scopes encoded in
|
||||
`src/config/operatedProductProfiles.js`.
|
||||
|
||||
---
|
||||
`play.wowne.ro` sells non-redeemable service credits for leaderboard and prestige play. It offers no
|
||||
prize, payout, or cash-out and is not offered or marketed as gambling. Classification depends on
|
||||
applicable law.
|
||||
|
||||
## Step 0 — Stage an immutable reviewed release
|
||||
`monerogue.app` is a no-real-value test surface. Its coins are Monero stagenet test data, not money.
|
||||
Never send mainnet XMR to it, and never enable crypto-match payouts on it. A stagenet payout path is
|
||||
a technical test surface, not authorization to offer real-money wagering.
|
||||
|
||||
Build and verify one runtime artifact using [RELEASE_ARTIFACT.md](./RELEASE_ARTIFACT.md), then
|
||||
transfer only that blob into a new root-owned mode-0700 candidate inbox. Compare the host's SHA-256
|
||||
with the independently recorded literal digest; the transferred sidecar is not an independent
|
||||
provenance source.
|
||||
`ALLOW_MAINNET_PAYOUTS=true` is an engineering interlock only. Attaching redeemable value requires
|
||||
jurisdiction-specific legal review, age and access controls, responsible-play measures, and an
|
||||
operational review that this document does not provide. This documentation is not legal advice.
|
||||
|
||||
Such Software production uses only the separate, default-closed fleet playbooks for inert blob
|
||||
staging, two-dump clone validation, wallet candidate preparation/promotion, and one-instance release
|
||||
activation. Each operation is bound to literal hashes and exact receipts; none infers a release,
|
||||
dump, wallet, predecessor, or instance. Do not substitute manual extraction under
|
||||
`/var/www/<instance>/releases`, dependency installation, service commands, or a hand-written
|
||||
`current` switch. Never make a release directory writable by either application service identity.
|
||||
Independent MIT deployments leave `OPERATED_PRODUCT_PROFILE` unset and are not constrained by any of
|
||||
the product decisions above.
|
||||
|
||||
---
|
||||
## Prerequisites
|
||||
|
||||
## Step 1 — Reconfigure MAINNET: free + purchased-credit prestige, NO payouts
|
||||
- A reviewed, tagged commit with the full test suite passing.
|
||||
- A release artifact built and verified per [RELEASE_ARTIFACT.md](./RELEASE_ARTIFACT.md).
|
||||
- A passing `npm run preflight` for each instance's environment file.
|
||||
- A pre-deploy database backup for each instance (see [LOGS_AND_BACKUP.md](./LOGS_AND_BACKUP.md)).
|
||||
|
||||
Install the reviewed source profile `src/.env.mainnet.example` as `/etc/wownerogue/app.env` (mode
|
||||
0640, `root:wownerogue`), replace every placeholder, and review these money gates. Environment
|
||||
examples are intentionally not present in the runtime artifact; provision this file through the
|
||||
hash-pinned fleet/source change, never from the application release directory.
|
||||
## Operated product profile contracts
|
||||
|
||||
When `OPERATED_PRODUCT_PROFILE` is set, `validateOperatedProductProfile()` runs during preflight and
|
||||
normal startup and rejects any network, identity, or economic-scope drift. Both profiles require:
|
||||
|
||||
| Setting | Required value |
|
||||
|---------|----------------|
|
||||
| `OPERATOR_NAME` | `Such Software` |
|
||||
| `OPERATOR_CONTACT_URL` | `mailto:apps@such.software` |
|
||||
| `OPERATOR_CONTACT_LABEL` | `apps@such.software` |
|
||||
| `HOSTED_BY` | the profile's exact `https://` public root |
|
||||
| `LEGAL_POLICY_VERSION`, `TERMS_EFFECTIVE_DATE` | the profile's exact values |
|
||||
| `PAYMENTS_ENABLED`, `FREE_PLAY_ENABLED`, `PAID_ACKNOWLEDGEMENT_REQUIRED` | `true` |
|
||||
| `SOLO_ENABLED`, `TAVERN_ENABLED`, `MATCH_ENABLED` | `true` |
|
||||
| `MATCH_CRYPTO_RACE_ENABLED`, `MATCH_PAYOUTS_ENABLED` | `false` |
|
||||
| `ALLOW_MAINNET_PAYOUTS` | `false` |
|
||||
| products | no standalone cosmetic products, and no `grants` key on a credit package |
|
||||
|
||||
Per profile:
|
||||
|
||||
| Setting | `such-play-wow-prestige` | `such-monerogue-stagenet` |
|
||||
|---------|--------------------------|---------------------------|
|
||||
| `CRYPTO_TYPE` / `MONERO_NETWORK` | `WOW` / `mainnet` | `XMR` / `stagenet` |
|
||||
| `GAME_MODE` | `PAID_CREDITS` | `PAID_SINGLE` |
|
||||
| `PAYMENT_MODES` | `credits` | `direct` |
|
||||
| `DIRECT_PAYMENT_ENABLED` | `false` | `true` |
|
||||
| `CREDITS_ENABLED` | `true` | `false` |
|
||||
| `PAYOUTS_ENABLED` | `false` | `true` |
|
||||
| `DIRECT_PAYOUTS_ENABLED` | `false` | `true` |
|
||||
| `CREDITS_PAYOUTS_ENABLED` | `false` | `false` |
|
||||
| `DIRECT_PAYOUT_ESCAPE` / `DIRECT_PAYOUT_TREASURE` | not applicable | exactly `2.0` / `3.0` |
|
||||
|
||||
The profile also controls what `/api/game-modes` publishes: `selectPublicPaidModeDescriptors()`
|
||||
emits only the profile's own paid mode, so a Wownero instance never advertises `PAID_SINGLE` and a
|
||||
Monerogue instance never advertises `PAID_CREDITS`.
|
||||
|
||||
## Step 0: Stage an immutable release
|
||||
|
||||
Build one runtime artifact, then transfer only that blob into a root-owned mode-0700 candidate
|
||||
inbox. Compare the host's SHA-256 against an independently recorded digest; the sidecar file that
|
||||
travelled with the blob is not an independent provenance source.
|
||||
|
||||
Blob staging, two-dump clone validation ([CLONE_MIGRATIONS.md](./CLONE_MIGRATIONS.md)), wallet
|
||||
candidate preparation and promotion, and per-instance release activation are performed by the
|
||||
separate, default-closed, hash-pinned fleet automation. Each operation is bound to literal hashes
|
||||
and exact receipts and infers no release, dump, wallet, predecessor, or instance. Manual extraction
|
||||
under `/var/www/<instance>/releases`, manual dependency installation, manual service commands, and a
|
||||
hand-written `current` symlink switch are not supported substitutes. A release directory is never
|
||||
writable by either application service identity.
|
||||
|
||||
The artifact contains only `LICENSE`, `html/**`, and the runtime subset of `src/**`. Environment
|
||||
examples, tests, documentation, the balance simulator, and root-level operator tooling and service
|
||||
units are excluded by construction, so none of them can be provisioned out of a release directory.
|
||||
|
||||
## Step 1: Configure the mainnet instance
|
||||
|
||||
Install `src/.env.mainnet.example` as `/etc/wownerogue/app.env`, mode 0640, owner `root:wownerogue`,
|
||||
and replace every `CHANGE_ME` value. Provision this file through the hash-pinned fleet or source
|
||||
change, never from the application release directory.
|
||||
|
||||
The decisive money gates for this instance:
|
||||
|
||||
```ini
|
||||
OPERATED_PRODUCT_PROFILE=such-play-wow-prestige
|
||||
OPERATOR_NAME=Such Software
|
||||
OPERATOR_CONTACT_URL=mailto:apps@such.software
|
||||
OPERATOR_CONTACT_LABEL=apps@such.software
|
||||
FREE_PLAY_ENABLED=true # offer free play as a choice (Pleb board)
|
||||
GAME_MODE=PAID_CREDITS # exact operated-profile legacy/socket identity
|
||||
PAYMENT_MODES=credits # only purchased-credit prestige is operated here
|
||||
DIRECT_PAYMENT_ENABLED=false # per-run payment is outside the operated scope
|
||||
GAME_MODE=PAID_CREDITS # legacy/socket mode identity for the credits product
|
||||
PAYMENT_MODES=credits
|
||||
FREE_PLAY_ENABLED=true # free play is a choice; free runs rank on the Pleb board
|
||||
CREDITS_ENABLED=true
|
||||
DIRECT_PAYMENT_ENABLED=false # per-run payment is outside this product scope
|
||||
PAYOUTS_ENABLED=false # no crypto leaves the house wallet
|
||||
DIRECT_PAYOUTS_ENABLED=false
|
||||
CREDITS_PAYOUTS_ENABLED=false
|
||||
DIRECT_REQUIRES_ADDRESS=false # no payout = no payout address needed
|
||||
DIRECT_REQUIRES_ADDRESS=false # no payout means no payout address is collected
|
||||
CREDITS_REQUIRES_ADDRESS=false
|
||||
TRUST_PROXY=true
|
||||
TRUST_PROXY_HOPS=1 # exactly one NPM hop
|
||||
```
|
||||
|
||||
Players buy non-redeemable credits for leaderboard/prestige play; free players use the Pleb board.
|
||||
This operated product offers no winnings, payouts, or cash-out and is not offered or marketed as
|
||||
gambling. Classification depends on applicable law; this documentation is not legal advice or a
|
||||
jurisdiction-specific compliance approval.
|
||||
Amounts on this instance are WOW atomic units (1 WOW = 1e11).
|
||||
|
||||
The following are post-activation verification commands, not authorization to restart the live
|
||||
service while the wallet/preflight and fleet activation gates remain open:
|
||||
Players buy non-redeemable credits for leaderboard and prestige play; free players use the Pleb
|
||||
board. The product offers no winnings, payouts, or cash-out.
|
||||
|
||||
The Wownero wallet, its key file, password, systemd unit, and RPC credentials are outside the scope
|
||||
of an application release. Changing any of them requires independently recoverable keyfile, password,
|
||||
and mnemonic custody, an off-host recovery proof, an isolated rehearsal, exact address and balance
|
||||
verification, and a separately reviewed maintenance and rollback plan. Root-level wallet templates in
|
||||
`scripts/deploy/` are operations source, not runtime files and not an instruction to install them.
|
||||
|
||||
After activation, verify:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart wownerogue.service
|
||||
journalctl -u wownerogue.service -n 50 --no-pager # verify: "Payment system: ENABLED", payouts disabled
|
||||
journalctl -u wownerogue.service -n 50 --no-pager # payment system enabled, payouts disabled
|
||||
curl -fsS localhost:3000/api/game-modes \
|
||||
| jq -e '.FREE.enabled == true and .PAID_CREDITS.enabled == true and (has("PAID_SINGLE") | not)'
|
||||
```
|
||||
|
||||
**MAINNET WALLET NO-GO:** this application release does not authorize changing, copying, stopping,
|
||||
or replacing the Wownero wallet, its key file, password, unit, or RPC authentication. Keep payouts
|
||||
off. A later wallet change requires independently recoverable keyfile/password and mnemonic custody,
|
||||
an off-host recovery proof, an isolated rehearsal, exact address/balance verification, and a
|
||||
separately reviewed maintenance/rollback plan. Root-level wallet templates are operations source,
|
||||
not files in the runtime artifact and not approval to install them.
|
||||
## Step 2: Prove the stagenet wallet boundary
|
||||
|
||||
---
|
||||
Wallet work is a custody operation separate from application release work. It runs from a clean,
|
||||
hash-pinned fleet revision against exactly one selected instance, and preserves every receipt.
|
||||
|
||||
## Step 2 — Prove and promote the MONERO STAGENET wallet boundary
|
||||
Candidate preparation proves that the fixed encrypted stagenet snapshot reopens the exact live
|
||||
address, has a spend key, and enforces strong Digest credentials, before it publishes inert
|
||||
dedicated-user material. Promotion stops only the selected legacy wallet, installs only the reviewed
|
||||
loopback-only unit, requires an unauthenticated `401` plus authenticated address, network, and
|
||||
balance probes, and rolls back automatically to the exact legacy unit on a failed gate.
|
||||
|
||||
Wallet work is a separate custody operation from application release work. Use only the reviewed
|
||||
`wowngeon_wallet_candidate` and `wowngeon_wallet_promote` roles from a clean, hash-pinned fleet
|
||||
revision, select exactly `monerogue`, and preserve every generic receipt. Candidate preparation
|
||||
must prove the fixed encrypted stagenet snapshot reopens the exact live address, has a spend key,
|
||||
and enforces the application's strong Digest credentials before it publishes inert dedicated-user
|
||||
material. Promotion must then stop only the selected legacy wallet, install only the reviewed
|
||||
loopback-only unit, require unauthenticated `401` plus authenticated address/network/balance probes,
|
||||
and automatically roll back to the exact legacy unit on a failed gate.
|
||||
Neither operation reads, exports, or proves a mnemonic. A passing keyfile and password proof is not
|
||||
independent seed custody and not an off-host recovery drill; record those separately until a
|
||||
human-controlled custody inventory and an isolated off-host restore establish them. That gap is not a
|
||||
reason to weaken a proof, print a secret, or apply the same workflow to the funded Wownero mainnet
|
||||
wallet.
|
||||
|
||||
The earlier non-mutating design record is
|
||||
[`README-monero-stagenet-parallel-candidate.md`](../scripts/deploy/README-monero-stagenet-parallel-candidate.md).
|
||||
It is not an installation procedure and its sample unit is not a promotion artifact.
|
||||
|
||||
Neither wallet role reads, exports, or proves a mnemonic. A passing keyfile/password proof is not
|
||||
independent seed custody or a full off-host recovery drill. Record those separately as unproven
|
||||
until a human-controlled custody inventory and isolated off-host restore establish them. This gap
|
||||
does not authorize weakening a proof, printing a secret, or applying either role to the funded
|
||||
Wownero mainnet wallet. Wownero wallet promotion and the corresponding full mainline application
|
||||
activation remain blocked pending that separate review. The dated
|
||||
[schema-neutral production state](PRODUCTION_STATE_2026-07-25.md) does not clear this gate.
|
||||
|
||||
## Step 3 — Create the monerogue identity, immutable layout, and DB
|
||||
## Step 3: Create the monerogue identity, layout, and database
|
||||
|
||||
```bash
|
||||
sudo useradd --system --home /var/www/monerogue --no-create-home --shell /usr/sbin/nologin monerogue
|
||||
@@ -123,146 +165,106 @@ sudo -u postgres createuser --pwprompt monerogue
|
||||
sudo -u postgres createdb --owner=monerogue --template=template0 monerogue
|
||||
```
|
||||
|
||||
The service identity owns its database role, not its application files. Do not clone source onto
|
||||
the production host or recursively `chown` `/var/www/monerogue` to the service user.
|
||||
The service identity owns its database role, not its application files. Do not clone source onto the
|
||||
production host and do not recursively `chown` `/var/www/monerogue` to the service user.
|
||||
|
||||
## Step 4 — The monerogue `.env`
|
||||
## Step 4: Configure the monerogue instance
|
||||
|
||||
This is the exact no-real-value stagenet test profile. Apply its non-secret policy keys through the
|
||||
fleet runtime-policy role; do not overwrite or log the protected environment. Keep
|
||||
`/etc/monerogue/app.env` as `root:monerogue` mode `0640`. Amounts are XMR atomic units
|
||||
(1 XMR = 1e12):
|
||||
Install `src/.env.stagenet.example` as `/etc/monerogue/app.env`, mode 0640, owner `root:monerogue`.
|
||||
Apply its non-secret policy keys through the fleet runtime-policy role; do not overwrite or log the
|
||||
protected environment. Amounts are XMR atomic units (1 XMR = 1e12).
|
||||
|
||||
Host-specific values to set:
|
||||
|
||||
```ini
|
||||
NODE_ENV=production
|
||||
PORT=3001
|
||||
TRUST_PROXY=true
|
||||
TRUST_PROXY_HOPS=1
|
||||
OPERATED_PRODUCT_PROFILE=such-monerogue-stagenet
|
||||
OPERATOR_NAME=Such Software
|
||||
OPERATOR_CONTACT_URL=mailto:apps@such.software
|
||||
OPERATOR_CONTACT_LABEL=apps@such.software
|
||||
LEGAL_POLICY_VERSION=2026-07-23-v2
|
||||
TERMS_EFFECTIVE_DATE=2026-07-23
|
||||
CRYPTO_TYPE=XMR
|
||||
MONERO_NETWORK=stagenet
|
||||
HOSTED_BY=https://monerogue.app
|
||||
DIFFICULTY_PRESET=casino
|
||||
|
||||
# NO REAL VALUE: test coins only. Never send mainnet XMR.
|
||||
|
||||
# --- payments + free play ---
|
||||
PAYMENTS_ENABLED=true
|
||||
GAME_MODE=PAID_SINGLE
|
||||
PAYMENT_MODES=direct
|
||||
FREE_PLAY_ENABLED=true # free and direct solo boards available
|
||||
|
||||
# --- direct (pay per game) WITH payouts (fake stagenet money) ---
|
||||
DIRECT_PAYMENT_ENABLED=true
|
||||
DIRECT_GAME_PRICE=10000000000 # 0.01 XMR
|
||||
DIRECT_REQUIRES_ADDRESS=true
|
||||
DIRECT_PAYOUTS_ENABLED=true
|
||||
DIRECT_PAYOUT_ESCAPE=2.0
|
||||
DIRECT_PAYOUT_TREASURE=3.0
|
||||
|
||||
# --- purchased credits are outside the operated Monerogue profile ---
|
||||
CREDITS_ENABLED=false
|
||||
CREDITS_PER_GAME=1
|
||||
CREDITS_REQUIRES_ADDRESS=false
|
||||
CREDITS_PAYOUTS_ENABLED=false
|
||||
# Inert compatibility values; no credit purchase or credit payout path may expose them.
|
||||
CREDITS_PAYOUT_BASE=10000000000
|
||||
CREDITS_PAYOUT_ESCAPE=2.0
|
||||
CREDITS_PAYOUT_TREASURE=3.0
|
||||
CREDITS_PACKAGES=[{"id":"small","credits":10,"price":"90000000000","bonus":0},{"id":"medium","credits":20,"price":"170000000000","bonus":2}]
|
||||
ALLOW_MIXED_MODE=false
|
||||
PREFER_CREDITS_FIRST=false
|
||||
|
||||
# --- payouts --- (verify the unlocked stagenet bankroll before every rollout)
|
||||
PAYOUTS_ENABLED=true
|
||||
ALLOW_MAINNET_PAYOUTS=false
|
||||
PAYOUT_MIN_AMOUNT=1000000000 # 0.001 XMR
|
||||
PAYOUT_MAX_PER_GAME=50000000000 # 0.05 XMR safety cap (real max is 3x*0.01 = 0.03)
|
||||
PAYOUT_BATCH_INTERVAL=300
|
||||
PAYOUT_MAX_RETRIES=3
|
||||
BALANCE_WARN=100000000000 # warn below 0.10 XMR unlocked
|
||||
BALANCE_CRITICAL=50000000000 # stop accepting new payout liability below 0.05 XMR
|
||||
# Tip: lower DIRECT_GAME_PRICE to 0.001 XMR (1000000000) for many more demo plays per the
|
||||
# small bankroll; refill from a stagenet faucet as needed.
|
||||
|
||||
# --- wallet RPC (monero stagenet) ---
|
||||
PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:38083
|
||||
|
||||
# --- daemon RPC (monerod stagenet) ---
|
||||
PRIMARY_RPC_ENDPOINT=http://127.0.0.1:38081
|
||||
PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:38083 # monero-wallet-rpc, stagenet
|
||||
PRIMARY_RPC_ENDPOINT=http://127.0.0.1:38081 # monerod, stagenet
|
||||
FALLBACK_RPC_ENDPOINT=http://127.0.0.1:38081
|
||||
RPC_POLL_INTERVAL=2000
|
||||
|
||||
# --- database ---
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=monerogue
|
||||
DB_USER=monerogue
|
||||
DB_PASSWORD=CHANGE_ME
|
||||
|
||||
# --- admin ---
|
||||
ADMIN_API_KEY=GENERATE_WITH_openssl_rand_hex_32
|
||||
|
||||
# --- smirk: disable for monero stagenet (Smirk doesn't support stagenet) ---
|
||||
SMIRK_ENABLED=false
|
||||
|
||||
# --- PvP financial boundary: free/prestige rulesets may evolve, crypto payouts may not ---
|
||||
MATCH_CRYPTO_RACE_ENABLED=false
|
||||
MATCH_PAYOUTS_ENABLED=false
|
||||
SMIRK_ENABLED=false # Smirk does not support stagenet
|
||||
```
|
||||
|
||||
The application unit separately requires `/etc/monerogue/wallet-rpc.env`, owned
|
||||
`root:monerogue` mode `0640`, containing only `WALLET_RPC_USER` and `WALLET_RPC_PASSWORD`. Provision
|
||||
it only through the reviewed wallet-candidate workflow. Never place either
|
||||
password in a command argument, unit `ExecStart`, shell history, or this runbook.
|
||||
Economy values, all in stagenet test coins with no real value:
|
||||
|
||||
Production startup proves that wallet RPC actually enforces Digest authentication: an intentional
|
||||
unauthenticated, read-only probe must receive a usable `401` Digest challenge before the
|
||||
authenticated identity probe is accepted. Merely setting application credentials is insufficient;
|
||||
a wallet RPC that still accepts the unauthenticated request makes preflight/readiness fail closed.
|
||||
```ini
|
||||
DIRECT_GAME_PRICE=10000000000 # 0.01 XMR per run
|
||||
DIRECT_PAYOUT_ESCAPE=2.0
|
||||
DIRECT_PAYOUT_TREASURE=3.0
|
||||
PAYOUT_MIN_AMOUNT=1000000000 # 0.001 XMR
|
||||
PAYOUT_MAX_PER_GAME=50000000000 # 0.05 XMR cap; the reachable maximum is 3 x 0.01 = 0.03
|
||||
PAYOUT_BATCH_INTERVAL=300
|
||||
PAYOUT_MAX_RETRIES=3
|
||||
BALANCE_WARN=100000000000 # warn below 0.10 XMR unlocked
|
||||
BALANCE_CRITICAL=50000000000 # stop accepting new payout liability below 0.05 XMR
|
||||
DIFFICULTY_PRESET=casino
|
||||
```
|
||||
|
||||
Verify the unlocked stagenet bankroll before every rollout. Lowering `DIRECT_GAME_PRICE` to
|
||||
`1000000000` (0.001 XMR) yields many more demo runs per faucet refill.
|
||||
|
||||
`CREDITS_PAYOUT_BASE`, `CREDITS_PAYOUT_ESCAPE`, `CREDITS_PAYOUT_TREASURE`, and `CREDITS_PACKAGES`
|
||||
remain in the file as inert compatibility values. `CREDITS_ENABLED=false` and
|
||||
`CREDITS_PAYOUTS_ENABLED=false` keep every credit purchase and credit payout path fail-closed, so
|
||||
nothing exposes them.
|
||||
|
||||
The stagenet profile leaves `FINANCIAL_EVENT_SINK_URL` and `FINANCIAL_EVENT_SINK_TOKEN` blank.
|
||||
Startup rejects a configured financial-event sink on a test network, because no-real-value events
|
||||
must never reach a real accounting sink.
|
||||
|
||||
The service unit additionally requires `/etc/monerogue/wallet-rpc.env`, owner `root:monerogue`, mode
|
||||
0640, containing only `WALLET_RPC_USER` and `WALLET_RPC_PASSWORD`. Provision it only through the
|
||||
wallet-candidate workflow. Neither password belongs in a command argument, a unit `ExecStart`, shell
|
||||
history, or this document.
|
||||
|
||||
Startup proves that wallet RPC actually enforces Digest authentication: a deliberate unauthenticated
|
||||
`get_version` probe must receive a usable `401` Digest challenge before the authenticated identity
|
||||
probe is accepted. Setting application credentials is not sufficient; a wallet RPC that still answers
|
||||
the unauthenticated request fails readiness closed with `WALLET_RPC_AUTH_NOT_ENFORCED`.
|
||||
|
||||
```bash
|
||||
sudo chmod 0640 /etc/monerogue/app.env
|
||||
sudo chown root:monerogue /etc/monerogue/app.env
|
||||
cd /var/www/monerogue/releases/<reviewed-release-id>/src
|
||||
cd /var/www/monerogue/releases/<release-id>/src
|
||||
sudo -u monerogue bash -c 'set -a; . /etc/monerogue/app.env; . /etc/monerogue/wallet-rpc.env; set +a; npm run preflight'
|
||||
```
|
||||
|
||||
## Step 5 — Install + start the monerogue instance
|
||||
## Step 5: Activate the instance and close the network
|
||||
|
||||
The runtime artifact contains no service units. Promote the wallet and activate `monerogue` only
|
||||
through the separately reviewed, hash-pinned `~/src/such-fleet` roles after the recovery,
|
||||
clone-validation, protected-environment, wallet, drain, and financial-audit gates pass. Do not copy
|
||||
a unit from the application release.
|
||||
The runtime artifact contains no service units. Wallet promotion and instance activation run through
|
||||
the hash-pinned fleet roles after the recovery, clone-validation, protected-environment, wallet,
|
||||
drain, and financial-audit gates pass. Do not copy a unit out of an application release.
|
||||
|
||||
Install the hash-pinned fleet copies of `wowngeon-firewall.sh` and
|
||||
`wowngeon-firewall.service`, then set the fixed proxy source as `NPM_SOURCE_IPV4=<address>` in
|
||||
`/etc/wowngeon/firewall.env`. Verify direct access to ports 3000/3001 is rejected while both
|
||||
domains, websocket upgrades, and local readiness probes still work.
|
||||
Install the hash-pinned copies of `scripts/deploy/wowngeon-firewall.sh` (mode 0755 at
|
||||
`/usr/local/sbin/wowngeon-firewall`) and `scripts/deploy/wowngeon-firewall.service`, then set the
|
||||
fixed proxy address as `NPM_SOURCE_IPV4=<address>` in `/etc/wowngeon/firewall.env`. The script is
|
||||
idempotent: it rejects any non-loopback traffic to ports 3000 and 3001 that does not come from that
|
||||
address, and rejects all non-loopback traffic to the wallet and daemon JSON-RPC ports 34568, 34570,
|
||||
38081, and 38083. Peer-to-peer ports are deliberately untouched.
|
||||
|
||||
## Step 6 — Verify end to end
|
||||
Confirm afterwards that direct access to ports 3000 and 3001 is refused while both domains, their
|
||||
websocket upgrades, and local readiness probes still work.
|
||||
|
||||
- `monerogue.app` loads; `play.wowne.ro` still healthy.
|
||||
- Each public modes document contains only its operated paid solo product:
|
||||
Wownero has `PAID_CREDITS` and no `PAID_SINGLE`; Monerogue has `PAID_SINGLE` and no
|
||||
`PAID_CREDITS`.
|
||||
- After wallet promotion and the separately approved bounded canary: buy credits / pay entry with a
|
||||
stagenet wallet → game starts → qualifying solo outcome → 2×/3× test-coin payout arrives.
|
||||
- After the corresponding gates clear, both boards populate: free game → Pleb, paid game → Hall of
|
||||
Champions, on each instance.
|
||||
- Mainnet, only after its separate recovery gate and release: confirm purchased-credit play never
|
||||
creates a payout or cash-out path.
|
||||
## Step 6: Verify end to end
|
||||
|
||||
---
|
||||
- `monerogue.app` loads and `play.wowne.ro` stays healthy.
|
||||
- Each public modes document contains only its own operated paid product: Wownero exposes
|
||||
`PAID_CREDITS` and no `PAID_SINGLE`; Monerogue exposes `PAID_SINGLE` and no `PAID_CREDITS`.
|
||||
- On Monerogue, run the bounded canary described in
|
||||
[STAGENET_FINANCIAL_CANARY.md](./STAGENET_FINANCIAL_CANARY.md): pay entry from a stagenet wallet,
|
||||
the game starts, a qualifying solo outcome pays 2x or 3x in test coins.
|
||||
- Both boards populate on each instance: a free game ranks on Pleb, a paid game on the Hall of
|
||||
Champions.
|
||||
- On mainnet, confirm that purchased-credit play creates no payout or cash-out path.
|
||||
|
||||
## Rollback
|
||||
|
||||
- Stop only the affected instance, atomically repoint `/var/www/<instance>/current` to the previous
|
||||
immutable release, and restart it. Keep the pre-deploy database backup for an operator-reviewed
|
||||
restore if a data rollback is required.
|
||||
- Never overwrite either legacy dirty checkout; preserve it until the new release has passed its soak.
|
||||
- Stop only the affected instance, atomically repoint `/var/www/<instance>/current` at the previous
|
||||
immutable release, and restart it.
|
||||
- Keep the pre-deploy database backup for an operator-reviewed restore if a data rollback is needed.
|
||||
The backup timer prunes dumps older than `BACKUP_RETENTION_DAYS` (default 14), so preserve a dump
|
||||
you may still need outside the pruned directory.
|
||||
- Preserve the previous release directory until the new one has passed its soak.
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
# Historical financial constraint release gate
|
||||
|
||||
Several migrations add `CHECK ... NOT VALID`. PostgreSQL enforces those constraints for new and
|
||||
changed rows, but it does not scan rows that existed before the constraint was added. A migration
|
||||
even a current migration ledger therefore does not, by itself, prove that all historical financial rows obey
|
||||
Several migrations add `CHECK ... NOT VALID`. PostgreSQL enforces those constraints for every new and
|
||||
changed row, but it does not scan rows that already existed when the constraint was added. A complete
|
||||
`schema_migrations` ledger therefore does not, by itself, prove that historical financial rows obey
|
||||
the current constraints.
|
||||
|
||||
Two repository-owned gates close that evidence gap without changing a live database.
|
||||
Two repository-owned gates close that evidence gap without changing a live database. Both cover the
|
||||
same 18 constraints across `payments`, `payouts`, `games`, `matches`, `match_queue_entries`,
|
||||
`race_entry_lots`, and `payment_refunds`.
|
||||
|
||||
## 1. Read-only historical audit
|
||||
|
||||
`scripts/deploy/financial-constraint-audit.sql` checks the exact connected database name, verifies
|
||||
the complete 18-constraint catalog inventory, and reports a named count for every historical
|
||||
predicate. It uses one repeatable-read, read-only transaction and rolls it back. Connection and
|
||||
credential handling remain operator-specific; do not put a database password in command history.
|
||||
the complete 18-constraint catalog inventory, and reports a named row count for every historical
|
||||
predicate. It runs in one repeatable-read, read-only transaction, takes no explicit table lock, and
|
||||
rolls back. Connection and credential handling stay operator-specific; keep database passwords out of
|
||||
command history.
|
||||
|
||||
```bash
|
||||
psql -X -v ON_ERROR_STOP=1 \
|
||||
@@ -20,27 +23,35 @@ psql -X -v ON_ERROR_STOP=1 \
|
||||
--file=scripts/deploy/financial-constraint-audit.sql
|
||||
```
|
||||
|
||||
Exit status `0` means all required constraints exist and every old row satisfies them. Any missing
|
||||
constraint or nonzero category exits `3`. Reconcile violations with a separately reviewed,
|
||||
backup-backed repair; this audit intentionally has no repair mode.
|
||||
Exit codes:
|
||||
|
||||
This check may be run read-only against production and should also run against every restored
|
||||
release candidate database. It reports how many constraints remain catalog-marked unvalidated;
|
||||
that count is informational because the second gate proves PostgreSQL's validator itself.
|
||||
| Code | Meaning |
|
||||
| --- | --- |
|
||||
| `0` | Every required constraint exists and every historical row satisfies it. |
|
||||
| `2` | `expected_database` was not supplied, or the connected database does not match it exactly. |
|
||||
| `3` | A required constraint is missing, or at least one predicate has a nonzero violation count. |
|
||||
|
||||
The violation predicates mirror PostgreSQL `CHECK` semantics: only `FALSE` counts as a violation,
|
||||
since `CHECK` accepts `NULL`. Reconcile any violation with a separately reviewed, backup-backed
|
||||
repair. The audit has no repair mode by design.
|
||||
|
||||
This check is safe against production and should also run against every restored release candidate
|
||||
database. It reports how many of the constraints are still catalog-marked unvalidated; that count is
|
||||
informational, because the second gate exercises PostgreSQL's own validator.
|
||||
|
||||
## 2. Explicit validation on a disposable restore
|
||||
|
||||
`scripts/deploy/financial-constraint-validate-restore.sql` includes the read-only audit, then issues
|
||||
an explicit `ALTER TABLE ... VALIDATE CONSTRAINT` for all 18 constraints inside a transaction. It
|
||||
checks `pg_constraint.convalidated` and rolls the entire transaction back, so no catalog flag is
|
||||
durably changed.
|
||||
confirms `pg_constraint.convalidated` for each one and rolls the whole transaction back, so no
|
||||
catalog flag changes durably.
|
||||
|
||||
`VALIDATE CONSTRAINT` still scans tables, takes locks, and transiently updates PostgreSQL catalogs.
|
||||
For that reason this script fails before `BEGIN` unless all three conditions hold:
|
||||
The script therefore refuses to reach `BEGIN` unless all three conditions hold:
|
||||
|
||||
- the connected name exactly equals `expected_database`;
|
||||
- the database name contains `restore` or `scratch`;
|
||||
- the exact disposable-restore confirmation is supplied.
|
||||
- the connected database name exactly equals `expected_database`;
|
||||
- that name contains `restore` or `scratch`;
|
||||
- the exact disposable-restore confirmation string is supplied.
|
||||
|
||||
Run it only against a freshly restored, isolated database with no application traffic:
|
||||
|
||||
@@ -51,19 +62,26 @@ psql -X -v ON_ERROR_STOP=1 \
|
||||
--file=scripts/deploy/financial-constraint-validate-restore.sql
|
||||
```
|
||||
|
||||
A pass proves both the explicit historical predicates and PostgreSQL's native validation path on
|
||||
that restored data. It does not mark live constraints validated. A later durable live
|
||||
`VALIDATE CONSTRAINT` operation requires its own reviewed maintenance plan, backup/restore proof,
|
||||
lock budget, and rollback decision; do not fold that mutation into application startup.
|
||||
Both scripts set `statement_timeout` to 10 minutes and `lock_timeout` to 5 seconds for the duration
|
||||
of their transaction.
|
||||
|
||||
A pass proves both the explicit historical predicates and PostgreSQL's native validation path on that
|
||||
restored data. It does not mark live constraints validated. Making a live `VALIDATE CONSTRAINT`
|
||||
durable is a separate operation that needs its own reviewed maintenance plan, backup and restore
|
||||
proof, lock budget, and rollback decision. Do not fold that mutation into application startup.
|
||||
|
||||
## Static coverage
|
||||
|
||||
The focused test inventories every migration occurrence of `NOT VALID` and fails if either gate
|
||||
omits a constraint:
|
||||
A focused test inventories every migration occurrence of `NOT VALID` and fails if either gate omits a
|
||||
constraint or if the inventory size drifts from 18:
|
||||
|
||||
```bash
|
||||
npm --prefix src test -- --runTestsByPath ../test/financialConstraintValidation.test.js
|
||||
```
|
||||
|
||||
Adding another `NOT VALID` constraint requires adding its exact historical predicate, its explicit
|
||||
restore validation statement, and its table/name entry to both gates.
|
||||
The test also asserts that the audit script contains no `ALTER TABLE` and no write statement, so the
|
||||
read-only gate cannot silently gain a mutation.
|
||||
|
||||
Adding another `NOT VALID` constraint requires three matching updates: its exact historical predicate
|
||||
and inventory entry in the audit script, its `VALIDATE CONSTRAINT` statement and inventory entry in
|
||||
the restore script, and its table and name in the test's constraint list.
|
||||
|
||||
+137
-27
@@ -1,26 +1,106 @@
|
||||
# Financial-event export
|
||||
# Financial event export
|
||||
|
||||
Migrations 041 and 042 create a durable outbox for confirmed customer payments and final refund
|
||||
states. Migration 042 stores an immutable source snapshot before delivery; the exporter never
|
||||
reconstructs an event from later-mutated payment rows. Payloads contain transaction classification,
|
||||
atomic amount, asset/network, catalog/provider identifiers, and receipt count, but no player ID,
|
||||
wallet address, session token, IP address, email address, or authentication material.
|
||||
Migrations `041_financial_event_outbox.sql` and `042_immutable_financial_event_snapshots.sql` create
|
||||
`financial_event_outbox`, a durable outbox for confirmed customer payments and final refund states.
|
||||
Database triggers append an immutable source snapshot inside the payment or refund transaction, so a
|
||||
slow or unreachable accounting receiver can never hold a money transaction open.
|
||||
`src/services/financialEventExporter.js` performs delivery asynchronously and never reconstructs an
|
||||
event from later-mutated payment rows.
|
||||
|
||||
Delivery is **at least once**. Every request sets `Idempotency-Key` to the immutable financial event
|
||||
ID. The receiving system must durably deduplicate that key before acknowledging with a 2xx response.
|
||||
A timeout or lost acknowledgement can otherwise produce a repeat request. The application marks a
|
||||
row delivered only after a 2xx response; bounded retries use a durable lease and exponential delay,
|
||||
then move an exhausted row to `dead_letter` for operator review.
|
||||
## Event sources
|
||||
|
||||
The public health response exposes only aggregate exporter state: enabled/running flags, backlog,
|
||||
dead-letter and ignored counts, and cumulative delivery counters. It exposes no sink URL,
|
||||
token, event ID, error text, payload, or source identity. Accounting export is informational and does
|
||||
not independently make gameplay readiness fail, so production monitoring must alert on backlog age,
|
||||
dead letters, and the exporter process logs/metrics needed to detect stale delivery.
|
||||
| Event kind | Trigger source | Fires when |
|
||||
| --- | --- | --- |
|
||||
| `payment.confirmed` | `payments` | `status` becomes `confirmed` and `received_amount > 0` |
|
||||
| `payment.refund` | `payment_refunds` | `status` reaches `recorded` (bookkeeping reversal) or `completed` (wallet refund) |
|
||||
|
||||
## Configuration contract
|
||||
Refunds in `requested`, `processing`, or `needs_review` are never accounting events. Confirmed
|
||||
payments with `received_amount = 0` lack economic evidence and are never turned into revenue.
|
||||
Uniqueness on `(event_kind, source_type, source_id)` and on `event_id` means one source produces at
|
||||
most one event; the foreign key to `payments` is `ON DELETE RESTRICT`.
|
||||
|
||||
Configure both of these only in the protected Wownero mainnet runtime environment:
|
||||
## Source snapshot
|
||||
|
||||
`payload_snapshot` uses schema `financial-event-source/v1`. A CHECK constraint restricts it to
|
||||
exactly this key set: `schema`, `event_id`, `event_kind`, `source_type`, `source_id`,
|
||||
`aggregate_id`, `occurred_at`, `atomic_amount`, `payment_type`, `product_id`, `provider_id`,
|
||||
`receipt_count`, `refund_status`. The constraint also pins `event_id`, `event_kind`, `source_type`,
|
||||
`source_id`, and `aggregate_id` to the matching columns.
|
||||
|
||||
The snapshot carries no player ID, wallet address, invoice destination, transaction hash, provider
|
||||
invoice ID, session token, IP address, email address, or authentication material.
|
||||
|
||||
## Delivery document
|
||||
|
||||
On the first delivery attempt the worker builds `delivery_payload` from the snapshot plus the
|
||||
deployment's chain profile (`src/chain/chainProfile.js`) and writes it once. Schema is
|
||||
`financial-event/v1`:
|
||||
|
||||
- `id`: the immutable event ID, also used as the idempotency key.
|
||||
- `occurred_at`: ISO-8601 timestamp derived from the snapshot.
|
||||
- `producer` / `product`: `wowngeon`.
|
||||
- `activity`: `customer-payment`, `customer-refund` (completed wallet refund), or
|
||||
`customer-payment-reversal` (recorded bookkeeping reversal).
|
||||
- `account_refs` and each leg's `account_ref`: `FINANCIAL_EVENT_ACCOUNT_REF`.
|
||||
- `provenance`: one entry of kind `wowngeon-payment` or `wowngeon-refund` referencing
|
||||
`payment-<id>` or `payment-refund-<id>`.
|
||||
- `legs`: one leg with the chain profile's `asset` symbol, decimal `quantity`, `direction` `in` for
|
||||
payments and `out` for refunds, and a `network` identifier.
|
||||
- `attributes`: `valuation_required`, `atomic_amount`, `atomic_decimals`, `source_type`,
|
||||
`source_id`, `aggregate_id`, plus `refund_status` for refunds or
|
||||
`payment_type` / `product_id` / `provider_id` / `receipt_count` for payments.
|
||||
|
||||
The leg's `usd_value` is `"0"` with empty `price_source` and `price_confidence`, and `memo` plus
|
||||
`attributes.valuation_required` state that the receiving system must apply USD valuation before
|
||||
confirming the entry. The exporter deliberately performs no pricing.
|
||||
|
||||
The `network` identifier is `wownero:mainnet` when `CRYPTO_TYPE` resolves to the WOW profile, and
|
||||
`monero:<MONERO_NETWORK>` otherwise. An unrecognised `CRYPTO_TYPE` resolves to the XMR-shaped
|
||||
default profile (12 decimals, 120000 ms mean block time).
|
||||
|
||||
Once written, the delivery document is immutable and every retry reuses it byte for byte.
|
||||
|
||||
## Delivery contract
|
||||
|
||||
Delivery is **at least once**. Each attempt is a `POST` with a bearer `authorization` header,
|
||||
`content-type: application/json`, `idempotency-key` set to the immutable event ID, manual redirect
|
||||
handling, and a 10-second timeout. The receiving system must durably deduplicate that key and
|
||||
durably record the event before answering 2xx. A timeout or lost acknowledgement otherwise produces
|
||||
a repeat request. The application marks a row `delivered` only after a 2xx response.
|
||||
|
||||
## Row lifecycle
|
||||
|
||||
`pending` → `in_flight` → `delivered`, with `dead_letter` and `ignored` as the other terminal
|
||||
states.
|
||||
|
||||
- A run first moves rows whose `attempts` already reached `FINANCIAL_EVENT_MAX_ATTEMPTS` to
|
||||
`dead_letter`.
|
||||
- Claiming selects the oldest due row with `FOR UPDATE SKIP LOCKED`, increments `attempts`, and
|
||||
takes a five-minute lease. An expired lease makes the row claimable again, so a crashed worker
|
||||
never strands an event.
|
||||
- A failed attempt returns the row to `pending` with backoff of 30 seconds doubled per attempt and
|
||||
capped at one hour, recording the sanitised error in `last_error`.
|
||||
- An attempt that exhausts `FINANCIAL_EVENT_MAX_ATTEMPTS` moves the row to `dead_letter` for
|
||||
operator review.
|
||||
- Each run processes at most `FINANCIAL_EVENT_BATCH_SIZE` rows.
|
||||
|
||||
### Immutability
|
||||
|
||||
A `BEFORE UPDATE` trigger rejects any change to `event_kind`, `aggregate_id`, `source_type`,
|
||||
`source_id`, `event_id`, or `payload_snapshot`. `delivery_payload` has exactly one legal transition,
|
||||
`NULL` to its final value, and only while the row is `in_flight`. A row cannot reach `delivered`
|
||||
without a delivery document.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Default | Notes |
|
||||
| --- | --- | --- |
|
||||
| `FINANCIAL_EVENT_SINK_URL` | unset | Enables export together with the token |
|
||||
| `FINANCIAL_EVENT_SINK_TOKEN` | unset | Bearer secret, at least 32 characters |
|
||||
| `FINANCIAL_EVENT_ACCOUNT_REF` | `wowngeon:receipts` | Non-PII account identifier |
|
||||
| `FINANCIAL_EVENT_POLL_MS` | `60000` | Clamped to 5000-3600000 |
|
||||
| `FINANCIAL_EVENT_BATCH_SIZE` | `20` | Clamped to 1-100 |
|
||||
| `FINANCIAL_EVENT_MAX_ATTEMPTS` | `8` | Clamped to 1-100 |
|
||||
|
||||
```dotenv
|
||||
FINANCIAL_EVENT_SINK_URL=https://ledger.operator.example/v1/events
|
||||
@@ -31,13 +111,43 @@ FINANCIAL_EVENT_BATCH_SIZE=20
|
||||
FINANCIAL_EVENT_MAX_ATTEMPTS=8
|
||||
```
|
||||
|
||||
Production requires HTTPS, forbids URL-embedded credentials, and requires a strong token. The account
|
||||
reference is a non-PII identifier. URL and token must be set together. Never place the token in Git,
|
||||
Ansible inventory, an invocation, a receipt, a log, or a public health check.
|
||||
The full environment template is `src/.env.example`; the mainnet and stagenet profiles are
|
||||
`src/.env.mainnet.example` and `src/.env.stagenet.example`.
|
||||
|
||||
Monero stagenet deliberately sends nothing to an accounting sink. Its pending, leased, or dead-letter
|
||||
test rows are moved to `ignored` with reason `non_mainnet_network`; startup rejects sink configuration
|
||||
on any non-mainnet network. This keeps **NO REAL VALUE** test activity out of real books.
|
||||
Startup validation (`src/config/environmentValidator.js`) and the exporter constructor both enforce:
|
||||
|
||||
- URL and token are set together or not at all.
|
||||
- The URL parses, uses `http:` or `https:`, and embeds no credentials. `https:` is required when
|
||||
`NODE_ENV=production`.
|
||||
- The token is at least 32 characters, contains at least 8 distinct characters, and matches no
|
||||
placeholder pattern such as `change_me`, `example`, `password`, or `secret`.
|
||||
- The account reference matches `^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$`.
|
||||
- Sink configuration is rejected on any non-mainnet `MONERO_NETWORK`.
|
||||
|
||||
Set the URL and token only in the protected mainnet runtime environment. Never place the token in
|
||||
Git, Ansible inventory, an invocation, a receipt, a log, or a public health check.
|
||||
|
||||
## Non-mainnet networks
|
||||
|
||||
When `MONERO_NETWORK` is not `mainnet`, export is disallowed and each run moves every `pending`,
|
||||
`in_flight`, or `dead_letter` row to `ignored` with reason `non_mainnet_network`. This keeps
|
||||
no-value test activity out of real books. Migration 042 separately marks legacy rows whose
|
||||
historical amount is unusable as `ignored` with reason `malformed_legacy_snapshot`, so they
|
||||
terminate instead of retrying forever.
|
||||
|
||||
## Health reporting
|
||||
|
||||
The public `/health` response includes a `financialEvents` object: the delivery contract string,
|
||||
`enabled`, `exportAllowed`, `running`, the `backlog`, `deadLetters`, and `ignoredRows` gauges, and
|
||||
the cumulative `delivered`, `deferred`, `deadLettered`, and `ignored` counters. It exposes no sink
|
||||
URL, token, account reference, event ID, error text, payload, source identity, or timestamp.
|
||||
|
||||
Accounting export is informational and does not make gameplay readiness fail, so production
|
||||
monitoring must alert on backlog age, dead letters, and the exporter's process logs and metrics in
|
||||
order to detect stale delivery.
|
||||
|
||||
The poll loop runs even with no sink configured, so the backlog gauge stays truthful and non-mainnet
|
||||
deployments keep suppressing newly appended rows.
|
||||
|
||||
## Operations
|
||||
|
||||
@@ -50,6 +160,6 @@ Before enabling mainnet export, prove in a disposable environment that the sink:
|
||||
5. supports alerting and reconciliation for a deliberately generated dead letter.
|
||||
|
||||
Keep the outbox in database backups. Do not delete or rewrite a dead-letter row to make monitoring
|
||||
green; reconcile it against the receiving ledger, preserve the evidence, and use a separately reviewed
|
||||
repair procedure. An absent sink is permitted, but confirmed mainnet events then remain pending and
|
||||
must be included in the production accounting handoff.
|
||||
green: reconcile it against the receiving ledger, preserve the evidence, and use a separately
|
||||
reviewed repair procedure. Running without a sink is permitted, but confirmed mainnet events then
|
||||
accumulate as pending rows and must be reconciled by another route.
|
||||
|
||||
@@ -1,42 +1,95 @@
|
||||
# Financial reconciliation
|
||||
|
||||
The admin dashboard has separate **Payouts** and **Refunds** views. Start with the
|
||||
**Needs Attention** filter and compare the row, application logs, and wallet history.
|
||||
The overview and each list show `processing` and `needs_review` counts; these are
|
||||
liabilities, not failed transfers that can safely be resent.
|
||||
Payouts and refunds move real funds, so any row that is not in a terminal state is treated
|
||||
as a liability with an unknown on-chain outcome rather than a failed transfer that can be
|
||||
resent. This document describes the states, the retry rules that follow from them, and the
|
||||
manual reconciliation procedure.
|
||||
|
||||
## State rules
|
||||
## Where to look
|
||||
|
||||
- A payout in `processing` or `needs_review` may already have broadcast. Never use the
|
||||
retry action for it.
|
||||
- The payout retry action is available only for a hashless `failed` or
|
||||
`permanently_failed` row. Use it only after proving the recorded error happened before
|
||||
broadcast and confirming that wallet history contains no matching transfer. The UI
|
||||
sends the API's required explicit confirmation.
|
||||
- A refund in `requested` has not yet been claimed by the refund worker. A refund in
|
||||
`processing` may have broadcast. A refund in `needs_review` is quarantined. There is no
|
||||
dashboard resend action for any refund state.
|
||||
- `recorded` means entitlements/payment state was reversed without an on-chain transfer;
|
||||
`completed` requires transaction-hash evidence.
|
||||
The admin dashboard (`html/admin.html`) has separate **Payouts** and **Refunds** tabs. Each
|
||||
has a status filter whose **Needs Attention** option selects exactly the non-terminal states:
|
||||
|
||||
- Payouts: `pending`, `processing`, `failed`, `needs_review`, `permanently_failed`.
|
||||
- Refunds: `requested`, `processing`, `needs_review`.
|
||||
|
||||
The overview panel and each list also report per-status counts. Compare the row against the
|
||||
service logs and the wallet's outgoing-transfer history before taking any action.
|
||||
|
||||
## Payout states
|
||||
|
||||
| State | Meaning |
|
||||
| --- | --- |
|
||||
| `pending` | Queued for the batch payout worker in `gameModeManager`. That worker owns these rows exclusively. |
|
||||
| `processing` | Claimed by a worker. The wallet call may already have broadcast. |
|
||||
| `completed` | Settled, with a transaction hash recorded. |
|
||||
| `failed` | The wallet call reported an error and the row is below the retry limit. |
|
||||
| `permanently_failed` | Retries reached `PAYOUT_MAX_RETRIES` (default 3). |
|
||||
| `needs_review` | Quarantined for manual reconciliation. Never retried automatically. |
|
||||
| `batched` | Legacy status from an earlier batching scheme. Current code does not write it; it remains accepted by the status constraint and is counted in dashboard totals. |
|
||||
|
||||
`PayoutRetryService` retries only `failed` rows below the retry limit, claiming each with
|
||||
`FOR UPDATE SKIP LOCKED` so the batcher and the retry worker cannot both send the same row.
|
||||
It also sweeps `processing` rows that have not advanced within its stale window and moves
|
||||
them to `needs_review` with the reason recorded in `last_error`. The stale window defaults to
|
||||
`max(PAYOUT_RETRY_INTERVAL_MS * 3, 15 minutes)`.
|
||||
|
||||
## Refund states
|
||||
|
||||
Refunds have their own durable outbox table, `payment_refunds`. Its state shape is enforced
|
||||
by a database CHECK constraint, so a state and its timestamps or evidence cannot disagree.
|
||||
|
||||
| State | Meaning |
|
||||
| --- | --- |
|
||||
| `recorded` | Entitlements and payment state were reversed with no on-chain transfer requested. Terminal. |
|
||||
| `requested` | Queued for a transfer. Not yet claimed. |
|
||||
| `processing` | Claimed. The wallet call may already have broadcast. |
|
||||
| `completed` | Settled. The constraint requires a non-empty `tx_hash`. |
|
||||
| `needs_review` | Quarantined. The constraint requires a non-empty `error_message`. |
|
||||
|
||||
Only `requested` rows may be claimed. No code path moves `processing` back to `requested`,
|
||||
so a crash or a lost wallet response can never produce an automatic second transfer.
|
||||
|
||||
## Retry rules
|
||||
|
||||
- A payout in `processing`, `needs_review`, or any state carrying a transaction hash must
|
||||
never be retried. Absence of a matching transfer in wallet history is not proof that no
|
||||
broadcast occurred.
|
||||
- `POST /api/admin/payouts/:id/retry` accepts only a hashless `failed` or
|
||||
`permanently_failed` row and requires an explicit `confirm` (query parameter or JSON body);
|
||||
anything else returns 409 or 400. The dashboard exposes the Retry button only for those
|
||||
rows and sends the confirmation after a local prompt. The update predicate re-checks every
|
||||
safety condition, so a concurrent status change cannot be overwritten.
|
||||
- Use the retry action only after proving the recorded error happened before broadcast and
|
||||
confirming that wallet history contains no matching transfer.
|
||||
- There is no resend action for any refund state, in the dashboard or the API.
|
||||
|
||||
## Reconciliation checklist
|
||||
|
||||
1. Preserve the database row, service logs, wallet files, and current backups before
|
||||
changing anything.
|
||||
2. Match currency/network, destination, exact atomic amount, creation/processing time,
|
||||
transaction hash (if present), and wallet outgoing-transfer history.
|
||||
2. Match currency and network, destination, exact atomic amount, creation and processing
|
||||
times, transaction hash if present, and wallet outgoing-transfer history.
|
||||
3. If a matching wallet transfer exists, record its hash and confirmation evidence in an
|
||||
audited remediation. Do not create a replacement transfer.
|
||||
4. If no transfer appears, treat that as inconclusive until the wallet is synchronized and
|
||||
the relevant time window and mempool are checked. Escalate ambiguous cases instead of
|
||||
retrying.
|
||||
5. Re-run the read-only financial audit after any reviewed database remediation and retain
|
||||
the before/after evidence with the incident record.
|
||||
the relevant time window and mempool have been checked. Escalate ambiguous cases instead
|
||||
of retrying.
|
||||
5. Re-run the read-only audit, `scripts/deploy/financial-audit.sql`, after any reviewed
|
||||
database remediation and retain the before and after evidence with the incident record.
|
||||
|
||||
Periodic alerts use the same aggregate states. `FINANCIAL_REVIEW_STALE_MS` controls when a
|
||||
payout `processing` row or refund `requested`/`processing` row is considered stale; the
|
||||
default is 15 minutes. Alerts are read-only and never move or retry a transfer.
|
||||
## Alerts
|
||||
|
||||
There is intentionally no generic "mark resolved" endpoint. A reviewed state transition
|
||||
needs a case-specific, audited remediation because the correct result depends on wallet
|
||||
evidence and whether value was actually transferred.
|
||||
`AlertService` runs periodic checks over the same aggregate states and sends two financial
|
||||
alerts: one for any payout or refund in `needs_review`, and one for payout `processing` rows
|
||||
or refund `requested`/`processing` rows that have not moved within `FINANCIAL_REVIEW_STALE_MS`
|
||||
(default 900000, that is 15 minutes). Both resolve automatically once their count returns to
|
||||
zero. Alerts are read-only: they never move, retry, or cancel a transfer.
|
||||
|
||||
Environment variables referenced here are documented in `src/.env.example`.
|
||||
|
||||
## No generic resolve endpoint
|
||||
|
||||
There is deliberately no "mark resolved" endpoint. A reviewed state transition needs a
|
||||
case-specific, audited remediation because the correct result depends on wallet evidence and
|
||||
on whether value was actually transferred.
|
||||
|
||||
+238
-87
@@ -1,54 +1,66 @@
|
||||
# Smirk Wallet Integration
|
||||
|
||||
How Wownerogue integrates with the [Smirk](https://smirk.cash) browser extension for wallet-based authentication.
|
||||
How Wowngeon integrates with the [Smirk](https://smirk.cash) browser extension for wallet-based
|
||||
sign-in, payments, and signed chat.
|
||||
|
||||
Smirk injects a frozen `window.smirk` object on every page, in the style of `window.ethereum`. Sites
|
||||
use it to request public keys, request signatures, read wallet addresses, and prompt an in-extension
|
||||
payment. Wowngeon uses all four.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
## Enablement
|
||||
|
||||
Smirk injects a `window.smirk` API (similar to MetaMask's `window.ethereum`) that websites can use to:
|
||||
Smirk is opt-in and mainnet-only:
|
||||
|
||||
1. Request wallet connection (get public keys)
|
||||
2. Request message signatures (for authentication)
|
||||
3. Get wallet addresses (for payouts)
|
||||
4. Request payments (send WOW directly from extension)
|
||||
|
||||
Wownerogue uses the WOW (Wownero) key for authentication via a challenge-response signature flow, and `requestPayment()` for in-extension payment prompts.
|
||||
- `SMIRK_ENABLED=true` must be set (see `src/.env.example`).
|
||||
- `src/auth/smirkPolicy.js` forces the feature off on any test network (stagenet/testnet),
|
||||
regardless of `SMIRK_ENABLED`.
|
||||
- The auth routes in `src/routes/auth.js` are only mounted when the policy returns true
|
||||
(`src/index.js`).
|
||||
- The server advertises the resolved state to clients as `smirkEnabled` on the `game_mode_info`
|
||||
socket event. The client fails closed: `SmirkAuth.init()` returns early unless
|
||||
`SocketHandlers._smirkEnabled === true`.
|
||||
|
||||
---
|
||||
|
||||
## Authentication Flow
|
||||
## Authentication
|
||||
|
||||
Two proof shapes are accepted. The client prefers NIP-98 and falls back to the legacy Ed25519 path
|
||||
when the wallet does not advertise nostr signing, or when nostr signing fails for any reason other
|
||||
than an explicit user cancellation.
|
||||
|
||||
Every auth call is session-gated. The caller must send the browser's session bearer token in the
|
||||
`X-Session-Token` header (`users.anon_token`, stored client-side as `wownerogue_token`). The token
|
||||
proves ownership of the socket, which by itself is public: Socket.IO ids are broadcast in Tavern and
|
||||
match state, so a socket id alone can never authorize attaching a wallet key.
|
||||
|
||||
```
|
||||
Client Server Extension
|
||||
| | |
|
||||
|-- POST /challenge --------->| |
|
||||
|<-------- { challenge } -----| |
|
||||
| | |
|
||||
|-- smirk.connect() ---------------------------------->|
|
||||
|<-------------- { publicKeys } -----------------------|
|
||||
| | |
|
||||
|-- smirk.signMessage(challenge) --------------------->|
|
||||
|<-------------- { signatures[] } ---------------------|
|
||||
| | |
|
||||
|-- POST /verify ------------>| |
|
||||
| { challenge, publicKey, | |
|
||||
| signature, socketId } | |
|
||||
|<------ { linked: true } ----| |
|
||||
| | |
|
||||
|-- smirk.getAddresses() ------------------------------>|
|
||||
|<-------------- { wow: "Wo..." } ---------------------|
|
||||
| | |
|
||||
|-- socket: address:update -->| |
|
||||
Client Server Extension
|
||||
| | |
|
||||
|-- POST /challenge ----------->| |
|
||||
| X-Session-Token, socketId | |
|
||||
|<------- { challenge } --------| |
|
||||
| | |
|
||||
|-- smirk.signNostrEvent(evt) ------------------------------->|
|
||||
|<---------------- signed kind:27235 event -------------------|
|
||||
| | |
|
||||
|-- POST /verify -------------->| |
|
||||
| X-Session-Token, | |
|
||||
| { socketId, event } | |
|
||||
|<---- { linked: true, ... } ---| |
|
||||
| | |
|
||||
|-- smirk.getAddresses() ------------------------------------>|
|
||||
|<---------------- { wow: "Wo..." } -------------------------|
|
||||
| | |
|
||||
|-- socket: address:update ---->| |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST `/api/auth/smirk/challenge`
|
||||
|
||||
Generate a challenge for signature verification.
|
||||
Issue a single-use nonce.
|
||||
|
||||
**Headers:** `X-Session-Token: <session token>`
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
@@ -60,13 +72,27 @@ Generate a challenge for signature verification.
|
||||
{ "challenge": "<64-char hex string>", "expiresIn": 300 }
|
||||
```
|
||||
|
||||
The challenge is a random 32-byte hex string stored in the database with a 5-minute TTL.
|
||||
The challenge is 32 random bytes, hex encoded, inserted into `smirk_challenges` bound to both the
|
||||
socket id and the stable `users.id` resolved from the session token. It expires after five minutes.
|
||||
Each call also prunes expired rows and consumed rows older than one hour.
|
||||
|
||||
Rate limit: 10 requests per minute per IP.
|
||||
|
||||
### POST `/api/auth/smirk/verify`
|
||||
|
||||
Verify a wallet signature and link it to the user session.
|
||||
Verify a wallet proof and link the wallet to the account.
|
||||
|
||||
**Request:**
|
||||
**Headers:** `X-Session-Token: <session token>`
|
||||
|
||||
**Request (NIP-98, preferred):**
|
||||
```json
|
||||
{
|
||||
"socketId": "<socket.io id>",
|
||||
"event": { "kind": 27235, "created_at": 0, "pubkey": "", "id": "", "sig": "", "content": "", "tags": [] }
|
||||
}
|
||||
```
|
||||
|
||||
**Request (legacy Ed25519):**
|
||||
```json
|
||||
{
|
||||
"socketId": "<socket.io id>",
|
||||
@@ -76,57 +102,127 @@ Verify a wallet signature and link it to the user session.
|
||||
}
|
||||
```
|
||||
|
||||
**Response (success):**
|
||||
The route selects the NIP-98 path when `event` is a non-array object, and the legacy path otherwise.
|
||||
|
||||
**Response (wallet linked to the current account):**
|
||||
```json
|
||||
{ "success": true, "linked": true, "message": "Smirk wallet linked successfully" }
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
- `400` - Missing fields, invalid/expired challenge, invalid signature, or wallet already linked to another account
|
||||
- `404` - No active session for the given socketId
|
||||
**Response (wallet already owned an account, so the caller is signed in to it):**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"linked": true,
|
||||
"adopted": true,
|
||||
"sessionToken": "<rotated bearer token for the wallet's account>",
|
||||
"address": "<that account's payout address or null>",
|
||||
"message": "Signed in to your wallet-linked account."
|
||||
}
|
||||
```
|
||||
|
||||
**Errors** are returned as `{ "error": "<CODE>", "message": "<safe text>" }`:
|
||||
|
||||
| Status | Code | Cause |
|
||||
|--------|------|-------|
|
||||
| 400 | `VALIDATION_ERROR` | Missing or oversized `socketId`, missing proof fields, invalid/expired/already-used challenge, unverifiable event or signature |
|
||||
| 401 | `SESSION_TOKEN_REQUIRED` | No `X-Session-Token` header |
|
||||
| 403 | `SESSION_OWNERSHIP_REQUIRED` | Token does not own the given `socketId` |
|
||||
| 409 | `WALLET_ADOPTION_CONFLICT`, `SOCKET_OWNERSHIP_CONFLICT` | Concurrent sign-in raced the adoption transaction; retry |
|
||||
| 429 | rate limited | More than 20 verify attempts per minute from one IP; `Retry-After` is set |
|
||||
|
||||
### GET `/api/auth/smirk/status`
|
||||
|
||||
Check if a session has a linked wallet.
|
||||
Report whether the caller's own account has a linked wallet.
|
||||
|
||||
**Query:** `?socketId=<socket.io id>`
|
||||
**Headers:** `X-Session-Token: <session token>`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{ "linked": true, "hasPayoutAddress": true }
|
||||
```
|
||||
|
||||
The caller is identified by the session token alone, because `socket_id` is volatile and changes on
|
||||
every refresh or reconnect; matching on the unguessable token gives the same guarantee (a caller can
|
||||
only read its own state) without breaking after a reload. `socketId` remains a required query
|
||||
parameter for API shape. A missing token returns 401 and an unknown token returns 403; neither
|
||||
discloses anything about another account.
|
||||
|
||||
---
|
||||
|
||||
## Signature Verification
|
||||
## Account adoption
|
||||
|
||||
The Smirk extension signs messages using Ed25519 with a **SHA256 prehash**:
|
||||
`smirk_public_key` carries a unique index over non-null values, so one wallet key maps to at most one
|
||||
account. When the proven key already belongs to a different account, verification does not fail.
|
||||
Instead a single transaction:
|
||||
|
||||
1. Extension receives the challenge string (e.g. `"a1b2c3..."`)
|
||||
2. Extension computes `msgHash = SHA256(UTF8_encode(challenge))`
|
||||
3. Extension signs `msgHash` with the Ed25519 private spend key
|
||||
4. Extension returns the 64-byte signature as hex
|
||||
1. Re-locks and re-checks the presented session row.
|
||||
2. Revokes the caller's temporary anonymous account (clears its `socket_id`, rotates its token).
|
||||
3. Rotates the wallet owner's bearer token and assigns the caller's socket to that account.
|
||||
4. Confirms exactly one account now owns the socket.
|
||||
|
||||
The server must verify against the same prehash:
|
||||
The response carries the rotated token as `sessionToken` with `adopted: true`. Live sockets for both
|
||||
accounts are disconnected after the response is sent, so nothing keeps running under stale identity.
|
||||
The client stores the new token in `localStorage` and reloads, which re-establishes credits, payout
|
||||
address, and history as the wallet's account.
|
||||
|
||||
---
|
||||
|
||||
## Proof verification
|
||||
|
||||
### NIP-98 (`src/utils/nip98.js`)
|
||||
|
||||
The challenge nonce is consumed first, atomically. A single
|
||||
`UPDATE ... WHERE used = FALSE ... RETURNING` flips the row for exactly one caller, so concurrent
|
||||
requests cannot both pass and there is no select-then-update window. The event is then verified
|
||||
against that exact nonce.
|
||||
|
||||
Verification requires all of:
|
||||
|
||||
- Well-formed kind `27235` event: numeric `kind` and `created_at`, 64-hex `pubkey` and `id`, 128-hex
|
||||
`sig`, string `content`, and `tags` as an array of string arrays.
|
||||
- The event id independently recomputed with `getEventHash` and matching the claimed id. This
|
||||
catches content or tag tampering without relying on any verification memoization inside
|
||||
nostr-tools, which stamps its result onto the event object.
|
||||
- A valid BIP-340 schnorr signature over secp256k1.
|
||||
- `|now - created_at| <= 120` seconds.
|
||||
- Exactly one `method` tag equal to `POST`.
|
||||
- Exactly one `u` tag whose URL path ends with `/api/auth/smirk/verify` and whose host equals the
|
||||
expected host.
|
||||
- Exactly one `challenge` tag equal to the consumed nonce.
|
||||
|
||||
The expected host comes from `HOSTED_BY` when configured, falling back to the request's own host for
|
||||
development and self-hosted deployments. The host check is a hard requirement, not a nicety: path
|
||||
matching alone would accept an event the same wallet signed for any other site whose path ends the
|
||||
same way, and because verification adopts whichever account owns the key and returns its session
|
||||
token, a borrowed event would be a full account takeover.
|
||||
|
||||
Verification is a pure function with no I/O, so it round-trips in unit tests against nostr-tools.
|
||||
The proven `pubkey` is an x-only secp256k1 nostr key, a different key namespace from the legacy
|
||||
Ed25519 spend key, so a NIP-98 sign-in registers the account under the nostr key.
|
||||
|
||||
### Legacy Ed25519
|
||||
|
||||
The nonce is consumed the same way, then the signature is verified over the raw UTF-8 bytes of the
|
||||
challenge string per RFC 8032:
|
||||
|
||||
```javascript
|
||||
const crypto = require('crypto');
|
||||
const nacl = require('tweetnacl');
|
||||
|
||||
const challengeHash = crypto.createHash('sha256').update(challenge).digest();
|
||||
const challengeBytes = Buffer.from(challenge, 'utf8');
|
||||
const signatureBytes = Buffer.from(signature, 'hex');
|
||||
const publicKeyBytes = Buffer.from(publicKey, 'hex');
|
||||
|
||||
const valid = nacl.sign.detached.verify(challengeHash, signatureBytes, publicKeyBytes);
|
||||
const valid = nacl.sign.detached.verify(challengeBytes, signatureBytes, publicKeyBytes);
|
||||
```
|
||||
|
||||
**Important:** Do NOT verify against `Buffer.from(challenge, 'hex')` (the raw hex-decoded bytes). The extension always SHA256-hashes the message string before signing.
|
||||
There is no SHA256 pre-hash and no hex-decode of the challenge: the bytes signed are the bytes of
|
||||
the challenge string itself.
|
||||
|
||||
---
|
||||
|
||||
## `window.smirk` API Reference
|
||||
|
||||
The extension injects a frozen `window.smirk` object on all pages.
|
||||
## `window.smirk` API reference
|
||||
|
||||
### Detection
|
||||
|
||||
@@ -136,9 +232,13 @@ if (typeof window.smirk !== 'undefined') {
|
||||
}
|
||||
```
|
||||
|
||||
Capabilities are feature-detected individually. Nostr support is detected as
|
||||
`typeof window.smirk.signNostrEvent === 'function' && typeof window.smirk.getNostrPublicKey === 'function'`.
|
||||
|
||||
### `smirk.connect()` -> `Promise<PublicKeys>`
|
||||
|
||||
Requests user approval to share public keys. Opens an approval popup on first use; subsequent calls from an approved origin return keys immediately.
|
||||
Requests user approval to share public keys. Opens an approval popup on first use; later calls from
|
||||
an approved origin return keys immediately.
|
||||
|
||||
```javascript
|
||||
const keys = await window.smirk.connect();
|
||||
@@ -162,6 +262,18 @@ const wowSig = result.signatures.find(s => s.asset === 'wow');
|
||||
|
||||
Message limit: 10,000 characters.
|
||||
|
||||
### `smirk.getNostrPublicKey()` -> `Promise<string>`
|
||||
|
||||
Returns the wallet's x-only nostr public key and grants the origin the Nostr scope. The grant is
|
||||
one-time per origin.
|
||||
|
||||
### `smirk.signNostrEvent(template)` -> `Promise<Event>`
|
||||
|
||||
Signs a nostr event template, filling in `pubkey`, `id`, and `sig`. Throws `NOT_AUTHORIZED` when the
|
||||
origin does not yet hold the Nostr scope. Both call sites therefore sign first and only call
|
||||
`getNostrPublicKey()` on that error, so a returning user sees one approval rather than two. The
|
||||
wallet still asks per signature for kind 27235; that prompt cannot be suppressed.
|
||||
|
||||
### `smirk.getAddresses()` -> `Promise<Addresses>`
|
||||
|
||||
Returns wallet addresses for all assets. Requires prior `connect()`.
|
||||
@@ -177,28 +289,31 @@ const addrs = await window.smirk.getAddresses();
|
||||
|
||||
### `smirk.requestPayment(options)` -> `Promise<PaymentResult>`
|
||||
|
||||
Prompts the user to send a payment from their Smirk wallet. Opens an approval popup showing the recipient address, amount, and description. Resolves when the user confirms and the TX is broadcast; rejects if the user denies or the extension context is invalid.
|
||||
Prompts the user to send a payment. Opens an approval popup showing the recipient address, amount,
|
||||
and description. Resolves once the user confirms and the transaction is broadcast; rejects if the
|
||||
user denies or the extension context is invalid.
|
||||
|
||||
```javascript
|
||||
const result = await window.smirk.requestPayment({
|
||||
address: 'Wo3MWeLE...', // Recipient address
|
||||
amount: '1', // Human-readable amount (NOT atomic units)
|
||||
asset: 'wow', // Asset ticker (lowercase)
|
||||
description: 'Single game entry' // Shown in approval popup
|
||||
address: 'Wo3MWeLE...', // Recipient address
|
||||
amount: '1', // Human-readable amount, not atomic units
|
||||
asset: 'wow', // Asset ticker, lowercase
|
||||
description: 'Single game entry' // Shown in the approval popup
|
||||
});
|
||||
// result.txid - Transaction hash (hex)
|
||||
// result.amount - Amount sent (string)
|
||||
```
|
||||
|
||||
**Important:** The `amount` field must be the human-readable value (e.g. `"1"` for 1 WOW), not atomic units. The extension handles conversion internally.
|
||||
`amount` is the human-readable value, for example `"1"` for 1 WOW. The extension converts to atomic
|
||||
units internally.
|
||||
|
||||
**Error cases:**
|
||||
- User clicks "Deny" → rejects with user cancellation error
|
||||
- Extension context invalidated (browser reloaded extension) → rejects with `"Extension context invalidated"`. The page must be refreshed to re-establish the connection.
|
||||
Rejection cases: the user denies the popup, or the extension context is invalidated (the browser
|
||||
reloaded the extension), which rejects with `"Extension context invalidated"` and requires a page
|
||||
refresh to re-establish the connection.
|
||||
|
||||
### `smirk.isConnected()` -> `Promise<boolean>`
|
||||
|
||||
Check if the current origin is connected (approved).
|
||||
Whether the current origin is approved.
|
||||
|
||||
### `smirk.disconnect()` -> `Promise<void>`
|
||||
|
||||
@@ -206,47 +321,83 @@ Revoke site access.
|
||||
|
||||
### `smirk.getPublicKeys()` -> `Promise<PublicKeys | null>`
|
||||
|
||||
Get public keys without prompting (only works if already connected). Returns `null` if not connected.
|
||||
Public keys without prompting. Returns `null` when the origin is not connected.
|
||||
|
||||
---
|
||||
|
||||
## Client-Side Implementation
|
||||
## Client implementation
|
||||
|
||||
See `html/js/network/smirkAuth.js` for auth and `html/js/network/socketHandlers.js` (`_trySmirkPayment`) for payments. Key points:
|
||||
**Sign-in:** `html/js/network/smirkAuth.js`.
|
||||
|
||||
- The `SmirkAuth` module is initialized after DOM ready and socket connection
|
||||
- It checks `SocketHandlers._smirkEnabled` (set by server's `game_mode_info` event) to gate the feature
|
||||
- On successful auth, it calls `smirk.getAddresses()` to auto-set the payout address via `socket.emit('address:update')`
|
||||
- The button shows install link if extension is not detected, connect button if it is
|
||||
- When a Smirk-connected user triggers a payment, `_trySmirkPayment()` calls `smirk.requestPayment()` with the human-readable amount. If the user confirms, the TX is broadcast and the server's existing mempool monitoring detects it. If the user denies or the extension context is stale, it falls back to the normal address/QR payment modal.
|
||||
- `SmirkAuth.init()` runs when `game_mode_info` arrives, with a 3-second fallback timer, and only
|
||||
when the server advertised `smirkEnabled === true`. Double initialization is guarded.
|
||||
- With the extension present it renders a connect button and calls `checkStatus()` to show the
|
||||
already-linked state on load; without it, an install link to `https://smirk.cash` is shown.
|
||||
- `login()` fetches a challenge, runs the NIP-98 or legacy proof, and on an `adopted` response stores
|
||||
the returned `sessionToken` and reloads the page.
|
||||
- Otherwise it calls `getAddresses()` and emits `address:update` with `addresses.wow` to set the
|
||||
payout address. A failure here is non-fatal: the user is told to set the address manually.
|
||||
- A `LOCKED` error is surfaced as an unlock prompt rather than a failure, because current wallet
|
||||
builds open the unlock popup from `connect()` and `signNostrEvent()` while older builds throw.
|
||||
|
||||
**Payments:** `_trySmirkPayment` in `html/js/network/socketHandlers.js`.
|
||||
|
||||
- On the `payment_created` event, the client uses `requestPayment()` when `SmirkAuth._isLinked` is
|
||||
true, the extension is present, and it exposes `requestPayment`.
|
||||
- The amount sent is `data.humanAmount || data.amountFormatted || String(data.amount)`, and the asset
|
||||
is the active crypto type lowercased. The description varies by payment type: game entry, credits
|
||||
package, or cosmetic pack.
|
||||
- On success the normal server-side mempool monitoring completes the flow; the UI moves to a
|
||||
waiting-for-confirmation state.
|
||||
- On rejection the client falls back to the address and QR modal. A user denial keeps the linked
|
||||
state; any other error clears `SmirkAuth._isLinked` so later payments go straight to the manual
|
||||
flow.
|
||||
|
||||
**Chat signing:** `html/js/smirkChatSign.js`.
|
||||
|
||||
- `smirkChatSign(socket, text, tag)` signs a kind:1 event carrying a `t` tag (default
|
||||
`wowngeon-global`) and emits it as `chat_signed`.
|
||||
- It resolves `false` whenever signing is unavailable or declined, so callers fall back to the normal
|
||||
unsigned send. Feature detection is end to end, which makes wiring it into a send site safe even
|
||||
when no wallet is connected.
|
||||
- The server verifies the event in `src/utils/verifyChatEvent.js` and binds the author to the npub
|
||||
the session authenticated as, so a player can only post as themselves. A valid signature by some
|
||||
other key is rejected.
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
The `smirk_challenges` table stores pending challenges:
|
||||
`smirk_challenges` holds pending nonces (migrations `009_smirk_integration.sql` and
|
||||
`036_smirk_challenge_user_binding.sql`):
|
||||
|
||||
| Column | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| id | serial | Primary key |
|
||||
| challenge | text | 64-char hex challenge string |
|
||||
| socket_id | text | Socket.io session ID |
|
||||
| used | boolean | Whether challenge has been consumed |
|
||||
| expires_at | timestamp | Auto-set to NOW() + 5 minutes |
|
||||
| challenge | varchar(64), unique | Hex challenge string |
|
||||
| socket_id | varchar(255) | Socket.IO session id the nonce was issued to |
|
||||
| user_id | integer, not null | Owning `users.id`, cascade delete |
|
||||
| used | boolean | Whether the nonce has been consumed |
|
||||
| expires_at | timestamp | Defaults to `NOW() + 5 minutes` |
|
||||
| created_at | timestamp | Row creation time |
|
||||
|
||||
The `users` table has a `smirk_public_key` column that stores the linked Ed25519 public key (hex).
|
||||
`users.smirk_public_key` (`varchar(128)`) stores the linked key, with a unique index over non-null
|
||||
values. Migration 036 also adds a unique index on non-null `users.socket_id`, making single socket
|
||||
ownership structural; the migration aborts if duplicate rows exist.
|
||||
|
||||
---
|
||||
|
||||
## Signature Schemes by Asset
|
||||
## Signature schemes by asset
|
||||
|
||||
| Asset | Curve | Hash | Format |
|
||||
|-------|-------|------|--------|
|
||||
| BTC | secp256k1 (ECDSA) | Double SHA256 with Bitcoin message prefix | Compact signature (64 bytes) |
|
||||
| LTC | secp256k1 (ECDSA) | Double SHA256 with Bitcoin message prefix | Compact signature (64 bytes) |
|
||||
| XMR | Ed25519 | SHA256 prehash | R \|\| s (64 bytes) |
|
||||
| WOW | Ed25519 | SHA256 prehash | R \|\| s (64 bytes) |
|
||||
| GRIN | Ed25519 | SHA256 prehash | R \|\| s (64 bytes) |
|
||||
| XMR | Ed25519 | Raw message bytes (RFC 8032) | R \|\| s (64 bytes) |
|
||||
| WOW | Ed25519 | Raw message bytes (RFC 8032) | R \|\| s (64 bytes) |
|
||||
| GRIN | Ed25519 | Raw message bytes (RFC 8032) | R \|\| s (64 bytes) |
|
||||
|
||||
Wownerogue only uses the WOW signature for authentication.
|
||||
Nostr signing (`signNostrEvent`) is separate from all of these: BIP-340 schnorr over secp256k1 with
|
||||
an x-only public key.
|
||||
|
||||
Wowngeon's legacy sign-in path uses only the WOW signature. The NIP-98 path uses only the nostr key.
|
||||
|
||||
+199
-125
@@ -1,185 +1,259 @@
|
||||
# Logs and Backup Configuration
|
||||
# Logs and Backup
|
||||
|
||||
This document covers log management and database backup strategies for production deployment.
|
||||
Operational reference for application logs and PostgreSQL backups in a production deployment.
|
||||
|
||||
---
|
||||
|
||||
## Log Management with systemd/journald
|
||||
## Application Logs
|
||||
|
||||
When running as a systemd service, logs automatically go to journald which handles rotation. Configure retention limits as needed.
|
||||
The server has no logging library and writes no log files. All diagnostics go to stdout and stderr,
|
||||
so under systemd they land in the journal of the unit that started the process. Log retention is
|
||||
therefore a journald configuration question, not an application one.
|
||||
|
||||
### Configure Journal Limits
|
||||
|
||||
Create `/etc/systemd/journald.conf.d/wownerogue.conf`:
|
||||
|
||||
```ini
|
||||
[Journal]
|
||||
# Max disk usage for all journals
|
||||
SystemMaxUse=500M
|
||||
# Max size per journal file
|
||||
SystemMaxFileSize=50M
|
||||
# Keep logs for 30 days max
|
||||
MaxRetentionSec=30day
|
||||
# Compress older entries
|
||||
Compress=yes
|
||||
```
|
||||
|
||||
Apply the configuration:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/systemd/journald.conf.d/
|
||||
sudo nano /etc/systemd/journald.conf.d/wownerogue.conf
|
||||
# paste the config above
|
||||
sudo systemctl restart systemd-journald
|
||||
```
|
||||
The repository ships two application units in `scripts/deploy/`: `wownerogue.service` (Wownero
|
||||
mainnet instance) and `monerogue.service` (Monero stagenet instance).
|
||||
|
||||
### Viewing Logs
|
||||
|
||||
```bash
|
||||
# All logs for the service
|
||||
# All logs for a unit
|
||||
journalctl -u wownerogue
|
||||
|
||||
# Follow live (like tail -f)
|
||||
# Follow live
|
||||
journalctl -u wownerogue -f
|
||||
|
||||
# Last 100 lines
|
||||
journalctl -u wownerogue -n 100
|
||||
|
||||
# Logs since today
|
||||
# Since today
|
||||
journalctl -u wownerogue --since today
|
||||
|
||||
# Logs from a specific time range
|
||||
journalctl -u wownerogue --since "2025-12-01" --until "2025-12-15"
|
||||
# A specific time range
|
||||
journalctl -u wownerogue --since "2026-01-01" --until "2026-01-15"
|
||||
|
||||
# Export to file for analysis
|
||||
journalctl -u wownerogue --since "2025-12-01" > wownerogue-dec.log
|
||||
|
||||
# Show only errors
|
||||
# Errors only
|
||||
journalctl -u wownerogue -p err
|
||||
|
||||
# Export for offline analysis
|
||||
journalctl -u wownerogue --since "2026-01-01" > wownerogue-jan.log
|
||||
```
|
||||
|
||||
The same commands work for `monerogue`, the wallet-RPC units, and the backup unit.
|
||||
|
||||
### Journal Retention
|
||||
|
||||
Journald limits apply to the whole journal, not to an individual unit, so these settings bound
|
||||
every service on the host. Create a drop-in such as `/etc/systemd/journald.conf.d/limits.conf`:
|
||||
|
||||
```ini
|
||||
[Journal]
|
||||
# Total disk budget for all journals
|
||||
SystemMaxUse=500M
|
||||
# Size cap per journal file
|
||||
SystemMaxFileSize=50M
|
||||
# Discard entries older than this
|
||||
MaxRetentionSec=30day
|
||||
Compress=yes
|
||||
```
|
||||
|
||||
Apply it:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/systemd/journald.conf.d/
|
||||
sudo install -m 0644 limits.conf /etc/systemd/journald.conf.d/limits.conf
|
||||
sudo systemctl restart systemd-journald
|
||||
```
|
||||
|
||||
Journald is a rolling operational log, not an audit archive. Records that must survive log
|
||||
rotation belong in the database. Accounting events are exported through the durable outbox
|
||||
described in [FINANCIAL_EVENT_EXPORT.md](./FINANCIAL_EVENT_EXPORT.md).
|
||||
|
||||
---
|
||||
|
||||
## PostgreSQL Automated Backups
|
||||
## PostgreSQL Backups
|
||||
|
||||
### Backup Script
|
||||
### The Validated Backup Job
|
||||
|
||||
The backup script is located at `scripts/backup_db.sh`. It reads database credentials from environment variables (same as your `.env` file).
|
||||
`scripts/deploy/wowngeon-db-backup.sh`, with its `wowngeon-db-backup.service` and
|
||||
`wowngeon-db-backup.timer` units, is the production backup path. Each run:
|
||||
|
||||
**Required environment variables:**
|
||||
- `DB_NAME` - Database name (default: `wownerogue`)
|
||||
- `DB_USER` - Database user
|
||||
- `DB_HOST` - Database host (default: `localhost`)
|
||||
- `BACKUP_DIR` - Where to store backups (default: `/var/backups/wownerogue`)
|
||||
- `RETENTION_DAYS` - How long to keep backups (default: `30`)
|
||||
- Dumps every database named in the script's loop. The shipped list is `wownerogue` and
|
||||
`monerogue`; a single-instance operator edits that loop to match the databases actually present.
|
||||
- Writes custom-format dumps (`pg_dump --format=custom --no-owner`) to a temporary file, verifies
|
||||
the catalog with `pg_restore --list`, sets mode `0600`, then renames into place, so a reader never
|
||||
observes a partial dump under its final name.
|
||||
- Records a SHA-256 sidecar next to each dump, also mode `0600`.
|
||||
- Deletes dumps and sidecars older than `BACKUP_RETENTION_DAYS`, and stale `.tmp` files older than
|
||||
one day.
|
||||
|
||||
### systemd Timer (Recommended)
|
||||
Configuration comes from two environment variables:
|
||||
|
||||
Use systemd timers for scheduled backups. Copy the service files to `/etc/systemd/system/`:
|
||||
| Variable | Default | Behaviour |
|
||||
| --- | --- | --- |
|
||||
| `BACKUP_ROOT` | `/var/backups/wowngeon/daily` | The script refuses to run against any other value. Changing the destination requires a reviewed change to the script itself. |
|
||||
| `BACKUP_RETENTION_DAYS` | `14` | Must be a positive integer. The service unit sets it explicitly to `14`. |
|
||||
|
||||
1. **Service file** (`wownerogue-backup.service`):
|
||||
- Located at `scripts/wownerogue-backup.service`
|
||||
- Runs the backup script with environment from your `.env` file
|
||||
The script also exits if `BACKUP_ROOT` does not exist or is a symlink, so a misconfigured or
|
||||
tampered path fails loudly instead of writing dumps somewhere unexpected.
|
||||
|
||||
2. **Timer file** (`wownerogue-backup.timer`):
|
||||
- Located at `scripts/wownerogue-backup.timer`
|
||||
- Runs daily at 3 AM with random delay up to 15 minutes
|
||||
Retention is deliberately short because this directory sits on the same disk as the database and
|
||||
exists for fast local recovery. Long-horizon history is the job of an independent, separately
|
||||
encrypted off-host archive, which the operator configures according to their recovery policy.
|
||||
|
||||
### Setup Commands
|
||||
The service unit runs as `postgres` with `UMask=0077` and a hardened sandbox: `ProtectSystem=strict`
|
||||
with `ReadWritePaths=/var/backups/wowngeon/daily` as the only writable location, an empty capability
|
||||
bounding set, and `RestrictAddressFamilies=AF_UNIX` so the job can reach the local PostgreSQL socket
|
||||
and nothing on the network. Because it authenticates as the `postgres` OS user over that socket, it
|
||||
needs no database password and reads no application `.env` file.
|
||||
|
||||
The timer fires daily at 03:20 local time with a randomized delay of up to 20 minutes, and
|
||||
`Persistent=true` so a run missed while the host was off happens at the next boot.
|
||||
|
||||
### Installation
|
||||
|
||||
Install these artifacts through the operator's reviewed change process rather than from the runtime
|
||||
release directory, matching the boundary described in [DEPLOY.md](./DEPLOY.md). The service unit
|
||||
expects the script at `/usr/local/sbin/wowngeon-db-backup`.
|
||||
|
||||
```bash
|
||||
# Create backup directory
|
||||
sudo mkdir -p /var/backups/wownerogue
|
||||
sudo chown $(whoami):$(whoami) /var/backups/wownerogue
|
||||
# Backup destination, owned by the database user only
|
||||
sudo install -d -o postgres -g postgres -m 0700 /var/backups/wowngeon/daily
|
||||
|
||||
# Make backup script executable
|
||||
chmod +x scripts/backup_db.sh
|
||||
# Script and units
|
||||
sudo install -o root -g root -m 0755 \
|
||||
scripts/deploy/wowngeon-db-backup.sh /usr/local/sbin/wowngeon-db-backup
|
||||
sudo install -o root -g root -m 0644 \
|
||||
scripts/deploy/wowngeon-db-backup.service /etc/systemd/system/
|
||||
sudo install -o root -g root -m 0644 \
|
||||
scripts/deploy/wowngeon-db-backup.timer /etc/systemd/system/
|
||||
|
||||
# Copy systemd files
|
||||
sudo cp scripts/wownerogue-backup.service /etc/systemd/system/
|
||||
sudo cp scripts/wownerogue-backup.timer /etc/systemd/system/
|
||||
|
||||
# Edit the service file to point to your installation path
|
||||
sudo nano /etc/systemd/system/wownerogue-backup.service
|
||||
# Update WorkingDirectory and EnvironmentFile paths
|
||||
|
||||
# Reload systemd and enable timer
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable wownerogue-backup.timer
|
||||
sudo systemctl start wownerogue-backup.timer
|
||||
sudo systemctl enable --now wowngeon-db-backup.timer
|
||||
|
||||
# Verify timer is scheduled
|
||||
systemctl list-timers | grep wownerogue
|
||||
# Confirm the schedule
|
||||
systemctl list-timers wowngeon-db-backup.timer
|
||||
```
|
||||
|
||||
### Manual Backup
|
||||
### Running a Backup on Demand
|
||||
|
||||
```bash
|
||||
# Run backup manually
|
||||
cd /path/to/wownerogue/src
|
||||
source .env
|
||||
../scripts/backup_db.sh
|
||||
|
||||
# Or via systemd
|
||||
sudo systemctl start wownerogue-backup.service
|
||||
journalctl -u wownerogue-backup.service -n 20
|
||||
sudo systemctl start wowngeon-db-backup.service
|
||||
journalctl -u wowngeon-db-backup.service -n 50
|
||||
ls -lh /var/backups/wowngeon/daily/
|
||||
```
|
||||
|
||||
### Restore from Backup
|
||||
Dumps are named `<database>-<UTC timestamp>.dump`, for example
|
||||
`wownerogue-20260726T032013Z.dump`.
|
||||
|
||||
### Verifying a Backup
|
||||
|
||||
Check integrity before trusting a dump:
|
||||
|
||||
```bash
|
||||
# List available backups
|
||||
ls -lh /var/backups/wownerogue/
|
||||
cd /var/backups/wowngeon/daily
|
||||
sudo -u postgres sha256sum -c wownerogue-20260726T032013Z.dump.sha256
|
||||
sudo -u postgres pg_restore --list wownerogue-20260726T032013Z.dump | head
|
||||
```
|
||||
|
||||
# Restore a specific backup (replace filename with actual backup)
|
||||
gunzip -c /var/backups/wownerogue/wownerogue_20251222_030000.sql.gz | psql -h $DB_HOST -U $DB_USER -d $DB_NAME
|
||||
### Restoring
|
||||
|
||||
# Or with explicit values
|
||||
gunzip -c /var/backups/wownerogue/wownerogue_20251222_030000.sql.gz | psql -h localhost -U your_user -d wownerogue
|
||||
Custom-format dumps restore with `pg_restore`, not `psql`. Restore into a scratch database first
|
||||
and promote only after inspection.
|
||||
|
||||
```bash
|
||||
# Restore into a fresh database
|
||||
sudo -u postgres createdb -O wownerogue wownerogue_restore
|
||||
sudo -u postgres pg_restore --no-owner --exit-on-error \
|
||||
--dbname=wownerogue_restore /var/backups/wowngeon/daily/wownerogue-20260726T032013Z.dump
|
||||
|
||||
# Sanity-check the restored copy
|
||||
sudo -u postgres psql -d wownerogue_restore -c "SELECT COUNT(*) FROM users;"
|
||||
sudo -u postgres psql -d wownerogue_restore -c \
|
||||
"SELECT filename FROM schema_migrations ORDER BY filename DESC LIMIT 1;"
|
||||
|
||||
# Discard the scratch copy when finished
|
||||
sudo -u postgres dropdb wownerogue_restore
|
||||
```
|
||||
|
||||
Restoring over a live database requires stopping the application unit first, because the server
|
||||
runs migrations at startup and holds pooled connections:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop wownerogue
|
||||
sudo -u postgres pg_restore --no-owner --clean --if-exists --exit-on-error \
|
||||
--dbname=wownerogue /var/backups/wowngeon/daily/wownerogue-20260726T032013Z.dump
|
||||
sudo systemctl start wownerogue
|
||||
```
|
||||
|
||||
Rehearse this path on a restored copy periodically. A backup that has never been restored is an
|
||||
untested assumption. The disposable clone migration gate in
|
||||
[CLONE_MIGRATIONS.md](./CLONE_MIGRATIONS.md) exercises restore plus the full migration set and is
|
||||
the natural place to combine the two rehearsals.
|
||||
|
||||
---
|
||||
|
||||
## Simple Single-Database Alternative
|
||||
|
||||
`scripts/backup_db.sh` is a smaller portable script for operators who do not want the hardened
|
||||
job: one database, gzipped plain SQL, credentials from the environment. Its companion units are
|
||||
`scripts/wownerogue-backup.service` and `scripts/wownerogue-backup.timer` (daily at 03:00 with up
|
||||
to 15 minutes of jitter).
|
||||
|
||||
| Variable | Default |
|
||||
| --- | --- |
|
||||
| `DB_NAME` | `wownerogue` |
|
||||
| `DB_USER` | `postgres` |
|
||||
| `DB_HOST` | `localhost` |
|
||||
| `DB_PORT` | `5432` |
|
||||
| `BACKUP_DIR` | `/var/backups/wownerogue` |
|
||||
| `RETENTION_DAYS` | `30` |
|
||||
|
||||
The first five match the application's own database settings in `src/.env.example`, so the service
|
||||
unit loads that file directly with `EnvironmentFile=`. Each run writes
|
||||
`<DB_NAME>_<YYYYmmdd_HHMMSS>.sql.gz`, refreshes a `<DB_NAME>_LATEST.sql.gz` symlink, deletes a
|
||||
partial file if `pg_dump` fails, and prunes dumps older than `RETENTION_DAYS`.
|
||||
|
||||
Because it connects over TCP as a database role, it needs working credentials, typically a
|
||||
`~/.pgpass` entry or `PGPASSWORD` in the unit environment. Plain-SQL dumps restore with `psql`:
|
||||
|
||||
```bash
|
||||
gunzip -c /var/backups/wownerogue/wownerogue_LATEST.sql.gz \
|
||||
| psql -h "$DB_HOST" -U "$DB_USER" -d wownerogue_restore
|
||||
```
|
||||
|
||||
A cron equivalent, for hosts without systemd timers:
|
||||
|
||||
```cron
|
||||
0 3 * * * cd /var/www/wownerogue/src && set -a && . ./.env && set +a && /var/www/wownerogue/scripts/backup_db.sh >> /var/log/wownerogue-backup.log 2>&1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alternative: Cron-based Backups
|
||||
## What Database Backups Do Not Cover
|
||||
|
||||
If you prefer cron over systemd timers:
|
||||
A database dump alone does not restore a working instance. Back up these separately, with access
|
||||
controls at least as strict as the dumps:
|
||||
|
||||
```bash
|
||||
# Edit crontab
|
||||
crontab -e
|
||||
- **Wallet keyfiles and their passwords.** The wallet-RPC units keep wallet state under
|
||||
`/var/lib/<instance>-wallet` (see `scripts/deploy/wownero-wallet-rpc.conf.example`). Losing these
|
||||
loses the funds; the database records balances, never keys.
|
||||
- **Instance secrets.** `/etc/wownerogue/app.env`, `/etc/wownerogue/wallet-rpc.env`, and the
|
||||
equivalents for other instances. `src/.env.example` is the full annotated template for what these
|
||||
contain.
|
||||
- **Reverse proxy and TLS configuration**, which lives outside the application tree.
|
||||
|
||||
# Add this line (runs daily at 3 AM)
|
||||
0 3 * * * cd /path/to/wownerogue/src && source .env && ../scripts/backup_db.sh >> /var/log/wownerogue-backup.log 2>&1
|
||||
```
|
||||
Keep at least one copy of everything above off the host that runs the database. Same-disk backups
|
||||
protect against operator error, not against losing the machine.
|
||||
|
||||
---
|
||||
|
||||
## Backup Verification
|
||||
## Monitoring Checklist
|
||||
|
||||
Periodically verify your backups work:
|
||||
|
||||
```bash
|
||||
# Create a test database
|
||||
createdb -h localhost -U $DB_USER wownerogue_test
|
||||
|
||||
# Restore backup to test database
|
||||
gunzip -c /var/backups/wownerogue/wownerogue_LATEST.sql.gz | psql -h localhost -U $DB_USER -d wownerogue_test
|
||||
|
||||
# Verify data
|
||||
psql -h localhost -U $DB_USER -d wownerogue_test -c "SELECT COUNT(*) FROM users;"
|
||||
|
||||
# Drop test database
|
||||
dropdb -h localhost -U $DB_USER wownerogue_test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Monitoring Backup Health
|
||||
|
||||
Add to your monitoring checklist:
|
||||
|
||||
- [ ] Check backup timer is active: `systemctl status wownerogue-backup.timer`
|
||||
- [ ] Verify recent backups exist: `ls -la /var/backups/wownerogue/ | tail -5`
|
||||
- [ ] Check backup sizes are reasonable (not 0 bytes)
|
||||
- [ ] Periodically test restore process
|
||||
- [ ] Monitor disk space: `df -h /var/backups`
|
||||
- Timer is active and scheduled: `systemctl list-timers wowngeon-db-backup.timer`
|
||||
- Last run succeeded: `systemctl status wowngeon-db-backup.service`
|
||||
- Recent dumps exist and are non-empty: `ls -lh /var/backups/wowngeon/daily/ | tail`
|
||||
- Checksums verify: `sha256sum -c` on the newest sidecar
|
||||
- Dump sizes track database growth rather than dropping sharply
|
||||
- Backup volume has headroom: `df -h /var/backups`
|
||||
- Off-host copies are current
|
||||
- A restore rehearsal has happened recently enough to be meaningful
|
||||
|
||||
+176
-140
@@ -1,9 +1,10 @@
|
||||
# Match Mode — Race/PvP Multiplayer
|
||||
# Match Mode: Real-Time Multiplayer Races
|
||||
|
||||
> Status: **Implemented** — all five milestones complete. Single-player and Tavern modes are untouched.
|
||||
Match mode adds real-time multiplayer races. It reuses the shared-world engine (`Room` / `Occupant`),
|
||||
the render kit, the payment and payout systems, and provably-fair seeding. Single-player and Tavern
|
||||
modes are unaffected by it.
|
||||
|
||||
Match mode adds real-time multiplayer races to Wownerogue. It reuses the existing shared-world
|
||||
engine (`Room`/`Occupant`), the render kit, the payment/payout systems, and provably-fair seeding.
|
||||
Match mode is opt-in. Nothing in this document is active unless `MATCH_ENABLED=true`.
|
||||
|
||||
## Supported economies
|
||||
|
||||
@@ -11,37 +12,43 @@ engine (`Room`/`Occupant`), the render kit, the payment/payout systems, and prov
|
||||
|---|---|---|---|
|
||||
| `free` | None | None | Pleb board |
|
||||
| `credits_prestige` | Credits | None (prestige only) | Prestige board |
|
||||
| `crypto_race` | Race-entry ticket | Winner-take-pot (minus house fee) | Hall of Champions |
|
||||
| `crypto_race` | Race-entry ticket | Winner-take-pot minus house fee | Hall of Champions |
|
||||
|
||||
Unredeemable credits are the same `users.credits` balance running with `CREDITS_PAYOUTS_ENABLED=false`;
|
||||
there is no separate "fake credit" system. Crypto races use non-refundable **race-entry tickets**
|
||||
(`users.race_entries`) so players never need an on-chain refund when leaving a queue before a match
|
||||
starts.
|
||||
`credits_prestige` spends the same `users.credits` balance used everywhere else; there is no separate
|
||||
"fake credit" system. On an instance running `CREDITS_PAYOUTS_ENABLED=false` those credits are
|
||||
unredeemable, so the economy is prestige-only.
|
||||
|
||||
## Lifecycle: a race every block
|
||||
Crypto races spend non-refundable race-entry tickets (`users.race_entries`, backed by rows in
|
||||
`race_entry_lots`), so leaving a queue before a match starts never requires an on-chain refund.
|
||||
|
||||
The existing solo `queueHandler` already reacts to new crypto blocks. `MatchScheduler` subscribes to
|
||||
the same block-count event. Free and value-bearing queues intentionally have different starts:
|
||||
## Lifecycle
|
||||
|
||||
- A free queue with ≥2 players drains into a `MatchRoom` immediately on the current block event.
|
||||
- A paid queue with ≥2 players at canonical header H atomically creates a `pending` match and links
|
||||
the exact FIFO queue rows and entrants. That transaction records a deterministic entrant-freeze
|
||||
commitment and fixes canonical header H+the configured entropy delay as its target; it does not
|
||||
read a block hash or create a playable seed. A fresh post-commit count is persisted while the
|
||||
`MatchScheduler` subscribes to the same block-count event as the solo `queueHandler`. Free and
|
||||
value-bearing queues start differently on purpose.
|
||||
|
||||
- A free queue with at least the ruleset minimum of players drains into a `MatchRoom` immediately on
|
||||
the current block event.
|
||||
- A paid queue at canonical header H atomically creates a `pending` match and links the exact FIFO
|
||||
queue rows and entrants. That transaction records a deterministic entrant-freeze commitment and
|
||||
fixes canonical header `H + MATCH_PAID_ENTROPY_DELAY_BLOCKS` as its target; it does not read a
|
||||
block hash or create a playable seed. A fresh post-commit daemon count is persisted while the
|
||||
target is still in the future.
|
||||
- Only a later, sufficiently confirmed block-count event can resolve that pending match. The
|
||||
scheduler twice requests the canonical hash for the exact target, verifies the frozen IDs again,
|
||||
derives the seed, and changes the match from `pending` to `starting` before collection/countdown.
|
||||
It immediately checks the hash again. Another height and server randomness are not substitutes.
|
||||
- If a queue has 1 player, they carry over and may leave until a freeze claims their row.
|
||||
scheduler requests the canonical hash for the exact target twice, verifies the frozen IDs again,
|
||||
derives the seed, and moves the match from `pending` to `starting` before ticket collection and
|
||||
countdown. It re-reads the hash immediately after activation. Another height and server randomness
|
||||
are not substitutes.
|
||||
- If a queue is below the minimum player count, its members carry over and may leave until a freeze
|
||||
claims their row.
|
||||
- If a queue is empty, nothing happens.
|
||||
|
||||
A match ends when:
|
||||
1. Its selected ruleset resolves (for example, first exit, last alive, or all players resolved).
|
||||
2. If `ruleset.timing.blockDeadline` is enabled, the first advancing canonical header after both
|
||||
the match's start header and `MATCH_MIN_DURATION_MS` of active play is observed. A duplicate
|
||||
block poll, the start header itself, or a block arriving before the floor cannot end the match;
|
||||
after an early block, the match waits for the next advancing header.
|
||||
|
||||
1. Its selected ruleset resolves (for example first exit, last alive, or all players resolved).
|
||||
2. `ruleset.timing.blockDeadline` is enabled and the first advancing canonical header is observed
|
||||
after both the match's start header and `MATCH_MIN_DURATION_MS` of active play. A duplicate block
|
||||
poll, the start header itself, or a block arriving before the floor cannot end the match; after an
|
||||
early block the match waits for the next advancing header.
|
||||
3. The `MATCH_HARD_CEILING_MS` absolute ceiling expires.
|
||||
|
||||
Final placement and `winnerId` come from the selected ruleset's deterministic rank strategy. The
|
||||
@@ -52,82 +59,111 @@ rankings.
|
||||
|
||||
### Engine layer (`src/multiplayer/`)
|
||||
|
||||
- `MatchRoom.js` — extends `Room`. Owns a shared deterministic dungeon, shared monster, player
|
||||
life/death/finish state, treasure, and the block/timer deadline. Serves as the synchronous tick
|
||||
resolver.
|
||||
- `MatchEngine.js` — server tick driver. Wraps a `MatchRoom` with a `setInterval` timer, calls
|
||||
- `MatchRoom.js` extends `Room`. Owns the shared deterministic dungeon, the shared monster when the
|
||||
ruleset includes one, player life/death/finish state, treasure, and the block/timer deadline. Acts
|
||||
as the synchronous tick resolver.
|
||||
- `MatchEngine.js` is the server tick driver. It wraps a `MatchRoom` with an interval timer, calls
|
||||
`resolveTick()`, and fires `onFinish` when the match ends.
|
||||
- `MatchState.js` — serialization helpers that produce broadcast/persistence shapes, including the
|
||||
renderer-agnostic `gameState` consumed by `html/js/render/sceneModel.js`.
|
||||
- `MatchState.js` holds serialization helpers that produce broadcast and persistence shapes,
|
||||
including the renderer-agnostic `gameState` consumed by `html/js/render/sceneModel.js`.
|
||||
|
||||
### Transport / lifecycle layer (`src/network/`)
|
||||
### Transport and lifecycle layer (`src/network/`)
|
||||
|
||||
- `matchQueue.js` — persisted per-economy queues (`free`, `credits_prestige`, `crypto_race`),
|
||||
join/leave, atomic credit/ticket escrow.
|
||||
- `matchScheduler.js` — block-cadence scheduler that drains queues into matches.
|
||||
- `matchManager.js` — Socket.IO rooms, match lifecycle, broadcasts, reconnect grace, persistence.
|
||||
- `matchPayoutService.js` — crypto-race pot accounting, house-fee split, winner payout record.
|
||||
- `matchLeaderboard.js` — posts match scores to the correct leaderboard.
|
||||
- `tavernMatchBridge.js` — forwards public match state to the tavern room for spectators.
|
||||
- `matchQueue.js` persists per-economy queues (`free`, `credits_prestige`, `crypto_race`), handles
|
||||
join and leave, and performs atomic credit and ticket-lot escrow.
|
||||
- `matchScheduler.js` is the block-cadence scheduler that freezes, activates, and drains queues into
|
||||
matches.
|
||||
- `matchFairness.js` builds and verifies the paid entrant-freeze commitment and derives match seeds.
|
||||
- `matchEconomyPolicy.js` is the single fail-closed admission contract for crypto matches, shared by
|
||||
the queue, scheduler, mode advertisement, and environment validator.
|
||||
- `matchManager.js` owns Socket.IO rooms, match lifecycle, broadcasts, reconnect grace, and
|
||||
persistence.
|
||||
- `matchPayoutService.js` handles crypto-race pot accounting, house-fee split, and the winner payout
|
||||
record.
|
||||
- `matchLeaderboard.js` posts match scores to the correct leaderboard.
|
||||
- `tavernMatchBridge.js` forwards public match state to the tavern room for spectators.
|
||||
|
||||
### Rulesets (`src/game/rulesets/`)
|
||||
|
||||
Built-in match rulesets are `race`, `last-alive`, `score-attack`, and `coop-escape`; `solo-classic`
|
||||
is solo-only. `resolveMatchRuleset()` fails closed to `race` for an unknown or solo-only id. See
|
||||
[RULESETS.md](RULESETS.md).
|
||||
|
||||
### Persistence
|
||||
|
||||
See migration `src/migrations/022_match_mode.sql`:
|
||||
Schema lives in `src/migrations/`:
|
||||
|
||||
- `matches` — one race, including the durable `pending` entrant-freeze envelope and later
|
||||
verifiable seed commitment, economy, pot/fee, winner.
|
||||
- `match_entrants` — per-player state, placement, score, payment link.
|
||||
- `match_events` — replay / spectator / audit feed.
|
||||
- `match_queue_entries` — persisted queue for restart safety.
|
||||
- `race_entry_transactions` — race-entry ticket ledger.
|
||||
- `users.race_entries` — hot-read ticket balance.
|
||||
- `payouts.match_id` — links winner payout to match.
|
||||
- `022_match_mode.sql` creates `matches`, `match_entrants`, `match_events`, `match_queue_entries`,
|
||||
and `race_entry_transactions`, adds `users.race_entries` and `payouts.match_id`.
|
||||
- `029_match_ruleset_id.sql` records the ruleset that produced each match.
|
||||
- `033_match_payout_liability.sql` adds `race_entry_lots` (per-payment funded ticket lots), links
|
||||
queue entries and entrants to their exact lot, and makes the accepted match liability immutable.
|
||||
- `040_paid_match_entropy_precommit.sql` adds `entropy_precommit_tip_height` and
|
||||
`entropy_precommit_verified_at`, with a check constraint requiring the witnessed tip to be below
|
||||
the target height for paid economies.
|
||||
|
||||
`032_solo_liability_invariants.sql` defines the unconditional unique index
|
||||
`idx_payouts_one_per_match`.
|
||||
|
||||
Tables:
|
||||
|
||||
- `matches` is one race, including the `pending` entrant-freeze envelope, the verifiable seed
|
||||
commitment, economy, pot and fee, and winner.
|
||||
- `match_entrants` is per-player state, placement, score, and payment link.
|
||||
- `match_events` is the replay, spectator, and audit feed.
|
||||
- `match_queue_entries` is the persisted queue, for restart safety.
|
||||
- `race_entry_transactions` is the race-entry ticket ledger.
|
||||
|
||||
### Match seed verification
|
||||
|
||||
Paid matches use `future-block-freeze-v2` / `future-chain-block-v2`. At canonical header H the
|
||||
server commits the exact economy, ruleset, sorted durable FIFO queue-entry IDs, configured delay,
|
||||
and target `H + MATCH_PAID_ENTROPY_DELAY_BLOCKS` in a `pending` row before requesting any hash.
|
||||
After that transaction commits, a fresh strict daemon count must prove that the target does not yet
|
||||
exist; the witnessed tip and verification timestamp are durable and immutable. A delayed commit
|
||||
that reaches the target is cancelled and every exact escrow anchor is refunded without reading the
|
||||
target hash. Legacy v1 or otherwise unverified pending freezes are also refunded at startup.
|
||||
Paid matches use the `future-block-freeze-v2` freeze and `future-chain-block-v2` seed versions. At
|
||||
canonical header H the server commits the exact economy, ruleset, sorted durable FIFO queue-entry
|
||||
IDs, configured delay, and target `H + MATCH_PAID_ENTROPY_DELAY_BLOCKS` into a `pending` row before
|
||||
requesting any hash. After that transaction commits, a fresh strict daemon count must prove the
|
||||
target does not yet exist; the witnessed tip and verification timestamp are durable and immutable. A
|
||||
delayed commit that reaches the target is cancelled and every exact escrow anchor is refunded without
|
||||
reading the target hash. Legacy v1 and otherwise unverified pending freezes are refunded at startup.
|
||||
|
||||
Activation waits until the target itself has the configured confirmation depth (the target counts
|
||||
as confirmation one). The scheduler reads count/header/count/header and requires both exact-height
|
||||
headers to have the same canonical hash. It re-reads that target immediately after the activation
|
||||
transaction and before ticket collection, gameplay, or client notification; an unavailable or
|
||||
changed hash aborts and refunds the match. The playable seed is SHA-256-derived only from the v2
|
||||
freeze commitment and exact target hash. The persisted/public proof records the delay, required
|
||||
Activation waits until the target itself has the configured confirmation depth, where the target
|
||||
counts as confirmation one. The scheduler reads count, header, count, header, and requires both
|
||||
exact-height headers to carry the same canonical hash. It re-reads that target immediately after the
|
||||
activation transaction and before ticket collection, gameplay, or client notification; an unavailable
|
||||
or changed hash aborts and refunds the match. The playable seed is SHA-256 derived only from the v2
|
||||
freeze commitment and the exact target hash. The persisted public proof records the delay, required
|
||||
confirmations, minimum activation tip, and post-commit witness. Confirmation settings are safety
|
||||
metadata rather than extra seed material.
|
||||
metadata, not extra seed material.
|
||||
|
||||
Monero-family `getblockcount` is a count, while `get_block(height)` uses zero-based header heights.
|
||||
The scheduler normalizes the event exactly once (`observedHeaderHeight = blockCount - 1`). Every
|
||||
persisted/disclosed `blockHeight` is the actual header height passed unchanged to `get_block`, so the
|
||||
published height resolves to the published hash without an off-by-one translation.
|
||||
Monero-family `getblockcount` returns a count while `get_block(height)` uses zero-based header
|
||||
heights. The scheduler normalizes the event exactly once (`observedHeaderHeight = blockCount - 1`).
|
||||
Every persisted or disclosed `blockHeight` is the actual header height passed unchanged to
|
||||
`get_block`, so a published height resolves to the published hash with no off-by-one translation.
|
||||
|
||||
This proves the documented seed derivation and deterministic dungeon; it does not prove honest
|
||||
input handling, block-source independence, payout delivery, or resistance to a malicious chain
|
||||
producer/operator. Free development matches may use a server-random commitment and are labelled
|
||||
`server commitment only`. Do not describe match mode as equivalent to the solo two-party fairness
|
||||
Free matches derive their seed from the current block hash when one is available. When it is not,
|
||||
they fall back to a server-random seed recorded as derivation version `server-random-v1`. Paid
|
||||
economies have no such fallback: a paid match without a `future-chain-block-v2` proof is retained
|
||||
rather than started.
|
||||
|
||||
This construction proves the documented seed derivation and the deterministic dungeon. It does not
|
||||
prove honest input handling, block-source independence, payout delivery, or resistance to a malicious
|
||||
chain producer or operator. Do not describe match mode as equivalent to the solo two-party fairness
|
||||
protocol without an independent review.
|
||||
|
||||
## Configuration
|
||||
|
||||
Add to `src/.env` (documented in `src/.env.example`):
|
||||
Set these in `src/.env`. The full annotated template is `src/.env.example`; a match-only excerpt is
|
||||
`src/.env.match.example`.
|
||||
|
||||
```bash
|
||||
# Enable match mode (default false)
|
||||
MATCH_ENABLED=false
|
||||
|
||||
# Max players per match (also bounded by the selected ruleset; built-ins max at 8)
|
||||
# Max players per match, also bounded by the selected ruleset (built-ins max at 8)
|
||||
MATCH_MAX_PLAYERS=4
|
||||
|
||||
# Server tick interval in ms
|
||||
MATCH_TICK_MS=250
|
||||
|
||||
# Server-selected gameplay ruleset; clients cannot override it
|
||||
# Server-selected gameplay ruleset; clients cannot override it.
|
||||
# race | last-alive | score-attack | coop-escape
|
||||
MATCH_RULESET_ID=race
|
||||
|
||||
@@ -148,7 +184,7 @@ MATCH_CREDITS_COST=1
|
||||
# Crypto race house fee percent (required; 0 <= fee < 100)
|
||||
MATCH_HOUSE_FEE_PERCENT=5
|
||||
|
||||
# Required per-player funded ticket value in atomic units.
|
||||
# Required per-player funded ticket value in atomic units
|
||||
MATCH_ENTRY_FEE_ATOMIC=5000000000
|
||||
|
||||
# Crypto admission requires both explicit match gates and an outer atomic-unit payout cap
|
||||
@@ -157,103 +193,103 @@ MATCH_PAYOUTS_ENABLED=false
|
||||
MATCH_PAYOUT_MAX=50000000000
|
||||
```
|
||||
|
||||
Crypto races also require:
|
||||
- A paid instance (`GAME_MODE=PAID_SINGLE` / `PAID_CREDITS` or equivalent `PAYMENT_MODES`).
|
||||
- At least one race-entry product with `grants.race_entries: N` and
|
||||
`grants.race_entry_value_atomic` exactly equal to `MATCH_ENTRY_FEE_ATOMIC`. The product price
|
||||
must cover `N * MATCH_ENTRY_FEE_ATOMIC`; confirmation records a durable lot per payment.
|
||||
- A competitive single-winner ruleset. `coop-escape` is rejected until split-payout semantics are
|
||||
implemented.
|
||||
Crypto races additionally require:
|
||||
|
||||
- A paid instance (`GAME_MODE=PAID_SINGLE` or `PAID_CREDITS`, or the equivalent `PAYMENT_MODES`).
|
||||
- At least one race-entry product with `grants.race_entries: N` and `grants.race_entry_value_atomic`
|
||||
exactly equal to `MATCH_ENTRY_FEE_ATOMIC`. The product price must cover `N * MATCH_ENTRY_FEE_ATOMIC`;
|
||||
confirmation records a durable lot per payment.
|
||||
- A competitive single-winner ruleset. `coop-escape` is rejected because it has no
|
||||
winner-takes-pot split contract.
|
||||
|
||||
## Client surfaces
|
||||
|
||||
- `/match.html` — dedicated race queue / race view with economy selector and race HUD.
|
||||
- `/tavern.html` — **Queue for Race** panel and **Active Races** list with Watch buttons. Tavern
|
||||
spectators receive `tavern_match_tick` / `tavern_match_end` events and render races through the
|
||||
existing render kit.
|
||||
- `/match.html` with `html/js/matchClient.js` is the dedicated race page: entry-mode selector,
|
||||
ruleset catalog, race HUD, render-style picker, and touch d-pad.
|
||||
- `/tavern.html` adds a Multiplayer Arena panel with the entry-mode selector and an active-match list
|
||||
with Watch buttons. Tavern spectators receive `tavern_match_tick` and `tavern_match_end` and render
|
||||
races through the render kit. A tavern viewer who is a player in the match keeps movement controls
|
||||
instead of a spectator camera.
|
||||
|
||||
## Socket events
|
||||
|
||||
Server emits:
|
||||
- `game_mode_info` — includes `modes.match { enabled, economies, maxPlayers, activeRuleset, rulesets }`.
|
||||
- `match_queue_joined` / `match_queue_left` — queue responses.
|
||||
- `match_joined` / `match_start` / `match_tick` / `match_end` — race lifecycle for players.
|
||||
- `tavern_match_list` / `tavern_match_tick` / `tavern_match_end` — public race feed for tavern
|
||||
|
||||
- `game_mode_info` includes `modes.match { enabled, economies, maxPlayers, activeRuleset, rulesets }`.
|
||||
- `match_queue_joined` and `match_queue_left` are queue responses.
|
||||
- `match_joined`, `match_start`, `match_tick`, `match_end` are the race lifecycle for players.
|
||||
- `match_rejoined` restores a reconnecting player; `match_settlement_pending` reports a result whose
|
||||
payout is still settling.
|
||||
- `player_forfeit` and `player_death` are in-match participant updates.
|
||||
- `match_error` is the benign catch-all failure response for any match request.
|
||||
- `tavern_match_list`, `tavern_match_tick`, `tavern_match_end` are the public race feed for tavern
|
||||
spectators.
|
||||
|
||||
Client sends:
|
||||
|
||||
- `match_queue` `{ economy, action: 'join' | 'leave' }`
|
||||
- `match_move` `{ dx, dy }`
|
||||
- `match_leave` — forfeit an active race.
|
||||
- `tavern_match_list` — request active races.
|
||||
- `match_reconnect` to rejoin an active or just-finished match after a reconnect.
|
||||
- `match_leave` to forfeit an active race.
|
||||
- `tavern_match_list` to request active races.
|
||||
|
||||
Identity for every match request is resolved from the connection, never from the payload.
|
||||
|
||||
## Leaderboards
|
||||
|
||||
- **Free competitive matches** create synthetic `games` rows with `game_mode = 'FREE'` for the
|
||||
Pleb board.
|
||||
- **Credits/prestige competitive matches** are served from authoritative `matches` and
|
||||
- Free competitive matches create synthetic `games` rows with `game_mode = 'FREE'` for the Pleb
|
||||
board.
|
||||
- Credits/prestige competitive matches are served from the authoritative `matches` and
|
||||
`match_entrants` rows by `GET /api/leaderboard?board=prestige`.
|
||||
- **Crypto competitive matches** create synthetic `games` rows with `game_mode = 'PAID_CREDITS'`
|
||||
for the generic self-hosted Hall of Champions board. Such Software's two operated profiles keep
|
||||
this economy disabled and exclude all match-generated rows from Champions.
|
||||
- Crypto competitive matches create synthetic `games` rows with `game_mode = 'PAID_CREDITS'` for the
|
||||
generic self-hosted Hall of Champions board. The operated product profiles
|
||||
(`such-play-wow-prestige`, `such-monerogue-stagenet`) keep this economy disabled and exclude all
|
||||
match-generated rows from Champions.
|
||||
|
||||
The public leaderboard endpoint requires `board=pleb|champions|prestige`; omitting `board` safely
|
||||
defaults to `pleb`. The removed mixed `board=all` behavior and unknown or blank values return 400
|
||||
instead of silently combining or translating economies.
|
||||
The public leaderboard endpoint accepts `board=pleb|champions|prestige`. Omitting `board` defaults to
|
||||
`pleb`; unknown or blank values return 400 rather than silently combining or translating economies.
|
||||
|
||||
An individual win requires the durable match winner and placement #1 to agree; reaching the exit
|
||||
alone is never treated as a win in last-alive or score-attack. `coop-escape` is collective, records
|
||||
no individual `winnerId`, and keeps its results in `matches` / `match_entrants`; it is intentionally
|
||||
excluded from individual boards until a dedicated team leaderboard exists.
|
||||
no individual `winnerId`, and keeps its results in `matches` and `match_entrants`. It is excluded
|
||||
from individual boards.
|
||||
|
||||
## Tests
|
||||
|
||||
Run the match-mode test suites in isolation:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
npx jest ../test/match
|
||||
npx jest test/match
|
||||
```
|
||||
|
||||
The suite includes block-deadline, authoritative leaderboard-result, co-op exclusion, durable
|
||||
finish, and finished-room reconnect regressions in addition to the core room/queue/payout tests.
|
||||
|
||||
## Files added / modified
|
||||
|
||||
New:
|
||||
- `src/migrations/022_match_mode.sql`
|
||||
- `src/multiplayer/MatchRoom.js`, `MatchEngine.js`, `MatchState.js`
|
||||
- `src/network/matchQueue.js`, `matchScheduler.js`, `matchManager.js`, `matchPayoutService.js`,
|
||||
`matchLeaderboard.js`, `tavernMatchBridge.js`
|
||||
- `html/match.html`, `html/js/matchClient.js`
|
||||
- `docs/MATCH_MODE.md`, `src/.env.match.example`
|
||||
- 7 test files
|
||||
|
||||
Modified:
|
||||
- `src/network/socketHandlers.js` — initializes match services and wires socket events.
|
||||
- `src/network/identityService.js` — adds `userForId()`.
|
||||
- `src/game/gameModeManager.js` — advertises `modes.match`; grants race-entry tickets on product
|
||||
payment.
|
||||
- `src/payments/productGrants.js` — recognizes `grants.race_entries`.
|
||||
- `src/index.js` — `/api/leaderboard?board=prestige`.
|
||||
- `html/tavern.html` — race queue / spectator UI.
|
||||
- `src/.env.example`, `docs/TAVERN_AND_MULTIPLAYER.md`, `docs/TODO.md`, `README.md`.
|
||||
The suites cover the room, queue, scheduler, payout service, and leaderboard, plus block-deadline,
|
||||
authoritative leaderboard-result, co-op exclusion, durable finish, crash recovery, client
|
||||
settlement, and finished-room reconnect regressions.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- Match mode is **opt-in** and inert unless `MATCH_ENABLED=true`.
|
||||
- `MATCH_RULESET_ID` is trusted server configuration; unknown/solo-only values fall back to `race`.
|
||||
- `MATCH_RULESET_ID` is trusted server configuration. Unknown or solo-only values fall back to
|
||||
`race`.
|
||||
- Crypto race payouts reuse the existing `payouts` table, batch processor, and retry service.
|
||||
- `MATCH_PAYOUTS_ENABLED=false` stops new admission and refunds queued ticket escrow. Matches whose
|
||||
pot/liability was already accepted still create their durable payout; the global payout master
|
||||
switch may pause dispatch without erasing it.
|
||||
- `MATCH_PAYOUTS_ENABLED=false` blocks new crypto admission. Queue entries persisted for a
|
||||
now-unavailable economy are cancelled and refunded transactionally during queue initialization.
|
||||
Matches whose pot and liability were already accepted still create their durable payout from the
|
||||
immutable admission snapshot; the global payout master switch may pause dispatch without erasing
|
||||
it.
|
||||
- The unconditional unique index `idx_payouts_one_per_match` prevents a replacement payout in every
|
||||
status, including `failed` and `needs_review`.
|
||||
- Only confirmed-payment ticket lots with the current exact entry value can enter a crypto race;
|
||||
legacy/admin-granted aggregate tickets cannot create a payout liability. The exact lot is held
|
||||
- Only confirmed-payment ticket lots with the current exact entry value can enter a crypto race, so
|
||||
legacy or admin-granted aggregate tickets cannot create a payout liability. The exact lot is held
|
||||
on queue join and consumed in the same transaction that accepts the immutable match liability.
|
||||
- Players who disconnect during a race have a 30-second grace period to reconnect.
|
||||
- Paid freeze/start block events are serialized. A crash before activation leaves a resumable
|
||||
`pending` freeze; a crash after activation is handled by abandoned-match recovery. Orderly
|
||||
shutdown stops the scheduler, waits for its block task, and transactionally cancels/refunds every
|
||||
- Players who disconnect during a race have a 30-second grace period to reconnect before an AFK kill.
|
||||
Match mappings are retained for a further 30 seconds after a finish so late reconnects still
|
||||
resolve the result.
|
||||
- Paid freeze and start block events are serialized. A crash before activation leaves a resumable
|
||||
`pending` freeze; a crash after activation is handled by abandoned-match recovery. Orderly shutdown
|
||||
stops the scheduler, waits for its block task, and transactionally cancels and refunds every
|
||||
still-pending freeze before shutting down match transport.
|
||||
|
||||
## See also
|
||||
|
||||
- [TAVERN_AND_MULTIPLAYER.md](TAVERN_AND_MULTIPLAYER.md)
|
||||
- [RULESETS.md](RULESETS.md)
|
||||
- [PRODUCTION_DISCLOSURES.md](PRODUCTION_DISCLOSURES.md)
|
||||
|
||||
+137
-76
@@ -1,105 +1,166 @@
|
||||
# Monetization — credits, catalog & entitlements (P1)
|
||||
# Monetization: credits, catalog, and entitlements
|
||||
|
||||
One unit of account (**credits**), one operator-owned **catalog**, and a tiered **entitlement** rule that
|
||||
decides who has unlocked which cosmetics — and now doubles as the premium-subscription payoff.
|
||||
Cosmetics are sold through three pieces: **credits** (the unit of account), an operator-owned
|
||||
**catalog** of packs, and an **entitlement rule** that decides which packs a given user has unlocked.
|
||||
Payouts are a separate system and are not affected by any of this.
|
||||
|
||||
## Credits
|
||||
|
||||
Credits are the unit of account. A direct/single-game entry is modeled as *buy 1 credit + spend 1 credit*
|
||||
(`gameModeManager.recordDirectEntryPurchase`) so it advances `total_credits_purchased` and unlocks the same
|
||||
threshold cosmetics as a real credit purchase. Payouts are orthogonal to credits.
|
||||
Two columns on `users` drive everything:
|
||||
|
||||
| Column | Meaning |
|
||||
|---|---|
|
||||
| `credits` | spendable balance; decremented when a game is entered with credits |
|
||||
| `total_credits_purchased` | lifetime credits purchased, cumulative, never decremented by play |
|
||||
|
||||
Credit packages are declared in `src/config/paymentConfig.js` under `modes.credits.packages`
|
||||
(`{ id, credits, price, bonus }`). On confirmation, `gameModeManager.processProductPaymentConfirmation`
|
||||
adds `credits + bonus` to both columns in one transaction and writes a `credit_transactions` row.
|
||||
|
||||
A direct (single-game) entry is modeled as *buy one credit, immediately spend it*
|
||||
(`gameModeManager.recordDirectEntryPurchase`): the balance nets to zero, `total_credits_purchased`
|
||||
advances by 1, and two `credit_transactions` rows (`direct_entry` purchase, `game_entry` spend)
|
||||
record the pair. A direct payment therefore unlocks the same threshold cosmetics as a credit purchase.
|
||||
|
||||
Refunds reverse what a payment granted. `src/services/paymentRefundService.js` deducts the granted
|
||||
credits, purchase progression, and race entries, restores the previous `premium_level`, and revokes
|
||||
pack grants. If the grant cannot be reversed exactly (credits already spent, progression already
|
||||
consumed, ambiguous provenance on a bare direct entry), the refund is parked as `needs_review`
|
||||
instead of leaving a cosmetic unlock behind.
|
||||
|
||||
## The catalog
|
||||
|
||||
`cosmetic_catalog` (migration `024`) is the operator-owned product table — it replaces the old hardcoded,
|
||||
split-brain `PACKS` (server + client). Columns: `pack_id, label, kind, projection, tier, unlock_min_credits,
|
||||
grant_only, sort_order, active, metadata`.
|
||||
`cosmetic_catalog` (migration `024`, repriced by migration `028`) is the operator-owned product
|
||||
table and the single server-authoritative source for pack definitions:
|
||||
|
||||
`src/services/catalogService.js` loads + caches it, falling back to `DEFAULT_CATALOG` if the table is
|
||||
missing (42P01) or empty — so the game runs before the migration and never hard-fails on catalog reads.
|
||||
The server ships `catalogSummary()` to the client, which merges it into `RK.PACKS` — the client no longer
|
||||
owns a parallel catalog.
|
||||
```
|
||||
pack_id, label, kind, projection, tier, unlock_min_credits,
|
||||
grant_only, sort_order, active, metadata
|
||||
```
|
||||
|
||||
`src/services/catalogService.js` loads active rows ordered by `sort_order`, caches them for 60
|
||||
seconds, and exposes `invalidate()`. It falls back to the built-in `DEFAULT_CATALOG` in
|
||||
`src/multiplayer/entitlements.js` when the table is missing (`42P01`), empty, or yields no valid
|
||||
rows, so the game runs on a fresh or partly-migrated database and never hard-fails on a catalog read.
|
||||
Rows without both `pack_id` and `label` are skipped.
|
||||
|
||||
`catalogSummary()` projects the catalog into the display-facing shape the client renders
|
||||
(`id, label, kind, projection, tier, premium, unlockMinCredits`).
|
||||
|
||||
## The unlock rule
|
||||
|
||||
`src/multiplayer/entitlements.js` — `snapshotForUser(user, grants, catalog)`. A pack unlocks if **any** of:
|
||||
`snapshotForUser(user, grants, catalog)` in `src/multiplayer/entitlements.js`. A pack is unlocked if
|
||||
**any** of:
|
||||
|
||||
1. it is free: `tier === 0`, `unlockMinCredits == null`, and not `grantOnly`
|
||||
2. an explicit grant exists in `user_pack_entitlements` (unexpired)
|
||||
3. `total_credits_purchased >= unlock_min_credits` (when the pack sets a threshold)
|
||||
4. the user's premium tier is `>=` the pack's `tier` (when `tier > 0`)
|
||||
|
||||
`TIER_OF = { free: 0, credits: 0, supporter: 1, premium: 2, operator: 3 }`. Clause 4 is the
|
||||
subscription hook. Buying credits maps to tier 0, so a purchase never blanket-unlocks the premium
|
||||
tiers; it only advances the threshold in clause 3.
|
||||
|
||||
The returned snapshot is `{ premium, level, tier, credits, totalCreditsPurchased, packs, catalog }`,
|
||||
where `packs` is a per-pack boolean map and `premium` is true when the user has any premium tier or
|
||||
any non-free pack unlocked.
|
||||
|
||||
## The credit ladder
|
||||
|
||||
Clause 3 keys off lifetime credits *purchased*, cumulatively. It is not deducted by play, so buying
|
||||
credits to play also walks the user up the cosmetic ladder and cosmetics never compete with the core
|
||||
loop. Each catalog row is data: a tilepack or a character skin, at any rung and any tier. Adding a
|
||||
pack means appending a catalog row, shipping the assets, and calling `RK.registerPack` on the client.
|
||||
|
||||
| Lifetime credits | Pack | Projection | Tier |
|
||||
|---|---|---|---|
|
||||
| free | `original` (bare original tiles) | topdown | 0 |
|
||||
| 1 | `roguelike-interior` | topdown | 1 |
|
||||
| 5 | `generated-skins` (character skins) | topdown | 1 |
|
||||
| 10 | `iso-dungeon` | iso | 2 |
|
||||
| 20 | `roguelike-dungeon` | topdown | 2 |
|
||||
| 40 | `iso-medieval` | iso | 3 |
|
||||
| 50 | `kenney-3d-characters` | 3d | 3 |
|
||||
|
||||
The `original` pack maps only the essentials (floor, wall, door, torch, features); furniture kinds
|
||||
fall back to floor, so the free tavern reads deliberately plain.
|
||||
|
||||
Because the tier column is also live, a `premium` subscription (tier 2) unlocks every pack at tier
|
||||
2 or below without any credit spend; tier 3 packs need `operator` or the credit threshold.
|
||||
|
||||
### Render techniques versus packs
|
||||
|
||||
`RK.RENDER_MODES` in `html/js/render/renderModes.js` defines four techniques: Tiled and ASCII are
|
||||
free, Iso and 3D are premium. `RK.canUseMode` grants a premium technique once the user has **any**
|
||||
unlocked pack for its projection, so the cheapest pack in a projection is effectively that
|
||||
technique's gate. Plain ASCII stays free for accessibility.
|
||||
|
||||
Client-side gating is a cosmetic honour gate, not a security boundary: rendering happens in the
|
||||
browser. The server-side boundary is `Entitlements.normalizeAppearance`, which resets an appearance
|
||||
whose avatar pack the user has not unlocked before it is persisted or broadcast.
|
||||
|
||||
## Subscription tiers
|
||||
|
||||
`levelForUser(user)` returns the higher of the stored `premium_level` and any `subscription_tier`,
|
||||
so an active subscription sets the entitlement tier and unlocks the packs at or below it. One
|
||||
subscription drives both chat perks and cosmetics; the chat side is documented in
|
||||
[CHAT_AND_NOSTR.md](CHAT_AND_NOSTR.md).
|
||||
|
||||
`identityService.entitlementsForUser` populates `subscription_tier` before taking the snapshot by
|
||||
calling `SubscriptionService.tierForNpub(user.smirk_public_key)`. The call is best-effort: any
|
||||
resolver failure leaves the field unset and the snapshot falls back to credit thresholds and grants.
|
||||
|
||||
```
|
||||
1. it's free (tier 0, no gate)
|
||||
2. an explicit grant (products → grants)
|
||||
3. lifetime credits PURCHASED ≥ the pack's unlock_min_credits threshold
|
||||
4. the user's premium TIER ≥ the pack's tier
|
||||
active subscription -> tierForNpub(npub) -> user.subscription_tier -> levelForUser -> tier
|
||||
|- relay: chat perks
|
||||
|- catalog: pack unlocks
|
||||
```
|
||||
|
||||
`TIER_OF = { free:0, credits:0, supporter:1, premium:2, operator:3 }`. Clause 4 is the premium hook.
|
||||
**Buying credits does NOT put you on the premium ladder** (`credits` → tier 0) — that was the old bug where
|
||||
any purchase unlocked every premium pack.
|
||||
`src/services/subscriptionService.js` resolves a tier from two optional sources, in order, and caches
|
||||
the result for 5 minutes. With neither configured it returns `null` and no one is auto-promoted.
|
||||
|
||||
### The ladder (loyalty model)
|
||||
|
||||
Clause 3 keys off **lifetime credits _purchased_**, cumulatively — it is **not deducted**, so buying credits
|
||||
to *play* also walks you up the cosmetic ladder (cosmetics never compete with the core loop). Each catalog
|
||||
row is just data — a **tilepack OR a char-skin**, at any credit rung — so the operator adds more of either by
|
||||
appending a row (+ assets + `registerPack` for a client pack). This IS the "infinite packs" system.
|
||||
|
||||
| Rung (lifetime credits) | Unlocks |
|
||||
| Environment variable | Meaning |
|
||||
|---|---|
|
||||
| **free** | `original` bare tiles (no furniture → the "ugly tavern", by design) · plain ASCII |
|
||||
| 1 | `roguelike-interior` (first premium tilepack) |
|
||||
| 5 | `generated-skins` (character skins) |
|
||||
| 10 | `iso-dungeon` — unlocks the **Iso** technique |
|
||||
| 20 | `roguelike-dungeon` (another tilepack) |
|
||||
| 40 | `iso-medieval` (second iso pack) |
|
||||
| 50 | `kenney-3d-characters` — unlocks **3D** |
|
||||
| `PREMIUM_NPUBS` | operator allowlist, `npub1…\|64hex[:tier],…`; npub entries are decoded to x-only hex to match `users.smirk_public_key` |
|
||||
| `PREMIUM_DEFAULT_TIER` | tier for allowlist entries with no explicit `:tier` (default `premium`) |
|
||||
| `SMIRK_PREMIUM_STATUS_URL` | optional HTTP endpoint answering premium-by-npub with `{ active, tier? }` |
|
||||
| `SMIRK_PREMIUM_STATUS_KEY` | bearer token sent to that endpoint |
|
||||
|
||||
A render **technique** (Iso/3D) is available once the user has *any* unlocked pack for its projection, so the
|
||||
cheapest pack in a projection is effectively that technique's gate. Plain ASCII stays free (accessibility).
|
||||
Source of truth: `cosmetic_catalog` table (migration 028) mirrored by `DEFAULT_CATALOG` in `entitlements.js`.
|
||||
The Smirk backend's `/premium/status` is self-only (user-token auth), so it cannot answer a lookup
|
||||
for an arbitrary npub. The allowlist covers that case with no backend dependency; the HTTP seam is
|
||||
for a service-scoped endpoint where one exists.
|
||||
|
||||
## Premium subscription → cosmetics
|
||||
## Reaching the client
|
||||
|
||||
`levelForUser(user)` returns the **higher** of the stored `premium_level` and an active
|
||||
`subscription_tier`. So a live **wowne.ro premium subscription** sets the entitlement tier, which unlocks
|
||||
tile/customization packs at/below it — **one subscription drives both chat perks and cosmetics** (see
|
||||
[CHAT_AND_NOSTR.md](CHAT_AND_NOSTR.md)).
|
||||
The server emits `identity_update` with `{ appearance, entitlements }`, where `entitlements` carries
|
||||
the `packs` map and the projected `catalog`. `RK.setEntitlementSnapshot` in
|
||||
`html/js/render/assetPacks.js` merges the served catalog into `RK.PACKS` (merge, not replace, so a
|
||||
client-side fallback a render mode depends on survives a catalog that omits it), recomputes the
|
||||
per-pack unlock map, and persists the snapshot to `localStorage` for the next load. Operator-added
|
||||
packs appear on the client without a client release.
|
||||
|
||||
Everything downstream — pack gating, the served catalog, the client unlock UI — already consumes `tier`.
|
||||
The data source is `src/services/subscriptionService.js` (`SubscriptionService.tierForNpub`), which
|
||||
`identityService.entitlementsForUser` calls to populate `user.subscription_tier` before the snapshot:
|
||||
`?unlock=1` sets a sticky per-browser QA bypass (`RK.renderModeTestUnlocks`) that unlocks premium
|
||||
modes and packs locally; `?unlock=0` clears it. It affects only the browser that sets it.
|
||||
|
||||
```
|
||||
active premium sub ──► SubscriptionService.tierForNpub(npub) ──► user.subscription_tier ──► levelForUser ──► tier
|
||||
├─ relay: chat perks (badge, no-PoW, VIP)
|
||||
└─ catalog: tile / skin / customization unlocks
|
||||
```
|
||||
## Operated product profiles
|
||||
|
||||
Two sources, checked in order (both optional — absent ⇒ tier null ⇒ unchanged legacy behavior):
|
||||
|
||||
| Env | Meaning |
|
||||
|-----|---------|
|
||||
| `PREMIUM_NPUBS` | operator allowlist `npub1…\|hex[:tier],…` — works **today**, zero backend dependency |
|
||||
| `PREMIUM_DEFAULT_TIER` | tier for allowlist entries without an explicit `:tier` (default `premium`) |
|
||||
| `SMIRK_PREMIUM_STATUS_URL` / `_KEY` | optional HTTP endpoint answering premium-by-npub `{active, tier?}`, for full automation |
|
||||
|
||||
> The Smirk backend's `/premium/status` is **self-only** (user-token auth), so the game can't look up
|
||||
> an arbitrary npub through it. Full automation needs a small backend addition — a service/by-npub
|
||||
> premium endpoint (or a webhook that sets the tier). Until then, `PREMIUM_NPUBS` is the working source.
|
||||
When `OPERATED_PRODUCT_PROFILE` is set, `src/config/operatedProductProfiles.js` rejects standalone
|
||||
cosmetic products (`products.cosmetic`) and any credit package carrying a `grants` object. Those
|
||||
deployments sell only the credits and bonus declared at the top level of a credit package, so the
|
||||
credit ladder and subscription tiers are the only unlock paths. Independent deployments do not set
|
||||
the variable and are not constrained by it.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
src/migrations/024_cosmetic_catalog.sql operator-owned catalog table
|
||||
src/services/catalogService.js load/cache + DEFAULT_CATALOG fallback
|
||||
src/multiplayer/entitlements.js snapshotForUser · levelForUser · TIER_OF · unlock rule
|
||||
src/services/subscriptionService.js premium tier by npub (PREMIUM_NPUBS allowlist + HTTP seam)
|
||||
src/network/identityService.js entitlementsForUser (loads catalog + grants + sub tier)
|
||||
src/game/gameModeManager.js recordDirectEntryPurchase (direct entry = 1 credit)
|
||||
src/migrations/024_cosmetic_catalog.sql catalog table
|
||||
src/migrations/028_cosmetic_ladder.sql ladder rows (idempotent upsert)
|
||||
src/services/catalogService.js load, cache, DEFAULT_CATALOG fallback
|
||||
src/multiplayer/entitlements.js snapshotForUser, levelForUser, TIER_OF, unlock rule
|
||||
src/services/subscriptionService.js premium tier by npub
|
||||
src/services/paymentRefundService.js entitlement reversal and needs-review routing
|
||||
src/network/identityService.js entitlementsForUser (catalog + grants + subscription tier)
|
||||
src/game/gameModeManager.js recordDirectEntryPurchase, credit package confirmation
|
||||
html/js/render/assetPacks.js client consumes the served catalog
|
||||
html/js/render/renderModes.js technique registry and gating
|
||||
```
|
||||
|
||||
## Open decisions
|
||||
|
||||
- Premium tiers: one flat "premium," or a ladder (supporter/premium/operator) where each rung unlocks
|
||||
progressively more cosmetics *and* chat perks? (`TIER_OF` already has the rungs.)
|
||||
- Full-automation source: add a service/by-npub premium endpoint to the Smirk backend (then set
|
||||
`SMIRK_PREMIUM_STATUS_URL`) vs. a webhook that stamps the tier. `PREMIUM_NPUBS` covers it manually today.
|
||||
|
||||
+73
-33
@@ -1,48 +1,88 @@
|
||||
# Multi-level dungeons
|
||||
|
||||
A run descends through `maxDepth` levels instead of a single map. This is the pacing lever that
|
||||
makes the block-timer provide the house edge on slow chains without a giant dungeon or a cheating
|
||||
monster (see [`BALANCE_SIM.md`](BALANCE_SIM.md) for why).
|
||||
A run descends through `maxDepth` levels instead of playing a single map. Depth is the pacing lever
|
||||
that lets the block timer supply the house edge on slow chains without an oversized dungeon or a
|
||||
monster that moves faster than the player. See [`BALANCE_SIM.md`](BALANCE_SIM.md) for the balance
|
||||
model behind that choice.
|
||||
|
||||
## Mechanic
|
||||
|
||||
- Each level is a normal preset-sized dungeon with its own entrance, exit, and a fair monster.
|
||||
- Reaching a **non-final** exit takes the **stairs down** → a fresh level is generated, the player
|
||||
is placed at its entrance, a new monster spawns, and fog-of-war resets. This is **not** a win.
|
||||
- Reaching the **final** level's exit **escapes** (win).
|
||||
- **Treasure lives only in the vault** (the final level). Intermediate levels are a race to the
|
||||
stairs; the 2×/3× treasure payout requires descending all the way and getting back out.
|
||||
- `maxDepth = 1` (single level) is byte-identical to the pre-multi-level game.
|
||||
- Each level is a normal preset-sized dungeon with its own entrance, exit, and a freshly placed
|
||||
monster.
|
||||
- Reaching a **non-final** exit takes the **stairs down**: a new level is generated, the player is
|
||||
placed at its entrance, a new monster spawns, and fog of war resets. Descending is not a win.
|
||||
- Reaching the **final** level's exit **escapes** the dungeon and wins the run.
|
||||
- **Treasure exists only on the final level.** Intermediate levels are a pure race to the stairs, so
|
||||
the treasure payout multiplier requires descending all the way and getting back out.
|
||||
- `maxDepth = 1` is the single-level case: `levelSeed` returns the master seed unchanged at depth 1,
|
||||
so a one-level run's layout is exactly what the master seed alone produces.
|
||||
- The monster does not take its turn on the arrival move of a descent. `afterPlayerMove` returns
|
||||
early on a `descend` result, so the player is never caught by a monster they have not yet seen.
|
||||
|
||||
## Provably fair across the descent
|
||||
## Provable fairness across the descent
|
||||
|
||||
The whole run regenerates from the one committed seed. Per-level seed
|
||||
(`levelSeed(masterSeed, depth)` in `provablyFair.js`): **level 1 = the master seed verbatim**
|
||||
(so single-level games are unchanged), deeper levels salt it (`<seed>:L<depth>`). Verify any level
|
||||
with `DungeonGenerator.regenerateFromSeed(levelSeed(seed, depth), cryptoType)`.
|
||||
Every level in a run regenerates from the one committed seed. `levelSeed(masterSeed, depth)` in
|
||||
`provablyFair.js` returns the master seed verbatim for level 1 and salts it as `<seed>:L<depth>` for
|
||||
deeper levels. Any level can be reproduced with:
|
||||
|
||||
```js
|
||||
DungeonGenerator.regenerateFromSeed(levelSeed(seed, depth), cryptoType, generationOptions);
|
||||
```
|
||||
|
||||
The `Game` constructor commits a layout fingerprint for **every** level of the run up front, before
|
||||
play, in `gameProof.layoutFingerprints` (one entry per depth, each carrying `depth`,
|
||||
`fingerprintVersion`, `generatorVersion`, and the SHA-256 `fingerprint`). `gameProof.layoutFingerprint`
|
||||
remains as a level-1 alias for older verification clients. Because the manifest is complete at
|
||||
creation time, a run that ends early still exposes a full, immutable record of the advertised
|
||||
descent.
|
||||
|
||||
Each level is re-fingerprinted as it is actually generated during play, and a mismatch against the
|
||||
committed manifest throws. Generator drift inside a live process becomes a hard failure rather than
|
||||
an unverifiable served layout.
|
||||
|
||||
`src/game/fairnessVerifier.js` replays the whole manifest: it rejects a manifest whose depths are
|
||||
incomplete or out of order, regenerates each level from its per-depth seed, nulls the treasure on
|
||||
every non-final level to match play, and reports per-level fingerprint matches alongside entrance,
|
||||
exit, treasure, and dimensions.
|
||||
|
||||
## Configuration
|
||||
|
||||
`maxDepth` comes from `difficultyConfig.levels`, set by `NETWORK_TUNING` per chain
|
||||
(GRIN 1 · XMR 2 · LTC 2 · WOW 4 · BTC 8, ∝ block time). Operator overrides: `DUNGEON_LEVELS=<n>`;
|
||||
`NETWORK_TUNING_DISABLED=true` forces a single level.
|
||||
`maxDepth` comes from `difficultyConfig.levels`, set per chain by `NETWORK_TUNING` in
|
||||
`src/game/difficultyConfig.js`. Depth scales with block time so that a slower chain gives a longer
|
||||
run before the block lands:
|
||||
|
||||
| Chain | Approx. block time | Levels |
|
||||
|---|---|---|
|
||||
| GRIN | ~1 min | 1 |
|
||||
| XMR | ~2 min | 1 |
|
||||
| LTC | ~2.5 min | 2 |
|
||||
| WOW | ~5 min | 2 |
|
||||
| BTC | ~10 min | 3 |
|
||||
|
||||
Resolution order for the level count, lowest precedence first: preset (no `levels` key, so the
|
||||
effective floor is 1) → `NETWORK_TUNING` for the chain → `customOverrides.levels` → the
|
||||
`DUNGEON_LEVELS` environment variable, which always wins and is clamped to a minimum of 1.
|
||||
|
||||
`NETWORK_TUNING_DISABLED=true` skips per-network tuning entirely, which leaves the run at a single
|
||||
level unless `DUNGEON_LEVELS` or an explicit override supplies a count. An unknown `cryptoType` is
|
||||
also left untuned and therefore single-level.
|
||||
|
||||
Dungeon size and monster behaviour are not varied by depth: `applyNetworkTuning` folds only the
|
||||
level count onto the resolved difficulty preset, and every level uses the same preset dimensions and
|
||||
the same fair monster settings.
|
||||
|
||||
## Where it lives
|
||||
|
||||
| Concern | Location |
|
||||
|---|---|
|
||||
| Level generation + descent | `src/game/game.js` — `_generateLevel(depth)`, `_descend()`, the exit branch in `movePlayer` (returns `event: 'descend'`), `depth`/`maxDepth` in `getState` |
|
||||
| Per-level seed | `src/game/provablyFair.js` — `levelSeed()` |
|
||||
| Level count (pacing) | `src/game/difficultyConfig.js` — `NETWORK_TUNING`, `DUNGEON_LEVELS` |
|
||||
| Server wiring | `src/network/socketHandlers.js` — `afterPlayerMove` `'descend'` branch (no game-over, no monster move on arrival) |
|
||||
| Client | `html/js/core/gameState.js` — clears accumulated map/explored/visible tiles on a `depth` change so the old level doesn't ghost through the new one |
|
||||
| Sim | `src/sim/simBots.js` — explorer bot resets fog-of-war on descent |
|
||||
| Tests | `test/multiLevel_ultra.test.js` |
|
||||
|
||||
## Open polish
|
||||
|
||||
- Client **"Level N / M" indicator** + a "descended" toast (the server sends `depth`/`maxDepth` and
|
||||
a `game_event: descend`; there is no client `game_event` handler yet).
|
||||
- Per-network **level counts are sim starting points** — pending a full multi-level calibration run.
|
||||
- **Escalating hazards per level** (level 1 tame → deep levels lava/poison) — the hazard-tile FX is
|
||||
already wired (`RENDER_PACKS.md`); it needs the generator to place hazards + gameplay stakes.
|
||||
| Level generation and descent | `src/game/game.js`: `_generateLevel(depth)`, `_descend()`, the exit branch in `movePlayer` returning `event: 'descend'`, and `depth`/`maxDepth` in `getState` |
|
||||
| All-level fingerprint manifest | `src/game/game.js`: `_buildLayoutFingerprintManifest()`, `getProofContext()` |
|
||||
| Per-level seed | `src/game/provablyFair.js`: `levelSeed()` |
|
||||
| Level count | `src/game/difficultyConfig.js`: `NETWORK_TUNING`, `applyNetworkTuning()`, `DUNGEON_LEVELS` |
|
||||
| Verification | `src/game/fairnessVerifier.js` |
|
||||
| Server wiring | `src/network/socketHandlers.js`: the `'descend'` branch in `afterPlayerMove` (no game over, no monster move on arrival) |
|
||||
| Event emission | `src/game/movementManager.js`: emits `game_event` with `event`, `depth`, `maxDepth` after the `game_update` |
|
||||
| Client state | `html/js/core/gameState.js`: clears accumulated map, explored, and visible tiles on a `depth` change so the previous level does not ghost through the new one |
|
||||
| Client feedback | `html/js/network/socketHandlers.js`: the `game_event` handler renders the descend message and on-screen banner with `Level N / M` |
|
||||
| Sim | `src/sim/simBots.js`: the explorer bot clears its known-tile set and remembered objectives on a depth change |
|
||||
| Tests | `test/multiLevel_ultra.test.js`, `test/networkTuning_ultra.test.js` |
|
||||
|
||||
+142
-56
@@ -1,85 +1,171 @@
|
||||
# Payments (P3)
|
||||
# Payments
|
||||
|
||||
The game no longer implements per-chain wallet/daemon code inline. It talks to pluggable **payment
|
||||
The game does not implement per-chain wallet or daemon code inline. It talks to pluggable **payment
|
||||
providers** behind one contract, so an operator routes each chain to whichever backend they run.
|
||||
|
||||
## The contract
|
||||
## Provider contract
|
||||
|
||||
`src/payments/providers/paymentProvider.js`:
|
||||
`src/payments/providers/paymentProvider.js` defines the interface. All amounts are atomic units for
|
||||
the chain (BigInt or an integer-valued string), never decimal floats.
|
||||
|
||||
```
|
||||
createInvoice({chain, amountAtomic, description, userId, orderId})
|
||||
→ {invoiceId, address, uri, amountAtomic, expiresAt, raw}
|
||||
getInvoiceStatus(ref) → {status, complete, paidAtomic, confirmations, raw}
|
||||
-> {invoiceId, address, uri, amountAtomic, expiresAt, raw}
|
||||
getInvoiceStatus(ref)
|
||||
-> {status: 'pending'|'processing'|'paid'|'expired'|'invalid', complete, paidAtomic, confirmations, raw}
|
||||
startWatch(ref, onUpdate, intervalMs) / stopWatch(ref)
|
||||
sendPayout({chain, address, amountAtomic}) → {txids, raw}
|
||||
validateAddress(chain, address) → {valid}
|
||||
supportsChain(chain) → bool
|
||||
sendPayout({chain, address, amountAtomic}) -> {txids, raw} (optional)
|
||||
validateAddress(chain, address) -> {valid, reason} (optional)
|
||||
supportsChain(chain) -> boolean
|
||||
```
|
||||
|
||||
`PaymentProviderRegistry` routes `chain → providerId` (operator-configurable) with a support-based
|
||||
fallback: if the routed provider can't serve a chain, any registered provider that *can* is used.
|
||||
`PaymentProviderRegistry` maps `chain -> providerId` and is operator-configurable. `getProvider(chain)`
|
||||
returns the routed provider; if that provider does not support the chain, the registry falls back to
|
||||
any registered provider that does, and finally to the default provider.
|
||||
|
||||
## The providers
|
||||
## Providers
|
||||
|
||||
| Provider | id | Serves | Notes |
|
||||
|----------|-----|--------|-------|
|
||||
| **BTCPay Greenfield** | `btcpay` | BTC, LTC | `src/payments/providers/btcpayProvider.js` — real BTCPay Server. Invoice priced in the chain's own crypto; address read from the invoice payment-methods; `token` auth. |
|
||||
| **xmrcheckout** | `xmrcheckout` | XMR | Same class, different endpoint — the checkout app exposes a **BTCPay-Greenfield-compatible** shim. |
|
||||
| **wowcheckout** | `wowcheckout` | WOW | Ditto (matched pair to xmrcheckout). |
|
||||
| **Native Monero** | `native-monero` | XMR, WOW | `src/payments/providers/nativeMoneroProvider.js` — wraps the existing `walletRPCService` (subaddress invoicing + `transfer_split` payouts). |
|
||||
| Provider | id | Serves | Implementation |
|
||||
|----------|-----|--------|----------------|
|
||||
| BTCPay Greenfield | `btcpay` | BTC, LTC by default | `src/payments/providers/btcpayProvider.js`, a BTCPay Server client |
|
||||
| xmrcheckout | `xmrcheckout` | XMR | Same class against a checkout app that exposes Greenfield-compatible routes |
|
||||
| wowcheckout | `wowcheckout` | WOW | Same class, matched pair to xmrcheckout |
|
||||
| Native Monero/Wownero | `native-monero` | XMR, WOW | `src/payments/providers/nativeMoneroProvider.js`, wraps `walletRPCService` |
|
||||
|
||||
The key discovery: **BTCPay, xmrcheckout, and wowcheckout all speak one Greenfield contract**, so a single
|
||||
`BTCPayProvider` class (constructor-configurable `id`) serves all three — just registered at different
|
||||
endpoints. See the `btcpay-infra-topology` note for the operator's LAN endpoints.
|
||||
BTCPay, xmrcheckout, and wowcheckout all speak the same Greenfield contract, so one `BTCPayProvider`
|
||||
class with a constructor-configurable `id` serves all three; they differ only in base URL, store id,
|
||||
API key, and chain set. Auth on all three is `Authorization: token <apiKey>`. Invoices are priced in
|
||||
the chain's own crypto by passing the chain ticker as the Greenfield `currency`, and the receiving
|
||||
address is read from the invoice's payment-methods endpoint.
|
||||
|
||||
## Bootstrap & routing
|
||||
The native provider keys invoices by their Monero/Wownero subaddress and returns the wallet's
|
||||
`addressIndex`, which is what lets monitoring be restored after a restart.
|
||||
|
||||
`buildProviderRegistry({env, walletService})` (`src/payments/providers/index.js`) is the single wiring seam:
|
||||
## Bootstrap and routing
|
||||
|
||||
- Native Monero/Wownero provider registers whenever a `walletService` exists.
|
||||
- Each Greenfield gateway activates from its own env trio:
|
||||
`BTCPAY_URL/_STORE_ID/_API_KEY` (chains from `BTCPAY_CHAINS`, default BTC,LTC),
|
||||
`XMRCHECKOUT_URL/_STORE_ID/_API_KEY` (XMR), `WOWCHECKOUT_URL/_STORE_ID/_API_KEY` (WOW).
|
||||
- Default routing: BTC/LTC → btcpay, XMR/WOW → their checkout gateway (else native). `PAYMENT_ROUTING`
|
||||
(`CHAIN:provider,…` or JSON) overrides any mapping.
|
||||
`buildProviderRegistry({env, walletService})` in `src/payments/providers/index.js` is the single
|
||||
wiring seam:
|
||||
|
||||
**Behavior-preserving:** with no gateway env set (prod today), only the native provider registers and
|
||||
*every* chain routes to it — byte-for-byte the legacy flow.
|
||||
- The native provider is registered whenever a `walletService` is supplied, and claims XMR and WOW.
|
||||
- Each Greenfield gateway activates from its own environment trio:
|
||||
`BTCPAY_URL` / `BTCPAY_STORE_ID` / `BTCPAY_API_KEY` (chain list from `BTCPAY_CHAINS`, default
|
||||
`BTC,LTC`), `XMRCHECKOUT_URL` / `_STORE_ID` / `_API_KEY` (XMR), and
|
||||
`WOWCHECKOUT_URL` / `_STORE_ID` / `_API_KEY` (WOW).
|
||||
- A dedicated checkout gateway wins over native for its own chain.
|
||||
- `PAYMENT_ROUTING` overrides every mapping. It accepts JSON (`{"BTC":"btcpay","XMR":"native-monero"}`)
|
||||
or a compact `CHAIN:provider,CHAIN:provider` string; unparseable values are ignored.
|
||||
- The default provider, used for chains with no explicit route, is the native provider when present,
|
||||
otherwise the first registered gateway.
|
||||
|
||||
## The hot path
|
||||
With no gateway environment set, only the native provider is registered and every chain routes to it,
|
||||
which is the single-chain wallet-RPC flow. `GameModeManager` builds this registry in its constructor
|
||||
unless one is injected.
|
||||
|
||||
- **Invoice creation** — `gameModeManager.createPaymentRequest` calls `getProvider(cryptoType).createInvoice(...)`.
|
||||
The native provider delegates to the same `walletService.createPaymentRequest`, so the subaddress and
|
||||
monitoring maps are identical to before.
|
||||
- **Monitoring** — `network/paymentHandlers._monitorAddress` calls `provider.startWatch`. Native passes the
|
||||
raw wallet status straight through (legacy path unchanged); `BTCPayProvider.getWalletStatus` maps a
|
||||
Greenfield invoice to the same raw shape (`Settled → confirmed+complete`, `Processing → in_mempool`,
|
||||
paid/required amounts from payment-methods via `ChainProfile` decimals). The confirmation logic is
|
||||
extracted to `_handlePaymentStatus`, shared by native + gateway.
|
||||
- **Payouts** stay native (the checkout apps are receive-only; XMR/WOW payouts remain on wallet-RPC).
|
||||
The general environment template is `src/.env.example`. The gateway variables above are read straight
|
||||
from the process environment; add them to the deploy host's environment when enabling a gateway.
|
||||
|
||||
## Turning a gateway on
|
||||
## Invoice lifecycle
|
||||
|
||||
1. Mint a store API key in the gateway's admin (money infra — a human step).
|
||||
2. Set the `*_URL/_STORE_ID/_API_KEY` env on the deploy host, using an address the host can actually reach
|
||||
(verify LAN reachability; a nebula/overlay IP may not route).
|
||||
3. Restart. Invoices for that chain now create + confirm through the gateway; no code redeploy.
|
||||
**Creation.** `gameModeManager.createPaymentRequest` resolves the provider for `CRYPTO_TYPE` and calls
|
||||
`createInvoice`. Any stale `pending` payments for the user are expired first, so a user holds at most
|
||||
one active invoice. The row is inserted into `payments` with `provider_id`, `provider_invoice_id`,
|
||||
`address_index`, the expected atomic amount, product grants, and the fairness commitment fields.
|
||||
Invoices expire at the provider-supplied `expiresAt`, or 30 minutes from creation when the provider
|
||||
does not supply one.
|
||||
|
||||
## Money math
|
||||
**Monitoring.** `network/paymentHandlers._monitorAddress` records a socket-to-payment mapping, arms a
|
||||
30 minute expiry timer, and calls `provider.startWatch(watchRef, onStatus, 2000)`. The watch reference
|
||||
is the subaddress for the native provider and the invoice id for a gateway. `onStatus` receives a raw
|
||||
wallet-style status (`in_mempool`, `confirmed`, `complete`, `amount`, `required`, `confirmations`,
|
||||
`receipts`): the native provider passes the wallet's own status through untouched, and
|
||||
`BTCPayProvider.getWalletStatus` maps a Greenfield invoice into the same shape (`Settled` and
|
||||
`Complete` become confirmed and complete, `Processing` becomes in-mempool, paid and required amounts
|
||||
come from the payment-methods response converted through `ChainProfile` decimals). Both feed the one
|
||||
handler, `_handlePaymentStatus`.
|
||||
|
||||
All amounts are **atomic BigInt**, decimals-parameterized via `src/money/atomic.js` (`format`, `add`,
|
||||
`mulByDecimal`, …) and `src/chain/chainProfile.js` (`decimalsFor`, `atomicDivisor`). Never floats. WOW = 11
|
||||
decimals, XMR/BTC/LTC per their chain, GRIN = 9.
|
||||
**Confirmation.** A mempool sighting queues a single-game entry or announces a pending purchase; a
|
||||
confirmed status applies the entitlement. Underpayment emits `payment_underpaid` and leaves the
|
||||
invoice open so the balance can be topped up to the same address. Confirmation is idempotent: an
|
||||
in-memory set is only a fast path, and the database status transition under `FOR UPDATE` is the source
|
||||
of truth, so a restart cannot double-apply. When paid fairness binding is required, an entry whose
|
||||
invoice carries no bound fairness proof is refused at both the queue and confirm steps and reported as
|
||||
`payment_review_required` rather than started.
|
||||
|
||||
## Files
|
||||
**Receipt evidence.** Every confirmation must be backed by durable receipts in `payment_receipts`
|
||||
(migration `037_payment_receipt_evidence.sql`). Native receipts are `chain_output` rows identified by
|
||||
`txHash:outputId` and carrying the invoice's `address_index`; gateway receipts are `provider_invoice`
|
||||
rows identified by the invoice id. A unique index on `(provider_id, evidence_id)` makes each piece of
|
||||
evidence consumable exactly once, and a database trigger rejects any transition into `confirmed` whose
|
||||
confirmed receipts do not cover `expected_amount`. This is why one transaction with several outputs,
|
||||
or one invoice topped up by several transactions, both settle correctly.
|
||||
|
||||
**Late payments.** `src/services/latePaymentReconciler.js` scans expired invoices, re-reads status
|
||||
through the owning provider (`getWalletStatus` for gateways, wallet RPC for native), and records
|
||||
durable evidence in `payment_late_reviews` for manual adjudication. A late receipt never grants an
|
||||
entitlement on its own. See [FINANCIAL_RECONCILIATION.md](FINANCIAL_RECONCILIATION.md).
|
||||
|
||||
## Payouts
|
||||
|
||||
Payouts run through the native wallet service, not through the routed provider: the checkout gateways
|
||||
are receive-only. `gameModeManager` sends winnings via `walletService.processPayout` for single
|
||||
payouts and `processBatchPayout` for batches, both of which use `transfer_split` and return
|
||||
`tx_hash_list`. `src/payments/payoutRetryService.js` retries failures. `NativeMoneroProvider`
|
||||
implements `sendPayout` so the contract is complete, and it delegates to the same batch call.
|
||||
|
||||
Payout address validation prefers `walletService.validateAddress`, which is network-aware
|
||||
(mainnet/stagenet/testnet), and falls back to the provider's `validateAddress` only when the wallet
|
||||
service exposes none.
|
||||
|
||||
Payout behaviour is gated by `PAYOUTS_ENABLED` as a master switch with subordinate per-mode flags
|
||||
(`DIRECT_PAYOUTS_ENABLED`, `CREDITS_PAYOUTS_ENABLED`, `MATCH_PAYOUTS_ENABLED`). See
|
||||
[PRODUCTION_DISCLOSURES.md](PRODUCTION_DISCLOSURES.md).
|
||||
|
||||
## Money and chain parameters
|
||||
|
||||
All amounts are atomic integers. Arithmetic goes through `src/money/atomic.js` (`toBig`, `toSafe`,
|
||||
`sum`, `add`, `mulByDecimal`, `format`), and decimals come from `src/chain/chainProfile.js`
|
||||
(`decimalsFor`, `atomicDivisor`). No floating-point money anywhere.
|
||||
|
||||
| Chain | Decimals | Mean block time | Family | URI scheme |
|
||||
|-------|----------|-----------------|--------|------------|
|
||||
| WOW | 11 | 300000 ms | monero | `wownero` |
|
||||
| XMR | 12 | 120000 ms | monero | `monero` |
|
||||
| BTC | 8 | 600000 ms | utxo | `bitcoin` |
|
||||
| LTC | 8 | 150000 ms | utxo | `litecoin` |
|
||||
| GRIN | 9 | 60000 ms | mimblewimble | `grin` |
|
||||
|
||||
An unknown or unset chain resolves to an XMR-shaped default (12 decimals, 120000 ms, monero family)
|
||||
so a mis-set `CRYPTO_TYPE` degrades instead of crashing. `meanBlockTimeMsFor` is what block-time-aware
|
||||
difficulty scales against; per-chain dungeon sizing lives in `NETWORK_TUNING` in
|
||||
`src/game/difficultyConfig.js`.
|
||||
|
||||
Payment QR codes are generated by `src/payments/qrService.js` as `monero:` or `wownero:` URIs with
|
||||
`tx_amount` and a truncated `tx_description`. It returns `null` when the optional `qrcode` package is
|
||||
absent, and the address and amount are always shown as text as well.
|
||||
|
||||
## Enabling a gateway
|
||||
|
||||
1. Mint a store API key in the gateway's own admin interface.
|
||||
2. Set that gateway's `*_URL`, `*_STORE_ID`, and `*_API_KEY` on the deploy host, using an address the
|
||||
host can actually reach. Verify reachability from the host itself; an overlay-network address may
|
||||
not route from the service account.
|
||||
3. Restart the service. Invoices for that chain are created and confirmed through the gateway with no
|
||||
code change.
|
||||
|
||||
## File map
|
||||
|
||||
```
|
||||
src/payments/providers/paymentProvider.js contract + registry
|
||||
src/payments/providers/paymentProvider.js contract + registry
|
||||
src/payments/providers/btcpayProvider.js Greenfield client (btcpay / xmrcheckout / wowcheckout)
|
||||
src/payments/providers/nativeMoneroProvider.js wraps walletRPCService
|
||||
src/payments/providers/index.js buildProviderRegistry (env → registry)
|
||||
src/payments/walletRPCService.js Monero/Wownero wallet-RPC
|
||||
src/game/gameModeManager.js createPaymentRequest (invoice seam)
|
||||
src/network/paymentHandlers.js _monitorAddress / _handlePaymentStatus (monitoring seam)
|
||||
src/payments/providers/index.js buildProviderRegistry (env -> registry)
|
||||
src/payments/walletRPCService.js Monero/Wownero wallet RPC, payouts
|
||||
src/payments/payoutRetryService.js payout retry worker
|
||||
src/payments/qrService.js payment-URI QR codes
|
||||
src/chain/chainProfile.js decimals, block times, families, URI schemes
|
||||
src/money/atomic.js atomic-integer money arithmetic
|
||||
src/game/gameModeManager.js invoice creation, confirmation, receipts, payouts
|
||||
src/network/paymentHandlers.js monitoring and status handling
|
||||
src/services/latePaymentReconciler.js post-expiry evidence and review records
|
||||
src/migrations/037_payment_receipt_evidence.sql receipts, late reviews, confirmation-evidence trigger
|
||||
```
|
||||
|
||||
+111
-64
@@ -1,33 +1,50 @@
|
||||
# Production disclosures and paid-action gate
|
||||
|
||||
This implementation makes the running server describe its actual free, paid-prestige, payout,
|
||||
test-network, and PvP switches. It does **not** claim that the product is licensed or lawful in a
|
||||
particular place. Obtain jurisdiction-specific advice before accepting assets with real value.
|
||||
The server derives its public disclosures from its own runtime switches, so the words shown to a
|
||||
player describe the free, paid-prestige, payout, test-network, and PvP configuration that instance
|
||||
actually runs. Nothing here asserts that a deployment is licensed or lawful in a particular place.
|
||||
Obtain jurisdiction-specific advice before accepting assets with real value.
|
||||
|
||||
Implementation: `src/config/commerceDisclosurePolicy.js` (disclosure generation and acknowledgement
|
||||
validation), `src/config/operatedProductProfiles.js` (operated-product contracts),
|
||||
`src/config/environmentValidator.js` (startup and preflight checks),
|
||||
`html/js/legal/commerceConsent.js` (browser acknowledgement), `src/views/legalPages.js`
|
||||
(`/terms`, `/privacy`, `/responsible-play`).
|
||||
|
||||
## Operated-product boundary
|
||||
|
||||
Such Software (`apps@such.software`) operates only:
|
||||
Such Software (`apps@such.software`) operates two public services and nothing else.
|
||||
|
||||
| Profile | Public service | Exact scope |
|
||||
|---|---|---|
|
||||
| `such-play-wow-prestige` | `play.wowne.ro` | Wownero mainnet free play and pay-for-credits leaderboard/prestige. Credits are non-redeemable; there are no prizes, payouts, cash-out, or crypto racing, and the service is not offered/marketed as gambling. Legal classification depends on applicable law. |
|
||||
| `such-monerogue-stagenet` | `monerogue.app` | Monero stagenet direct-entry solo only. Single-player 2×/3× test gambling mechanics use test coins with **NO REAL VALUE**. Purchased-credit entry and crypto-match payouts are off. Never mainnet XMR. |
|
||||
| Profile | Service | Policy version | Scope |
|
||||
|---|---|---|---|
|
||||
| `such-play-wow-prestige` | `play.wowne.ro` | `2026-07-22-v1`, effective `2026-07-22` | Wownero mainnet free play plus pay-for-credits leaderboard and prestige play. Credits are non-redeemable service entitlements. No prizes, payouts, cash-out, or crypto racing, and the service is not offered or marketed as gambling. Legal classification depends on applicable law. |
|
||||
| `such-monerogue-stagenet` | `monerogue.app` | `2026-07-23-v2`, effective `2026-07-23` | Monero stagenet direct-entry solo play only. The single-player 2x and 3x outcomes are test gambling mechanics using test coins with **NO REAL VALUE**. Purchased-credit entry and crypto-match payouts are off. Never mainnet XMR. |
|
||||
|
||||
The corresponding `.env.mainnet.example` and `.env.stagenet.example` files opt into a fail-closed
|
||||
startup contract with `OPERATED_PRODUCT_PROFILE`. Preflight and normal startup reject any drift in
|
||||
host, operator identity, chain/network, paid modes, multipliers, or payout gates. Independent MIT
|
||||
self-hosts leave this setting unset and configure a truthful operator identity of their own.
|
||||
The operated Wownero disclosure is version `2026-07-22-v1`; Monerogue's direct-only scope is a
|
||||
material change and therefore uses version `2026-07-23-v2`, effective 2026-07-23.
|
||||
Setting `OPERATED_PRODUCT_PROFILE` to one of those identifiers opts a deployment into a fail-closed
|
||||
startup contract. Preflight (`npm run preflight`) and normal startup reject any drift from the
|
||||
profile in:
|
||||
|
||||
- `HOSTED_BY` public root, `OPERATOR_NAME`, `OPERATOR_CONTACT_URL`, `OPERATOR_CONTACT_LABEL`;
|
||||
- `LEGAL_POLICY_VERSION` and `TERMS_EFFECTIVE_DATE`;
|
||||
- `CRYPTO_TYPE`, `MONERO_NETWORK`, `GAME_MODE`, and `PAYMENT_MODES`;
|
||||
- direct/credits mode switches, every payout switch, and `ALLOW_MAINNET_PAYOUTS`;
|
||||
- the stagenet product's exact `DIRECT_PAYOUT_ESCAPE=2` and `DIRECT_PAYOUT_TREASURE=3` multipliers;
|
||||
- the product catalog: no standalone products, and a credit package may grant only its top-level
|
||||
credits and bonus. The whole `grants` object is rejected, including keys added later, so a new
|
||||
entitlement cannot silently widen the public product contract.
|
||||
|
||||
Independent MIT self-hosts leave `OPERATED_PRODUCT_PROFILE` unset and configure a truthful operator
|
||||
identity of their own; none of the profile constraints apply to them. Reference environments are
|
||||
`src/.env.mainnet.example` and `src/.env.stagenet.example`; the full annotated template is
|
||||
`src/.env.example`.
|
||||
|
||||
## Open-source and operator disclaimer
|
||||
|
||||
MIT permission to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies is
|
||||
subject to retaining the copyright and permission notice in all copies or substantial portions.
|
||||
The software is provided “AS IS”, without warranty of any kind, as stated in `LICENSE`. The MIT
|
||||
License governs rights in the source. Documentation, examples, and disclosure copy are
|
||||
informational only; they are not legal advice or a determination that a deployment complies with
|
||||
applicable law.
|
||||
MIT permission to use, copy, modify, merge, publish, distribute, sublicense, and sell copies is
|
||||
subject to retaining the copyright and permission notice in all copies or substantial portions. The
|
||||
software is provided "AS IS", without warranty of any kind, as stated in `LICENSE`. The MIT License
|
||||
governs rights in the source. Documentation, examples, and disclosure copy are informational only;
|
||||
they are not legal advice or a determination that a deployment complies with applicable law.
|
||||
|
||||
A third-party or self-hosted operator is solely responsible for its deployment, product design,
|
||||
legal compliance, funds, players, representations, and support. The MIT License does not make that
|
||||
@@ -37,85 +54,115 @@ third-party deployments.
|
||||
|
||||
## Required production settings
|
||||
|
||||
Every production instance with `PAYMENTS_ENABLED=true` fails preflight/startup unless all of these
|
||||
are explicit and valid:
|
||||
With `NODE_ENV=production` and payments resolved as enabled, startup and preflight fail unless each
|
||||
of these is explicit and valid:
|
||||
|
||||
```dotenv
|
||||
OPERATOR_NAME=Truthful responsible operator name
|
||||
OPERATOR_CONTACT_URL=mailto:support@example.com
|
||||
OPERATOR_CONTACT_LABEL=Support
|
||||
LEGAL_POLICY_VERSION=2026-07-22-v1
|
||||
TERMS_EFFECTIVE_DATE=2026-07-22
|
||||
MINIMUM_AGE=18
|
||||
PAID_ACKNOWLEDGEMENT_REQUIRED=true
|
||||
```
|
||||
|
||||
Production match mode also requires explicit paid-fairness timing, even if only prestige credits
|
||||
are presently exposed:
|
||||
Constraints: `OPERATOR_NAME` is 2 to 120 characters and may not contain placeholder text;
|
||||
`OPERATOR_CONTACT_URL` must parse as an `https:` or `mailto:` URL without placeholder text;
|
||||
`LEGAL_POLICY_VERSION` is 1 to 64 characters of `[A-Za-z0-9._-]` starting alphanumeric;
|
||||
`TERMS_EFFECTIVE_DATE` is a real `YYYY-MM-DD` date; `MINIMUM_AGE` is an integer from 18 through 120.
|
||||
|
||||
`OPERATOR_CONTACT_LABEL` is optional. Without it the disclosure derives a label from the contact
|
||||
URL. An operated product profile does require its exact configured value.
|
||||
|
||||
`RESTRICTED_LOCATIONS_NOTICE` is optional application copy. Add it only after legal review; text is
|
||||
not geofencing.
|
||||
|
||||
Changing the terms or any material disclosure requires a new `LEGAL_POLICY_VERSION`. That
|
||||
invalidates every prior browser-session acknowledgement.
|
||||
|
||||
### Match fairness timing
|
||||
|
||||
Production with `MATCH_ENABLED=true` also requires explicit paid-fairness timing, even when only
|
||||
prestige credits are exposed:
|
||||
|
||||
```dotenv
|
||||
MATCH_PAID_ENTROPY_DELAY_BLOCKS=2
|
||||
MATCH_PAID_ENTROPY_CONFIRMATIONS=2
|
||||
```
|
||||
|
||||
Both are bounded safe integers from 2 through 100. Paid PvP persists the actual delay,
|
||||
confirmation requirement, activation threshold, and post-commit daemon-tip witness in its public
|
||||
proof; changing these settings does not reinterpret an already-frozen match.
|
||||
|
||||
`RESTRICTED_LOCATIONS_NOTICE` is optional application copy. Add it only after legal review; text is
|
||||
not geofencing. Changing the terms or material disclosures requires a new `LEGAL_POLICY_VERSION`,
|
||||
which invalidates prior browser-session acknowledgements.
|
||||
Both are safe integers from 2 through 100. A paid match freezes its entry set, commits to a target
|
||||
block `DELAY_BLOCKS` ahead, and derives its seed only from a header confirmed to the configured
|
||||
depth. The persisted proof records the derivation, the confirmation requirement, the minimum
|
||||
confirmed tip height, and the pre-commit daemon-tip witness with its timestamp, so changing these
|
||||
settings later cannot reinterpret an already-frozen match.
|
||||
|
||||
## Enforcement contract
|
||||
|
||||
- `/api/disclosures` returns non-secret, no-store policy data derived from runtime switches.
|
||||
- `/api/disclosures` returns non-secret, `no-store` policy data derived from runtime switches.
|
||||
- `/terms`, `/privacy`, and `/responsible-play` render the active mode and operator metadata.
|
||||
- Before a payment invoice, paid solo/credit entry, or paid PvP queue join, the browser requires
|
||||
unselected age/reading/risk boxes. Test networks require an additional mainnet/test-coin box.
|
||||
- Acceptance is a five-field canonical record (policy version plus four booleans), scoped to the
|
||||
current browser tab and policy version, and cleared on rejection or socket disconnect.
|
||||
- The server validates the exact current version and booleans before consuming a fairness offer,
|
||||
opening an invoice, consuming a credit, or escrowing a paid PvP entry. A modified client cannot
|
||||
bypass this check.
|
||||
- Free entry remains available without a paid acknowledgement where the instance offers it.
|
||||
- Before a payment invoice, paid solo or credit entry, or a paid PvP queue join, the browser
|
||||
presents unchecked age, terms-read, and risk boxes and enables the continue button only when all
|
||||
are selected. Test networks add a mainnet/test-coin box.
|
||||
- Acceptance is a five-field canonical record: `policyVersion` plus `ageEligible`, `termsRead`,
|
||||
`riskAccepted`, and `testnetUnderstood`. It lives in `sessionStorage`, so it is scoped to the
|
||||
browser tab and to the exact policy version, and it is cleared on cancel, on a version mismatch,
|
||||
and on socket disconnect.
|
||||
- The server independently validates the exact current version and every boolean before consuming a
|
||||
fairness offer, opening an invoice, consuming a credit, or escrowing a paid PvP entry. The check
|
||||
runs at each of those handlers, so a modified client cannot bypass it.
|
||||
- The paid-action requirement does not lapse when invoice intake is paused: already-purchased
|
||||
credits and race tickets still carry value and are still gated.
|
||||
- Free entry stays available without a paid acknowledgement where the instance offers it. Early
|
||||
entry is treated as free only on a free-only instance, because mixed and credits instances may
|
||||
consume a credit.
|
||||
|
||||
The acknowledgement is a player statement, not proof of identity, age, capacity, or location.
|
||||
The acknowledgement is a player statement. It does not verify identity, age, capacity, or location.
|
||||
|
||||
## Mode language
|
||||
|
||||
| Runtime mode | Player-facing contract |
|
||||
|---|---|
|
||||
| Free | No entry payment; free leaderboard; no free payout |
|
||||
| Paid, payouts off | Entry/credits/product purchase and paid prestige leaderboard; no crypto prize |
|
||||
| Paid, solo payouts on | Full entry can be lost; only pre-entry recorded outcomes/amounts qualify |
|
||||
| Crypto PvP on | Paid ticket escrow and configured winner/fee contract; separate from free/prestige PvP |
|
||||
| Paid, payouts off | Entry, credits, or product purchase and paid prestige leaderboard; no cryptocurrency prize |
|
||||
| Paid, solo payouts on | The full entry can be lost; only outcomes and amounts shown before entry qualify |
|
||||
| Crypto PvP on | Paid ticket escrow and the configured winner/fee contract; separate from free and prestige PvP |
|
||||
| XMR stagenet/testnet | Valueless test currency; explicit warning never to send mainnet XMR |
|
||||
|
||||
For the operated products, the generic rows narrow further: `play.wowne.ro` exposes purchased
|
||||
credits only, offers no payout/cash-out, and is not marketed as gambling; `monerogue.app` labels
|
||||
its direct-entry 2×/3× stagenet mechanics as **NO REAL VALUE** and keeps purchased-credit entry and
|
||||
For the operated products the generic rows narrow further: `play.wowne.ro` exposes purchased credits
|
||||
only, offers no payout or cash-out, and is not marketed as gambling; `monerogue.app` labels its
|
||||
direct-entry 2x and 3x stagenet mechanics as **NO REAL VALUE** and keeps purchased-credit entry and
|
||||
crypto-match payouts disabled.
|
||||
|
||||
## Unresolved launch decisions
|
||||
## Operator decisions before enabling real-value modes
|
||||
|
||||
Technical copy and checkboxes do not resolve these. Keep real-value modes gated until the responsible
|
||||
operator records decisions for:
|
||||
Disclosure copy and checkboxes are product safety controls, not a substitute for the following. A
|
||||
responsible operator records decisions on each before exposing a real-value mode:
|
||||
|
||||
1. entity/operator identity, licences/registrations, permitted and excluded locations;
|
||||
1. entity and operator identity, licences and registrations, permitted and excluded locations;
|
||||
2. age and location verification appropriate to those locations;
|
||||
3. consumer terms: refunds, late/under/overpayments, complaints, dispute venue, and downtime;
|
||||
4. privacy roles, processors, retention/deletion schedule, user-rights procedure, breach response,
|
||||
and whether a privacy-minimized durable acknowledgement record is legally required;
|
||||
5. responsible-play features such as account-level deposit/time/loss limits and durable self-exclusion;
|
||||
6. tax, sanctions/AML, accounting, and cryptocurrency reporting obligations;
|
||||
7. an independent review of game rules, advertised multipliers, house fee, bankroll, and provable-fair claims.
|
||||
3. consumer terms: refunds, late, under, and overpayments, complaints, dispute venue, and downtime;
|
||||
4. privacy roles, processors, retention and deletion schedule, user-rights procedure, breach
|
||||
response, and whether a privacy-minimized durable acknowledgement record is legally required;
|
||||
5. responsible-play features such as account-level deposit, time, and loss limits, and durable
|
||||
self-exclusion;
|
||||
6. tax, sanctions and AML, accounting, and cryptocurrency reporting obligations;
|
||||
7. an independent review of game rules, advertised multipliers, house fee, bankroll, and
|
||||
provable-fair claims.
|
||||
|
||||
Stagenet can be used for end-to-end mechanics without representing test coins as money. Mainnet
|
||||
payouts remain a separate, explicit operational and legal decision.
|
||||
Stagenet exercises end-to-end mechanics without representing test coins as money. Mainnet payouts
|
||||
are a separate, explicit operational and legal decision.
|
||||
|
||||
## Front-end dependency note
|
||||
## Front-end dependency notes
|
||||
|
||||
The same-origin jQuery copy is 3.7.1 from the Ubuntu `libjs-jquery` package; exact provenance and
|
||||
SHA-256 are in `html/js/lib/jquery-3.7.1.PROVENANCE.md`. The CSP still permits inline script/style
|
||||
because current pages depend on them. A nonce migration must move every inline handler/script before
|
||||
removing `unsafe-inline`; do not partially tighten it and strand payment/game controls.
|
||||
SHA-256 are in `html/js/lib/jquery-3.7.1.PROVENANCE.md`. No runtime CDN or package-registry request
|
||||
is needed to serve it.
|
||||
|
||||
Third-party renderer code executes with the same privileges as the game and payment UI, so CDN
|
||||
execution is off unless an operator sets `RENDERER_CDN_ENABLED=true`. Enabling it adds
|
||||
`cdn.jsdelivr.net` to `script-src` and `connect-src` and logs a warning in production. The
|
||||
Three.js build served from `/vendor/three/<version>` is same-origin.
|
||||
|
||||
The CSP permits inline script and style because current pages depend on them. A nonce migration has
|
||||
to move every inline handler and script first; a partial tightening removes payment and game
|
||||
controls from the page.
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
# `play.wowne.ro` production state — 2026-07-25
|
||||
|
||||
> This is a dated evidence record, not a deployment procedure or authorization to repeat an
|
||||
> exceptional release path. The normal gates in [DEPLOY.md](DEPLOY.md),
|
||||
> [DEPLOY_INSTANCES.md](DEPLOY_INSTANCES.md), and [RELEASE_ARTIFACT.md](RELEASE_ARTIFACT.md) remain
|
||||
> controlling.
|
||||
|
||||
## Active schema-neutral release
|
||||
|
||||
`play.wowne.ro` is serving the immutable release
|
||||
`20260725T121348Z-28d513-overlay-a9d6f8a`. It was prepared from exact predecessor
|
||||
`20260721T143902Z-28d513b29b36` and carries feature source marker
|
||||
`a9d6f8a1bd1af7e77f7872629e325452ed5ae46a`.
|
||||
|
||||
The overlay changes only browser/static renderer and Tavern behavior plus the same-origin
|
||||
Three.js runtime:
|
||||
|
||||
- single-player Tiled, ASCII, Iso, 3D, pack, and camera controls return keyboard focus to the game;
|
||||
- Tavern entry is explicitly anonymous, waits for session readiness, exposes pending/error states,
|
||||
and remains retryable;
|
||||
- Three.js `0.160.0` is loaded from versioned same-origin vendor routes;
|
||||
- production CDN renderer execution remains off;
|
||||
- the existing Wownero mainnet purchased-credit profile remains unchanged, with every payout path
|
||||
off.
|
||||
|
||||
The live database migration ledger still ends at
|
||||
`038_economic_identity_immutability.sql`. The overlay neither applied nor marked migrations
|
||||
039–043 and did not adopt their payment/accounting behavior.
|
||||
|
||||
## Verification evidence
|
||||
|
||||
- Complete source suite: 123 suites passed, 979 tests passed.
|
||||
- Local and public readiness probes passed with PostgreSQL, Wownero daemon, and wallet dependencies
|
||||
ready.
|
||||
- External HTTPS and Socket.IO/WebSocket smoke tests passed through the public domain.
|
||||
- A fresh browser anonymously entered the Tavern and rendered local Three.js plus the generated GLB
|
||||
model from same-origin resources.
|
||||
- Single-player mode/pack clicks retained exact `#game-display` focus; a live 3D run rendered WebGL,
|
||||
attached the animated model, and removed its temporary fallback.
|
||||
- Activation rollback was exercised by failed smoke gates before the corrected release passed.
|
||||
|
||||
Operator-host evidence:
|
||||
|
||||
| Record | SHA-256 |
|
||||
|---|---|
|
||||
| Release manifest | `8ffd5077d906c7307094eda9f1c2332fd7d1edf1bf3b337807dc8c8e5a54b780` |
|
||||
| Activation receipt | `be9ee6c8657222e11464a6e4aff6aebede067e54e83d5d70aa1cf2ac7fa5b437` |
|
||||
|
||||
## Full mainline cutover remains STOP-SHIP
|
||||
|
||||
This production state does **not** mean the complete mainline runtime artifact has been activated.
|
||||
Mainline contains migrations through 043; the existing Wownerogue database remains five migrations
|
||||
behind. A full cutover must not proceed until the reviewed fleet runbook's clone-validation,
|
||||
daemon/wallet, accounting, drain, activation-receipt, and rollback gates all pass for the exact
|
||||
candidate and predecessor.
|
||||
|
||||
The schema-neutral overlay is not precedent for bypassing those gates. Future activation or
|
||||
rollback must use the reviewed fleet control plane with literal targets, hashes, and receipts.
|
||||
+102
-68
@@ -1,29 +1,33 @@
|
||||
# Deterministic PvP capture harness
|
||||
|
||||
`src/scripts/pvp-capture.js` creates a clean, reproducible multiplayer match for trailers, ads,
|
||||
`src/scripts/pvp-capture.js` produces a clean, reproducible multiplayer match for trailers, ads,
|
||||
camera work, and gameplay review. It supports the built-in Escape Race, Last Alive, Score Attack,
|
||||
and Co-op Escape rulesets. Each take uses the authoritative `MatchRoom` and `MatchEngine`, drives
|
||||
deterministic bots through the installed Socket.IO client, and records the existing browser render
|
||||
kit with the installed Playwright Chromium.
|
||||
and Co-op Escape rulesets. Each take runs the authoritative `MatchRoom` and `MatchEngine`, drives
|
||||
deterministic bots over Socket.IO, and records the browser render kit with the locally installed
|
||||
Playwright Chromium.
|
||||
|
||||
This is intentionally not a production feature or an admin endpoint. The harness:
|
||||
This is a development tool, not a production feature or an admin endpoint. The harness:
|
||||
|
||||
- starts a separate HTTP/Socket.IO process bound to `127.0.0.1` on an ephemeral port;
|
||||
- accepts no remote target URL and cannot attach to a normal Wowngeon instance;
|
||||
- creates one in-memory `free` match with zero entry fee, pot, house fee, or payout;
|
||||
- imports no application, database, wallet, queue, provider, or payout service;
|
||||
- requires `NODE_ENV=development` or `NODE_ENV=test` and the explicit
|
||||
- starts a separate HTTP/Socket.IO process bound to `127.0.0.1` on an ephemeral port, and rejects
|
||||
any request whose `Host` header is not loopback;
|
||||
- accepts no remote target URL, so it cannot attach to a running Wowngeon instance;
|
||||
- creates one in-memory `free` match with zero entry fee, pot, house fee, and house fee percent,
|
||||
and re-checks that invariant at room creation, at match start, and after finalization;
|
||||
- imports no application, database, wallet, queue, provider, or payout service. Its only game
|
||||
imports are `MatchRoom`, `MatchEngine`, and the ruleset registry;
|
||||
- requires `NODE_ENV=development` or `NODE_ENV=test` plus the explicit
|
||||
`--confirm-local-free-only` flag;
|
||||
- refuses to run when a payment, credits, payout, crypto-race, mainnet-payout, or wallet endpoint
|
||||
is enabled/configured; and
|
||||
- refuses to run when a payments, credits, payout, match-payout, crypto-race, mainnet-payout, or
|
||||
wallet-endpoint variable is enabled or configured;
|
||||
- refuses inherited crypto, game-mode, difficulty, dungeon, monster, treasure, or network-tuning
|
||||
overrides so a committed seed cannot silently mean different footage in another shell; and
|
||||
- loads only repository assets. CDN/3D renderers are unavailable in this tool.
|
||||
overrides, so a committed seed cannot silently mean different footage in another shell;
|
||||
- serves only repository assets and forces the CDN renderer flag off, so the 3D renderer is
|
||||
unavailable here.
|
||||
|
||||
Those guards make setting an environment variable in the live service insufficient to enable the
|
||||
harness. Someone must deliberately start this separate loopback-only CLI with its confirmation
|
||||
flag. On a staging machine, run it as a separate development/test process; it never uses the
|
||||
staging app or staging database.
|
||||
Setting an environment variable on a live service is therefore not enough to enable the harness.
|
||||
Someone must deliberately start this separate loopback-only CLI with its confirmation flag. On a
|
||||
staging machine, run it as its own development or test process; it never uses the staging app or
|
||||
staging database.
|
||||
|
||||
## Record a video
|
||||
|
||||
@@ -39,10 +43,9 @@ NODE_ENV=development npm run capture:pvp -- \
|
||||
--screenshot /tmp/wowngeon-pvp-final.png
|
||||
```
|
||||
|
||||
The repository already declares `playwright-core`; the command uses its existing local Chromium
|
||||
and FFmpeg bundle. It never downloads a browser or package. If that browser bundle is missing, the
|
||||
CLI fails with a direct explanation. The deterministic control harness remains usable without a
|
||||
browser:
|
||||
`playwright-core` is a declared devDependency. The command uses its existing local Chromium and
|
||||
FFmpeg bundle and never downloads a browser or a package. If that bundle is missing, the CLI stops
|
||||
with a direct explanation. The deterministic control harness remains usable without a browser:
|
||||
|
||||
```bash
|
||||
NODE_ENV=test npm run capture:pvp -- \
|
||||
@@ -52,34 +55,61 @@ NODE_ENV=test npm run capture:pvp -- \
|
||||
--trace /tmp/wowngeon-pvp-trace.json
|
||||
```
|
||||
|
||||
Run `npm run capture:pvp -- --help` for every option. `--ruleset` accepts `race`, `last-alive`,
|
||||
`score-attack`, or `coop-escape`; this is separate from `--mode`, whose offline renderer choices
|
||||
are `tiles`, `ascii`, and `iso`. Every ruleset accepts 2–8 bots in this harness.
|
||||
## Options
|
||||
|
||||
The default 1280×720 spectator camera anchors on `bot-1`; choose another player with `--focus
|
||||
bot-N`. In the default `--camera action` mode, deaths, treasure pickups, and exits temporarily take
|
||||
focus before the camera returns to that anchor. `--camera focus` disables those cuts. Camera zoom
|
||||
in the tiled and ASCII renderers adjusts to the nearest action cluster around the anchor: it keeps
|
||||
the closest three players in a portrait composition or four in landscape, then pushes in or pulls
|
||||
back as that cluster changes. The isometric renderer uses a stable, cover-clamped zoom because its
|
||||
diagonal projection needs different fit geometry. A short renderer-space movement lead shows where
|
||||
the focused player is heading, while an upper-frame bias keeps portrait action clear of the
|
||||
result lower third. The tested 1080×1920 and 1920×1080 tiled frames fill the stage. At match end, a
|
||||
competitive camera returns to the authoritative winner for a restrained push-in instead of hiding
|
||||
the playfield behind a centered modal.
|
||||
Run `npm run capture:pvp -- --help` for the same list at the terminal.
|
||||
|
||||
The capture-only grade slightly lowers brightness while restoring contrast and saturation, which
|
||||
reduces torch and character washout. A separate screen-space roster
|
||||
keeps all 2–8 player names and states legible without stacking labels on one dungeon tile; the
|
||||
winner remains highlighted while other rows recede on a competitive final frame, while cooperative
|
||||
team rows remain equally visible. The footer displays a short deterministic replay ID, not a
|
||||
signature or independent verification claim. The complete seed and every state hash remain in the
|
||||
| Flag | Accepted values | Default |
|
||||
|---|---|---|
|
||||
| `--confirm-local-free-only` | required safety acknowledgement | none |
|
||||
| `--seed HEX64` | exactly 64 hexadecimal characters | fixed curated seed |
|
||||
| `--players N` | 2 through 8, within the ruleset's own limit | 4 |
|
||||
| `--ticks N` | 1 through 2000 hard capture limit | 180 |
|
||||
| `--tick-ms N` | 50 through 1000 real-time frame step | 180 |
|
||||
| `--ruleset ID` | `race`, `last-alive`, `score-attack`, `coop-escape` | `race` |
|
||||
| `--mode MODE` | `tiles`, `ascii`, `iso` | `tiles` |
|
||||
| `--focus BOT_ID` | `bot-1` through `bot-N` for the configured bot count | `bot-1` |
|
||||
| `--camera MODE` | `action` or `focus` | `action` |
|
||||
| `--viewport WxH` | 640x360 through 3840x2160 | 1280x720 |
|
||||
| `--output FILE.webm` | video destination | `wowngeon-pvp-capture.webm` in the temp dir |
|
||||
| `--trace FILE.json` | trace destination | the output path with a `.json` extension |
|
||||
| `--screenshot FILE.png` | optional final-frame screenshot | none |
|
||||
| `--headed` | show the capture browser while recording | off |
|
||||
| `--no-video` | run bot control and write only the trace | off |
|
||||
|
||||
`--ruleset` selects gameplay and is independent of `--mode`, which selects an offline renderer.
|
||||
Network-loaded renderers are rejected. All four capture rulesets accept 2 through 8 bots here: the
|
||||
harness raises Score Attack's own minimum of 1 to 2, because a capture needs a field.
|
||||
|
||||
## Camera and presentation
|
||||
|
||||
The spectator camera anchors on `--focus`, default `bot-1`. In `--camera action`, deaths, treasure
|
||||
pickups, and exits briefly take focus before the camera returns to that anchor; `--camera focus`
|
||||
disables those cuts.
|
||||
|
||||
In the tiled and ASCII renderers, zoom tracks the action cluster around the anchor: it fits the
|
||||
closest three players in a portrait composition or four in landscape, then pushes in or pulls back
|
||||
as that cluster changes. The isometric renderer uses a stable, cover-clamped zoom because its
|
||||
diagonal projection needs different fit geometry. Every zoom is clamped to at least stage cover, so
|
||||
the playfield fills the frame at both 1080x1920 and 1920x1080. A short renderer-space movement lead
|
||||
shows where the focused player is heading, and an upper-frame bias keeps portrait action clear of
|
||||
the result card in the lower third. At match end, a competitive camera returns to the authoritative
|
||||
winner for a restrained push-in rather than covering the playfield with a centered modal.
|
||||
|
||||
The capture-only grade lowers brightness slightly while restoring contrast and saturation, which
|
||||
reduces torch and character washout. A screen-space roster keeps all 2 to 8 player names and states
|
||||
legible without stacking labels on one dungeon tile; world-space player labels are suppressed for
|
||||
the same reason. On a competitive final frame the winner row stays highlighted while other rows
|
||||
recede; cooperative rulesets have no winner row, so team rows remain equally visible. The footer
|
||||
shows a short deterministic replay ID derived from the seed hash. It is an identifier, not a
|
||||
signature or an independent verification claim. The complete seed and every state hash live in the
|
||||
JSON trace.
|
||||
|
||||
`FREE EXHIBITION • NO CASH PRIZES` describes this isolated footage only. It is not a substitute for
|
||||
campaign review or campaign-level disclosures. Paid-credit, no-cash-out/no-prize, age, jurisdiction,
|
||||
certification, and platform-required disclosures remain outside the harness output and must be
|
||||
added and reviewed for each operated product and advertising channel.
|
||||
campaign review or campaign-level disclosures. Paid-credit, no-cash-out, no-prize, age,
|
||||
jurisdiction, certification, and platform-required disclosures sit outside the harness output and
|
||||
must be added and reviewed for each operated product and advertising channel. See
|
||||
[PRODUCTION_DISCLOSURES.md](PRODUCTION_DISCLOSURES.md).
|
||||
|
||||
## Capture each built-in mode
|
||||
|
||||
@@ -89,8 +119,8 @@ Use the same safety acknowledgement for every take:
|
||||
|---|---|---|
|
||||
| `race` | Treasure route plus staggered sprints to the exit | Competitive race winner |
|
||||
| `last-alive` | Seek and strike the nearest active rival | Last survivor |
|
||||
| `score-attack` | Build progress/treasure score, then bank an exit | Top score and points |
|
||||
| `coop-escape` | Staggered team pathing to the exit | Team escape count, never a fake competitive winner |
|
||||
| `score-attack` | Build progress and treasure score, then bank an exit | Top score and points |
|
||||
| `coop-escape` | Staggered team pathing to the exit | Team escape count, never a competitive winner |
|
||||
|
||||
For example, record a Last Alive take:
|
||||
|
||||
@@ -105,13 +135,15 @@ NODE_ENV=development npm run capture:pvp -- \
|
||||
--trace /tmp/wowngeon-last-alive-ad.json
|
||||
```
|
||||
|
||||
Swap only `--ruleset` and output names to capture Score Attack or Co-op Escape. Headlines, active
|
||||
player terminology, roster states, footer copy, and final results all follow the selected ruleset.
|
||||
Score Attack closes the local exhibition deadline as soon as every bot has died or banked an exit,
|
||||
avoiding a motionless tail without changing the authoritative scoring/finalization path.
|
||||
Swap only `--ruleset` and the output names to capture Score Attack or Co-op Escape. Headlines,
|
||||
active-player terminology, roster states, footer copy, and final results all follow the selected
|
||||
ruleset. Score Attack normally runs to its deadline; once every bot is dead or has banked an exit
|
||||
there are no legal inputs left, so this isolated exhibition closes its local deadline immediately
|
||||
instead of recording a long motionless tail. The authoritative scoring and finalization path is
|
||||
unchanged.
|
||||
|
||||
For a short 9:16 ad take, the curated seed produces a complete six-racer arc in about nine seconds.
|
||||
Follow the eventual winner so the finish remains legible on a phone screen:
|
||||
For a short 9:16 ad take, the default seed with six racers produces a complete arc in roughly nine
|
||||
seconds and `bot-4` wins. Follow that bot so the finish stays legible on a phone screen:
|
||||
|
||||
```bash
|
||||
NODE_ENV=development npm run capture:pvp -- \
|
||||
@@ -124,10 +156,7 @@ NODE_ENV=development npm run capture:pvp -- \
|
||||
--screenshot /tmp/wowngeon-pvp-ad-vertical.png
|
||||
```
|
||||
|
||||
Use the emitted JSON trace as the authoritative replay record. The video is marketing footage; it
|
||||
does not create a leaderboard row, credit, payment, ticket, or payout.
|
||||
|
||||
For a crowded 16:9 arena take, the same seed supports the built-in eight-player maximum:
|
||||
The same seed supports the eight-player maximum for a crowded 16:9 arena take:
|
||||
|
||||
```bash
|
||||
NODE_ENV=development npm run capture:pvp -- \
|
||||
@@ -141,8 +170,8 @@ NODE_ENV=development npm run capture:pvp -- \
|
||||
--trace /tmp/wowngeon-pvp-ad-8player.json
|
||||
```
|
||||
|
||||
The recorded eight-player proof and its no-video control both ended with `bot-4` at tick 49 and
|
||||
the same final state hash. This checks that browser recording does not alter the authoritative race.
|
||||
The emitted JSON trace is the authoritative replay record. The video is marketing footage; it
|
||||
creates no leaderboard row, credit, payment, ticket, or payout.
|
||||
|
||||
## Reproducibility
|
||||
|
||||
@@ -155,13 +184,18 @@ NODE_ENV=development npm run capture:pvp -- \
|
||||
```
|
||||
|
||||
Each bot receives a numbered tick and the same authoritative state over Socket.IO. The server waits
|
||||
for every bot response, queues inputs in stable bot order, and lets the engine resolve them using its
|
||||
committed per-tick action priority before advancing. Bot goals are ruleset-aware but contain no
|
||||
randomness beyond the committed dungeon seed. Missing or malformed input aborts the run instead of
|
||||
silently changing the replay. Every run writes a versioned JSON trace beside the video containing
|
||||
the ruleset, presentation metadata, seed, exact inputs, events, camera configuration, state hashes,
|
||||
placements, scores, and mode-correct outcome.
|
||||
for every bot response, queues inputs in stable bot order, and lets the engine resolve them with its
|
||||
committed per-tick action priority before advancing. Bot goals are ruleset-aware and contain no
|
||||
randomness beyond the committed dungeon seed; bots after the first move on staggered cadences so a
|
||||
crowded field stays readable and deterministic head-on swaps cannot loop forever. Missing or
|
||||
malformed input aborts the run instead of silently changing the replay.
|
||||
|
||||
The gameplay trace is deterministic, and records that gameplay environment overrides were refused.
|
||||
Encoded WebM bytes can differ across Chromium/FFmpeg versions
|
||||
or host timing, so use the JSON state hashes—not the video checksum—to compare replays.
|
||||
Every run writes a versioned JSON trace, owner-readable only, beside the video. It records the
|
||||
ruleset, presentation metadata, seed and seed hash, exact per-tick inputs, engine events, camera
|
||||
configuration, per-tick state hashes, placements, scores, the mode-correct outcome, and the fact
|
||||
that gameplay environment overrides were refused.
|
||||
|
||||
To confirm that browser recording does not perturb the simulation, run the same command twice, once
|
||||
with `--no-video`, and compare `result.finalStateHash` in the two traces. Encoded WebM bytes can
|
||||
differ across Chromium and FFmpeg versions or with host timing, so compare replays with the JSON
|
||||
state hashes rather than a video checksum.
|
||||
|
||||
+105
-62
@@ -1,52 +1,76 @@
|
||||
# Immutable release artifacts
|
||||
|
||||
`scripts/deploy/build-release-artifact.sh` is the local, non-activating boundary between a reviewed
|
||||
Git commit and the two production application instances. It produces a **runtime artifact**, not a
|
||||
source or operations bundle. It does not read an environment file, install a package, connect to
|
||||
`deb`, or move either `current` symlink.
|
||||
Git commit and a production application instance. It produces a runtime artifact, not a source or
|
||||
operations bundle. It does not read an environment file, install a package, contact a deployment
|
||||
host, or move a `current` symlink.
|
||||
|
||||
## Artifact contents
|
||||
|
||||
The archive holds a single top-level directory named after the release ID. That directory contains
|
||||
`LICENSE`, `html/**`, the runtime subset of `src/**`, and release metadata.
|
||||
|
||||
Everything else in the commit is excluded on purpose: `docs/`, `test/`, the root `scripts/` tree
|
||||
(service units, firewall and backup jobs, the hash-pinned financial audit SQL, the wallet output
|
||||
splitter), environment examples, `src/sim`, `src/jest.config.js`, and the operator-only commands
|
||||
`src/scripts/pvp-capture.js`, `src/scripts/stagenet-financial-canary.js`, `src/scripts/setup_db.js`,
|
||||
and `src/scripts/smoke.js`. Tests and operator tooling are source and control-plane inputs; they are
|
||||
not production runtime files, and sharing a Git commit with the runtime does not make them part of
|
||||
it.
|
||||
|
||||
`src/scripts/preflight.js` and `src/scripts/migrate-disposable-clone.js` do ship, because the staging
|
||||
gates run them from the candidate. The staged `src/package.json` is rewritten to expose only the
|
||||
runnable `start`, `preflight`, and `db:migrate:clone` scripts. That rewrite is metadata-only and
|
||||
changes neither dependencies nor lockfile resolution.
|
||||
|
||||
Release metadata:
|
||||
|
||||
| File | Covers |
|
||||
| --- | --- |
|
||||
| `.release-id` | Release directory name |
|
||||
| `.release-commit` | Full 40-character source commit |
|
||||
| `.release-source-sha256` | The prefix-free, runtime-allowlisted `git archive` tar |
|
||||
| `.release-package-lock-sha256` | The shipped `src/package-lock.json` |
|
||||
| `.release-files.sha256` | Every extracted runtime and metadata file |
|
||||
| `<release-id>.tar.gz.sha256` (external sidecar) | The final compressed blob |
|
||||
|
||||
`.release-files.sha256` is generated before any dependency install, so it covers the shipped bytes
|
||||
and never covers `src/node_modules`.
|
||||
|
||||
Builds are reproducible: for the same commit, release ID, and toolchain the `.tar.gz` is
|
||||
byte-identical, because tar entries are sorted by name, ownership is numeric `0:0`, modes are
|
||||
normalized, mtimes are pinned to the commit timestamp, and gzip runs with `-n`. The artifact and its
|
||||
sidecar are published by hard link, so a concurrent build using the same release ID fails rather than
|
||||
overwriting a published file. Both land mode 0600 in a mode-0700 output directory.
|
||||
|
||||
## Build gate
|
||||
|
||||
Commit every intended source and documentation change first. Review that commit and confirm that a
|
||||
secret scanner has not found credentials embedded in otherwise legitimate source files; filename
|
||||
policy alone cannot detect a hard-coded secret. Then run:
|
||||
policy alone cannot detect a hard-coded secret.
|
||||
|
||||
```bash
|
||||
git status --short # must print nothing
|
||||
./scripts/deploy/build-release-artifact.sh # defaults to git-<12-character HEAD prefix>
|
||||
cd dist/releases
|
||||
sha256sum --check <release-id>.tar.gz.sha256
|
||||
```
|
||||
|
||||
The builder fails on any tracked or non-ignored untracked worktree change, tracked symlink/gitlink,
|
||||
runtime `.env` or credential-capable dotfile, unsafe release ID, or existing output. Ignored local
|
||||
files are never read because the archive comes only from the selected commit. The archive contains
|
||||
only `LICENSE`, `html/**`, the runtime subset of `src/**`, and deterministic release metadata.
|
||||
It intentionally contains no `docs/`, `test/`, root `scripts/`, deployment service units, wallet
|
||||
migration tools, environment examples, ad-capture bot, stagenet transfer canary, or development
|
||||
database/smoke helpers. Simulation/Jest sources are also excluded, and the staged package manifest
|
||||
retains only the runnable `start`, `preflight`, and `db:migrate:clone` commands. The guarded
|
||||
`src/scripts/preflight.js` and
|
||||
`src/scripts/migrate-disposable-clone.js` remain available for staging checks.
|
||||
`.release-source-sha256` identifies the prefix-free,
|
||||
runtime-allowlisted `git archive`; the external sidecar identifies the final compressed artifact;
|
||||
`.release-files.sha256` verifies every extracted runtime/metadata file without covering the later
|
||||
`src/node_modules` install.
|
||||
|
||||
Run the complete test suite from the clean source commit **before** building. Tests and operator
|
||||
tools are source/control-plane inputs; they are not production runtime files:
|
||||
Run the complete test suite from the clean source commit before building:
|
||||
|
||||
```bash
|
||||
cd src
|
||||
npm ci --ignore-scripts --no-audit --no-fund
|
||||
npm test
|
||||
cd ..
|
||||
git status --short # must still print nothing
|
||||
./scripts/deploy/build-release-artifact.sh
|
||||
git status --short # must print nothing
|
||||
./scripts/deploy/build-release-artifact.sh # defaults to git-<12-character HEAD prefix>
|
||||
cd dist/releases
|
||||
sha256sum --check <release-id>.tar.gz.sha256
|
||||
```
|
||||
|
||||
Then test the runtime bytes that will be shipped. In a disposable directory, verify the artifact
|
||||
sidecar and manifest and install only the locked production dependency graph:
|
||||
The builder refuses to run on any tracked or non-ignored untracked worktree change, a tracked
|
||||
symlink, gitlink, or unsafe file mode, a filename containing a newline or carriage return, an unsafe
|
||||
tracked path, tracked `.git` or `node_modules` state, a tracked non-example `.env` file, a tracked
|
||||
`.npmrc`, `.yarnrc`, or `.netrc`, an unsafe release ID, or an existing artifact or checksum. Ignored
|
||||
local files are never read, because the archive is produced from the selected commit rather than the
|
||||
worktree.
|
||||
|
||||
Then test the runtime bytes that will ship. In a disposable directory, verify the sidecar and
|
||||
manifest and install only the locked production dependency graph:
|
||||
|
||||
```bash
|
||||
cd <directory-containing-artifact>
|
||||
@@ -59,8 +83,20 @@ npm ci --omit=dev --ignore-scripts --no-audit --no-fund
|
||||
npm ls --omit=dev
|
||||
```
|
||||
|
||||
Advisory lookup is a separate, explicitly authorized network operation. Do not use `npm audit fix`
|
||||
to mutate a candidate or live release.
|
||||
Advisory lookup is a separate, explicitly authorized network operation. Do not use `npm audit fix` to
|
||||
mutate a candidate or a live release.
|
||||
|
||||
## Release identity at runtime
|
||||
|
||||
`src/config/releaseIdentity.js` reads `.release-id` and `.release-commit` from the release root at
|
||||
startup. Each must be a non-symlink regular file with a link count of 1, no write bits, and a size
|
||||
between 1 and 128 bytes, and it is re-stat-ed after reading to reject a file that changed mid-read.
|
||||
The two values must agree: the ID must be `git-<first 12 characters of the commit>`. Under
|
||||
`NODE_ENV=production` the process refuses to start when that check fails, so an artifact built with a
|
||||
custom `--release-id` will not run in production even though the builder accepts the name.
|
||||
|
||||
`/health`, `/health/ready`, and the status disclosure report the verified `release.id` and
|
||||
`release.commit`. That is how activation confirms which bytes are actually serving traffic.
|
||||
|
||||
## Production staging gate
|
||||
|
||||
@@ -68,46 +104,53 @@ Use the same runtime artifact SHA-256 for `monerogue` and `wownerogue`. Transfer
|
||||
root-controlled staging path and verify the operator-pinned 64-hex digest before extraction. The
|
||||
activation boundary must then:
|
||||
|
||||
1. reject an existing release ID, links/special files, an unexpected top-level directory, or unsafe
|
||||
ownership/path components;
|
||||
1. reject an existing release ID, links or special files, an unexpected top-level directory, or
|
||||
unsafe ownership or path components;
|
||||
2. install only locked production dependencies with
|
||||
`npm ci --omit=dev --ignore-scripts --no-audit --no-fund` while staging is writable;
|
||||
`npm ci --omit=dev --ignore-scripts --no-audit --no-fund` while staging is still writable;
|
||||
3. run `npm ls --omit=dev`, recheck `.release-files.sha256`, then make the complete release
|
||||
`root:root` and non-writable before it can become `current`;
|
||||
4. restore each fresh pre-deploy dump into a separately named clone database, run
|
||||
`npm run db:migrate:clone` under the exact disposable-target contract in
|
||||
[CLONE_MIGRATIONS.md](./CLONE_MIGRATIONS.md), require its exact manifest proof (currently through
|
||||
migration 043), and run the separately reviewed and hash-pinned fleet copy of
|
||||
`financial-audit.sql` there—the SQL is intentionally not inside the runtime artifact;
|
||||
5. run `npm run preflight` from the candidate with each final protected environment. Preflight is
|
||||
intentionally non-mutating and does not replace database, daemon, or wallet health checks.
|
||||
`npm run db:migrate:clone` under the disposable-target contract in
|
||||
[CLONE_MIGRATIONS.md](./CLONE_MIGRATIONS.md), and require its exact manifest proof: the
|
||||
`schema_migrations` ledger must equal the candidate's complete migration manifest, in order, with
|
||||
no missing or extra entry, contiguous from 001 and at or above the runner's floor of migration
|
||||
043. Run the separately reviewed and hash-pinned fleet copy of `financial-audit.sql` against that
|
||||
clone; the SQL is intentionally not inside the runtime artifact;
|
||||
5. run `npm run preflight` from the candidate against each final protected environment. Preflight
|
||||
validates payment and environment configuration only. It connects to neither PostgreSQL nor either
|
||||
RPC, so it does not replace database, daemon, or wallet health checks.
|
||||
|
||||
The clone runner is deliberately non-activating. Such Software production activation must use the
|
||||
separately reviewed, default-closed `wowngeon_release_activate` role in `~/src/such-fleet`; do not
|
||||
substitute manual extraction, dependency installation, service commands, or a hand-written
|
||||
`current` switch. The role still refuses activation until the matching clone-validation receipt,
|
||||
wallet-promotion receipt, drain/audit gates, literal hashes, and rollback target satisfy the
|
||||
complete contract in `~/src/such-fleet/RUNBOOK-wowngeon.md`.
|
||||
The clone runner is deliberately non-activating. Production activation uses the separately reviewed,
|
||||
default-closed `wowngeon_release_activate` role in the fleet control-plane repository. Do not
|
||||
substitute manual extraction, dependency installation, service commands, or a hand-written `current`
|
||||
switch. That role refuses activation until the matching clone-validation receipt, wallet-promotion
|
||||
receipt, drain and audit gates, literal hashes, and rollback target all satisfy the contract in the
|
||||
fleet runbook `RUNBOOK-wowngeon.md`.
|
||||
|
||||
## Source and operations boundary
|
||||
|
||||
The Git checkout remains the review/test source. `~/src/such-fleet` is the deployment control plane
|
||||
for hash-pinned SQL, service units, backup/firewall jobs, clone validation, activation, and rollback.
|
||||
Neither source-tree deployment scripts nor fleet tools are copied beneath the application release
|
||||
directory. A runtime artifact hash does not attest a separately delivered operations file; record
|
||||
and verify an independent hash for every such file in the fleet change plan.
|
||||
The Git checkout is the review and test source. The fleet control-plane repository owns hash-pinned
|
||||
SQL, service units, backup and firewall jobs, clone validation, activation, and rollback. Neither
|
||||
source-tree deployment scripts nor fleet tools are copied beneath the application release directory.
|
||||
A runtime artifact hash attests nothing about a separately delivered operations file; record and
|
||||
verify an independent hash for every such file in the fleet change plan.
|
||||
|
||||
## Activation and rollback gate
|
||||
|
||||
Drain public ingress and require zero active and queued games. Take and validate fresh dumps; run the
|
||||
financial audit before and after stopping only the affected app. Preserve and validate the exact
|
||||
predecessor target, switch `current` atomically, then start stagenet first. Require bounded local
|
||||
`/health/live` and `/health/ready`, exact network/money-mode disclosures, public TLS, WebSocket, and
|
||||
leaderboard/intake smoke tests before traffic is restored. The stagenet payout canary is a later,
|
||||
one-shot gate and is never a mainnet command.
|
||||
Drain public ingress and require zero active and queued games. Take and validate fresh dumps, and run
|
||||
the financial audit before and after stopping only the affected app. Preserve and validate the exact
|
||||
predecessor target, switch `current` atomically, then start the stagenet instance first.
|
||||
|
||||
Application rollback is safe only if the predecessor was tested against the *post-migration* clone
|
||||
schema. Migrations are forward-only and run one transaction per file at application startup; a
|
||||
Before traffic is restored, require bounded local `/health/live` and `/health/ready`, the expected
|
||||
network and money-mode disclosures, public TLS, WebSocket, and leaderboard and intake smoke tests.
|
||||
`/health/ready` answers 503 while any check is degraded and reports `chain.network`,
|
||||
`money.paymentsEnabled`, `money.payoutsEnabled`, and the verified release identity, so the disclosure
|
||||
comparison is exact rather than approximate. The stagenet payout canary is a later, one-shot gate and
|
||||
is never a mainnet command.
|
||||
|
||||
Application rollback is safe only if the predecessor was tested against the post-migration clone
|
||||
schema. Migrations are forward-only and run one transaction per file at application startup, so a
|
||||
mid-sequence failure can leave earlier files committed. On activation failure, stop the candidate,
|
||||
atomically restore the validated predecessor symlink, start it, and require both the exact target and
|
||||
readiness. Do not automatically restore the database after new writes; keep the dump for an
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
# Wownerogue Remediation & Cleanup Plan
|
||||
|
||||
> Derived from the deep code review (May 2026). Goal: fix every BAD/UGLY finding, remove
|
||||
> the spaghetti, and reach a **real-funds launch gate**. Phases are ordered so launch-blockers
|
||||
> get surgical fixes first, then correctness hardening, then structural cleanup, then a
|
||||
> test/ops overhaul to lock it in.
|
||||
|
||||
## Guiding principles
|
||||
1. **Stabilize before restructuring.** Fix exploitable money bugs as minimal patches, ship, *then* refactor.
|
||||
2. **Single source of truth** for the three things that bite: money (one BigInt module), identity (one `user_id`), randomness (one per-game seeded RNG).
|
||||
3. **Defense in depth.** Every check at the DB *and* app level; every render escapes *and* the server escapes.
|
||||
4. **Every change ships with a test that fails before and passes after.**
|
||||
|
||||
Key: `S` ≤ half day · `M` ~1–3 days · `L` ~1 week+ · 🔴 launch-blocker · 🟠 pre-launch · 🟡 post-launch
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Stop the bleeding (launch-blockers)
|
||||
|
||||
- [x] **0.1 🔴 S — Verify amount received ≥ expected before confirming.** ✅ Gate on `status.complete` in `paymentHandlers.js`; underpaid → stay pending + `payment_underpaid` warning (once). `received_amount` now recorded for single-game + credits. Regression test added (`payment_flow.integration.test.js`). _113 tests green._
|
||||
- [x] **0.2 🔴 M — Make provably-fair real.** ✅ Per-game seeded RNG (`seedToInt`) threaded through `dungeon.js` (Digger reseed + all `Math.random()` → `rng`), `monster.js` (`seededShuffle`), `game.js` (chase + generation). `DungeonGenerator.regenerateFromSeed` + `layoutFingerprint`; `/api/verify` regenerates & returns fingerprint. Determinism test suite added (`provablyFair.test.js`). _Verifier can now reproduce the exact dungeon from the seed._
|
||||
- [x] **0.3 🔴 M — Fix batch-payout `tx_hash` collision + double-pay.** ✅ Migration 015 drops the payouts `tx_hash` unique index (batches legitimately share a tx_hash) and extends the one-per-game guard to cover `processing`. Batch completion now happens in ONE transaction via `id = ANY` (no row stranded mid-batch); BIGINT stat math (no float). Ambiguous batch failures → `needs_review` (retry service won't re-send) + operator alert. New `batchPayout.test.js` covers both paths.
|
||||
- [x] **0.4 🔴 S — Harden identity transport.** ✅ Client sends `anon_token` in handshake `auth` (not the URL query → no proxy/referer/access-log leakage); server reads `auth` first with a query-string fallback for older clients. Token is now **rotated on every resume** (`rotateToken`), so a leaked token is invalidated when the real owner reconnects; client already persists the returned token. Rotation + old-token-replay-rejection test added to `security.test.js`.
|
||||
- [x] **0.5 🟠 S — Single-game disconnect recovery.** ✅ `recoverPendingPayments` now also recovers confirmed-but-unconsumed `single_game` payments (no `games.payment_id` link) by granting the equivalent credits, idempotently (per-payment reason key, row-locked). Disconnect preservation made deterministic: the stable `dbUserId` is **stamped on the game at creation**, so suspend/restore never needs a fragile disconnect-time DB lookup — every game (paid or free) is reconnectable whenever a session exists; the drop-branch is now only the genuine no-session edge. New `singleGameRecovery.test.js` (grant / consumed / idempotent).
|
||||
- [x] **0.6 🟠 S — Client + admin XSS and CSP.** ✅ Single shared `escapeHtml` helper (`html/js/core/escapeHtml.js`, escapes all five chars incl. both quotes); leaderboard delegates to it. Chat sinks (`onChatBroadcast`/`onChatHistory`) now escape username + message. Admin panel escapes user-controlled fields (payout address, socket id, player name, status) and its local `escapeHtml` now escapes quotes (safe in `title="…"`). CSP + `X-Content-Type-Options`/`X-Frame-Options`/`Referrer-Policy` headers added via middleware in `index.js` (`connect-src 'self'` keeps Socket.IO working; nonce-based tightening tracked as Phase 4.4).
|
||||
|
||||
## Phase 1 — Money correctness hardening (🟠)
|
||||
- [x] **1.1 M — `src/money/atomic.js` BigInt module.** ✅ Exact integer money math (`toBig`/`toSafe`/`sum`/`add`/`mulByDecimal`/`format`). `calculatePayout` now computes `base * multiplier` via BigInt (half-up rounding, narrows to number only when exact). `walletRPCService` batch total + fee sum use `money.sum` instead of float `reduce`. Property tests (`moneyAtomic.test.js`) prove exactness on amounts above 2^53 where the old float path drifted.
|
||||
- [x] **1.2 S — Snapshot payout config at game start.** ✅ Migration 016 adds `payout_escape_amount`/`payout_treasure_amount` (BIGINT) + multipliers to `games`. Both start paths stamp the resolved payout amounts (via `_computePayoutSnapshot`); `completeGame` pays from the snapshot (falls back to live calc only for pre-snapshot games). New `payoutSnapshot.test.js` proves a mid-game config change can't alter an in-flight payout.
|
||||
- [x] **1.3 S — Close payout uniqueness holes.** ✅ `processing` covered in the unique index (migration 015) and in the in-code existing-payout guard. Migration 017 adds idempotent `NOT VALID` CHECK constraints on `payments.status`, `payments.payment_type`, and `payouts.status` so a typo'd status can't silently orphan a money row (enforces new writes, won't choke on legacy rows). _(games.status/game_mode CHECKs deferred — transient states, lower money-impact.)_
|
||||
- [x] **1.4 S — Fix retry stat guard + non-atomic tx_hash write.** ✅ The "already on-chain" path used a `NOT EXISTS` guard against the row it had just marked completed (always skipped → never counted); replaced with a conditional `UPDATE … WHERE status <> 'completed' RETURNING` so stats count exactly once. Successful retries now store `tx_hash` + mark completed + count stats in ONE transaction (no stranded-tx_hash window). New `payoutRetry.test.js` covers count-once, no-double-count, and atomic completion.
|
||||
- [x] **1.5 S — Fix dead refund send-path.** ✅ The admin refund called a nonexistent `walletRPCService.sendPayment()` (guarded by a `typeof` check), so refunds were silently never sent while still reporting success. Now uses the real `processPayout()` (validates address + transfers, returns `{success, txHash}`) and refunds the **actually received** amount (`received_amount`, falling back to expected).
|
||||
- [x] **1.6 S — `credits` → BIGINT; idempotent 010; atomic migration runner.** ✅ Migration 018 widens `users.credits` + `credit_transactions.amount/balance_after` to BIGINT. Migration 010 rewritten with conname-guarded `DO` blocks (re-runnable, no startup wedge). Runner now applies each migration **and** records the ledger row in one transaction (no applied-but-unrecorded state). Verified end-to-end: all 18 migrations applied cleanly to a throwaway Postgres DB, re-run skipped via the ledger, `credits` confirmed `bigint`, all CHECKs present.
|
||||
|
||||
## Phase 2 — Identity, abuse & concurrency (🟠)
|
||||
- [x] **2.1 L — Unify identity resolution on stable `user_id`.** ✅ `getOrCreateUser` now resolves through SessionManager's stable `anon_token`→`id` identity first (re-selecting a fresh row by stable id), falling back to the legacy `socket_id` lookup only when no session exists — eliminating the duplicate "orphan" user row that caused credit/payout desync. `gameModeManager.sessionManager` wired in SocketHandlers. `anon_token` is already `UNIQUE` (migration 003), so the stable-identity constraint exists. New `identityResolution.test.js` proves no duplicate row is created when a session exists. _Deferred: re-keying the in-memory `activeGames` map by `user_id` (invasive; already mitigated in 0.5 by stamping `dbUserId` on the game) — tracked as a follow-up._
|
||||
- [x] **2.2 M — Reconnect-proof rate limiting.** ✅ New `rateLimitContext` helper resolves a STABLE id (`anon_token`→`id`, falling back to socket id) + the real client IP. All socket-event limiters (game:start, address:set, chat:message) now pass both — previously they passed `socket.id` and **no IP at all**, so reconnecting reset every limit and the "IP-limited" prefixes never actually applied. `chat:` added to IP-limited prefixes; `connection:new` uses the real IP. Express `trust proxy` + XFF honored only when `TRUST_PROXY=true` (documented in `.env.example`). Contradictory default limit values aligned. New `rateLimit.test.js` (reconnect can't bypass, IP backstop, XFF gating).
|
||||
- [x] **2.3 M — Input validation + leak fixes.** ✅ `register_client` validates `clientId` (type/length/charset) before it enters `clientSocketMap` — closes the map-poisoning vector. `mempoolNotified` is now a Map with real TTL eviction in PaymentHandlers (the dead duplicate Set + its random-10% eviction removed from SocketHandlers). `sessions` cache evicted on disconnect (`removeSocket`, called after suspend reads it). `sessionManager.dispose()` / `suspendedGameManager.cleanup()` / `paymentHandlers.dispose()` wired into `shutdown()`. `checkGamesTimeout` snapshots before iterating + awaits sequentially (was mutate-during-`forEach` + fire-and-forget). New `inputValidation.test.js`.
|
||||
|
||||
## Phase 3 — Game integrity (🟠)
|
||||
- [x] **3.1 S — Anti-instant-death grace only (NO fairness floor).** ✅ Random block timing is the game's core mechanic and is deliberately preserved — early entry is a knowingly-risky bet, not a bug. The only change is a tiny configurable grace (`GAME_START_GRACE_MS`, default 2000ms, 0 to disable) so a block landing the instant a game starts can't kill the player before the dungeon renders / before their first move is possible. _(Rejected the originally-planned uniform time budget — it would gut the random-timing mechanic.)_
|
||||
- [x] **3.2 S — Reachability guarantee.** ✅ `DungeonGenerator.generate` now BFS-checks entrance→exit reachability and regenerates if unreachable (paid-but-unwinnable guard). Retries are **deterministic** (per-attempt ROT seed = `seedInt+attempt`, seeded rng stream advances), so verify-regeneration replays the same attempts and reproduces the exact dungeon — provably-fair determinism preserved. New `isReachable` + tests (25 seeds all reachable; walled-off target rejected).
|
||||
- [x] **3.3 S — Multi-step monster collision fix.** ✅ Collision is now checked after **each** monster sub-step, not just after the loop. With `movesPerPlayerMove > 1` (casino's 1.5× speed) the monster could previously step onto the player's tile and off again between checks, phasing through and missing the catch (player-favorable, eroded the house edge). New `monsterCollision.test.js`.
|
||||
|
||||
## Phase 4 — De-spaghetti / structural cleanup (🟡)
|
||||
- [x] **4.1 L — Break up `index.js` (substantially).** ✅ Extracted the verify HTML → `views/verifyPage.js` (now HTML-escaped), all 15 admin endpoints → `routes/admin.js`, and the 3 smirk endpoints → `routes/auth.js`, each as DI factories with smoke tests. **index.js: 2029 → 882 lines (-57%).** _Remaining user/public routes left inline — they're interleaved with bootstrap and have no route tests, so further slicing is diminishing-return/higher-risk; the structure + pattern are established for later._
|
||||
- [~] **4.2 M — Kill duplicates (partial).** ✅ Deleted the dead `handlePlayerMove` handler (a footgun that double-stepped the monster + bypassed escape/collision detection) and its orphaned `playerMoveTimestamps` map/cleanup — the live path is `MovementManager`. _Remaining (lower-value/higher-effort): merge `rpcService`+`rpccalls`, unify `payout_address` column widths, address the `QueryValidator` false-positive — deferred._
|
||||
- [⏸] **4.3 M — Config snapshots — DEFERRED (risk > value).** The `process.env` config write-back is ugly but **load-bearing**: it's how DB-persisted hot-reload config reaches the env-based readers — `difficultyConfig` selects the difficulty preset from `process.env.GAME_MODE`, and `debugManager`'s simulated-blocks **safety gate** reads it. A proper `ConfigService` means rewiring those sensitive readers; with no config-reload tests and zero correctness/safety upside, the risk isn't justified right now. Documented rather than rewritten.
|
||||
- [~] **4.4 M — Frontend de-spaghetti (safe wins done).** ✅ Shared `escapeHtml` already added in 0.6. Removed the duplicate `_gameMode` declaration (the later `null` silently shadowed the earlier `'free'`). Removed the dead `SocketHandlers._appendMessage` calls in `smirkAuth.js` (the method never existed; always fell through to the working `.text()` append). Gated `broadcastManager`'s high-volume per-event logs (user-count, chat-message-with-PII, game-update, game-start) behind debug mode. Upgraded the same-origin vendored jQuery from 3.4.1 to distro-sourced 3.7.1 with a recorded package version and SHA-256. _Deferred: the nested-`setTimeout` init-ordering rewrite and a complete nonce-based CSP migration (both require broader browser regression coverage)._
|
||||
- [x] **4.5 S — Delete stale `test/` scratch files.** ✅ Removed 14 non-jest leftovers (5 `.js` scratch scripts + 9 `.html` debug pages) — confirmed they contained no jest constructs and were referenced nowhere. `test/` is now exclusively the 21 real jest suites.
|
||||
|
||||
## Phase 5 — Test & ops overhaul (🟠 — the launch gate)
|
||||
- [ ] **5.1 L — Real money-path tests** against Postgres (no stubbed `withTransaction`): under/overpay, double-confirm, batch dispatch, credit race, retry idempotency; property-test `money/atomic.js`.
|
||||
- [ ] **5.2 S — Run real `wownero-wallet-rpc` integration test** (open in TODO since day one).
|
||||
- [ ] **5.3 S — `environmentValidator` hard-stop** (`process.exit(1)`); add admin-key/mainnet/simulated-block checks (`src/index.js:33`).
|
||||
- [ ] **5.4 S — Reconcile docs** (README vs `.env.example` house odds); document real provably-fair.
|
||||
- [ ] **5.5 M — Durability:** DB/Redis-backed idempotency + rate limiter.
|
||||
- [ ] **5.6 S — TLS** via reverse proxy.
|
||||
|
||||
---
|
||||
|
||||
## Sequencing
|
||||
Build `money/atomic.js` (1.1) and per-game RNG (0.2) **early** — Phases 1/3/4 depend on them.
|
||||
```
|
||||
Phase 0 (days) → freeze features, ship → Phase 1 (+5.1/5.2 in parallel)
|
||||
→ Phase 2 → Phase 3 → Phase 4 (safe now) → finish Phase 5 → LAUNCH GATE
|
||||
```
|
||||
|
||||
## Launch gate (real funds)
|
||||
- [ ] Under/overpayment correct + reconciled (0.1)
|
||||
- [ ] Provably-fair reproduces dungeon, or claim removed (0.2)
|
||||
- [ ] No double-payout under batch+retry, test-proven (0.3, 1.3, 1.4, 5.1)
|
||||
- [ ] All money math BigInt, property-tested (1.1)
|
||||
- [ ] One identity, impersonation closed (0.4, 2.1)
|
||||
- [ ] XSS closed + CSP (0.6)
|
||||
- [ ] Real wallet-rpc integration test executed (5.2)
|
||||
- [ ] Validator hard-stops on misconfig (5.3)
|
||||
- [ ] TLS in front (5.6)
|
||||
|
||||
_Estimate: ~3–4 weeks to launch gate; Phase 0 in days._
|
||||
+107
-81
@@ -1,39 +1,55 @@
|
||||
# Render kit: modes, packs & FX
|
||||
# Render kit: modes, packs and FX
|
||||
|
||||
The client render kit (`html/js/render/`, namespace `RK`) draws the **same `Scene`** in multiple
|
||||
techniques and art styles, so the tavern and dungeon are rich, interchangeable environments and new
|
||||
art can be added as products.
|
||||
The client render kit (`html/js/render/`, namespace `RK`) draws the same `Scene` in several
|
||||
techniques and art styles, so the tavern and the dungeon are interchangeable environments and new
|
||||
art can be shipped as a product without touching gameplay code.
|
||||
|
||||
## Modes vs. packs
|
||||
|
||||
- **Render modes = techniques** (how a scene is drawn): `Tiled`, `ASCII`, `Iso`, `3D`
|
||||
(`renderModes.js`). Each declares a `projection` (`topdown` / `iso` / `3d`).
|
||||
- **Packs = sets/styles within a projection** (chosen via the pack picker): e.g. topdown *Roguelike
|
||||
Interior* ↔ *Roguelike Dungeon*, iso *Dungeon* ↔ *Medieval*. Registered in `packRegistry.js` /
|
||||
`assetPacks.js`. "Fancy" is **not** a mode — it's a pack (tiles + FX).
|
||||
- A premium mode unlocks once the user has **any** unlocked pack for its projection.
|
||||
- The scene model is **kind-based** (`floor`/`wall`/`table`/`torch`/`monster`/…), so one pack renders
|
||||
both the tavern (`sceneFromTavern`) and the dungeon (`sceneFromGameState`) — that's what makes
|
||||
packs interchangeable across both.
|
||||
- **Render modes are techniques** (how a scene is drawn): `Tiled`, `ASCII`, `Iso`, `3D`
|
||||
(`renderModes.js`). Each declares a `projection` of `topdown`, `iso`, or `3d`.
|
||||
- **Packs are the sets and styles within a projection**, chosen from the pack picker and registered
|
||||
in `packRegistry.js` / `assetPacks.js`.
|
||||
- A premium mode is usable once the account has any unlocked pack for that mode's projection
|
||||
(`RK.canUseMode`).
|
||||
- The scene model is kind-based (`floor`, `wall`, `table`, `torch`, `monster`, and so on), so one
|
||||
pack renders both the tavern (`sceneFromTavern`) and the dungeon (`sceneFromGameState`). That is
|
||||
what makes packs interchangeable across town and dungeon.
|
||||
|
||||
Entitlement: packs are gated by the operator-owned cosmetic catalog (`cosmetic_catalog` table,
|
||||
`src/multiplayer/entitlements.js`) — free / grant / lifetime-spend / subscription-tier. See
|
||||
`MONETIZATION.md`.
|
||||
Registered packs (`assetPacks.js`):
|
||||
|
||||
For browser QA, `?unlock=1` sets a sticky local bypass for render-mode and pack display gates;
|
||||
`?unlock=0` clears it. This affects only that browser's `localStorage`, grants no server
|
||||
entitlement, and is not an operator access-control mechanism.
|
||||
| Pack id | Projection | Label |
|
||||
|---------|-----------|-------|
|
||||
| `original` | topdown | Original Tiles |
|
||||
| `roguelike-interior` | topdown | Roguelike Interior |
|
||||
| `roguelike-dungeon` | topdown | Roguelike Dungeon |
|
||||
| `iso-dungeon` | iso | Isometric Dungeon |
|
||||
| `iso-medieval` | iso | Medieval Town |
|
||||
| `kenney-3d-characters` | 3d | Animated 3D |
|
||||
|
||||
The active pack is stored per projection (`rk_pack_<projection>` in `localStorage`, an in-memory map
|
||||
under Node). A stored pack that is unregistered or locked is ignored in favour of the first unlocked
|
||||
pack for that projection.
|
||||
|
||||
Entitlement is per pack, not per purchase: each pack maps to a row in the operator-owned
|
||||
`cosmetic_catalog` table and resolves through `src/multiplayer/entitlements.js` as free, grant-only,
|
||||
lifetime-credits threshold, or subscription tier. `entitlements.js` also carries a built-in default
|
||||
catalog used when the table is unavailable. See `MONETIZATION.md`.
|
||||
|
||||
For browser QA, `?unlock=1` sets a sticky local bypass for render-mode and pack display gates and
|
||||
`?unlock=0` clears it. It affects only that browser's `localStorage`, grants no server entitlement,
|
||||
and is not an operator access-control mechanism.
|
||||
|
||||
## Gameplay focus contract
|
||||
|
||||
Single-player keyboard movement is deliberately accepted only while `#game-display` owns browser
|
||||
focus, so typing in chat or another form cannot move the player. Choosing Tiled, ASCII, Iso, 3D, a
|
||||
pack, or a camera control immediately returns focus to that gameplay area without scrolling.
|
||||
Single-player keyboard movement is accepted only while `#game-display` owns browser focus, so typing
|
||||
in chat or another form cannot move the player. Choosing Tiled, ASCII, Iso, 3D, a pack, or a camera
|
||||
control immediately returns focus to the gameplay area without scrolling (`RK.SPGame.focusGameplay`).
|
||||
|
||||
## Local 3D runtime
|
||||
|
||||
Three.js `0.160.0` is an exact production dependency. The server advertises the local version in
|
||||
`/runtime-config.js` and exposes only the browser build and required addons beneath:
|
||||
`/runtime-config.js` and publishes only the browser build and the two required addons:
|
||||
|
||||
```text
|
||||
/vendor/three/0.160.0/three.module.min.js
|
||||
@@ -41,85 +57,95 @@ Three.js `0.160.0` is an exact production dependency. The server advertises the
|
||||
/vendor/three/0.160.0/addons/utils/SkeletonUtils.js
|
||||
```
|
||||
|
||||
The modules are imported lazily when 3D is selected. Local delivery is always enabled in the
|
||||
application runtime and takes precedence over external sources. `RENDERER_CDN_ENABLED=true` makes
|
||||
the jsDelivr renderer source path eligible only in a runtime where local delivery is disabled. All
|
||||
checked-in environment profiles keep external renderer execution off, including production, so
|
||||
Three.js remains same-origin under CSP.
|
||||
The modules are imported lazily the first time 3D is selected (`RK.ensureThree`), so ASCII and Tiled
|
||||
users never download them. Local delivery is always enabled in the application runtime and takes
|
||||
precedence over external sources. `RENDERER_CDN_ENABLED=true` makes the jsDelivr renderer source
|
||||
path eligible only in a runtime where local delivery is disabled, and widens the CSP to trust
|
||||
third-party script. All checked-in environment profiles keep it off, so Three.js stays same-origin.
|
||||
|
||||
Three.js library delivery is separate from generated model delivery. Runtime GLBs under
|
||||
`html/assets/generated/3d/` are gitignored and must be provisioned independently; the renderer
|
||||
keeps a low-poly fallback when a model cannot load and removes it once the animated GLB is attached.
|
||||
Library delivery is separate from model delivery. Runtime GLBs under `html/assets/generated/3d/` are
|
||||
gitignored and must be provisioned independently; the renderer keeps a low-poly placeholder while a
|
||||
model cannot load and removes it once the animated GLB is attached.
|
||||
|
||||
## The FX layer (`fxLayer.js`, `RK.fx`)
|
||||
|
||||
Pure-canvas animated FX (CSP-safe, no WebGL), shared by the tiled **and** iso renderers so a scene
|
||||
lights up the same way in either projection:
|
||||
Pure-canvas animated FX, CSP-safe and with no WebGL, shared by the tiled and iso renderers so a
|
||||
scene lights up the same way in either projection:
|
||||
|
||||
- `RK.fx.fire` — dancing two-layer flame + warm glow (torches, hearths).
|
||||
- `RK.fx.hazard` — pulsing overlay for hazard tiles (`lava` / `poison` / `spikes`) with a
|
||||
- `RK.fx.fire` and `RK.fx.flame`: dancing two-layer flame plus warm glow, for torches and hearths.
|
||||
- `RK.fx.hazard`: pulsing overlay for hazard tiles (`lava`, `poison`, `spikes`), taking a
|
||||
footprint-clip callback so it fits square cells and iso diamonds alike.
|
||||
- `RK.fx.flicker` / `pulse` / `glow` — the primitives.
|
||||
- `RK.fx.flicker`, `RK.fx.pulse`, `RK.fx.glow`: the primitives.
|
||||
|
||||
A tile carries `fx: 'fire'` or `hazard: '<kind>'` in the scene legend (`sceneModel.js`); the renderer
|
||||
draws only the tile's floor **base** in the static pass and lets `RK.fx` paint the animated layer in
|
||||
the live loop. The tavern has wall braziers + a hearth; the dungeon hazard kinds are wired and render
|
||||
the moment the generator emits their chars (`L`/`P`/`^`) — pending the generator + gameplay stakes.
|
||||
A tile carries `fx: 'fire'` or `hazard: '<kind>'` in the scene legend (`sceneModel.js`). The
|
||||
renderer draws only the tile's base in the static pass and lets `RK.fx` paint the animated layer in
|
||||
the live loop. The tavern places wall braziers and a hearth. Dungeon hazards are keyed off the map
|
||||
characters `L`, `P`, and `^` in `dungeonTileKind`, so a generator that emits them renders hazards
|
||||
with no renderer change.
|
||||
|
||||
## Lighting model (iso & 3D)
|
||||
Torches are wall-mounted: the generator places them only on wall cells adjacent to floor, so
|
||||
`DUNGEON_LEGEND.torch` uses `over: 'wall'` and the fire branch draws the wall base plus the
|
||||
procedural flame.
|
||||
|
||||
Both premium projections light the dungeon rather than just tinting it, and both derive their
|
||||
lighting from the same `scene.lightGrid` the 2D modes use — so switching projection changes the
|
||||
technique, never what you are allowed to see.
|
||||
## Lighting model (iso and 3D)
|
||||
|
||||
Both premium projections light the dungeon rather than tinting it, and both derive lighting from the
|
||||
same `scene.lightGrid` the 2D modes use, so switching projection changes the technique and never
|
||||
what a player is allowed to see.
|
||||
|
||||
**Iso** composites in one pass per frame:
|
||||
|
||||
- tiles are **shaded opaque** (`_shadedImage`, quantized to 6 cached levels) instead of alpha-faded.
|
||||
Alpha-fading made remembered walls translucent, so the dungeon read as a haze of overlapping
|
||||
ghosts; multiplying a sprite's own pixels toward a cold near-black keeps distant stone *solid and
|
||||
unlit*, which is what memory should look like;
|
||||
- **light pools** (`_lightPool`) are additive, squashed to the ground plane, and emitted by torches
|
||||
(flickering), the player's lantern, and objective beacons on the stairs/treasure;
|
||||
- **crevice AO** (`_wallAO`) darkens floor cells by how many walls they touch;
|
||||
- **embers** rise off each flame, seeded per cell so they don't reshuffle every frame;
|
||||
- a **cold blue wash** (`_memoryWash`) separates remembered rooms from torchlit ones by colour
|
||||
temperature. The wash colour must stay *darker* than the tiles it covers — a mid-tone blue lifts
|
||||
- tiles are shaded opaque (`_shadedImage`, quantized to six cached levels) rather than alpha-faded.
|
||||
Multiplying a sprite's own pixels toward a cold near-black keeps distant stone solid and unlit;
|
||||
alpha-fading would make remembered walls translucent and let the tiles behind them show through.
|
||||
- light pools (`_lightPool`) are additive, squashed to the ground plane, and emitted by torches
|
||||
(flickering), the player's lantern, and objective beacons on the stairs and treasure.
|
||||
- crevice AO (`_wallAO`) darkens floor cells by how many walls they touch.
|
||||
- embers rise off each flame, seeded per cell so they do not reshuffle every frame.
|
||||
- a cold blue wash (`_memoryWash`) separates remembered rooms from torchlit ones by colour
|
||||
temperature. The wash colour must stay darker than the tiles it covers: a mid-tone blue lifts
|
||||
memory brighter than the lit centre and inverts the depth hierarchy.
|
||||
|
||||
**3D** (`threeRenderer.js`) draws the level as **three InstancedMeshes** (walls / ground / props),
|
||||
so a 70×35 dungeon costs three draw calls and a fog-of-war update is a few thousand matrix writes
|
||||
rather than ~2500 mesh allocations. Wall and floor materials are generated on a canvas at startup
|
||||
(coursed stone, irregular flagstones, both doubling as bump maps) — no new downloads, nothing for
|
||||
the CSP to block. On top: a low warm key light with a player-tracking shadow frustum, a pool of six
|
||||
point lights reassigned each frame to the nearest braziers, a hand-held lantern, drifting dust motes,
|
||||
and a **wall cutaway** that squashes the walls between the isometric camera and the player.
|
||||
**3D** (`threeRenderer.js`) draws the level as three InstancedMeshes (walls, ground, props), so a
|
||||
70x35 dungeon costs three draw calls and a fog-of-war update is a few thousand matrix writes instead
|
||||
of thousands of mesh allocations. Wall and floor materials are generated on a canvas at startup
|
||||
(coursed stone, irregular flagstones, both doubling as bump maps), so there is nothing extra to
|
||||
download and nothing for the CSP to block. On top of that: a low warm key light with a
|
||||
player-tracking shadow frustum, a pool of six point lights reassigned each frame to the nearest
|
||||
braziers, a hand-held lantern, drifting dust motes, and a wall cutaway that squashes the walls
|
||||
between the isometric camera and the player.
|
||||
|
||||
Two things that are easy to get wrong here:
|
||||
Two constraints that are easy to get wrong:
|
||||
|
||||
- **Fog is measured from the CAMERA**, which sits ~17 world units back along the isometric offset —
|
||||
- Fog is measured from the camera, which sits about 17 world units back along the isometric offset,
|
||||
not from the player. A fog band tuned as if it started at the player puts the player a quarter of
|
||||
the way into fog and crushes the scene to near-black.
|
||||
- **Do not apply `lightGrid` to albedo at full strength in 3D.** The point lights already provide
|
||||
falloff; multiplying by the grid as well double-darkens every surface. 3D compresses it
|
||||
(`0.38 + 0.62·lb`) and lets the lamps shape the scene.
|
||||
- `lightGrid` must not be applied to albedo at full strength in 3D. The point lights already provide
|
||||
falloff, so multiplying by the grid as well double-darkens every surface. 3D compresses it to
|
||||
`0.38 + 0.62 * lb` and lets the lamps shape the scene.
|
||||
|
||||
Features (`entrance` / `exit` / `treasure` / items) are **set dressing, not avatars** in both
|
||||
projections: stairs with a beacon shaft, a gold chest, a spinning pickup. Before that distinction
|
||||
existed, every one of them was drawn as a humanoid, so the entrance looked like a second player
|
||||
standing at the map origin.
|
||||
Features (`entrance`, `exit`, `treasure`, items) are set dressing, not avatars, in both projections:
|
||||
stairs with a beacon shaft, a gold chest, a spinning pickup. Rendering them through the entity path
|
||||
would draw each one as a humanoid, which makes the entrance look like a second player standing at
|
||||
the map origin.
|
||||
|
||||
## Other pieces
|
||||
## Module reference
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `isoRenderer.js` | Canvas isometric projection. Orientation-aware walls (`_S` for x-runs, `_W` for y-runs, corner tile), floor variety, contact shadows + vignette juice. Kenney iso dungeon set (`assets/kenney/iso-dungeon/`). |
|
||||
| `tileRenderer.js`| Top-down atlas renderer. `over` compositing (object tiles drawn on a base tile), torch-lit vignette, ember/flicker loop, animated fire/hazard emitters. |
|
||||
| `zoomControl.js` | `RK.attachZoom(host)` — wheel/pinch zoom (0.4–4.0), dblclick reset, pixelated scaling. |
|
||||
| `catSprites.js` | Animated tavern cat (Pet Cats Pack idle strips). |
|
||||
| `packRegistry.js`| Multi-pack-per-projection registry: active-pack selection persisted per projection, entitlement-gated, graceful fallback. Node-testable. |
|
||||
| `threeRenderer.js` | Same-origin Three.js scene. Instanced level geometry, procedural stone/flagstone materials, torch light pool + shadows, dedicated feature/monster meshes, dust motes, wall cutaway, fog-aware entity retention, animated GLB cloning, camera follow, and complete renderer/resource cleanup. |
|
||||
| `chainAmbience.js` | `RK.mountChainAmbience(host)` — drifting top-block-hash fragments, a block-height readout, and a ripple when a block lands. Consumes the public `blockheight` broadcast; decoration only, and it pauses/prunes itself while the tab is hidden. |
|
||||
| `spGameRenderer.js` | Single-player render bridge, player-centred camera, local 3D lazy-load, and explicit gameplay-focus restoration. |
|
||||
| `sceneModel.js` | Renderer-agnostic `Scene` plus the tavern and game-state adapters, the dungeon legend, and tile-character mapping. Handles both array grids and the client's sparse `{y:{x:v}}` maps. |
|
||||
| `renderModes.js` | Mode registry, entitlement checks, renderer factory, and lazy Three.js loading. |
|
||||
| `tileRenderer.js` | Top-down atlas renderer. `over` compositing (object tiles drawn on a base tile), torch-lit vignette, ember and flicker loop, animated fire and hazard emitters. |
|
||||
| `asciiRenderer.js` | Glyph renderer for the free ASCII mode. |
|
||||
| `isoRenderer.js` | Canvas isometric projection. Orientation-aware walls (`_S` for x-runs, `_W` for y-runs, corner tile), floor variety, contact shadows, vignette. Kenney iso dungeon set (`html/assets/kenney/iso-dungeon/`). |
|
||||
| `threeRenderer.js` | Same-origin Three.js scene. Instanced level geometry, procedural stone and flagstone materials, torch light pool and shadows, dedicated feature and monster meshes, dust motes, wall cutaway, fog-aware entity retention, animated GLB cloning, camera follow, and full renderer and resource cleanup. |
|
||||
| `packRegistry.js` | Multi-pack-per-projection registry: active-pack selection persisted per projection, entitlement-gated, graceful fallback. Node-testable. |
|
||||
| `assetPacks.js` | Pack definitions and asset payloads; registers the shipped packs. |
|
||||
| `zoomControl.js` | `RK.attachZoom(host)` and `RK.attachCamera(host)`: wheel and pinch zoom (0.4 to 4.0), double-click reset, pixelated scaling. |
|
||||
| `fxLayer.js` | Shared canvas FX primitives, fire, and hazard overlays. |
|
||||
| `catSprites.js` | Animated tavern cat (Pet Cats Pack idle strips, 10 frames of 50x50). |
|
||||
| `chainAmbience.js` | `RK.mountChainAmbience(host)`: drifting top-block-hash fragments, a block-height readout, and a ripple when a block lands. Consumes the public `blockheight` broadcast, is decoration only, and pauses and prunes itself while the tab is hidden. |
|
||||
| `spGameRenderer.js` | Single-player render bridge (`RK.SPGame`): player-centred camera, local 3D lazy-load, gameplay-focus restoration, and fallback to the legacy render engine when the kit is unavailable. |
|
||||
|
||||
Tests: `test/renderPackResolver.test.js`, `test/packRegistry_ultra.test.js`,
|
||||
`test/rendererCdnPolicy.test.js`, and `test/pvpUiPolish.test.js`.
|
||||
Tests: `test/renderScene.test.js`, `test/renderPackResolver.test.js`, `test/packRegistry_ultra.test.js`,
|
||||
`test/rendererCdnPolicy.test.js`, `test/pvpUiPolish.test.js`.
|
||||
|
||||
+166
-68
@@ -1,97 +1,195 @@
|
||||
# Rulesets & PvP (P4)
|
||||
# Rulesets
|
||||
|
||||
Gameplay is a **data object**. Instead of hardcoding "first to the exit wins," the match engine reads a
|
||||
`Ruleset`, so new modes (PvP last-alive, score attack, co-op) are data — not new subsystems.
|
||||
Gameplay is a data object. The match engine reads a `Ruleset` instead of hardcoding "first to the
|
||||
exit wins," so additional modes (PvP last-alive, score attack, co-op escape) are configuration rather
|
||||
than new subsystems.
|
||||
|
||||
## The Ruleset
|
||||
## The Ruleset object
|
||||
|
||||
`src/game/rulesets/Ruleset.js` — `defineRuleset(spec)` normalizes a partial spec to a frozen, clamped
|
||||
object:
|
||||
`src/game/rulesets/Ruleset.js` exposes `defineRuleset(spec)`, which normalizes a partial spec into a
|
||||
complete, frozen, range-clamped ruleset. An empty spec yields the classic single-dungeon escape.
|
||||
|
||||
```
|
||||
id, label, mode
|
||||
world { difficultyPreset, maps }
|
||||
entities { monster, monsterCount, pvpCombat }
|
||||
players { min, max }
|
||||
winCondition { type } first-to-exit | last-alive | high-score | all-escape
|
||||
timing { tickMs, minDurationMs, hardCeilingMs, blockDeadline }
|
||||
economy { model, houseFeePercent, payoutMultipliers }
|
||||
```
|
||||
| Field | Meaning | Default | Clamp |
|
||||
| --- | --- | --- | --- |
|
||||
| `id`, `label` | Identity and display name | `custom` / `Custom` | |
|
||||
| `mode` | Coarse family: `solo`, `race`, `pvp`, `coop` | `race` | |
|
||||
| `world.difficultyPreset` | Key passed to `getDifficultyConfig` | `normal` | |
|
||||
| `world.maps` | Reserved for multi-level matches; the match engine generates one map | `1` | 1..10 |
|
||||
| `entities.monster` | Spawn the shared monster | `true` | |
|
||||
| `entities.monsterCount` | Reserved count field | `1` (`0` when `monster` is false) | 0..8 |
|
||||
| `entities.pvpCombat` | Stepping onto a living rival strikes them down | `false` | |
|
||||
| `players.min` / `players.max` | Entrant bounds; `min` is lowered to `max` if it exceeds it | `1` / `4` | 1..32 |
|
||||
| `winCondition.type` | `first-to-exit`, `last-alive`, `high-score`, `all-escape` | `first-to-exit` | |
|
||||
| `timing.tickMs` | Engine tick interval | `250` | 50..5000 |
|
||||
| `timing.minDurationMs` | Active-play floor before a block deadline may fire | `20000` | 0..600000 |
|
||||
| `timing.hardCeilingMs` | Absolute match length ceiling | `240000` | 1000..3600000 |
|
||||
| `timing.blockDeadline` | End on the first advancing canonical header | `true` | |
|
||||
| `economy.model` | `free`, `credits_prestige`, `crypto_race` | `free` | |
|
||||
| `economy.houseFeePercent` | House rake for pot modes | `0` | |
|
||||
| `economy.payoutMultipliers` | `escape`, `escapeWithTreasure` | `2`, `3` | |
|
||||
| `metadata` | Free-form descriptor bag (`description` is surfaced to clients) | `{}` | |
|
||||
|
||||
`registry.js` ships the built-ins — `solo-classic` (descriptor for the live single-player mode),
|
||||
`race`, `last-alive`, `score-attack`, `coop-escape` — plus `getRuleset(id)`, `listRulesets()`, and
|
||||
`rulesetFromMatchOpts()` (maps the legacy MatchRoom option bag to a `race` ruleset, preserving old
|
||||
behavior).
|
||||
Unrecognized `winCondition.type` and `economy.model` values fall back to the defaults rather than
|
||||
propagating, and every nested section is frozen.
|
||||
|
||||
## Built-in rulesets
|
||||
|
||||
`src/game/rulesets/registry.js` ships the catalog and the helpers `getRuleset(id, overrides)`,
|
||||
`listRulesets()`, `listMatchRulesets()`, `resolveMatchRuleset(id)`, and `rulesetFromMatchOpts(opts)`.
|
||||
|
||||
| id | mode | players | win condition | notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `solo-classic` | `solo` | 1 | `first-to-exit` | Catalog descriptor for the single-player escape. Never selectable for a multiplayer queue. |
|
||||
| `race` | `race` | 2..8 | `first-to-exit` | Classic escape race; `race` difficulty preset; `crypto_race` economy. |
|
||||
| `last-alive` | `pvp` | 2..8 | `last-alive` | `pvpCombat` enabled, monster present, `normal` preset. |
|
||||
| `score-attack` | `race` | 1..8 | `high-score` | Escaping does not end the match; score decides. |
|
||||
| `coop-escape` | `coop` | 2..8 | `all-escape` | `pvpCombat` disabled; ends when every survivor escapes. |
|
||||
|
||||
`listMatchRulesets()` filters out `mode === 'solo'`, giving the server, scheduler, and clients one
|
||||
stable multiplayer allowlist. `resolveMatchRuleset(id)` fails closed to `race` for unknown or
|
||||
solo-only ids. `rulesetFromMatchOpts()` maps the legacy `MatchRoom` option bag
|
||||
(`economy`, `difficultyPreset`, `maxPlayers`) onto a `race` ruleset; the legacy `variant` label does
|
||||
not change the win condition.
|
||||
|
||||
## Win conditions as strategies
|
||||
|
||||
`src/game/rulesets/winConditions.js` — each condition is a small strategy the `MatchRoom` consults at
|
||||
three decision points:
|
||||
`src/game/rulesets/winConditions.js` implements each condition as a small strategy object that
|
||||
`MatchRoom` consults at three decision points:
|
||||
|
||||
```
|
||||
onExit(room, id) a player reached the exit — end the match? set a winner?
|
||||
onDeath(room) a player died — is the match over?
|
||||
onExit(room, id) a player reached the exit: end the match? set a winner?
|
||||
onDeath(room) a player died: is the match over?
|
||||
rank(room) final ordering (best-first) for placement; competitive modes also set winnerId
|
||||
```
|
||||
|
||||
- **`FIRST_TO_EXIT`** is a *verbatim port* of the original race logic, so existing matches are
|
||||
byte-identical.
|
||||
- **`LAST_ALIVE`** (PvP): reaching the exit means you *survived* (safe, not an instant win); the match
|
||||
ends when ≤1 contender remains; `rank` orders last-standing → escaped → died-later. Combined with
|
||||
`entities.pvpCombat`, stepping onto a living rival **strikes them down** (attacker holds position).
|
||||
Same-tick intents use a seed-derived priority over stable entrant slots, never packet arrival order;
|
||||
an entrant eliminated earlier in that committed order cannot move or attack post-mortem.
|
||||
- **`HIGH_SCORE`**: no instant win; rank by score at the end.
|
||||
- **`ALL_ESCAPE`** (co-op): ends when everyone alive has escaped, records placements as progress
|
||||
metadata, and deliberately leaves the individual `winnerId` null.
|
||||
- **`FIRST_TO_EXIT`**: the first player out wins and the match ends immediately. Ranking orders
|
||||
winner, then finished, alive, Manhattan distance to the exit, treasure, fewer moves, id.
|
||||
- **`LAST_ALIVE`** (PvP): reaching the exit means you survived, not that you won; the match ends when
|
||||
at most one contender remains in play and at least two started. Ranking orders last-standing,
|
||||
escaped survivor, then later deaths above earlier ones (`deathOrder`), then treasure, distance, id.
|
||||
With `entities.pvpCombat`, moving onto a living rival's tile strikes them down and the attacker
|
||||
holds position.
|
||||
- **`HIGH_SCORE`**: no instant win. Score rewards escaping, carrying treasure, move efficiency, and
|
||||
proximity to the exit for players still inside when the match resolves.
|
||||
- **`ALL_ESCAPE`** (co-op): ends when every player still alive has escaped (or all have died) and
|
||||
reuses the race ordering for placements.
|
||||
|
||||
## Where it plugs in
|
||||
`resolveWinCondition(type)` returns `FIRST_TO_EXIT` for any unknown type.
|
||||
|
||||
`MatchRoom` (`src/multiplayer/MatchRoom.js`) resolves a ruleset from `opts.ruleset` / `opts.rulesetId` /
|
||||
legacy opts, then delegates: `_checkResolution` → `winCondition.onExit`, `_killPlayer` →
|
||||
`winCondition.onDeath`, `finalize` → `winCondition.rank`. It honors `entities.monster` (optional monster)
|
||||
and `entities.pvpCombat`, and takes timing from the ruleset. `MatchEngine` is a thin tick driver
|
||||
(`room.resolveTick()` on an interval).
|
||||
## Engine integration
|
||||
|
||||
**Scope note:** the *live single-player* engine (`src/game/game.js`) is intentionally **not** refactored to
|
||||
consume rulesets (too risky to touch the deployed path) — `solo-classic` is a catalog descriptor only.
|
||||
Multiplayer rulesets are selected by the server with `MATCH_RULESET_ID`; unknown or solo-only values fail
|
||||
closed to `race`. The active ruleset and safe catalog metadata are advertised to clients, but clients cannot
|
||||
select executable rules. Match mode remains gated by `MATCH_ENABLED` (inert by default).
|
||||
`MatchRoom` (`src/multiplayer/MatchRoom.js`) resolves its ruleset with the precedence
|
||||
`opts.ruleset` (an explicit spec), then `opts.rulesetId` plus optional `opts.rulesetOverrides`, then
|
||||
`rulesetFromMatchOpts(opts)`. It then:
|
||||
|
||||
When `timing.blockDeadline` is true, `MatchScheduler` offers each advancing canonical header to
|
||||
`MatchManager`. An active room expires only when that header is strictly later than its start
|
||||
header and its active-play `minDurationMs` floor has elapsed. Duplicate polls and same-header
|
||||
starts are idempotent; `blockDeadline: false` leaves the hard ceiling as the only clock-based end.
|
||||
- spawns the shared monster only when `entities.monster` is set;
|
||||
- takes `minDurationMs` and `hardCeilingMs` from `timing` (the manager and scheduler may override);
|
||||
- clamps `maxPlayers` into 2..32 from `opts.maxPlayers` or `players.max`;
|
||||
- delegates `_checkResolution` to `winCondition.onExit`, `_killPlayer` to `winCondition.onDeath`, and
|
||||
`finalize` to `winCondition.rank`;
|
||||
- treats co-op modes (`mode === 'coop'` or `all-escape`) as collective, recording placements as
|
||||
progress metadata and leaving the individual `winnerId` null;
|
||||
- exposes a client-safe summary (`id`, `label`, `mode`, `description`, `winCondition`, `pvpCombat`).
|
||||
|
||||
For paid multiplayer, the selected ruleset ID is part of the durable entrant freeze at canonical
|
||||
header H. The seed uses only the exact configured future-header hash after that freeze and its
|
||||
required confirmation wait; changing the active
|
||||
ruleset cannot reinterpret a pending entrant set or trigger a server-random fallback.
|
||||
Terminal players stay in `occupants` so spectators and final results can render them, but corpses and
|
||||
escaped heroes are pass-through: co-op and score attack require several players to cross the single
|
||||
exit tile on different ticks. Living, in-play players remain solid, and an occupant with no match
|
||||
state stays solid rather than failing open into an overlap.
|
||||
|
||||
Same-tick intents resolve in a committed order derived from `sha256(seed:tick:entrantSlot)`, never
|
||||
packet arrival order. An entrant eliminated earlier in that order cannot move or attack afterwards;
|
||||
the attempt emits `move_failed` with reason `eliminated_before_action`.
|
||||
|
||||
`MatchEngine` (`src/multiplayer/MatchEngine.js`) is a thin, transport-agnostic tick driver that calls
|
||||
`room.resolveTick()` on an interval and reports results to its owner.
|
||||
|
||||
The single-player engine (`src/game/game.js`) does not consume rulesets; `solo-classic` is a catalog
|
||||
descriptor for parity and display only.
|
||||
|
||||
## Selection and safety
|
||||
|
||||
Multiplayer rulesets are trusted server configuration, selected with `MATCH_RULESET_ID` (default
|
||||
`race`). Clients receive the active ruleset and the safe catalog metadata for display, but cannot
|
||||
select or author executable rules. Match mode as a whole is gated by `MATCH_ENABLED`, which must be
|
||||
the literal string `true` to be active.
|
||||
|
||||
Crypto payout admission requires single-winner semantics. `matchEconomyPolicy` accepts a ruleset only
|
||||
when its mode is neither `solo` nor `coop`, its win condition is one of `first-to-exit`,
|
||||
`last-alive`, or `high-score`, and `players.max` is at least 2; otherwise admission is denied with
|
||||
`unsupported_crypto_ruleset`. `environmentValidator` applies the same check at boot and additionally
|
||||
requires `MATCH_MAX_PLAYERS` to fall inside the ruleset's player contract. Match persistence and
|
||||
transport require at least two entrants, so the effective minimum is raised to 2 even for rulesets
|
||||
that are also valid as a one-player challenge.
|
||||
|
||||
`src/migrations/029_match_ruleset_id.sql` persists the selected ruleset on each match
|
||||
(`matches.ruleset_id`, default `race`) with a partial index on finished matches, keeping newer
|
||||
built-ins auditable without widening the money and economy keys.
|
||||
|
||||
## Timing and block deadlines
|
||||
|
||||
`matchScheduler` resolves timing with the precedence: explicit constructor argument, then
|
||||
`MATCH_TICK_MS` / `MATCH_MIN_DURATION_MS` / `MATCH_HARD_CEILING_MS`, then the ruleset's `timing`,
|
||||
then the built-in defaults.
|
||||
|
||||
When `timing.blockDeadline` is true, each advancing canonical header is offered to `MatchManager`.
|
||||
An active room expires with reason `block_deadline` only when the header is strictly later than the
|
||||
room's start header and its active-play `minDurationMs` floor has elapsed. Strict height comparison
|
||||
protects rooms created by the same header, and duplicate polls are idempotent. With
|
||||
`blockDeadline: false`, the hard-ceiling watchdog owned by `MatchManager` is the only clock-based end.
|
||||
|
||||
For paid multiplayer, the ruleset id is part of the durable entrant freeze at the freeze header:
|
||||
`buildPaidEntrantFreeze` commits version, freeze height, target height, delay, economy, ruleset id,
|
||||
and the canonical queue entry ids into a single hash. `deriveFutureBlockMatchSeed` then accepts only
|
||||
the exact committed future header, after its required confirmation wait, and mixes the freeze
|
||||
commitment into the seed. Changing the active ruleset cannot reinterpret a pending entrant set or
|
||||
trigger a server-random fallback.
|
||||
|
||||
## Economy per mode
|
||||
|
||||
A ruleset's `economy` field (`model`, `houseFeePercent`, `payoutMultipliers`) maps onto the existing
|
||||
`gameModeManager` fields (single-player: multiplier-on-entry-fee) and `matchPayoutService` (PvP:
|
||||
winner-takes-pot-minus-house). See [MONETIZATION.md](MONETIZATION.md).
|
||||
A ruleset's `economy` block (`model`, `houseFeePercent`, `payoutMultipliers`) maps onto the existing
|
||||
payout paths: `gameModeManager` for single player (multiplier on entry fee) and `matchPayoutService`
|
||||
for pot modes (winner takes the pot minus the house fee). See [MONETIZATION.md](MONETIZATION.md) and
|
||||
[MATCH_MODE.md](MATCH_MODE.md).
|
||||
|
||||
## Pending: block-time-aware difficulty
|
||||
## World presets and per-chain tuning
|
||||
|
||||
`difficultyConfig` presets (easy/normal/hard/casino) tune dungeon size, monster behavior, treasure, and a
|
||||
target house-win-rate — but currently key off payment mode, **not** mean block time. Coupling difficulty
|
||||
to `ChainProfile.meanBlockTimeMsFor(chain)` (so Grin ~1 min stays fair vs BTC ~10 min) is a pending
|
||||
game-balance change, not just wiring.
|
||||
`src/game/difficultyConfig.js` holds the world half of a ruleset. `getDifficultyPreset` selects a
|
||||
preset from `DIFFICULTY_PRESET` or an explicit override; with neither set it returns `normal` for
|
||||
free play (`GAME_MODE=FREE` and `PAYMENTS_ENABLED` not true) and `casino` otherwise. Presets tune
|
||||
dungeon size and openness, monster start distance, speed, aggressiveness and vision, treasure
|
||||
placement, and a target house win rate.
|
||||
|
||||
Block time enters through `NETWORK_TUNING`, which folds a per-chain level count onto the resolved
|
||||
preset so a run's cumulative length scales with the chain's mean block time. Each level stays
|
||||
base-sized with a fair monster; the house edge comes from racing the block across the whole descent
|
||||
rather than from one oversized map or a faster-than-fair monster.
|
||||
|
||||
| Chain | Mean block time | Levels |
|
||||
| --- | --- | --- |
|
||||
| GRIN | ~1 min | 1 |
|
||||
| XMR | ~2 min | 1 |
|
||||
| LTC | ~2.5 min | 2 |
|
||||
| WOW | ~5 min | 2 |
|
||||
| BTC | ~10 min | 3 |
|
||||
|
||||
Precedence in `getDifficultyConfig` runs low to high: preset, per-chain tuning, environment variables
|
||||
(`DUNGEON_LEVELS`, `DUNGEON_*`, `MONSTER_*`), then explicit overrides passed by the caller. Set
|
||||
`NETWORK_TUNING_DISABLED=true` to skip the per-chain step. The level count drives multi-level solo
|
||||
runs; `MatchRoom` consumes only the dungeon geometry from the resolved config and generates a single
|
||||
shared map.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
src/game/rulesets/Ruleset.js defineRuleset + WIN/ECONOMY enums
|
||||
src/game/rulesets/winConditions.js FIRST_TO_EXIT (race) / LAST_ALIVE / HIGH_SCORE / ALL_ESCAPE
|
||||
src/game/rulesets/registry.js built-ins + safe match resolver/catalog helpers
|
||||
src/multiplayer/MatchRoom.js consumes the ruleset; PvP combat; delegates win/lose/rank
|
||||
src/multiplayer/MatchEngine.js tick driver
|
||||
src/network/matchScheduler.js advancing-header deadline dispatch
|
||||
src/network/matchManager.js active-play floor + idempotent expiry
|
||||
src/game/difficultyConfig.js world presets (the ruleset's `world` half)
|
||||
src/game/rulesets/Ruleset.js defineRuleset + WIN / ECONOMY enums
|
||||
src/game/rulesets/winConditions.js FIRST_TO_EXIT / LAST_ALIVE / HIGH_SCORE / ALL_ESCAPE
|
||||
src/game/rulesets/registry.js built-ins + match resolver and catalog helpers
|
||||
src/multiplayer/MatchRoom.js consumes the ruleset; PvP combat; delegates win/lose/rank
|
||||
src/multiplayer/MatchEngine.js tick driver
|
||||
src/network/matchScheduler.js advancing-header deadline dispatch
|
||||
src/network/matchManager.js active-play floor + idempotent expiry
|
||||
src/network/matchEconomyPolicy.js crypto admission contract (player bounds, win semantics)
|
||||
src/network/matchFairness.js paid entrant freeze + future-block seed derivation
|
||||
src/game/difficultyConfig.js world presets and per-chain level tuning
|
||||
src/migrations/029_match_ruleset_id.sql persists the selected ruleset on each match
|
||||
```
|
||||
|
||||
@@ -1,59 +1,105 @@
|
||||
# XMR stagenet financial canary
|
||||
|
||||
`src/scripts/stagenet-financial-canary.js` is the release-gate harness for Monerogue's two
|
||||
direct-entry solo outcomes. Purchased-credit entry is outside the operated product profile. The
|
||||
harness is intentionally narrow: one new anonymous user, one invoice, at most one funding
|
||||
transfer, one bounded game, and one payout. It supports these scenarios:
|
||||
`src/scripts/stagenet-financial-canary.js` is the release-gate harness for the two direct-entry
|
||||
solo money outcomes. Purchased-credit entry is outside the operated stagenet profile and the
|
||||
harness requires it to be disabled. The harness is deliberately narrow: one new anonymous user,
|
||||
one invoice, at most one funding transfer, one bounded game, and one payout.
|
||||
|
||||
| `E2E_SCENARIO` | Entry | Required outcome | Exact payout |
|
||||
|---|---|---|---|
|
||||
| `direct-2x` | one `single_game` invoice | escape without treasure | invoice amount × 2 |
|
||||
| `direct-3x` | one `single_game` invoice | collect treasure, then escape | invoice amount × 3 |
|
||||
|
||||
Stagenet XMR is valueless test currency. Never send mainnet XMR to a stagenet address. This harness refuses mainnet-facing application state and funding-wallet addresses, but that guard is not a substitute for checking the wallet and daemon commands yourself.
|
||||
Stagenet XMR is valueless test currency. Never send mainnet XMR to a stagenet address. The harness
|
||||
refuses mainnet-facing application state and mainnet funding-wallet addresses, but that guard is
|
||||
not a substitute for checking the wallet and daemon commands yourself.
|
||||
|
||||
## Hard safety properties
|
||||
## Safety envelope
|
||||
|
||||
The harness fails closed unless all of these are true:
|
||||
The harness fails closed unless all of these hold.
|
||||
|
||||
- The application URL explicitly names `127.0.0.1`, `localhost`, or `::1` on dedicated port `3102`. It cannot target the public router, LAN hostname, or Nebula address.
|
||||
- Readiness, public disclosures, REST mode data, and Socket.IO mode data all identify real XMR stagenet with payments and payout dispatch enabled.
|
||||
- Direct solo is enabled with exact 2× escape and 3× treasure multipliers. Purchased-credit solo
|
||||
and its payout path are disabled.
|
||||
- The current `/api/disclosures` response requires the canonical five-field acknowledgement. The harness refetches it immediately before the value-bearing action and echoes exactly `policyVersion`, `ageEligible`, `termsRead`, `riskAccepted`, and `testnetUnderstood`.
|
||||
- The selected PostgreSQL database name contains `canary`, `e2e`, and the selected scenario tag
|
||||
(`direct` for 2× or `treasure` for 3×). Its connection must be localhost, the harness session is
|
||||
read-only, current financial migrations must exist, and the database must contain only the
|
||||
migration-seeded admin with no game or financial rows.
|
||||
- Before any invoice, funding-wallet call, or transfer, a nonce challenge binds the application pool to the harness connection's exact PostgreSQL cluster identifier, database OID, and database name. A same-named database on another cluster does not pass.
|
||||
- The funding wallet RPC is localhost-only and either digest-authenticated or enabled by a separate explicit unauthenticated-local-RPC acknowledgement.
|
||||
- The funding wallet validates both its own address and the invoice as stagenet. `get_address_index` must prove the invoice does not belong to the funding wallet; this prevents a meaningless house-wallet self-payment.
|
||||
- Live execution needs three exact confirmations plus an atomic-unit transfer ceiling. The transfer gate is marked used before the sole `transfer` RPC call, so an ambiguous transport failure is never retried.
|
||||
- `E2E_TARGET` explicitly names `127.0.0.1`, `localhost`, or `::1` on dedicated port `3102`, with
|
||||
no path, query, fragment, or embedded credentials. It cannot target a public hostname or a LAN
|
||||
address.
|
||||
- `/health/ready` reports a real (non-simulated) stagenet chain, database/chain/wallet checks up,
|
||||
and both payment intake and payout dispatch enabled.
|
||||
- `/api/game-modes`, `/api/stats`, and the Socket.IO `game_mode_info` payload all identify the
|
||||
`such-monerogue-stagenet` operated profile, `sXMR`, direct entry and direct payouts enabled,
|
||||
purchased-credit entry and credit payouts disabled, Smirk disabled, crypto match payouts
|
||||
disabled, and match economies limited to `free` and `credits_prestige`. `PAID_CREDITS` must be
|
||||
absent from the public mode descriptors entirely.
|
||||
- Direct payout multipliers are exactly 2 for escape and 3 for escape with treasure, in both the
|
||||
REST and Socket.IO views.
|
||||
- `/api/disclosures` is `no-store`, requires paid-action acknowledgement, carries the operated
|
||||
product's `2×/3×` scope notice and `NO REAL VALUE` warning, and exposes `/terms`, `/privacy`,
|
||||
and `/responsible-play`. The harness echoes exactly `policyVersion`, `ageEligible`, `termsRead`,
|
||||
`riskAccepted`, and `testnetUnderstood`, and refetches the disclosure immediately before the
|
||||
value-bearing action so a policy change aborts the run before any invoice exists.
|
||||
- The selected PostgreSQL database passes the naming rules below, connects over loopback, is
|
||||
opened with `default_transaction_read_only = on`, carries the required financial migrations, and
|
||||
contains only the migration-seeded admin with no game or financial rows.
|
||||
- Before any invoice, funding-wallet call, or transfer, a nonce challenge binds the application's
|
||||
own pool to the harness connection's exact PostgreSQL cluster identifier, database OID, and
|
||||
database name. A same-named database on another cluster does not pass.
|
||||
- The funding wallet RPC origin is loopback-only and is either digest-authenticated or explicitly
|
||||
enabled with `E2E_ALLOW_UNAUTH_FUNDING_RPC=I_ACCEPT_LOCAL_UNAUTH_RPC`.
|
||||
- The funding wallet validates both its own primary address and the invoice address as stagenet.
|
||||
`get_address_index` must fail on the invoice address, proving the invoice does not belong to the
|
||||
funding wallet and the run is not a house-wallet self-payment.
|
||||
- Live execution requires three exact confirmation strings (`E2E_CONFIRM`,
|
||||
`E2E_SCENARIO_CONFIRM`, `E2E_CANARY_PROFILE`), an explicit `E2E_MAX_TRANSFER_ATOMIC` ceiling, and
|
||||
the database nonce file. The one-shot transfer gate is marked used before the sole `transfer` RPC
|
||||
call, so an ambiguous transport failure is never retried.
|
||||
- The bot is bounded to one dungeon depth, 3,000 moves, and eight minutes. The 2× scenario treats
|
||||
treasure as blocked. The 3× scenario treats the exit as blocked until treasure is collected.
|
||||
- Wallet addresses, session tokens, proof seeds, transaction hashes, and credentials are never printed. Failure messages redact those shapes.
|
||||
the treasure tile as blocked; the 3× scenario treats the exit tile as blocked until treasure is
|
||||
collected. Both scenarios treat the monster tile as blocked.
|
||||
- Server-side fog of war is asserted, not bypassed: a start state that already names the exit or
|
||||
the treasure fails the run. The bot explores frontiers until an objective is revealed.
|
||||
- Wallet addresses, session tokens, proof seeds, transaction hashes, and credentials are never
|
||||
printed. Failure messages redact those shapes.
|
||||
|
||||
The harness never creates, restores, exports, migrates, or backs up a wallet. It never deploys code, changes router state, or edits fleet configuration.
|
||||
The harness never creates, restores, exports, migrates, or backs up a wallet, and performs no
|
||||
deployment or infrastructure change. Its only write actions are the Socket.IO gameplay events, the
|
||||
single funding transfer, and whatever the application itself persists in response.
|
||||
|
||||
## Required topology
|
||||
|
||||
Use two different stagenet wallets:
|
||||
Use two different stagenet wallets.
|
||||
|
||||
1. The canary application owns the normal house wallet used to create invoice subaddresses and dispatch payouts.
|
||||
1. The canary application owns the normal house wallet that creates invoice subaddresses and
|
||||
dispatches payouts.
|
||||
2. A separate, low-balance funding wallet sends the direct-entry payment and receives the payout.
|
||||
Its primary address (account 0, index 0) is registered as the canary identity's payout address.
|
||||
|
||||
Expose only the separate funding wallet RPC to the harness, on loopback. Use a dedicated port such
|
||||
as `38085`; port `38083` belongs to the promoted house wallet and must not be reused by the funding
|
||||
wallet. Do not give the harness the house-wallet RPC URL or credentials. Keep the funding balance
|
||||
no larger than needed for the selected invoice plus fees.
|
||||
Expose only the funding wallet RPC to the harness, on loopback. Use a dedicated port such as
|
||||
`38085`. In `src/.env.stagenet.example`, port `38083` belongs to the promoted house wallet and must
|
||||
not be reused: pointing the harness at it would hand a test script the RPC that holds real balances
|
||||
and dispatches payouts. Do not give the harness the house-wallet RPC URL or credentials. Keep the
|
||||
funding balance no larger than the invoice plus fees.
|
||||
|
||||
Before live use, independently verify that both wallet files are recoverable according to the operator's wallet-backup procedure. Do not put seeds, private keys, wallet passwords, transaction keys, or mnemonic output in this runbook, shell history, screenshots, CI variables, or canary logs. This harness deliberately does not inspect or print seed material.
|
||||
Before live use, independently verify that both wallet files are recoverable under the operator's
|
||||
wallet-backup procedure. Do not put seeds, private keys, wallet passwords, transaction keys, or
|
||||
mnemonic output in this runbook, shell history, screenshots, CI variables, or canary logs. The
|
||||
harness deliberately does not inspect or print seed material.
|
||||
|
||||
## Build the disposable canary instance
|
||||
|
||||
Run the exact release candidate intended for production, but bind it only to localhost port `3102`. Use a dedicated PostgreSQL database for each scenario; do not run both scenarios in the same database and never reuse a database after any live attempt.
|
||||
Run the exact release candidate intended for production, bound only to localhost port `3102`. Use
|
||||
a dedicated PostgreSQL database per scenario. Do not run both scenarios against one database, and
|
||||
never reuse a database after any live attempt.
|
||||
|
||||
Recommended game-profile overrides for the one-shot financial check are:
|
||||
The instance must use the intended production money configuration, which
|
||||
`src/.env.stagenet.example` already encodes:
|
||||
|
||||
- `CRYPTO_TYPE=XMR`, `MONERO_NETWORK=stagenet`, a real stagenet daemon and house wallet RPC;
|
||||
- `OPERATED_PRODUCT_PROFILE=such-monerogue-stagenet`;
|
||||
- `PAYMENTS_ENABLED=true`, `DIRECT_PAYMENT_ENABLED=true`, `DIRECT_PAYOUTS_ENABLED=true`,
|
||||
`PAYOUTS_ENABLED=true`, `ALLOW_MAINNET_PAYOUTS=false`;
|
||||
- `CREDITS_ENABLED=false`, `CREDITS_PAYOUTS_ENABLED=false`;
|
||||
- `DIRECT_PAYOUT_ESCAPE=2.0`, `DIRECT_PAYOUT_TREASURE=3.0`;
|
||||
- `PAID_ACKNOWLEDGEMENT_REQUIRED=true` with current operator and legal disclosure metadata.
|
||||
|
||||
Recommended game-profile overrides for the one-shot financial check:
|
||||
|
||||
```dotenv
|
||||
DIFFICULTY_PRESET=easy
|
||||
@@ -61,26 +107,39 @@ MONSTER_SPEED=0
|
||||
DUNGEON_LEVELS=1
|
||||
```
|
||||
|
||||
`MONSTER_SPEED=0` leaves the verifiable monster placement in the committed dungeon but prevents movement during the canary. The harness verifies the one-level state; the exact `E2E_CANARY_PROFILE` confirmation below is the operator's assertion that the easy/static-monster configuration is the one actually running.
|
||||
`MONSTER_SPEED=0` leaves the monster placed in the committed dungeon, so its seeded position stays
|
||||
verifiable, but its per-turn move accumulator never reaches one step. The harness independently
|
||||
asserts the one-level state; `E2E_CANARY_PROFILE=EASY_STATIC_MONSTER_ONE_LEVEL` is the operator's
|
||||
assertion that this easy, static-monster configuration is the one actually running.
|
||||
|
||||
The canary instance must otherwise use the intended production money configuration, including:
|
||||
### Scenario database names
|
||||
|
||||
- `CRYPTO_TYPE=XMR`, `MONERO_NETWORK=stagenet`, real stagenet daemon and house wallet RPC;
|
||||
- direct paid mode and direct payouts enabled;
|
||||
- purchased-credit mode and credit payouts disabled;
|
||||
- exact direct 2× escape and 3× treasure multipliers;
|
||||
- production paid-action acknowledgement enabled and current operator/legal disclosure metadata.
|
||||
Both the harness and the application validate the database name, with slightly different rules,
|
||||
and the name must satisfy both.
|
||||
|
||||
Use a fresh database name such as:
|
||||
- The harness (`validateExpectedDatabaseName`) requires a simple `[a-z0-9_]` name containing
|
||||
`canary`, `e2e`, and the scenario tag: `direct` for `direct-2x`, `treasure` for `direct-3x`.
|
||||
- The application (`src/services/canaryDatabaseIdentity.js`) requires `canary`, `e2e`, and either
|
||||
`direct` or `credits` as an underscore-delimited token.
|
||||
|
||||
Names that satisfy both:
|
||||
|
||||
```text
|
||||
monerogue_canary_direct_e2e
|
||||
monerogue_canary_treasure_e2e
|
||||
monerogue_canary_direct_e2e # direct-2x
|
||||
monerogue_canary_direct_treasure_e2e # direct-3x
|
||||
```
|
||||
|
||||
First boot the release candidate against the new database with payout dispatch disabled solely to run startup migrations. Stop it after readiness. Then run database preflight. Finally boot the same release candidate and same database with the intended payout configuration on localhost port `3102`. Do not point a public router at this instance.
|
||||
### Boot order
|
||||
|
||||
Enable the identity endpoint only on that final isolated canary boot:
|
||||
1. Boot the release candidate against the new database with payout dispatch disabled, solely to
|
||||
run startup migrations. Stop it once it reports ready.
|
||||
2. Run the database preflight.
|
||||
3. Boot the same release candidate against the same database with the intended payout
|
||||
configuration on localhost port `3102`. Do not point a public router at this instance.
|
||||
|
||||
### Database identity endpoint
|
||||
|
||||
Enable the identity endpoint only on that final isolated boot:
|
||||
|
||||
```dotenv
|
||||
NODE_ENV=production
|
||||
@@ -90,15 +149,22 @@ CANARY_EXPECT_DATABASE=monerogue_canary_direct_e2e
|
||||
CANARY_DATABASE_NONCE_FILE=/run/credentials/canary/database-nonce
|
||||
```
|
||||
|
||||
Use the corresponding `treasure` database name for the 3× scenario. If none of the three
|
||||
`CANARY_DATABASE_*` variables is set, the endpoint is not registered. A partial configuration,
|
||||
non-XMR-stagenet process, non-production process, wrong port, or `DB_NAME` mismatch aborts startup.
|
||||
The endpoint signs its database identity and a fresh caller challenge; it never returns the nonce.
|
||||
It also rejects any TCP peer that is not loopback, without trusting proxy-forwarded client headers.
|
||||
Use the corresponding `treasure` database name for the 3× scenario. If none of
|
||||
`CANARY_DATABASE_HANDSHAKE`, `CANARY_EXPECT_DATABASE`, and `CANARY_DATABASE_NONCE_FILE` is set, the
|
||||
endpoint is not registered. A partial configuration, a non-production process, a non-XMR-stagenet
|
||||
process, a port other than `3102`, a non-canary database name, or a `DB_NAME` that differs from
|
||||
`CANARY_EXPECT_DATABASE` aborts startup.
|
||||
|
||||
The endpoint serves `GET /api/canary/database-identity` as `no-store`. It requires a 256-bit
|
||||
lowercase-hex `X-Canary-Database-Challenge` header, returns the cluster id, database OID, and
|
||||
database name, and HMACs that tuple plus the challenge under the nonce. It never returns the nonce
|
||||
itself. It rejects any TCP peer that is not loopback, judging by the socket's remote address rather
|
||||
than proxy-forwarded client headers, so an accidentally routed canary discloses nothing.
|
||||
|
||||
## Protect local secret files
|
||||
|
||||
Credential files read by the harness must be regular files, not symlinks, and have no group/world permission bits (for example mode `0600` or `0400`). Prefer file-backed variables:
|
||||
Credential files read by the harness must be regular files, not symlinks, and must have no
|
||||
group or world permission bits (for example mode `0600` or `0400`). Prefer file-backed variables:
|
||||
|
||||
```text
|
||||
E2E_FUNDING_RPC_USER_FILE=/run/credentials/canary/funding-rpc-user
|
||||
@@ -107,16 +173,24 @@ E2E_DATABASE_URL_FILE=/run/credentials/canary/database-url
|
||||
E2E_DATABASE_NONCE_FILE=/run/credentials/canary/database-nonce
|
||||
```
|
||||
|
||||
Create a new random 32-byte nonce for each scenario and expose the same protected file to the
|
||||
application (`CANARY_DATABASE_NONCE_FILE`) and harness (`E2E_DATABASE_NONCE_FILE`). Do not put its
|
||||
contents in an environment variable, command argument, log, or screenshot. Delete the nonce after
|
||||
the isolated canary is stopped; it is not a wallet key or recovery artifact.
|
||||
`E2E_FOO` and `E2E_FOO_FILE` are mutually exclusive for each secret.
|
||||
|
||||
Alternatively, `E2E_DATABASE_ENV_FILE` may point to a protected dotenv file containing `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, and `DB_PASSWORD`. Set exactly one database source. `DB_HOST` and database URLs must explicitly use loopback.
|
||||
Create a new random nonce for each scenario (`openssl rand -hex 32`) and expose the same protected
|
||||
file to the application (`CANARY_DATABASE_NONCE_FILE`) and to the harness
|
||||
(`E2E_DATABASE_NONCE_FILE`). The nonce file must be an absolute path holding exactly 64 lowercase
|
||||
hex characters; it is opened with `O_NOFOLLOW`. Do not put its contents in an environment variable,
|
||||
command argument, log, or screenshot. Delete the nonce after the isolated canary is stopped; it is
|
||||
not a wallet key or recovery artifact.
|
||||
|
||||
Alternatively, `E2E_DATABASE_ENV_FILE` may point to a protected dotenv file containing `DB_HOST`,
|
||||
`DB_PORT`, `DB_NAME`, `DB_USER`, and `DB_PASSWORD`. Set exactly one database source. `DB_HOST` and
|
||||
database URLs must explicitly name loopback, and the database they name must equal
|
||||
`E2E_EXPECT_DATABASE`.
|
||||
|
||||
## Read-only preflights
|
||||
|
||||
The public preflight performs no Socket.IO session registration, invoice creation, database mutation, or wallet RPC call:
|
||||
The public preflight performs no Socket.IO session registration, invoice creation, database
|
||||
mutation, or wallet RPC call:
|
||||
|
||||
```bash
|
||||
E2E_MODE=preflight \
|
||||
@@ -124,7 +198,8 @@ E2E_TARGET=http://127.0.0.1:3102 \
|
||||
npm --prefix src run canary:stagenet
|
||||
```
|
||||
|
||||
The database preflight opens only a read-only PostgreSQL session. Run it while the freshly migrated database still contains no canary session:
|
||||
The database preflight opens only a read-only PostgreSQL session and contacts nothing else. Run it
|
||||
while the freshly migrated database still contains no canary session:
|
||||
|
||||
```bash
|
||||
E2E_MODE=database-preflight \
|
||||
@@ -134,12 +209,14 @@ E2E_DATABASE_URL_FILE=/run/credentials/canary/direct-database-url \
|
||||
npm --prefix src run canary:stagenet
|
||||
```
|
||||
|
||||
For the 3× scenario, use `E2E_SCENARIO=direct-3x`, a database name containing `treasure`, and its
|
||||
separate protected connection file.
|
||||
For the 3× scenario use `E2E_SCENARIO=direct-3x`, a database name containing `treasure`, and its
|
||||
own protected connection file.
|
||||
|
||||
## Live direct 2× escape
|
||||
|
||||
Review the configured direct entry atomic amount before setting `E2E_MAX_TRANSFER_ATOMIC`. The ceiling must be at least the advertised invoice amount and should equal that amount when practical.
|
||||
Review the configured direct entry price before setting `E2E_MAX_TRANSFER_ATOMIC`. The ceiling must
|
||||
be at least the advertised invoice amount and should equal it when practical; with the template's
|
||||
`DIRECT_GAME_PRICE=10000000000` (0.01 sXMR) the ceiling is `10000000000`.
|
||||
|
||||
```bash
|
||||
E2E_MODE=live-stagenet \
|
||||
@@ -159,19 +236,13 @@ E2E_CANARY_PROFILE=EASY_STATIC_MONSTER_ONE_LEVEL \
|
||||
npm --prefix src run canary:stagenet
|
||||
```
|
||||
|
||||
The harness binds a fresh fairness offer to the direct invoice before revealing its address, transfers the exact invoice amount, waits for receipt-backed confirmation and automatic game start, avoids treasure, escapes, and verifies an exact 2× payout.
|
||||
|
||||
## Live direct 3× treasure escape
|
||||
|
||||
Review the configured direct-entry atomic amount before setting the ceiling. As with the 2× run,
|
||||
the ceiling must be at least the advertised invoice amount and should equal that amount when
|
||||
practical.
|
||||
|
||||
```bash
|
||||
E2E_MODE=live-stagenet \
|
||||
E2E_SCENARIO=direct-3x \
|
||||
E2E_TARGET=http://127.0.0.1:3102 \
|
||||
E2E_EXPECT_DATABASE=monerogue_canary_treasure_e2e \
|
||||
E2E_EXPECT_DATABASE=monerogue_canary_direct_treasure_e2e \
|
||||
E2E_DATABASE_URL_FILE=/run/credentials/canary/treasure-database-url \
|
||||
E2E_DATABASE_NONCE_FILE=/run/credentials/canary/database-nonce \
|
||||
E2E_FUNDING_RPC_URL=http://127.0.0.1:38085 \
|
||||
@@ -185,35 +256,87 @@ E2E_CANARY_PROFILE=EASY_STATIC_MONSTER_ONE_LEVEL \
|
||||
npm --prefix src run canary:stagenet
|
||||
```
|
||||
|
||||
The harness binds a fresh fairness offer to the direct invoice before revealing its address,
|
||||
transfers the exact invoice amount, waits for receipt-backed confirmation and automatic game
|
||||
start, collects treasure before allowing the exit, and verifies an exact 3× payout based on the
|
||||
invoice amount.
|
||||
## Live run sequence
|
||||
|
||||
1. Register an anonymous Socket.IO client and capture its session token and `game_mode_info`.
|
||||
2. Refetch `/api/disclosures` and abort if the policy version moved since preflight.
|
||||
3. Save the funding wallet's primary address as the payout destination.
|
||||
4. Request a fresh fairness offer, then create the `single_game` invoice bound to that offer and a
|
||||
random 256-bit client seed. The invoice must echo proof version 2 with the same offer id,
|
||||
commitment, and client seed, must not be a reused invoice, and must equal the advertised atomic
|
||||
entry price.
|
||||
5. Prove the invoice address is stagenet and is not owned by the funding wallet.
|
||||
6. Broadcast the sole transfer of the exact invoice amount, after checking that unlocked balance
|
||||
covers the amount plus `E2E_FEE_CUSHION_ATOMIC`.
|
||||
7. Wait for `payment_confirmed` and the automatic `game_start`, and check the started dungeon is
|
||||
bound to the invoice's fairness proof.
|
||||
8. Run the bounded bot to the scenario outcome.
|
||||
9. Verify the reveal: the server seed hashes to the pre-game commitment, the effective seed is
|
||||
`HMAC-SHA256(serverSeed, clientSeed)`, and the outcome metadata matches.
|
||||
10. Verify the committed payout amount and multiplier, poll the owned payout history until the
|
||||
payout is `completed` with a transaction hash, and confirm the funding wallet observes the
|
||||
exact incoming amount.
|
||||
11. Run the database settlement assertions.
|
||||
|
||||
## Exact settlement assertions
|
||||
|
||||
After the funding wallet observes the incoming payout, the harness checks the read-only database connection. A passing run has exactly:
|
||||
A passing run leaves the scenario database with exactly:
|
||||
|
||||
- two users: the migration-seeded admin and one canary identity;
|
||||
- one confirmed native-Monero payment with `received_amount == expected_amount` and valid confirmation evidence;
|
||||
- one unique confirmed `chain_output` receipt whose amount exactly equals the invoice;
|
||||
- one won/escaped game with consumed entry evidence, committed 2×/3× immutable payout terms, the selected paid mode, matching fairness v2 identity, and the expected treasure flag;
|
||||
- one completed payout with the exact amount, multiplier, reason, payout address, and transaction evidence;
|
||||
- no refunds, late reviews, matches, match queues, race-entry ledgers, or race-entry lots.
|
||||
- two users: the migration-seeded admin and one canary identity with a payout address and one
|
||||
game played;
|
||||
- one `confirmed` `native-monero` payment of `payment_type = single_game` with
|
||||
`received_amount == expected_amount`, confirmation evidence, a valid transaction hash, and an
|
||||
immutable invoice destination identity (`subaddress`, `provider_invoice_id`, `address_index`);
|
||||
- product identity `single_game` granting zero credits, zero race entries, no packs, and no
|
||||
premium level;
|
||||
- fairness proof version 2 bound and consumed exactly once, matching the offer, commitment, and
|
||||
client seed;
|
||||
- one unique `confirmed` `chain_output` receipt whose amount exactly equals the invoice, whose
|
||||
`evidence_id` is `tx_hash:output_id`, and whose address index matches the payment;
|
||||
- one `won`/`escaped` game in the selected paid mode, with consumed entry evidence, a revealed
|
||||
proof, the expected treasure flag, and immutable payout terms committing both the 2× escape and
|
||||
3× treasure amounts;
|
||||
- one `completed` payout with the exact amount, multiplier, reason
|
||||
(`escape` or `escape_with_treasure`), payout address, and transaction evidence;
|
||||
- exactly two credit-ledger rows, `direct_entry +1` and `game_entry -1`, both settling to a zero
|
||||
balance with no linked payment id, leaving `credits = 0` and `total_credits_purchased = 1`;
|
||||
- zero refunds, late reviews, entitlement grants, pack entitlements, matches, match entrants,
|
||||
match events, match queue entries, race-entry transactions, and race-entry lots.
|
||||
|
||||
Each scenario additionally requires exactly the `direct_entry +1` and `game_entry -1`
|
||||
credit-ledger rows, a zero balance, unified purchase progress of one, the game linked to the
|
||||
fairness-bound payment, and no product-entitlement marker.
|
||||
## Optional bounded tunables
|
||||
|
||||
Each is a positive integer with a hard safety bound; exceeding the bound fails the run.
|
||||
|
||||
| Variable | Default | Maximum |
|
||||
|---|---|---|
|
||||
| `E2E_PAYMENT_TIMEOUT_MS` | 1800000 | 2700000 |
|
||||
| `E2E_PAYOUT_TIMEOUT_MS` | 900000 | 1800000 |
|
||||
| `E2E_RPC_POLL_MS` | 2000 | 10000 |
|
||||
| `E2E_BOT_MOVE_DELAY_MS` | 140 | 1000 |
|
||||
| `E2E_BOT_MAX_MOVES` | 3000 | 3000 |
|
||||
| `E2E_BOT_TIMEOUT_MS` | 480000 | 480000 |
|
||||
|
||||
`E2E_FEE_CUSHION_ATOMIC` defaults to `1000000000` and has no maximum beyond
|
||||
`Number.MAX_SAFE_INTEGER`. `E2E_MAX_TRANSFER_ATOMIC` has no default and is mandatory for live runs.
|
||||
|
||||
## Failure handling
|
||||
|
||||
Before the line `one exact, non-retriable XMR stagenet transfer was broadcast`, a failure means no funding transfer was attempted. Correct the configuration, recreate a fresh scenario database if any app session was made, and repeat the preflights.
|
||||
The harness prints `one exact, non-retriable XMR stagenet transfer was broadcast` immediately after
|
||||
its sole `transfer` call.
|
||||
|
||||
At or after that line, never rerun the command and never reuse the database. A transport timeout can be ambiguous even when the wallet accepted the transfer. Preserve the database and both wallet files, stop the isolated canary, and reconcile the invoice receipt, payment, game, payout, and wallet histories manually. The harness intentionally performs no automatic retry, refund, database cleanup, wallet cleanup, or destructive recovery.
|
||||
Before that line, a failure means no funding transfer was attempted. Correct the configuration,
|
||||
recreate a fresh scenario database if any application session was made, and repeat the preflights.
|
||||
|
||||
Only after both scenarios pass on separate databases should the release be considered financially canary-tested. Passing does not authorize deployment; deployment and router/fleet changes remain separate reviewed operations.
|
||||
At or after that line, never rerun the command and never reuse the database. A transport timeout
|
||||
can be ambiguous even when the wallet accepted the transfer. Preserve the database and both wallet
|
||||
files, stop the isolated canary, and reconcile the invoice receipt, payment, game, payout, and
|
||||
wallet histories manually. The harness performs no automatic retry, refund, database cleanup,
|
||||
wallet cleanup, or destructive recovery.
|
||||
|
||||
## Local verification (no network or wallet use)
|
||||
A release is financially canary-tested only after both scenarios pass on separate databases.
|
||||
Passing does not authorize deployment; deployment remains a separate reviewed operation.
|
||||
|
||||
## Offline verification
|
||||
|
||||
```bash
|
||||
node --check src/scripts/stagenet-financial-canary.js
|
||||
@@ -221,4 +344,5 @@ npm --prefix src test -- --runTestsByPath ../test/canaryDatabaseIdentity.test.js
|
||||
npm --prefix src run canary:stagenet -- --help
|
||||
```
|
||||
|
||||
These commands parse the harness, run its pure/static safety tests, and print help. They do not contact the application, database, or either wallet.
|
||||
These parse the harness, run its pure and static safety tests, and print help. They contact no
|
||||
application, database, or wallet.
|
||||
|
||||
+236
-211
@@ -1,62 +1,85 @@
|
||||
# Tavern & Multiplayer — Design & Build Plan
|
||||
# Tavern and Multiplayer
|
||||
|
||||
Match mode has its own dedicated design doc: see `docs/MATCH_MODE.md`.
|
||||
The Tavern is a shared social room; Match mode is a shared competitive dungeon. Both are built on
|
||||
the same room engine, both are opt-in per instance, and neither affects the single-player path when
|
||||
disabled.
|
||||
|
||||
**Status:** Tavern and server-selected multiplayer rulesets (`race`, `last-alive`, `score-attack`,
|
||||
`coop-escape`) are implemented (see `docs/MATCH_MODE.md`). Both surfaces are disabled by default
|
||||
and gated by config, so existing single-player play is unaffected.
|
||||
Match rules, timing, fairness, and settlement have their own reference: `docs/MATCH_MODE.md`.
|
||||
Ruleset definitions are documented in `docs/RULESETS.md`.
|
||||
|
||||
## Shared engine: one Room, two modes
|
||||
|
||||
Both modes are built on a single shared component — a real-time, multi-occupant `Room`:
|
||||
Both surfaces sit on a real-time, multi-occupant `Room`:
|
||||
|
||||
- **Tavern** — a peaceful Room: players pick an avatar, walk around, chat, and watch live games.
|
||||
- **Multiplayer** — the same Room engine with a dungeon, monsters, and ruleset-selected
|
||||
collision/combat behavior.
|
||||
- **Tavern**: a peaceful room. Players pick an avatar, walk around, chat, and watch live games.
|
||||
- **Match**: the same room engine with a dungeon, a monster, and ruleset-selected collision and
|
||||
combat behavior.
|
||||
|
||||
The engine (`src/multiplayer/`) has no Socket.IO, database, or Wownerogue coupling. A manager
|
||||
The engine under `src/multiplayer/` has no Socket.IO, database, or application coupling. A manager
|
||||
layer owns transport (Socket.IO rooms, broadcasts) and the server-tick timer. Keeping the engine
|
||||
transport-agnostic makes it unit-testable in isolation and reusable as a standalone package.
|
||||
|
||||
## Modes
|
||||
`src/multiplayer/`:
|
||||
|
||||
**Tavern** — a social space that reuses the existing lobby and spectator infrastructure. Players
|
||||
choose an avatar, move around a shared map, and chat. A spectator camera lets them watch live
|
||||
single-player or multiplayer games from inside the room.
|
||||
- `Occupant.js`: a shared-world avatar (position, name, avatar id, facing).
|
||||
- `Room.js`: map and walkability, server-authoritative one-tile movement, optional occupant
|
||||
collision, a `tick()` hook for autonomous systems (a heartbeat in the Tavern), and
|
||||
snapshot/full-state serialization.
|
||||
- `tavernMap.js`: the default tavern layout. Rows are built to a uniform width, and one legend
|
||||
drives both client tiles (`sceneModel` `TAVERN_LEGEND`/`TILE_OF`) and `Room` walkability, so the
|
||||
two cannot disagree about what is solid. Walkable characters are floor, spawn, rug, chair, and
|
||||
door; everything else blocks, which is what makes players weave between furniture.
|
||||
- `appearance.js`: the cosmetic identity model and its normalizer.
|
||||
- `entitlements.js`: the server-side entitlement policy.
|
||||
- `MatchRoom.js`, `MatchState.js`, `MatchEngine.js`: match world, state, and lifecycle.
|
||||
|
||||
## Tavern
|
||||
|
||||
A social space that reuses the lobby and spectator infrastructure. Players choose an avatar, move
|
||||
around a shared map, and chat. A spectator camera lets them watch live single-player or match games
|
||||
from inside the room, and a queue button lets them enter a match.
|
||||
|
||||
### Entering anonymously
|
||||
|
||||
The Tavern requires no account or login. On connection, the browser registers the client and waits
|
||||
for the normal anonymous session handshake (`session_token`, `session_resumed`, or the associated
|
||||
identity response) before enabling **Enter tavern**. The token is stored as `wownerogue_token` in
|
||||
that browser's `localStorage` and is reused after refresh or reconnect.
|
||||
The Tavern requires no account or login. On connection the browser registers the client and waits
|
||||
for the anonymous session handshake (`session_token`, `session_resumed`, or the associated identity
|
||||
response) before enabling **Enter tavern**. The token is stored as `wownerogue_token` in that
|
||||
browser's `localStorage` and reused after refresh or reconnect.
|
||||
|
||||
The join form accepts its submit action, including Enter from the optional name field. Its button
|
||||
shows `Connecting…`, `Preparing…`, or `Entering…` while the corresponding step is pending. A
|
||||
reads `Connecting…`, `Preparing…`, or `Entering…` while the corresponding step is pending. A
|
||||
disconnect or connection error resets readiness and retries automatically; a join with no server
|
||||
response becomes a visible retryable error after ten seconds. Cosmetic availability is still
|
||||
normalized and enforced by server entitlement policy.
|
||||
response becomes a visible retryable error after ten seconds. Cosmetic selections are normalized and
|
||||
enforced by server entitlement policy regardless of what the client sends.
|
||||
|
||||
**Multiplayer Match** — a shared dungeon Room using the operator-selected ruleset. Players may race
|
||||
to escape, fight to remain last alive, compete for score, or cooperate. Block-bounded rulesets end
|
||||
on the first advancing header after their active-play duration floor; every match also has a hard
|
||||
ceiling. Match rooms enable the collision/combat rules selected by the ruleset; the Tavern does not.
|
||||
## Match
|
||||
|
||||
## Operator Policy
|
||||
A shared dungeon room using the operator-selected ruleset. Depending on the ruleset, players race to
|
||||
escape, fight to remain last alive, compete for score, or cooperate. Block-bounded rulesets end on
|
||||
the first advancing block header after their active-play duration floor (`ruleset.timing.minDurationMs`),
|
||||
and every match also has a hard ceiling. Match rooms enable the collision and combat rules the
|
||||
ruleset selects; the Tavern does not.
|
||||
|
||||
The server operator controls which modes are enabled and, for multiplayer, the economic model.
|
||||
Mode activation, the multiplayer ruleset, and multiplayer economy switches are startup environment
|
||||
configuration. Change `SOLO_ENABLED`, `TAVERN_ENABLED`, `MATCH_ENABLED`, `MATCH_RULESET_ID`, or the
|
||||
`MATCH_*` economy settings in the deployment configuration and restart the service. The separate
|
||||
database-backed `ConfigPersistence` allowlist covers a limited set of solo payment/difficulty
|
||||
values; it does not hot-reload match modes or rulesets. There is no per-room ruleset editor today.
|
||||
Built-in rulesets are `race`, `last-alive`, `score-attack`, and `coop-escape`. A `solo-classic`
|
||||
descriptor exists for catalog parity; the single-player engine does not consume it.
|
||||
|
||||
Which modes are enabled is surfaced to clients in the `game_mode_info` event as
|
||||
`modes: { solo, tavern, match }` (from `SOLO_ENABLED`, `TAVERN_ENABLED`, and `MATCH_ENABLED`).
|
||||
The client shows or hides entry points from this, so any single mode can run on its own — including
|
||||
a Tavern-only instance. Solo is on unless explicitly disabled; Tavern and Match are opt-in.
|
||||
## Operator policy
|
||||
|
||||
Multiplayer economic models (operator-selected per instance):
|
||||
The operator controls which modes are enabled and, for match play, the economic model. Mode
|
||||
activation, the ruleset, and match economy switches are startup environment configuration: set
|
||||
`SOLO_ENABLED`, `TAVERN_ENABLED`, `MATCH_ENABLED`, `MATCH_RULESET_ID`, or the `MATCH_*` economy
|
||||
settings and restart the service. The server reads them once at startup:
|
||||
it does not hot-reload match modes or rulesets, and there is no per-room
|
||||
ruleset editor. The full environment template is `src/.env.example`.
|
||||
|
||||
Enabled modes are surfaced to clients in `game_mode_info` as
|
||||
`modes: { solo, tavern, multiplayer, match }`. `solo`, `tavern`, and `multiplayer` are booleans;
|
||||
`match` is an object carrying `enabled`, the available `economies`, `maxPlayers`, the
|
||||
`activeRuleset`, and the full ruleset catalog. The client shows or hides entry points from this, so
|
||||
any single mode can run on its own, including a Tavern-only instance. Solo is on unless explicitly
|
||||
disabled; Tavern and Match are opt-in.
|
||||
|
||||
Match economic models:
|
||||
|
||||
| Model | Description |
|
||||
|---|---|
|
||||
@@ -64,62 +87,81 @@ Multiplayer economic models (operator-selected per instance):
|
||||
| `credits_prestige` | Spend credits; competitive results use the separate PvP Prestige board, with no crypto payout. |
|
||||
| `crypto_race` | Use a backed race-entry ticket; an eligible competitive winner receives the disclosed pot less the configured fee. |
|
||||
|
||||
The baseline defaults are conservative (free, no payout). Instance deployment configuration may
|
||||
enable additional economies; operators are responsible for ensuring their selected configuration
|
||||
complies with applicable laws in their jurisdiction.
|
||||
`free` is always offered. `credits_prestige` requires credits mode or free play to be enabled.
|
||||
`crypto_race` additionally requires its own switch, a payout cap, a compatible ruleset, and the
|
||||
global payout safety gate, all evaluated by `matchPayoutAdmissionPolicy`. Baseline defaults are
|
||||
conservative (free, no payout). Operators are responsible for ensuring their selected configuration
|
||||
complies with applicable law in their jurisdiction.
|
||||
|
||||
## Avatars
|
||||
## Character appearance contract
|
||||
|
||||
Avatars are cosmetic. Availability is controlled by Operator Policy — for example, a set available
|
||||
to everyone plus additional avatars unlocked by credit purchases. The avatar id is stored on the
|
||||
occupant and broadcast to the room; unlock eligibility is enforced at join time.
|
||||
The public identity shape is `appearance: { avatar, tint, equipment, colors }`. The server
|
||||
normalizes this shape, persists it on `users.appearance`, stores it on the `Occupant` at join time,
|
||||
and broadcasts it in room snapshots. Renderers use `appearance` as the canonical cosmetic identity;
|
||||
the flat `avatar` field remains as a compatibility shortcut and as the first discriminator for
|
||||
sprite and skin loading.
|
||||
|
||||
## Character Appearance Contract
|
||||
Normalization in `src/multiplayer/appearance.js` is total: an unknown avatar id collapses to
|
||||
`default`, unknown equipment collapses to `none` per slot, and unknown tints, skin tones, and hair
|
||||
colors collapse to their defaults. Flat colour avatars carry no equipment or colour detail; 3D model
|
||||
avatars carry colours but no 2D equipment overlays; character avatars carry both.
|
||||
|
||||
The public identity shape is `appearance: { avatar, tint, equipment }`. The server normalizes this
|
||||
shape, persists it on `users.appearance`, stores it on the `Occupant` at join time, and broadcasts
|
||||
it in room snapshots. Renderers use `appearance` as the canonical cosmetic identity; the flat
|
||||
`avatar` field remains as a compatibility shortcut and as the first discriminator for sprite/skin
|
||||
loading.
|
||||
The contract is renderer-agnostic:
|
||||
|
||||
This contract is intentionally renderer-agnostic:
|
||||
- **Top-down grid packs** resolve `avatar` to a base tile or frame and composite `equipment`
|
||||
overlays above it. `tint` recolours only the authored recolour pixels, so outlines, skin, wood,
|
||||
and metal stay intact.
|
||||
- **Isometric packs** keep the same `appearance` ids and supply an isometric resolver for base
|
||||
frames, direction rows, anchor position, and equipment overlays. A missing item falls back by role
|
||||
(`round_shield` to `shield` to `none`) rather than changing the saved identity.
|
||||
- **3D packs** resolve the same ids to GLB assets, material variants, and animation clips. Source
|
||||
models and intermediates stay out of git; shipped GLB or baked sprite outputs are hosted and
|
||||
lazy-loaded like premium sprite sheets.
|
||||
|
||||
- **Top-down grid packs** resolve `avatar` to a base tile/frame and composite `equipment` overlays
|
||||
above it. `tint` recolours only the authored recolour pixels so outlines, skin, wood, and metal
|
||||
remain intact.
|
||||
- **Isometric packs** should keep the same `appearance` ids and supply an isometric resolver for
|
||||
base frames, direction rows, anchor/feet position, and equipment overlays. If an exact item is
|
||||
missing, the resolver should fall back by role (`round_shield -> shield -> none`) rather than
|
||||
changing the saved identity.
|
||||
- **True 3D packs** should resolve the same ids to GLB assets/material variants/animation clips.
|
||||
Source models and intermediates stay out of git; shipped GLB or baked sprite outputs are hosted
|
||||
and lazy-loaded like premium sprite sheets.
|
||||
The client may hide or label locked choices, but gating belongs at the server policy layer: identity
|
||||
saves and join requests normalize unauthorized premium avatar ids to an allowed fallback.
|
||||
|
||||
Premium costumes are catalog entries with an entitlement requirement. The client may hide or label
|
||||
locked choices, but real gating belongs at the server/operator policy layer: identity saves and
|
||||
join requests normalize unauthorized premium `avatar` ids to an allowed fallback.
|
||||
## Entitlements and the cosmetic catalog
|
||||
|
||||
Current baseline premium policy: a player is treated as premium after any successful credits
|
||||
purchase (`users.total_credits_purchased > 0`). That unlocks the preview premium packs
|
||||
(`generated-skins`, `iso-dungeon`, and `kenney-3d-characters`) unless a future operator policy
|
||||
tightens them. The policy shape is already pack-based so products can sell or grant different packs
|
||||
independently. Operator/admin grants go through
|
||||
`user_pack_entitlements(user_id, pack_id, source, expires_at, metadata)`, while
|
||||
`users.premium_level` is reserved for broader tiering.
|
||||
`src/multiplayer/entitlements.js` is the single policy module used by identity saves, tavern joins,
|
||||
render-mode gating, and payment confirmation. In production the operator-owned `cosmetic_catalog`
|
||||
table (migration 024) is loaded by `CatalogService` and passed in; the `DEFAULT_CATALOG` in the
|
||||
module is the fallback for fresh or partly migrated databases and for unit tests.
|
||||
|
||||
## Product Grants
|
||||
Each catalog entry is `{ id, label, kind, projection, tier, unlockMinCredits, grantOnly, premium }`.
|
||||
A pack is unlocked when any of the following holds:
|
||||
|
||||
- it is a free pack (tier 0, no credit threshold, not grant-only);
|
||||
- the user holds an explicit grant for it in `user_pack_entitlements`;
|
||||
- the user's lifetime credits purchased (`users.total_credits_purchased`, cumulative and never
|
||||
decremented by play) is at or above the pack's `unlockMinCredits`;
|
||||
- the user's premium tier is at or above the pack's tier.
|
||||
|
||||
Premium tier comes from `users.premium_level` or an active subscription tier, whichever is higher,
|
||||
over the ladder `supporter` (1), `premium` (2), `operator` (3). Buying credits does not by itself
|
||||
place a user on the tier ladder: `credits` maps to tier 0, and credit spend unlocks packs only
|
||||
through the `unlockMinCredits` thresholds.
|
||||
|
||||
The seed ladder is the free `original` tile pack plus premium packs at rising lifetime-credit
|
||||
thresholds: Roguelike Interior at 1, character skins at 5, isometric dungeon at 10, roguelike
|
||||
dungeon tiles at 20, isometric medieval at 40, and animated 3D characters at 50. Adding a rung is a
|
||||
catalog row plus assets, not a code change. See `docs/MONETIZATION.md` and `docs/RENDER_PACKS.md`.
|
||||
|
||||
`snapshotForUser()` returns `{ premium, level, tier, credits, totalCreditsPurchased, packs, catalog }`.
|
||||
The `packs` map is authoritative for gating; `canUsePack` consults it first and only falls back to
|
||||
the coarse `premium` flag when a caller supplies a legacy entitlement object without one.
|
||||
|
||||
## Product grants
|
||||
|
||||
Payments stay separated from entitlement policy:
|
||||
|
||||
- **Single-game entries** buy one paid game attempt and do not grant premium by default.
|
||||
- **Credits packages** grant credits and may bundle cosmetic/render packs or a premium tier.
|
||||
- **Single-game entries** buy one paid game attempt and grant no packs by default.
|
||||
- **Credits packages** grant credits and may bundle cosmetic packs or a premium tier.
|
||||
- **Cosmetic products** grant packs or tiers directly and may grant zero credits.
|
||||
|
||||
Catalog configuration supports `grants` on credit packages and a separate `COSMETIC_PRODUCTS`
|
||||
catalog. A grant payload is normalized to:
|
||||
`{ credits, packs: [{ id, expiresAt, source }], premiumLevel }`.
|
||||
|
||||
Examples:
|
||||
catalog (see `src/.env.example`). A grant payload normalizes to
|
||||
`{ credits, packs: [{ id, expiresAt, source }], premiumLevel }`, and unknown pack ids are dropped.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -146,168 +188,151 @@ Examples:
|
||||
}
|
||||
```
|
||||
|
||||
Confirmed product payments persist the normalized grant on `payments.product_grants`, update
|
||||
credit balances only when credits are part of the product, and upsert pack ownership in
|
||||
Confirmed product payments persist the normalized grant on `payments.product_grants`, update credit
|
||||
balances only when credits are part of the product, and upsert pack ownership in
|
||||
`user_pack_entitlements`. The client receives the updated entitlement snapshot through
|
||||
`credits_update` / `identity_update`, so the same premium state gates character saves, tavern joins,
|
||||
render modes, and later multiplayer rooms.
|
||||
`credits_update` and `identity_update`, so the same state gates character saves, tavern joins,
|
||||
render modes, and match rooms.
|
||||
|
||||
Supporting pieces:
|
||||
|
||||
- `src/migrations/020_user_appearance_and_pack_entitlements.sql`: persisted appearance, premium
|
||||
tier, and pack grants.
|
||||
- `src/migrations/021_payment_product_grants.sql`: product ids and normalized grants on payments.
|
||||
- `src/migrations/024_cosmetic_catalog.sql` and the ladder migrations that follow it: the
|
||||
operator-owned catalog.
|
||||
- `src/network/identityService.js`: appearance persistence and entitlement-aware normalization.
|
||||
- `src/payments/productGrants.js`: grant normalization and public summaries.
|
||||
|
||||
## Chat
|
||||
|
||||
The existing chat backend is retained and wrapped behind a `ChatProvider` interface so the backend
|
||||
can be swapped later without changing callers. The current backend remains the default.
|
||||
The chat backend sits behind a `ChatProvider` interface (`src/network/chat/`) so it can be swapped
|
||||
without changing callers. `SocketChatProvider` is the default: Socket.IO delivery plus Postgres
|
||||
history. `buildChatProvider()` returns the plain local provider unless `NOSTR_CHAT_ENABLED` is set
|
||||
and `NOSTR_CHAT_SCOPE` is `global`, in which case `NostrChatProvider` layers cross-server relay fan-out
|
||||
over it. See `docs/CHAT_AND_NOSTR.md`.
|
||||
|
||||
**Tavern chat is global chat.** When a `globalChatProvider` is injected, `tavern_chat` reaches every
|
||||
connected client and the persisted history, so it must clear the same bar as the lobby path:
|
||||
`TavernManager._moderateChat` applies the chat ban list, the reconnect-proof rate limiter
|
||||
(`stableId` + IP), and `user_id` attribution before publishing. Without those guards `tavern_chat` is
|
||||
simply an unauthenticated bypass of lobby moderation under a client-chosen display name. The
|
||||
per-occupant cooldown alone still governs the ephemeral tavern-scoped fallback room, which nobody
|
||||
outside the room ever sees.
|
||||
**Tavern chat is global chat.** When a global chat provider is injected, `tavern_chat` publishes to
|
||||
the global scope, reaching every connected client and the persisted history, so it must clear the
|
||||
same bar as the lobby path. `TavernManager._moderateChat` applies the chat ban list, the
|
||||
reconnect-proof rate limiter (keyed on the stable session id plus IP), and `user_id` attribution
|
||||
before publishing. Without those guards `tavern_chat` would be an unauthenticated bypass of lobby
|
||||
moderation under a client-chosen display name. When no global provider is injected, messages stay in
|
||||
the ephemeral tavern-scoped room that nobody outside it sees, and the per-occupant cooldown alone
|
||||
governs them.
|
||||
|
||||
Message text is escaped at publish time because delivery is trusted-escaped and rendered as HTML on
|
||||
the client.
|
||||
|
||||
Speech bubbles resolve the speaker from the occupant list **by display name**. The chat provider
|
||||
publishes a short `publicId`, never the raw socket id, so anything keying bubbles off `socketId`
|
||||
silently never fires.
|
||||
never fires.
|
||||
|
||||
## Chain ambience
|
||||
|
||||
The Tavern stage carries a decorative chain layer (`RK.mountChainAmbience`, `chainAmbience.js`):
|
||||
fragments of the real top block hash fade in around the border and drift away, a corner readout
|
||||
tracks the height, and a landing block sends a ripple around the frame plus a brighter burst.
|
||||
fragments of the top block hash fade in around the border and drift away, a corner readout tracks
|
||||
the height, and a landing block sends a ripple around the frame plus a brighter burst.
|
||||
|
||||
It consumes the public `blockheight` broadcast, which now carries an optional cosmetic tip
|
||||
(`hash`, `difficulty`, `txPoolSize`). The tip is refreshed from `get_info` **only when the height
|
||||
actually advances** — it is decoration, not worth an extra RPC every poll — and
|
||||
`RpcService.getChainTipInfo()` never throws and never feeds fairness, payouts, or match seeding.
|
||||
`DebugManager.getChainTip()` returns null under simulated blocks, since there is no chain to
|
||||
describe. The layer is `pointer-events:none`, honours `prefers-reduced-motion`, and stops spawning
|
||||
(and drops its backlog) while the tab is hidden — a hidden tab pauses CSS animations, so
|
||||
`animationend` never fires and motes would otherwise accumulate indefinitely.
|
||||
It consumes the public `blockheight` broadcast, which carries an optional cosmetic tip (`hash`,
|
||||
`difficulty`, `txPoolSize`). The tip is refreshed from `get_info` only when the height actually
|
||||
advances, since it is decoration and not worth an extra RPC per poll.
|
||||
`RpcService.getChainTipInfo()` never throws and never feeds fairness, payouts, or match seeding, and
|
||||
`DebugManager.getChainTip()` returns null under simulated blocks because there is no chain to
|
||||
describe.
|
||||
|
||||
## Rendering (art pass)
|
||||
The layer is `pointer-events: none`, marked `aria-hidden` with the corner readout as its accessible
|
||||
copy, honours `prefers-reduced-motion`, and stops spawning while the tab is hidden. A hidden tab
|
||||
pauses CSS animations, so `animationend` never fires and motes would otherwise accumulate
|
||||
indefinitely; the backlog is dropped on hide.
|
||||
|
||||
Rendering goes through a shared, renderer-agnostic **scene model**: game/tavern state is adapted
|
||||
into a `Scene` (a tile grid + entities), and any renderer draws it. This lives in
|
||||
`html/js/render/` and is used by the Tavern, multiplayer client, spectators, and the single-player
|
||||
render bridge.
|
||||
## Rendering
|
||||
|
||||
Selectable render techniques:
|
||||
- **ASCII** — monospace glyph grid (canvas). Always-available fallback; accessible.
|
||||
- **Tiled** — coloured tiles + entity sprites (canvas 2D). The default. Real tilesets/atlases
|
||||
slot in here later.
|
||||
- **Iso** — canvas isometric projection using Kenney's Isometric Miniature Dungeon runtime PNGs.
|
||||
- **3D** — Three.js projection, loaded lazily from the pinned same-origin `three@0.160.0` runtime.
|
||||
It attaches generated GLB avatars with `idle` / `run` / `jump` clips when available and keeps a
|
||||
lightweight low-poly fallback only when the model cannot load.
|
||||
Rendering goes through a renderer-agnostic **scene model**: game or tavern state is adapted into a
|
||||
`Scene` (a tile grid plus entities), and any renderer draws it. This lives in `html/js/render/` and
|
||||
serves the Tavern, the match client, spectators, and the single-player render bridge.
|
||||
|
||||
`fancyRenderer.js` remains an internal PixiJS prototype, not a registered selectable mode.
|
||||
Production top-down styling comes from interchangeable packs plus the shared canvas FX layer.
|
||||
Planned: **Fancy ASCII** — shader-lit, animated glyphs.
|
||||
Selectable render techniques (`RK.RENDER_MODES`):
|
||||
|
||||
Render mode is a per-user choice (persisted) with an operator default. Modes can be marked
|
||||
**premium** — a cosmetic entitlement intended to be unlocked with credits (the same Operator
|
||||
Policy pattern as avatar unlocks). Because rendering is client-side, this is a soft/cosmetic gate,
|
||||
not a hard boundary; premium-only *assets* (special tilesets, shaders) are the real lever if
|
||||
stronger gating is wanted. The render kit provides the engine and programmatic tiles/effects —
|
||||
polished pixel-art tilesets, sprite sheets, and shader effects are a separate content effort.
|
||||
| Mode | Implementation | Premium |
|
||||
|---|---|---|
|
||||
| `tiles` | Coloured tiles and entity sprites on canvas 2D. The default. | No |
|
||||
| `ascii` | Monospace glyph grid on canvas. Always-available fallback; accessible. | No |
|
||||
| `iso` | Canvas isometric projection using the isometric dungeon pack art. | Yes |
|
||||
| `3d` | Three.js projection with generated GLB avatars (`idle`, `run`, `jump` clips) and a low-poly fallback when a model cannot load. | Yes |
|
||||
|
||||
**CSP note:** If the internal PixiJS prototype is invoked, it loads `@pixi/unsafe-eval`
|
||||
immediately after `pixi.js` to precompile shaders without adding `'unsafe-eval'` to CSP. It also
|
||||
requires the server's explicit external-renderer policy; no current mode button invokes it.
|
||||
A premium mode unlocks once the user holds any unlocked pack for that mode's projection, so
|
||||
entitlement is expressed once in the catalog rather than duplicated per mode. Render mode is a
|
||||
per-user persisted choice with an operator default. Because rendering is client-side this is a
|
||||
cosmetic gate; premium-only *assets* are the enforceable lever.
|
||||
|
||||
## Asset delivery & performance
|
||||
`fancyRenderer.js` is an internal PixiJS prototype, not a registered selectable mode. No mode button
|
||||
invokes it. If it is invoked directly it loads `@pixi/unsafe-eval` immediately after `pixi.js` so
|
||||
shaders precompile without adding `'unsafe-eval'` to the CSP, and it requires the server's external
|
||||
renderer policy to be on.
|
||||
|
||||
Rules for keeping the tree lean and the client fast:
|
||||
Production top-down styling comes from interchangeable packs plus the shared canvas FX layer. The
|
||||
render kit provides the engine and programmatic tiles and effects; authored tilesets, sprite sheets,
|
||||
and shader effects are a separate content effort.
|
||||
|
||||
- **Heavy source artifacts never go in git.** 3D models and generation intermediates (`*.glb`,
|
||||
`*.fbx`, `*.blend`, …) are produced by the sprite pipeline (`~/src/docs/animated-sprite-pipeline.md`)
|
||||
and are gitignored. Only the *outputs* the game loads are considered for shipping.
|
||||
- **Generated / premium assets are provisioned, not committed.** Premium skins and runtime GLBs
|
||||
live under `html/assets/generated/` (gitignored). They may be provisioned into the same-origin
|
||||
runtime path or delivered by an explicitly reviewed asset host and CSP policy; they are not
|
||||
present in the Git release artifact. The base tier's small Kenney CC0 tiles stay in-repo.
|
||||
- **Lazy-load by mode / entitlement.** Nothing heavy loads until it's actually needed:
|
||||
- ASCII and Tiled need no WebGL library. The unregistered Fancy prototype requests PixiJS plus
|
||||
`@pixi/unsafe-eval` only if called directly.
|
||||
- A premium skin's sprite sheet is fetched only when that skin is selected and the player is
|
||||
entitled to it.
|
||||
- The exact production Three.js dependency is served beneath `/vendor/three/0.160.0/` and
|
||||
imported only when **3D** is selected. Local delivery takes precedence; external renderer
|
||||
execution remains off unless `RENDERER_CDN_ENABLED=true`.
|
||||
- Generated GLBs are emitted by `scripts/build_kenney_3d_characters.py` from local Kenney FBX
|
||||
sources into `html/assets/generated/3d/` (gitignored).
|
||||
- **Optimize what ships.** Sprite sheets are delivered as **WebP** (alpha), right-sized to display
|
||||
resolution, content-hashed with a long cache TTL. (Example: the demo walk sheet is 633 KB as PNG,
|
||||
65 KB as WebP — ~10×.)
|
||||
## Asset delivery and performance
|
||||
|
||||
## Milestones
|
||||
- **Heavy source artifacts are not committed.** 3D models and generation intermediates (`*.glb`,
|
||||
`*.fbx`, `*.blend`) are gitignored. Only the outputs the game loads are candidates for shipping.
|
||||
- **Generated and premium assets are provisioned, not committed.** Premium skins and runtime GLBs
|
||||
live under `html/assets/generated/`, which is gitignored. They are provisioned into the
|
||||
same-origin runtime path or delivered by an explicitly reviewed asset host and CSP policy, and are
|
||||
not present in the Git release artifact. The base tier's small Kenney CC0 tiles stay in-repo.
|
||||
- **Load by mode and entitlement.** ASCII and Tiled need no WebGL library. A premium skin's sprite
|
||||
sheet is fetched only when that skin is selected and the player is entitled to it. Generated GLBs
|
||||
are emitted by `scripts/build_kenney_3d_characters.py` from local Kenney FBX sources into
|
||||
`html/assets/generated/3d/`.
|
||||
- **Three.js is served from this origin.** `src/index.js` publishes the browser build and addons
|
||||
under `/vendor/three/0.160.0/` from the pinned `three` dependency, with immutable versioned URLs
|
||||
so a cached copy cannot survive an upgrade to different bytes. Pages declare it through an import
|
||||
map, and `RK.ensureThree` imports it only when 3D is selected. `RENDERER_CDN_ENABLED` defaults to
|
||||
false: third-party renderer code would execute with the same privileges as the game and payment
|
||||
UI, so trusting a CDN is an explicit operator decision and widens the CSP when enabled.
|
||||
- **Optimize what ships.** Sprite sheets are delivered as WebP with alpha, sized to display
|
||||
resolution, content-hashed with a long cache TTL. The demo walk sheet is roughly ten times smaller
|
||||
as WebP than as PNG.
|
||||
|
||||
Tavern:
|
||||
- **T0** — Shared engine scaffold: `Room`, `Occupant`, tavern map, unit tests. (Done)
|
||||
- **T1** — `TavernManager`: Socket.IO room, server-tick timer, join/move/leave broadcasts; minimal
|
||||
client (`html/tavern.html`) rendering avatars; gated behind `TAVERN_ENABLED`. (Done)
|
||||
- **T2a** — `ChatProvider` seam + tavern chat. (Done)
|
||||
- **T2b** — Spectator camera into live solo and multiplayer games from the Tavern. (Done)
|
||||
- **T3** — Avatar picker with policy-gated unlocks. (Done)
|
||||
## Client and transport
|
||||
|
||||
Multiplayer:
|
||||
- **M0** — Match Room with a dungeon, shared monster, server ticks, and bounded lifecycle. (Done)
|
||||
- **M1** — Player-vs-player combat for the `last-alive` ruleset. (Done)
|
||||
- **M2** — Environmental hazards.
|
||||
- **M3** — Free, credit-prestige, and gated crypto-race economies wired in. (Done)
|
||||
`src/network/tavernManager.js` owns transport and lifecycle: one shared `Room`, the server-tick
|
||||
timer (default 100ms, override with `TAVERN_TICK_MS`), and Socket.IO room broadcasts on the
|
||||
`tavern:<roomId>` channel. It is wired into `SocketHandlers` (`tavern_join`, `tavern_move`,
|
||||
`tavern_leave`, plus disconnect cleanup and shutdown) and is inert unless `TAVERN_ENABLED=true`.
|
||||
|
||||
Rendering:
|
||||
- **R0** — Shared render kit: scene model + selectable ASCII / Tiled / Iso / 3D techniques,
|
||||
internal Fancy prototype, and a mode switch wired to the Tavern. Premium-mode gating
|
||||
scaffolded. (Done)
|
||||
- **R1** — Wire the main game to the render kit behind the existing display layer. (Done)
|
||||
- **R2** — Real tilesets / sprite sheets and the Fancy-ASCII tier.
|
||||
- **R3** — Wire premium render modes to credit entitlements (Operator Policy). (Done)
|
||||
`src/network/tavernMatchBridge.js` relays match progress into the Tavern (`tavern_match_tick`,
|
||||
`tavern_match_end`) and requires both `TAVERN_ENABLED` and `MATCH_ENABLED`.
|
||||
|
||||
All milestones are additive and config-gated; the single-player path is unchanged.
|
||||
`html/tavern.html` is the browser client: establish or resume an anonymous session, choose an
|
||||
optional name and avatar, enter the room, walk with keyboard or on-screen controls, chat, watch solo
|
||||
or match games, or join a match queue. No account login is required.
|
||||
|
||||
## Current status
|
||||
`html/js/render/` is the render kit: `sceneModel.js` (renderer-agnostic adapters), the ASCII, tiled,
|
||||
isometric, and Three.js renderers, the internal Fancy prototype, and `renderModes.js` (registry,
|
||||
factory with graceful fallback, entitlements, persistence). Clients expose a mode toolbar where
|
||||
applicable; premium modes are marked and gated by `RK.entitlements`.
|
||||
|
||||
`src/multiplayer/`
|
||||
- `Occupant.js` — a shared-world avatar (position, name, avatar id, facing).
|
||||
- `Room.js` — map/walkability, server-authoritative one-tile movement, optional occupant
|
||||
collision, a `tick()` hook for future autonomous systems, and snapshot/full-state serialization.
|
||||
- `tavernMap.js` — a procedurally generated (rectangular) default tavern layout.
|
||||
## Tests
|
||||
|
||||
`src/network/tavernManager.js` — transport + lifecycle: a shared Room, the server-tick timer,
|
||||
and Socket.IO room broadcasts. Wired into `SocketHandlers` (`tavern_join` / `tavern_move` /
|
||||
`tavern_leave`, plus disconnect cleanup and shutdown). Inert unless `TAVERN_ENABLED=true`.
|
||||
- `test/tavernRoom.test.js`: the room engine.
|
||||
- `test/tavernManager.test.js`: the manager, including chat moderation.
|
||||
- `test/tavernMatchBridge.test.js`: match-to-tavern relay.
|
||||
- `test/chatProvider.test.js`: the provider seam.
|
||||
- `test/renderScene.test.js`: the scene adapter.
|
||||
- `test/entitlements.test.js`, `test/catalogEntitlements_ultra.test.js`: entitlement policy and the
|
||||
operator catalog.
|
||||
- `test/identityService.test.js`, `test/appearance.test.js`, `test/appearanceModel3d.test.js`:
|
||||
appearance normalization and persistence.
|
||||
- `test/productGrants.test.js`: grant normalization.
|
||||
- `test/pvpUiPolish.test.js`: anonymous readiness and join states.
|
||||
- `test/rendererCdnPolicy.test.js`: the same-origin Three.js runtime policy.
|
||||
|
||||
`html/tavern.html` — the browser client: establish/resume an anonymous session, choose an optional
|
||||
name/avatar, enter the room, walk with keyboard or on-screen controls, chat, watch
|
||||
solo/multiplayer games, or join a match. No account login is required.
|
||||
## Running locally
|
||||
|
||||
`src/network/chat/` — the `ChatProvider` seam. `ChatProvider` (interface) + `SocketChatProvider`
|
||||
(default: Socket.IO delivery + Postgres history). Both the global chat (`ChatHandler`) and tavern
|
||||
chat deliver through it, so the backend can be swapped (e.g. a Nostr channel) without changing
|
||||
callers. Tavern chat is room-scoped and ephemeral.
|
||||
|
||||
`html/js/render/` — the render kit: `sceneModel.js` (renderer-agnostic adapters), ASCII, tiled,
|
||||
isometric, and Three.js renderers plus the internal Fancy (PixiJS) prototype, and
|
||||
`renderModes.js` (registry, factory with graceful fallback, entitlements, persistence). Clients
|
||||
expose a mode toolbar where applicable; premium modes are marked and gated by `RK.entitlements`.
|
||||
|
||||
Tests: `test/tavernRoom.test.js` (engine), `test/tavernManager.test.js` (manager incl. chat),
|
||||
`test/chatProvider.test.js`, `test/renderScene.test.js` (scene adapter),
|
||||
`test/entitlements.test.js`, `test/identityService.test.js`, and `test/productGrants.test.js`.
|
||||
`test/pvpUiPolish.test.js` covers the anonymous readiness/join states, and
|
||||
`test/rendererCdnPolicy.test.js` covers the same-origin Three.js runtime policy. End-to-end
|
||||
join/move/leave, local 3D, and chat over a real socket are verified out-of-band.
|
||||
|
||||
Payment/product entitlement pieces:
|
||||
- `src/migrations/020_user_appearance_and_pack_entitlements.sql` — persisted appearance,
|
||||
premium tier, and pack grants.
|
||||
- `src/migrations/021_payment_product_grants.sql` — product ids and normalized grants on payments.
|
||||
- `src/network/identityService.js` — server-side appearance persistence and entitlement-aware
|
||||
normalization.
|
||||
- `src/payments/productGrants.js` — product grant normalization/public summaries.
|
||||
- `src/multiplayer/entitlements.js` — server policy snapshot used by identity, tavern joins, and
|
||||
payment confirmations.
|
||||
|
||||
Enable locally with `TAVERN_ENABLED=true` (optionally `TAVERN_TICK_MS`), then open `/tavern.html`
|
||||
in two browser tabs.
|
||||
Set `TAVERN_ENABLED=true` (optionally `TAVERN_TICK_MS`), start the server, and open `/tavern.html`
|
||||
in two browser tabs. Add `MATCH_ENABLED=true` and a `MATCH_RULESET_ID` to exercise match entry from
|
||||
the Tavern.
|
||||
|
||||
-188
@@ -1,188 +0,0 @@
|
||||
## Match Mode (Complete)
|
||||
|
||||
All milestones implemented. See `docs/MATCH_MODE.md` for architecture and configuration.
|
||||
|
||||
- [x] Design review: economy models, block-cadence lifecycle, ticket system, premium integration
|
||||
- [x] Database schema: `matches`, `match_entrants`, `match_events`, `match_queue_entries`, `race_entry_transactions`, `users.race_entries`, `payouts.match_id`
|
||||
- [x] Engine scaffold: `MatchRoom`, `MatchEngine`, `MatchState` with unit tests
|
||||
- [x] Per-economy queues and block-cadence `MatchScheduler`
|
||||
- [x] Socket.IO `MatchManager` and free races
|
||||
- [x] Credits/prestige races and separate Prestige leaderboard
|
||||
- [x] Crypto race-entry tickets and winner-take-pot payout
|
||||
- [x] Client (`match.html`, `matchClient.js`) and Tavern spectator integration
|
||||
- [x] Reconnect/AFK tests
|
||||
- [x] Final doc pass and operator deployment notes
|
||||
- [x] Unit tests: 40 passing across 7 suites
|
||||
|
||||
# Wownerogue Development Todo
|
||||
|
||||
## Rendering, Balance & Multi-level (2026-07)
|
||||
|
||||
Done:
|
||||
- [x] **Render packs**: modes (technique) vs packs (style) split; "Fancy" retired to a topdown pack; multi-pack registry; Kenney iso dungeon set + orientation-aware walls; per-projection pack picker. See `RENDER_PACKS.md`.
|
||||
- [x] **FX layer (`RK.fx`)**: shared canvas fire/hazard/glow; tavern braziers + hearth; dungeon hazard tiles wired. Zoom (`zoomControl.js`); animated cat.
|
||||
- [x] **Balance sim** (`src/sim/`): headless harness over the real engine; measured house-win per network. See `BALANCE_SIM.md`.
|
||||
- [x] **Per-network tuning**: `cryptoType` now shapes difficulty via `NETWORK_TUNING` (was a dead param).
|
||||
- [x] **WOW block-time fix**: 2 min → **5 min** (measured on the live daemon); `chainProfile.js`.
|
||||
- [x] **Multi-level dungeons**: descend N levels ∝ block time; provably-fair per level; treasure in the vault. See `MULTI_LEVEL.md`.
|
||||
|
||||
Pending:
|
||||
- [ ] **Multi-level calibration run**: lock per-network level counts against a target house-win (sim multi-level support is in; counts are currently starting points).
|
||||
- [x] **Block-bounded pot race** (flagship): `Ruleset.timing.blockDeadline` expires active matches
|
||||
on the first advancing header after the play-time floor; winner-take-pot-minus-fee admission
|
||||
is durable and co-op is excluded until split-payout semantics exist.
|
||||
- [x] **PvP wiring**: the server selects `race`, `last-alive`, `score-attack`, or `coop-escape`
|
||||
with `MATCH_RULESET_ID`; match state and persistence record the active ruleset.
|
||||
- [ ] **Hazard gameplay**: generator places lava/poison/spikes (FX already renders them) + damage stakes; escalate per level.
|
||||
- [ ] **Client polish**: "Level N/M" indicator + "descended" toast (server sends `depth`/`maxDepth` + `game_event: descend`).
|
||||
|
||||
## High Priority
|
||||
- [x] **Verify Server Startup**: Server starts cleanly with `npm run dev` ✅
|
||||
- [x] **Test Payment Flow**: Fixed critical bug where payment confirmation wasn't updating DB status ✅
|
||||
- [x] **Test Payouts**: Payout flow verified with duplicate prevention and proper mode tracking ✅
|
||||
- [x] **Address Persistence**: Verify that payout addresses persist across server restarts via session tokens. ✅
|
||||
- [x] **Mixed Mode Logic**: Per-user mode determination based on credits balance ✅
|
||||
- [x] **Credits Package Bug**: Credits correctly parsed from package info on confirmation ✅
|
||||
- [x] **Stagenet Support**: Added MONERO_NETWORK config with UI warnings for non-mainnet ✅
|
||||
- [x] **Difficulty Tuning**: Configurable presets (easy/normal/hard/casino) for house edge control ✅
|
||||
- [x] **Provably Fair**: SHA-256 commitment scheme for verifiable game generation ✅
|
||||
|
||||
## Recent Fixes (Jan 2026)
|
||||
- [x] **Payment DB Status Bug**: Fixed critical issue where single_game payments weren't marked 'confirmed' in DB, causing game start to fail ✅
|
||||
- [x] **TileMap Character Error**: Fixed '●' character not in tileMap causing tile mode rendering failures ✅
|
||||
- [x] **Test Suite Fixes**: All 106 tests now pass (10 test suites) ✅
|
||||
- [x] **Credit Deduction Bug**: Fixed race condition where credits could go negative - now uses `WHERE credits >= $1` ✅
|
||||
- [x] **Payout Mode Bug**: Fixed `completeGame` using global `gameMode` instead of recorded `payment_mode` from game record ✅
|
||||
- [x] **Duplicate Payout Prevention**: Added check for existing payout before processing to prevent double-payouts ✅
|
||||
- [x] **Dead Code Cleanup**: Removed unused MoneroPayService (was never used, WalletRPCService is the actual service) ✅
|
||||
- [x] **Game Reconnection/Resume**: Added `SuspendedGameManager` to preserve and restore games when users disconnect and reconnect. Users in dungeon or awaiting payment can now resume seamlessly ✅
|
||||
|
||||
## New Features (Just Added)
|
||||
- [x] **Chat History Persistence**: `ChatHistoryManager` stores messages in PostgreSQL with 30-day retention ✅
|
||||
- [x] **Chat History on Connect**: New users receive last 50 messages on connect via `chat_history` event ✅
|
||||
- [x] **Spectator System**: Full spectator mode with `SpectatorManager` for watching live games ✅
|
||||
- [x] **Active Games List**: Real-time game list with pagination, sorting, and auto-refresh ✅
|
||||
- [x] **Spectate UI**: "Watch Games" button, live game panel, and spectator controls ✅
|
||||
- [x] **Spectator Broadcasts**: Game updates broadcast to spectators via Socket.IO rooms ✅
|
||||
- [x] **Help Modal**: Added instructions modal with game rules, controls, and payment mode info ✅
|
||||
- [x] **Dynamic Treasure Icon**: Treasure tile now shows $W (Wownero) or $M (Monero) based on crypto type ✅
|
||||
- [x] **Address Status Indicator**: Payout address button shows warning when no address is set ✅
|
||||
|
||||
## User Feedback (Beta Tester - Dec 2025)
|
||||
- [ ] **History Panel Bug**: User reported payout not showing in History after winning with treasure (needs investigation - may be timing/status issue with "confirmed" vs "pending" payouts)
|
||||
- [x] **Early Dungeon Entry**: Allow credits/free mode users to enter dungeon immediately without waiting for next block (risky - they die if block found before escape). Toggleable via config. ✅
|
||||
|
||||
## Improvements / Refactoring
|
||||
- [x] **Error Handling**: Extended `normalizeError` to queueHandler, queueManager, sessionManager ✅
|
||||
- [x] **Configuration Persistence**: Added `configPersistence.js` for hot-reloadable DB-backed config ✅
|
||||
- [x] **Monitoring Dashboard**: Created `/admin.html` with live metrics (games, queue, memory, wallet) ✅
|
||||
- [x] **Frontend Cleanup**: Removed rot.js (using minified), game1.mid (unused), .DS_Store, moved tiles.xcf ✅
|
||||
- [x] **Provably Fair UI**: Hash commitment shown at game start, seed revealed with verification link at game end ✅
|
||||
- [x] **Verification Endpoint**: `/verify/:gameId` HTML page + `/api/verify` JSON endpoint for public verification ✅
|
||||
|
||||
## Documentation
|
||||
- [x] **API Documentation**: README updated with accurate API surface including health check and Socket.IO events ✅
|
||||
- [x] **Session Persistence**: README documents session token behavior and localStorage requirements ✅
|
||||
- [x] **Difficulty System**: README documents difficulty presets and house edge tuning ✅
|
||||
- [x] **Provably Fair**: README documents cryptographic verification system ✅
|
||||
- [x] **Deployment Guide**: README includes production deployment with Nginx/Caddy configs and systemd ✅
|
||||
|
||||
## Pre-Beta Checklist
|
||||
- [x] All 106 tests pass (10 test suites) ✅
|
||||
- [x] Health check endpoint available at `/health` ✅
|
||||
- [x] REST endpoints implemented: `/api/user/:socketId/credits`, `/api/user/:socketId/mode`, `/api/user/:socketId/address` ✅
|
||||
- [x] Payout eligibility shown per payment mode in UI ✅
|
||||
- [x] Session persistence tooltip and localStorage warning added ✅
|
||||
- [x] Stagenet/testnet warning banner for Monero non-mainnet ✅
|
||||
- [x] Chat history persisted and sent to new users ✅
|
||||
- [x] Spectator system functional with live game viewing ✅
|
||||
- [ ] **TLS/HTTPS**: Configure reverse proxy (Nginx/Caddy) with SSL certificates before exposing to real users.
|
||||
- [x] **Database Backups**: `scripts/backup_db.sh` + systemd timer for daily backups with 30-day retention (see `LOGS_AND_BACKUP.md`) ✅
|
||||
- [ ] **Manual Integration Test**: Run full payment flow with real `wownero-wallet-rpc` (not mocked).
|
||||
- [x] **Rate Limit Tuning**: Current defaults (60 games/hour, 100 payouts/day) suitable for launch ✅
|
||||
- [x] **Log Rotation**: Using systemd journald with configurable retention (see `LOGS_AND_BACKUP.md`) ✅
|
||||
|
||||
## Security Audit Fixes (Jan 2026)
|
||||
- [x] **XSS Vulnerability**: Complete HTML entity escaping in chat handler (`&`, `<`, `>`, `"`, `'`)
|
||||
- [x] **tx_hash Idempotency**: UNIQUE partial indexes on payments/payouts tables (migration 008)
|
||||
- [x] **Timing-Safe Auth**: Admin API uses `crypto.timingSafeEqual()` for key comparison
|
||||
- [x] **Transactional Payouts**: Payout flow creates pending record first, updates atomically
|
||||
- [x] **Payout Retry Service**: Auto-retries failed payouts (max 3 attempts, 5-min interval)
|
||||
- [x] **Address Validation**: `validateAddress()` called before every payout attempt
|
||||
- [x] **Transaction Status Check**: `checkTransactionStatus()` verifies blockchain before retry
|
||||
|
||||
## Smirk Wallet Integration (Jan-Feb 2026)
|
||||
- [x] **Database Schema**: `smirk_public_key` column, `smirk_challenges` table (migration 009)
|
||||
- [x] **Auth Endpoints**: `/api/auth/smirk/challenge`, `/verify`, `/status`
|
||||
- [x] **Frontend Module**: `smirkAuth.js` with extension detection and login flow
|
||||
- [x] **Address Modal**: Shows Smirk connect button or install link
|
||||
- [x] **Native Payments**: `requestPayment()` integration — Smirk-connected users get auto-prompted via extension instead of manual address/QR. Falls back to normal flow on rejection.
|
||||
|
||||
## Admin Dashboard (Jan 2026)
|
||||
- [x] **Alert Service**: Email alerts via Resend for low balance, disconnects, failed payouts
|
||||
- [x] **Stats Endpoints**: `/api/admin/stats/overview`, `/payouts`, `/games`, `/users`
|
||||
- [x] **Admin UI**: Full dashboard at `/admin.html` with charts and tables
|
||||
- [x] **Payout Retry**: Manual retry button in admin dashboard
|
||||
|
||||
## Scoring & Leaderboard (Feb 2026)
|
||||
- [x] **Scoring System**: New formula (max 800 pts) — base escape + treasure bonus + speed bonus + efficiency bonus
|
||||
- [x] **Score Persistence**: `score` column on games table, `high_score` on users (migration 014)
|
||||
- [x] **Leaderboard API**: `GET /api/leaderboard?board=pleb|champions|prestige&period=all|week|month`
|
||||
with display-name support and no mixed-economy fallback (`board` omitted → Pleb; invalid/legacy
|
||||
`all` → 400)
|
||||
- [x] **Leaderboard UI**: Modal with rank, name, best score, wins, games. Period tabs (All/Week/Month)
|
||||
- [x] **Display Names**: `/nick` chat command stores `display_name` on users table
|
||||
- [x] **Real-time Updates**: `leaderboard_update` broadcast on game wins, auto-refreshes open modal
|
||||
- [x] **Difficulty Tuning**: Casino preset hardened — 70x35 dungeon, 1.5x monster speed, 97% chase, 22-tile vision
|
||||
|
||||
## Payout Reliability (Feb 2026)
|
||||
- [x] **Fire-and-Forget INSERT Fix**: `_insertGameRecord` now awaited, prevents race conditions ✅
|
||||
- [x] **Double Game-Over Prevention**: `activeGames.delete()` moved before any `await` in `handleGameOver` ✅
|
||||
- [x] **DB-Level Duplicate Payout Prevention**: Unique partial index on `payouts(game_id)` (migration 012) ✅
|
||||
- [x] **Payout Address Locking**: Address captured at game start, stored in `games.payout_address` ✅
|
||||
- [x] **Dead Code Removal**: Deleted unused `processGameCompletion()` method ✅
|
||||
- [x] **Same-Block Payout Batching**: Payouts within 5-second window batched into one `transfer_split` call ✅
|
||||
- [x] **Output Splitting Script**: `scripts/splitOutputs.js` for wallet output management ✅
|
||||
- [x] **transfer_split**: Switched from `transfer` to `transfer_split` for output resilience ✅
|
||||
|
||||
## Pre-Production Testing Checklist
|
||||
|
||||
### Security Verification
|
||||
- [ ] Chat XSS: `<script>` and `"onclick=` render as plain text
|
||||
- [ ] Duplicate tx_hash insertion fails with constraint error
|
||||
- [ ] Payout creates pending record before RPC call
|
||||
- [ ] Failed payouts have records for retry
|
||||
- [ ] Retry service processes failed payouts (check logs)
|
||||
|
||||
### Smirk Integration Verification
|
||||
- [ ] "Connect Smirk" button appears when extension installed
|
||||
- [ ] "Get Smirk Wallet" link appears when extension NOT installed
|
||||
- [ ] Challenge generated and stored in DB
|
||||
- [ ] Signature verification links public key to user
|
||||
- [ ] Payout address auto-populated from wallet
|
||||
- [ ] Anonymous sessions still work (backwards compatible)
|
||||
|
||||
### End-to-End Testing
|
||||
- [ ] Play full game with Smirk login
|
||||
- [ ] Win and receive payout to Smirk address
|
||||
- [ ] Verify payout recorded in transaction history
|
||||
- [ ] Manual integration test with real wallet-rpc
|
||||
|
||||
---
|
||||
|
||||
## Known Issues / Future Improvements
|
||||
|
||||
### Backend
|
||||
- [x] **Database Transactions**: `withTransaction()` helper for atomic financial operations ✅
|
||||
- [ ] **Payment Idempotency**: Replace in-memory Set with database-backed idempotency keys for payment confirmation
|
||||
- [x] **Payout Retry Queue**: Automatic retry mechanism via `payoutRetryService.js` ✅
|
||||
- [x] **Address Validation**: Wallet RPC `validate_address` method ✅
|
||||
- [ ] **Redis Rate Limiting**: Replace in-memory rate limiter with Redis for persistence across restarts
|
||||
- [ ] **Full Payout Batching**: Implement configurable batch intervals beyond same-block 5-second window
|
||||
- [ ] **Wallet Health Monitoring**: Track spendable output count, alert when outputs are low
|
||||
|
||||
### Frontend
|
||||
- [ ] **Socket Reconnection**: Add `reconnect` and `disconnect` handlers to restore state after connection loss
|
||||
- [ ] **Payment Timeout UI**: Show countdown timer for payment expiration, auto-clean expired payment UI
|
||||
- [ ] **Event Cleanup**: Add proper cleanup for event listeners when modules are re-initialized
|
||||
- [ ] **Connection Status Indicator**: Show real-time socket connection state in UI
|
||||
@@ -1,168 +0,0 @@
|
||||
# Wallet keyfile recovery proof
|
||||
|
||||
**Status: source and synthetic tests only. This helper has not been installed on or run against
|
||||
`deb`, and this document does not authorize a host mutation or a production ceremony.**
|
||||
|
||||
This runbook covers one deliberately narrow question: can an audited encrypted wallet snapshot and
|
||||
the password already exposed in the matching legacy wallet-RPC process open offline and reproduce
|
||||
the exact public address currently returned by that process? The fixed helper is
|
||||
`scripts/deploy/wallet-keyfile-recovery-proof.py`.
|
||||
|
||||
The answer is emitted as exactly one status line:
|
||||
|
||||
```text
|
||||
result=passed
|
||||
```
|
||||
|
||||
or:
|
||||
|
||||
```text
|
||||
result=refused
|
||||
```
|
||||
|
||||
There is intentionally no diagnostic mode. Never add shell tracing, a debugger, `strace`, command
|
||||
line capture, wallet-RPC response logging, or exception rendering to investigate a refusal. Those
|
||||
would defeat the secret-handling boundary this proof is meant to preserve.
|
||||
|
||||
## What a pass means
|
||||
|
||||
A pass establishes all of the following for the selected profile at that moment:
|
||||
|
||||
- the expected legacy systemd unit has a stable MainPID, executable, UID/GID, cgroup, listener,
|
||||
network flag, RPC port, and exact `--wallet-file` argument;
|
||||
- its already-exposed inline wallet password could be copied from the NUL-delimited process command
|
||||
line without rendering it;
|
||||
- both fixed, root-only pre-production snapshot files were real, single-link regular files with the
|
||||
audited metadata and were copied byte-for-byte into a newly created private `/run` directory;
|
||||
- the matching wallet-RPC executable opened that copied pair offline on the fixed loopback proof
|
||||
port as the exact legacy UID/GID, with an empty supplementary-group list, using a password file and
|
||||
an ephemeral Digest credential;
|
||||
- the proof endpoint rejected an unauthenticated request with HTTP 401, accepted an authenticated
|
||||
`get_address` request with HTTP 200, returned an address shaped for the expected network, and that
|
||||
address exactly equalled the current live address;
|
||||
- that restored wallet signed one fixed, non-secret proof message and its own wallet-RPC verified the
|
||||
signature as a spend-key signature (or, for the legacy response shape, verified the default
|
||||
spend-key signature), so a watch-only keyfile cannot pass; and
|
||||
- the candidate stopped, its listener disappeared, and only the exact ephemeral files and directory
|
||||
were securely cleaned up.
|
||||
|
||||
The helper does not stop, start, restart, reload, reconfigure, or write to a live unit or live wallet.
|
||||
The proof candidate is offline; it cannot refresh, submit transactions, or contact a daemon.
|
||||
|
||||
## What a pass does not mean
|
||||
|
||||
This is **not seed custody** and it is **not an off-host disaster-recovery test**. In particular, a
|
||||
pass does not establish:
|
||||
|
||||
- that a mnemonic or seed has ever been recorded, is readable, is complete, or is held by the right
|
||||
custodians;
|
||||
- that the encrypted keyfile, its password, or a seed exists independently off this host—the password
|
||||
used here comes only from the already-running process, not from an independent custody source;
|
||||
- that Restic media, repository credentials, retention, or an actual off-host restore path works;
|
||||
- that a restore can rescan chain history, discover the expected balance, or spend funds;
|
||||
- that daemon, database, application, DNS, proxy, Nebula, or fleet recovery works; or
|
||||
- that payouts, wagering, mainnet money movement, or a production release is authorized.
|
||||
|
||||
Do not query, display, export, hash, or automate retrieval of a mnemonic to fill this gap. Seed
|
||||
custody requires a separate human-controlled inventory and an off-host restore drill with an agreed
|
||||
handling ceremony. Until that evidence exists, record seed custody and off-host recovery as
|
||||
**unproven**, even if this helper passes.
|
||||
|
||||
## Fixed audited profiles
|
||||
|
||||
No path, unit, binary, port, user, or RPC method is accepted from the caller. The only accepted
|
||||
profile names are `monero-stagenet` and `wownero-mainnet`.
|
||||
|
||||
| Profile | Fixed legacy identity | Fixed snapshots | Private proof target |
|
||||
| --- | --- | --- | --- |
|
||||
| `monero-stagenet` | `monero-wallet-rpc.service`, `jw:jw`, `/usr/bin/monero-wallet-rpc`, wallet `/home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4/test`, `127.0.0.1:38083`, `--stagenet` | `/var/backups/wowngeon/wallets/monero-stagenet-preprod-20260721T1140Z.wallet` and `/var/backups/wowngeon/wallets/monero-stagenet-preprod-20260721T1140Z.keys` | outer `/run/wowngeon-wallet-keyfile-proof-monero-stagenet`, inner `candidate`, `127.0.0.1:39084` |
|
||||
| `wownero-mainnet` | `wownero-wallet-rpc.service`, `jw:jw`, `/usr/bin/wownero-wallet-rpc`, wallet `/home/jw/Programs/wow-11.3.0/game`, audited legacy listener `0.0.0.0:34570` | `/var/backups/wowngeon/wallets/wownero-mainnet-preprod-20260721T1140Z.wallet` and `/var/backups/wowngeon/wallets/wownero-mainnet-preprod-20260721T1140Z.keys` | outer `/run/wowngeon-wallet-keyfile-proof-wownero-mainnet`, inner `candidate`, `127.0.0.1:39570` |
|
||||
|
||||
The live equality observations are unauthenticated because the legacy sources are. Monero is
|
||||
loopback-only; Wownero's wildcard listener is an insecure legacy source contained by the host
|
||||
firewall, not a state this proof endorses. `current_live_address` makes one read-only public-address
|
||||
observation solely for equality; a match is **not** an RPC-hardening or network-security pass. The
|
||||
helper neither changes nor resolves the wildcard/unauthenticated service. Its proof candidate always
|
||||
binds loopback and requires an ephemeral Digest credential.
|
||||
|
||||
Each fixed proof path is a root-owned outer directory, `root:jw` mode `0710`, which prevents the
|
||||
candidate identity from listing or writing its parent. Root builds the inner `candidate` subtree and
|
||||
files before publishing only that subtree as `jw:jw` mode `0700` with `0600` files. Immediately before
|
||||
exec, the child clears supplementary groups, sets all real/effective/saved GIDs and UIDs to `jw:jw`,
|
||||
clears retained capabilities, then reapplies no-dump, no-new-privileges, and zero-core limits. The
|
||||
parent verifies empty inheritable/permitted/effective/ambient capability sets. Wallet-RPC never runs
|
||||
as root.
|
||||
|
||||
The audited snapshot directory is `/var/backups/wowngeon/wallets`, `root:root` mode `0700`; each
|
||||
snapshot is `root:root` mode `0600` with one link. Its parent is `/var/backups/wowngeon`,
|
||||
`root:postgres` mode `0710`. Any deviation refuses the proof.
|
||||
|
||||
## Review and synthetic verification
|
||||
|
||||
Before any future host ceremony, review the helper and run the disposable fixture locally:
|
||||
|
||||
```bash
|
||||
python3 -m py_compile scripts/deploy/wallet-keyfile-recovery-proof.py \
|
||||
scripts/deploy/wallet-keyfile-recovery-fixture-test.py
|
||||
python3 scripts/deploy/wallet-keyfile-recovery-fixture-test.py
|
||||
```
|
||||
|
||||
The fixture uses only generated temporary files and synthetic credentials. It covers exact argument
|
||||
selection and wiping, indirect or duplicate password refusal, network mismatch, link and metadata
|
||||
failures, source mutation during copy, byte equality, cleanup containment, Digest authentication
|
||||
shape, candidate config shape, privilege dropping, fixed-message spend signing/verification,
|
||||
watch-only refusal, and proof-port collision. It never opens a real wallet.
|
||||
|
||||
Review must also confirm that the executable still contains only read-only `systemctl is-active` and
|
||||
`systemctl show` calls; only the `get_address`, `sign`, and `verify` wallet RPC methods; no
|
||||
caller-controlled paths, ports, or messages; and only the two status strings as runtime output. The
|
||||
address and signature are never output or retained as ceremony evidence.
|
||||
|
||||
## Future authorized ceremony
|
||||
|
||||
Run Monero stagenet first. Wownero mainnet remains an offline keyfile-open proof only and must not be
|
||||
treated as approval to enable payouts or submit a transaction.
|
||||
|
||||
Only after a reviewed revision is explicitly approved for installation should an operator install
|
||||
that exact file to a root-owned, non-writable path such as
|
||||
`/usr/local/sbin/wowngeon-wallet-keyfile-recovery-proof` with owner `root:root` and mode `0700`.
|
||||
Do not run it from a mutable checkout. Record the reviewed source revision, but do not record wallet
|
||||
bytes, addresses, passwords, command lines, Digest material, wallet-derived hashes, or RPC bodies.
|
||||
|
||||
Before each profile:
|
||||
|
||||
1. Confirm no debugger, tracing wrapper, shell xtrace, terminal recorder, or broad process collector
|
||||
will observe the root helper.
|
||||
2. Confirm the expected proof port is not reserved for another operation and the exact fixed `/run`
|
||||
proof directory does not exist.
|
||||
3. Confirm the legacy unit is expected to remain online and unchanged for the short proof window.
|
||||
4. Invoke the installed helper as root with exactly one fixed profile argument and capture only its
|
||||
one-line status.
|
||||
|
||||
The future invocations are:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/wowngeon-wallet-keyfile-recovery-proof monero-stagenet
|
||||
sudo /usr/local/sbin/wowngeon-wallet-keyfile-recovery-proof wownero-mainnet
|
||||
```
|
||||
|
||||
These commands are examples for a later authorized ceremony, not evidence they have been run.
|
||||
|
||||
After each invocation, verify only that its fixed proof port is closed and its fixed proof directory
|
||||
is absent. A pass is invalid if cleanup did not complete; the helper reports refusal in that case.
|
||||
|
||||
## Refusal or interrupted execution
|
||||
|
||||
`result=refused` is intentionally non-diagnostic. Do not weaken the helper or print an exception to
|
||||
learn why. Review the fixed-path metadata, expected unit state, listener ownership, port availability,
|
||||
and synthetic suite through non-secret checks, then arrange a new reviewed ceremony.
|
||||
|
||||
If a fixed proof directory remains after interruption, **do not blindly remove it**. Treat it as
|
||||
potentially containing wallet material. First establish through metadata-only checks that no proof
|
||||
wallet-RPC process or proof-port listener remains, keep the incident off shared logs, and have an
|
||||
authorized operator review cleanup of that exact path. Never use a recursive command against `/run`
|
||||
or a variable/glob target.
|
||||
|
||||
The next independent backup milestone is a human-approved seed-custody inventory followed by a
|
||||
restore from genuinely off-host media into an isolated environment. That is the evidence needed to
|
||||
answer “do we have recoverable wallet seeds?”; this helper deliberately cannot answer it.
|
||||
+2
-2
@@ -148,7 +148,7 @@
|
||||
<button id="characterButton" class="ctrl-btn ctrl-btn--character" title="Create / customize your character">
|
||||
🎭 <span id="characterButtonLabel" class="lbl-full">CHARACTER</span><span class="lbl-short">CHAR</span>
|
||||
</button>
|
||||
<!-- Render mode + tile-pack picker (Style) — populated by spRenderPicker.js from entitlements. -->
|
||||
<!-- Render mode + tile-pack picker (Style), populated by spRenderPicker.js from entitlements. -->
|
||||
<div id="renderPicker" class="hidden"></div>
|
||||
|
||||
<!-- EXPLORE -->
|
||||
@@ -313,7 +313,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="text-align: center; color: #666; margin: 10px 0; font-size: 12px;">— or enter manually —</div>
|
||||
<div style="text-align: center; color: #666; margin: 10px 0; font-size: 12px;">or enter manually</div>
|
||||
|
||||
<form id="addressModalForm">
|
||||
<label for="addressModalInput" class="modal-label">Payout Address</label>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Shared HTML-escaping helper — the single source of truth for escaping untrusted
|
||||
* Shared HTML-escaping helper: the single source of truth for escaping untrusted
|
||||
* (server- or user-supplied) text before it is inserted into innerHTML / jQuery .html().
|
||||
* Escapes all five HTML-significant characters, including both quote types so the
|
||||
* result is safe in both element text and quoted attribute values.
|
||||
|
||||
+57
-81
@@ -1,15 +1,14 @@
|
||||
// Refactored Game Object - Main game controller using modular architecture
|
||||
// This provides a mock socket when the real one isn't available
|
||||
// Main game controller. Modules (DisplayManager, ScreenManager, RenderEngine, GameState) hold the
|
||||
// implementation; this object wires them together and owns the game lifecycle.
|
||||
// Stub socket so emit() is always callable when the real socket has not been created yet.
|
||||
if (typeof window !== 'undefined' && !window.socket) {
|
||||
window.socket = {
|
||||
emit: function(event, data) {
|
||||
// Mock socket emit - removed debug logging
|
||||
}
|
||||
emit: function(event, data) {}
|
||||
};
|
||||
}
|
||||
|
||||
var Game = {
|
||||
_screenWidth: 25, // Default values, will be updated from options
|
||||
_screenWidth: 25, // Overridden from options when available
|
||||
_screenHeight: 19,
|
||||
_inputEnabled: true,
|
||||
_messageLog: [],
|
||||
@@ -21,13 +20,11 @@ var Game = {
|
||||
_gameActive: false, // True when game is running (playing or spectating)
|
||||
|
||||
init: function() {
|
||||
// Update screen dimensions from options
|
||||
if (typeof options !== 'undefined') {
|
||||
this._screenWidth = options.width || 25;
|
||||
this._screenHeight = options.height || 19;
|
||||
}
|
||||
|
||||
// Initialize all modules
|
||||
if (!DisplayManager.init(this._screenWidth, this._screenHeight)) {
|
||||
return false;
|
||||
}
|
||||
@@ -35,8 +32,7 @@ var Game = {
|
||||
ScreenManager.init(this._screenWidth, this._screenHeight);
|
||||
RenderEngine.init(this._screenWidth, this._screenHeight);
|
||||
GameState.init();
|
||||
|
||||
// Draw welcome screen
|
||||
|
||||
ScreenManager.drawWelcomeScreen();
|
||||
|
||||
return true;
|
||||
@@ -58,12 +54,12 @@ var Game = {
|
||||
|
||||
DisplayManager.clearDisplay();
|
||||
|
||||
// Force clear all display caches and artifacts
|
||||
// Drop cached display glyphs so no welcome-screen artifacts survive into the level.
|
||||
if (typeof DisplayManager !== 'undefined' && DisplayManager.forceClearToBlack) {
|
||||
DisplayManager.forceClearToBlack();
|
||||
}
|
||||
|
||||
// Stop the block simulation to prevent welcome screen from interfering
|
||||
|
||||
// The welcome screen's block simulation draws on the same display and must be stopped first.
|
||||
if (typeof ScreenManager !== 'undefined' && ScreenManager.stopBlockSimulation) {
|
||||
ScreenManager.stopBlockSimulation();
|
||||
}
|
||||
@@ -71,9 +67,9 @@ var Game = {
|
||||
try {
|
||||
GameState.setGameActive(true);
|
||||
|
||||
// Clear any previous game state but preserve what we need for initialization
|
||||
// Clear previous game state. _visibleTiles is left alone: initializeMap seeds it from
|
||||
// the server's initial visible set.
|
||||
GameState._exploredTiles = {};
|
||||
// Don't clear _visibleTiles yet - let initializeMap handle it properly
|
||||
GameState._map = {};
|
||||
GameState._items = {};
|
||||
GameState._monster = null;
|
||||
@@ -81,17 +77,14 @@ var Game = {
|
||||
GameState._exit = null;
|
||||
GameState._treasure = null;
|
||||
|
||||
// Initialize player
|
||||
GameState.initializePlayer(playerData);
|
||||
|
||||
// Initialize other game objects
|
||||
GameState._monster = monsterData;
|
||||
GameState._items = itemData;
|
||||
GameState._entrance = null;
|
||||
GameState._exit = null;
|
||||
GameState._treasure = null;
|
||||
|
||||
// Initialize lighting and torch data
|
||||
if (lightingData) {
|
||||
GameState._lighting = lightingData;
|
||||
}
|
||||
@@ -99,7 +92,6 @@ var Game = {
|
||||
GameState._torches = torchData;
|
||||
}
|
||||
|
||||
// Initialize map and validate player position
|
||||
GameState.initializeMap(mapData, initialVisibleTiles, this._screenWidth, this._screenHeight);
|
||||
|
||||
if (!GameState.validatePlayerPosition(this._screenWidth, this._screenHeight)) {
|
||||
@@ -107,10 +99,8 @@ var Game = {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compute field of view
|
||||
GameState.computeFieldOfView();
|
||||
|
||||
// Setup scheduler and engine
|
||||
if (!GameState._scheduler) GameState._scheduler = new ROT.Scheduler.Simple();
|
||||
else GameState._scheduler.clear();
|
||||
if (!GameState._engine) GameState._engine = new ROT.Engine(GameState._scheduler);
|
||||
@@ -125,8 +115,7 @@ var Game = {
|
||||
|
||||
this._drawGameScreen();
|
||||
|
||||
// JUICE: kick off the FX overlay (ambient embers + flicker + particle loop).
|
||||
// Purely additive; all calls are no-ops if the FX overlay module is absent.
|
||||
// Starts the FX overlay (ambient embers, flicker, particle loop). No-op without window.FX.
|
||||
this._fxStart();
|
||||
|
||||
return true;
|
||||
@@ -147,44 +136,41 @@ var Game = {
|
||||
}
|
||||
},
|
||||
|
||||
// Update game state with new data from server
|
||||
// Apply a state update pushed by the server.
|
||||
updateGameState: function(data) {
|
||||
// JUICE: snapshot treasure/item count so we can detect a server-driven pickup below.
|
||||
// Snapshot of the treasure/item count; a drop after the update means a pickup happened.
|
||||
const prevTreasureCount = (window.FX) ? this._fxTreasureCells().length : 0;
|
||||
|
||||
const needsRedraw = GameState.updateGameState(data);
|
||||
|
||||
// Redraw the game screen if any relevant data changed
|
||||
if (needsRedraw) {
|
||||
this._drawGameScreen();
|
||||
}
|
||||
|
||||
// JUICE: react to server-driven changes (pickups, proximity) with FX.
|
||||
if (window.FX && needsRedraw) {
|
||||
try {
|
||||
const newTreasureCount = this._fxTreasureCells().length;
|
||||
if (newTreasureCount < prevTreasureCount) {
|
||||
// A treasure/item vanished near us -> gold burst (pickup).
|
||||
// An item vanished: gold burst for the pickup.
|
||||
const pp = this._playerScreenPixel();
|
||||
window.FX.burst(pp.x, pp.y, '#ffd700', 28);
|
||||
} else {
|
||||
// Otherwise run proximity effects for server-authoritative moves.
|
||||
// Proximity effects for server-authoritative moves.
|
||||
this._fxOnPlayerMoved();
|
||||
}
|
||||
} catch (e) { /* FX is best-effort; never break gameplay */ }
|
||||
}
|
||||
},
|
||||
|
||||
// Legacy alias (some handlers referenced updateGame previously)
|
||||
// Alias kept for handlers that call updateGame.
|
||||
updateGame: function(data) {
|
||||
return this.updateGameState(data);
|
||||
},
|
||||
|
||||
// Handle game over event from server
|
||||
// Handle the game-over event from the server.
|
||||
endGame: function(data) {
|
||||
try {
|
||||
GameState.setGameActive(false);
|
||||
// Decide which screen to draw
|
||||
if (data && data.reason === 'monster') {
|
||||
if (ScreenManager.drawLoseScreen) ScreenManager.drawLoseScreen('monster');
|
||||
} else if (data && data.reason === 'escaped') {
|
||||
@@ -195,34 +181,31 @@ var Game = {
|
||||
if (ScreenManager.drawLoseScreen) ScreenManager.drawLoseScreen('other');
|
||||
}
|
||||
|
||||
// JUICE: celebratory or fatal FX on the outcome. Loop keeps running so the
|
||||
// burst/shake actually renders; it is torn down on return-to-title (below).
|
||||
// Outcome FX. The loop stays running so the burst/shake renders; it is torn down on
|
||||
// return-to-title below.
|
||||
if (window.FX) {
|
||||
try {
|
||||
const pp = this._playerScreenPixel();
|
||||
const escaped = !!(data && data.reason === 'escaped');
|
||||
if (escaped) {
|
||||
// Win: gold burst + brief bright flash.
|
||||
window.FX.burst(pp.x, pp.y, '#ffd700', 40);
|
||||
window.FX.flash('rgba(255,215,0,1)', 0.35, 400);
|
||||
if (data && data.treasure) {
|
||||
window.FX.burst(pp.x, pp.y, '#fbbf24', 24);
|
||||
}
|
||||
} else {
|
||||
// Loss (monster / timeout / other): shake + red flash.
|
||||
// Monster, timeout or other loss.
|
||||
window.FX.shake(12, 500);
|
||||
window.FX.flash('rgba(255,0,0,1)', 0.4, 450);
|
||||
}
|
||||
// Gameplay is over: quiet the ambient torch embers.
|
||||
// Gameplay is over, so quiet the ambient torch embers.
|
||||
if (window.FX.setAmbient) window.FX.setAmbient(false);
|
||||
} catch (e) { /* best-effort */ }
|
||||
}
|
||||
|
||||
// After short delay, allow user to return to title with Enter/start
|
||||
const score = data && typeof data.score === 'number' ? data.score : 0;
|
||||
const treasure = !!(data && data.treasure);
|
||||
|
||||
// Display score / treasure info overlay
|
||||
if (ScreenManager && ScreenManager.drawCenteredText && DisplayManager.ensureDisplay()) {
|
||||
const baseY = Math.floor(ScreenManager._screenHeight / 2) + 5;
|
||||
ScreenManager.drawCenteredText(baseY, `Score: ${score}`);
|
||||
@@ -231,37 +214,37 @@ var Game = {
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce + delayed activation of restart
|
||||
// Restart accepts Enter only after activateDelay, so a keypress in flight at game over
|
||||
// cannot skip the outcome screen.
|
||||
this._awaitingRestart = false;
|
||||
const activateDelay = 800; // ms before we accept Enter
|
||||
const autoReturnDelay = 10000; // auto return after 10s
|
||||
const activateDelay = 800;
|
||||
const autoReturnDelay = 10000;
|
||||
|
||||
// Show hint after activation delay
|
||||
setTimeout(() => {
|
||||
this._awaitingRestart = true;
|
||||
if (ScreenManager && ScreenManager.drawCenteredText && DisplayManager.ensureDisplay()) {
|
||||
const y = ScreenManager._screenHeight - 2;
|
||||
const fullHint = 'Press Enter to return to title';
|
||||
const shortHint = 'Press Enter';
|
||||
const maxLen = ScreenManager._screenWidth - 2; // leave small margin
|
||||
const maxLen = ScreenManager._screenWidth - 2; // Leaves a one-cell margin each side
|
||||
const hint = fullHint.length > maxLen ? shortHint : fullHint;
|
||||
ScreenManager.drawCenteredText(y, hint);
|
||||
}
|
||||
}, activateDelay);
|
||||
|
||||
// Auto return timer
|
||||
clearTimeout(this._autoReturnTimer);
|
||||
this._autoReturnTimer = setTimeout(() => {
|
||||
if (this._awaitingRestart) {
|
||||
this._awaitingRestart = false;
|
||||
this._fxStop(); // JUICE: tear down FX loop so it never leaks
|
||||
this._fxStop(); // Tears down the FX loop so requestAnimationFrame does not leak
|
||||
if (ScreenManager && ScreenManager.drawWelcomeScreen) {
|
||||
ScreenManager.drawWelcomeScreen();
|
||||
}
|
||||
}
|
||||
}, autoReturnDelay);
|
||||
|
||||
// One-time listener setup
|
||||
// The keydown listener is attached once and gated on _awaitingRestart, so repeated
|
||||
// game-overs do not stack handlers.
|
||||
if (!this._restartListenerAttached) {
|
||||
this._restartListenerAttached = true;
|
||||
document.addEventListener('keydown', (e) => {
|
||||
@@ -269,7 +252,7 @@ var Game = {
|
||||
if (e.key === 'Enter') {
|
||||
this._awaitingRestart = false;
|
||||
clearTimeout(this._autoReturnTimer);
|
||||
this._fxStop(); // JUICE: tear down FX loop so it never leaks
|
||||
this._fxStop(); // Tears down the FX loop so requestAnimationFrame does not leak
|
||||
if (ScreenManager && ScreenManager.drawWelcomeScreen) {
|
||||
ScreenManager.drawWelcomeScreen();
|
||||
}
|
||||
@@ -289,9 +272,9 @@ var Game = {
|
||||
|
||||
_drawGameScreen: function() {
|
||||
const gameState = GameState.getGameStateForRender();
|
||||
// Render the dungeon through the render kit (Tiled / ASCII / Iso / 3D + unlocked packs) with
|
||||
// a player-centered camera + goblin monster sprite. The legacy RenderEngine remains a HARD
|
||||
// fallback (used only if the kit is unavailable or a render throws), so the game never blanks.
|
||||
// The dungeon renders through the render kit (Tiled / ASCII / Iso / 3D plus unlocked packs)
|
||||
// with a player-centered camera. RenderEngine is the fallback path, used only when the kit
|
||||
// is unavailable or a render fails, so a live game never blanks.
|
||||
var rk = false;
|
||||
if (window.RK && RK.SPGame && RK.SPGame.available()) {
|
||||
RK.SPGame.show();
|
||||
@@ -312,7 +295,7 @@ var Game = {
|
||||
}
|
||||
},
|
||||
|
||||
// Screen drawing methods - delegate to ScreenManager
|
||||
// Screen drawing delegates to ScreenManager.
|
||||
_drawWelcomeScreen: function() {
|
||||
ScreenManager.drawWelcomeScreen();
|
||||
},
|
||||
@@ -327,16 +310,14 @@ var Game = {
|
||||
|
||||
drawWaitingScreen: function() {
|
||||
ScreenManager.drawWaitingScreen();
|
||||
// Overlay unconfirmed payment badge if present
|
||||
if (this._unconfirmedPayment && typeof ScreenManager !== 'undefined' && DisplayManager.ensureDisplay()) {
|
||||
const display = DisplayManager.getDisplay();
|
||||
const badgeText = 'Unconfirmed (mempool)';
|
||||
const xStart = Math.max(1, Math.floor((this._screenWidth - badgeText.length) / 2));
|
||||
const y = 1; // top line inside border
|
||||
const y = 1; // First row inside the border
|
||||
for (let i = 0; i < badgeText.length; i++) {
|
||||
display.draw(xStart + i, y, badgeText[i], 'rgba(180,230,255,0.9)', 'transparent');
|
||||
}
|
||||
// Small pulsing dot
|
||||
const pulse = 0.4 + Math.sin(Date.now()/300)*0.4;
|
||||
display.draw(xStart - 2, y, '*', `rgba(120,200,255,${pulse})`, 'transparent');
|
||||
}
|
||||
@@ -348,7 +329,7 @@ var Game = {
|
||||
ScreenManager.stopWaitingScreen();
|
||||
},
|
||||
|
||||
// Display mode switching - delegate to DisplayManager
|
||||
// Display mode switching delegates to DisplayManager.
|
||||
switchToAsciiMode: function() {
|
||||
DisplayManager.switchToAsciiMode();
|
||||
},
|
||||
@@ -357,12 +338,11 @@ var Game = {
|
||||
DisplayManager.switchToTileMode();
|
||||
},
|
||||
|
||||
// Player movement - delegate to GameState
|
||||
// Player movement delegates to GameState.
|
||||
movePlayer: function(dx, dy) {
|
||||
const moved = GameState.movePlayer(dx, dy, this._screenWidth, this._screenHeight);
|
||||
if (moved) {
|
||||
this._drawGameScreen();
|
||||
// JUICE: sparkle near treasure, gold burst on pickup, red edge pulse near monsters.
|
||||
if (window.FX) {
|
||||
try { this._fxOnPlayerMoved(); } catch (e) { /* best-effort */ }
|
||||
}
|
||||
@@ -370,21 +350,20 @@ var Game = {
|
||||
},
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// JUICE / FX helpers. These ONLY call the window.FX overlay API (GFX1);
|
||||
// they never define it. Every call is guarded so the game stays fully
|
||||
// functional when the FX module is absent (match.html / tavern.html).
|
||||
// Coordinates are computed with the SAME camera-centered mapping the
|
||||
// renderer uses (the player is always drawn at the screen center).
|
||||
// FX helpers. These only consume the window.FX overlay API and never define it. Every call is
|
||||
// guarded, so the game stays fully functional where the FX module is absent (match.html,
|
||||
// tavern.html). Coordinates use the same camera-centered mapping as the renderer: the player is
|
||||
// always drawn at the center of the view.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
// The ROT.js display container is the base canvas FX layers above.
|
||||
// The ROT.js display container is the base canvas that FX layers sit above.
|
||||
_baseCanvas: function() {
|
||||
if (typeof DisplayManager === 'undefined' || !DisplayManager.getDisplay) return null;
|
||||
var display = DisplayManager.getDisplay();
|
||||
return (display && display.getContainer) ? display.getContainer() : null;
|
||||
},
|
||||
|
||||
// Convert a world cell -> center pixel in base-canvas space.
|
||||
// Convert a world cell to its center pixel in base-canvas space.
|
||||
_cellToScreenPixel: function(wx, wy) {
|
||||
var cell = (window.options && window.options.tileWidth) ? window.options.tileWidth : 32;
|
||||
var centerX = Math.floor(this._screenWidth / 2);
|
||||
@@ -401,7 +380,7 @@ var Game = {
|
||||
return this._cellToScreenPixel(p.x, p.y);
|
||||
},
|
||||
|
||||
// Chebyshev (king-move) distance — matches the grid feel.
|
||||
// Chebyshev (king-move) distance, matching movement on the grid.
|
||||
_fxDist: function(ax, ay, bx, by) {
|
||||
return Math.max(Math.abs(ax - bx), Math.abs(ay - by));
|
||||
},
|
||||
@@ -436,7 +415,7 @@ var Game = {
|
||||
|
||||
var pp = this._playerScreenPixel();
|
||||
|
||||
// Treasure proximity: on-cell => gold burst, nearby => sparkle.
|
||||
// Treasure proximity: on the cell gives a gold burst, within two cells gives a sparkle.
|
||||
var cells = this._fxTreasureCells();
|
||||
var onTreasure = false, nearTreasure = false;
|
||||
for (var i = 0; i < cells.length; i++) {
|
||||
@@ -450,10 +429,9 @@ var Game = {
|
||||
window.FX.sparkle(pp.x, pp.y, '#fbbf24');
|
||||
}
|
||||
|
||||
// NOTE: removed the monster-proximity red screen flash — it fired on nearly every step
|
||||
// (monster usually within 3 tiles) and read as a constant annoying pulse. The monster is
|
||||
// already visible on-screen, so proximity needs no full-screen flash. Reserve FX.flash for
|
||||
// genuine one-shot events (win/lose in handleGameOver).
|
||||
// Monster proximity deliberately triggers no screen flash: the monster is usually within a
|
||||
// few tiles and already visible on screen, so a per-step flash would fire almost constantly.
|
||||
// FX.flash is reserved for one-shot events such as win and lose.
|
||||
},
|
||||
|
||||
// Attach + start the FX loop for a fresh game.
|
||||
@@ -468,7 +446,7 @@ var Game = {
|
||||
} catch (e) { /* best-effort */ }
|
||||
},
|
||||
|
||||
// Stop + clear the FX loop so requestAnimationFrame never leaks.
|
||||
// Stop and clear the FX loop so requestAnimationFrame does not leak.
|
||||
_fxStop: function() {
|
||||
if (!window.FX) return;
|
||||
try {
|
||||
@@ -490,7 +468,7 @@ var Game = {
|
||||
}
|
||||
},
|
||||
|
||||
// Debug functions - delegate to GameState
|
||||
// Debug helpers delegate to GameState.
|
||||
debugPrintMap: function() {
|
||||
GameState.debugPrintMap(this._screenWidth, this._screenHeight);
|
||||
},
|
||||
@@ -499,7 +477,7 @@ var Game = {
|
||||
GameState.debugTileMapping();
|
||||
},
|
||||
|
||||
// Properties for backward compatibility
|
||||
// Accessors forwarding to GameState, kept for callers that read Game.* directly.
|
||||
get _gameActive() {
|
||||
return GameState.isGameActive();
|
||||
},
|
||||
@@ -537,7 +515,7 @@ var Game = {
|
||||
Game._pendingPaymentDetected = function(info) {
|
||||
this._unconfirmedPayment = true;
|
||||
this._unconfirmedPaymentInfo = info || {};
|
||||
this._awaitingPayment = false; // no longer just awaiting creation; we've seen a tx
|
||||
this._awaitingPayment = false; // A transaction exists, so this is no longer awaiting creation
|
||||
};
|
||||
|
||||
Game._pendingPaymentConfirmed = function() {
|
||||
@@ -550,7 +528,7 @@ Game._paymentRequested = function() {
|
||||
this._awaitingPayment = true;
|
||||
};
|
||||
|
||||
// Debug utility functions (keep as-is for compatibility)
|
||||
// Debug utilities: log to console, to the on-page debug panel, and to the server.
|
||||
const GameDebug = {
|
||||
log: function(message) {
|
||||
console.log(message);
|
||||
@@ -563,12 +541,11 @@ const GameDebug = {
|
||||
if (debugContent) {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
debugContent.innerHTML += `<div>[${timestamp}] ${message}</div>`;
|
||||
// Keep only last 10 debug messages
|
||||
// Cap the panel at the ten most recent messages.
|
||||
const lines = debugContent.children;
|
||||
if (lines.length > 10) {
|
||||
debugContent.removeChild(lines[0]);
|
||||
}
|
||||
// Auto-scroll to bottom
|
||||
const debugDisplay = document.getElementById('debug-display');
|
||||
debugDisplay.scrollTop = debugDisplay.scrollHeight;
|
||||
}
|
||||
@@ -583,15 +560,14 @@ const GameDebug = {
|
||||
},
|
||||
body: JSON.stringify({ message: message })
|
||||
}).catch(err => {
|
||||
// Silently fail if server unavailable
|
||||
// Debug reporting is best-effort; an unavailable server must not surface an error.
|
||||
});
|
||||
} catch (err) {
|
||||
// Silently fail
|
||||
// Debug reporting is best-effort.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Make them available globally
|
||||
if (typeof window !== 'undefined') {
|
||||
window.Game = Game;
|
||||
window.GameDebug = GameDebug;
|
||||
|
||||
+25
-53
@@ -57,7 +57,6 @@ var GameState = {
|
||||
console.warn("Invalid or missing playerData.x/y, using default player position.", playerData);
|
||||
this._player = { x: 12, y: 12 }; // Default position
|
||||
}
|
||||
// console.log("Player initialized to:", this._player);
|
||||
return this._player;
|
||||
},
|
||||
|
||||
@@ -68,7 +67,7 @@ var GameState = {
|
||||
|
||||
let mapInitialized = false;
|
||||
|
||||
// Try to initialize from mapData.tiles
|
||||
// Map source precedence: mapData.tiles, then array mapData, then initialVisibleTiles.
|
||||
if (mapData && mapData.tiles && typeof mapData.tiles === 'object' && Object.keys(mapData.tiles).length > 0) {
|
||||
for (var yKey in mapData.tiles) {
|
||||
const y = parseInt(yKey);
|
||||
@@ -79,8 +78,7 @@ var GameState = {
|
||||
}
|
||||
}
|
||||
mapInitialized = true;
|
||||
}
|
||||
// Try array-based mapData
|
||||
}
|
||||
else if (mapData && Array.isArray(mapData) && mapData.length > 0) {
|
||||
for (let y = 0; y < mapData.length; y++) {
|
||||
this._map[y] = {};
|
||||
@@ -91,8 +89,7 @@ var GameState = {
|
||||
}
|
||||
}
|
||||
mapInitialized = true;
|
||||
}
|
||||
// Try initialVisibleTiles
|
||||
}
|
||||
else if (initialVisibleTiles && typeof initialVisibleTiles === 'object' && Object.keys(initialVisibleTiles).length > 0) {
|
||||
this._visibleTiles = JSON.parse(JSON.stringify(initialVisibleTiles)); // Deep copy
|
||||
|
||||
@@ -107,7 +104,7 @@ var GameState = {
|
||||
mapInitialized = true;
|
||||
}
|
||||
|
||||
// Create default map if nothing worked
|
||||
// Fallback: a bordered empty room so the client still renders without server map data.
|
||||
if (!mapInitialized) {
|
||||
console.warn("No valid mapData or initialVisibleTiles provided. Creating default map for testing.");
|
||||
const defaultMapWidth = screenWidth;
|
||||
@@ -132,7 +129,6 @@ var GameState = {
|
||||
},
|
||||
|
||||
validatePlayerPosition: function(screenWidth, screenHeight) {
|
||||
// Validate player position against the map
|
||||
if (!this._map[this._player.y] || this._map[this._player.y][this._player.x] === undefined) {
|
||||
console.error(`Player position (${this._player.x}, ${this._player.y}) is invalid or outside map boundaries. Attempting to find fallback.`);
|
||||
let foundFallback = false;
|
||||
@@ -140,7 +136,7 @@ var GameState = {
|
||||
if (this._map[y_scan]) {
|
||||
for (let x_scan = 0; x_scan < screenWidth; x_scan++) {
|
||||
const tile = this._map[y_scan][x_scan];
|
||||
// Check for both legacy (0) and new ("'1", "'2") floor tiles
|
||||
// Floor is encoded either as 0 or as the tile ids "'1" / "'2".
|
||||
if (tile === 0 || tile === "'1" || tile === "'2") {
|
||||
this._player.x = x_scan;
|
||||
this._player.y = y_scan;
|
||||
@@ -160,18 +156,17 @@ var GameState = {
|
||||
},
|
||||
|
||||
computeFieldOfView: function() {
|
||||
// Initialize FOV with corrected wall detection callback
|
||||
// The callback reports transparency: true for tiles that pass light, false for blockers.
|
||||
var fov = new ROT.FOV.PreciseShadowcasting(function(x, y) {
|
||||
// Return true if the tile is transparent (walkable), false if it blocks light
|
||||
if (!this._map[y] || this._map[y][x] === undefined) {
|
||||
return false; // Unknown tiles block light
|
||||
}
|
||||
const tile = this._map[y][x];
|
||||
// Check for both legacy (0) and new ("'1", "'2") floor tiles
|
||||
// Floor is encoded either as 0 or as the tile ids "'1" / "'2".
|
||||
return tile === 0 || tile === "'1" || tile === "'2";
|
||||
}.bind(this));
|
||||
|
||||
// If map was NOT built from initialVisibleTiles, compute FOV now
|
||||
// A map built from initialVisibleTiles already carries visibility; only compute otherwise.
|
||||
if (!this._visibleTiles || Object.keys(this._visibleTiles).length === 0) {
|
||||
this._visibleTiles = {};
|
||||
fov.compute(this._player.x, this._player.y, 10, function(x, y, r, visibility) {
|
||||
@@ -181,13 +176,12 @@ var GameState = {
|
||||
}
|
||||
}.bind(this));
|
||||
}
|
||||
|
||||
// NOTE: Don't call updateExploredTiles() here on initial game load
|
||||
// Explored tiles should only be updated when player moves, not on first FOV computation
|
||||
|
||||
// Explored tiles accumulate on player movement, so the initial FOV pass does not call
|
||||
// updateExploredTiles().
|
||||
},
|
||||
|
||||
updateExploredTiles: function() {
|
||||
// Add all currently visible tiles to explored tiles
|
||||
for (var yKey in this._visibleTiles) {
|
||||
const y = parseInt(yKey);
|
||||
if (!this._exploredTiles[y]) this._exploredTiles[y] = {};
|
||||
@@ -211,9 +205,8 @@ var GameState = {
|
||||
try {
|
||||
let needsRedraw = false;
|
||||
|
||||
// Multi-level descent: a new depth means a brand-new level. The accumulated map /
|
||||
// explored / visible tiles belong to the level we just left, so clear them — otherwise
|
||||
// the old level's walls ghost through the fresh one.
|
||||
// A new depth is a new level: the accumulated map, explored and visible tiles belong to
|
||||
// the previous level and are cleared, otherwise its walls ghost through the fresh one.
|
||||
if (typeof data.depth === 'number' && data.depth !== this._depth) {
|
||||
this._depth = data.depth;
|
||||
if (typeof data.maxDepth === 'number') this._maxDepth = data.maxDepth;
|
||||
@@ -223,25 +216,21 @@ var GameState = {
|
||||
needsRedraw = true;
|
||||
}
|
||||
|
||||
// Update player state
|
||||
if (data.player) {
|
||||
this._player = data.player;
|
||||
needsRedraw = true;
|
||||
}
|
||||
|
||||
// Update monster state
|
||||
if (data.monster !== undefined) {
|
||||
this._monster = data.monster;
|
||||
needsRedraw = true;
|
||||
}
|
||||
|
||||
// Update items
|
||||
if (data.items !== undefined) {
|
||||
this._items = data.items;
|
||||
needsRedraw = true;
|
||||
}
|
||||
|
||||
// Update entrance, exit, treasure
|
||||
if (data.entrance !== undefined) {
|
||||
this._entrance = data.entrance;
|
||||
needsRedraw = true;
|
||||
@@ -255,7 +244,6 @@ var GameState = {
|
||||
needsRedraw = true;
|
||||
}
|
||||
|
||||
// Update lighting and torch data
|
||||
if (data.lighting !== undefined) {
|
||||
this._lighting = data.lighting;
|
||||
needsRedraw = true;
|
||||
@@ -265,11 +253,10 @@ var GameState = {
|
||||
needsRedraw = true;
|
||||
}
|
||||
|
||||
// Update visible tiles (most critical for movement feedback)
|
||||
// Visible tiles drive movement feedback, so they also refresh the explored set.
|
||||
if (data.visibleTiles && typeof data.visibleTiles === 'object' && Object.keys(data.visibleTiles).length > 0) {
|
||||
this._visibleTiles = data.visibleTiles;
|
||||
|
||||
// Debug log for specific problematic coordinates
|
||||
|
||||
if (this._visibleTiles[18] && (this._visibleTiles[18][36] !== undefined || this._visibleTiles[18][35] !== undefined)) {
|
||||
const clientDebug = `🔍 CLIENT y=18: x=35: ${this._visibleTiles[18][35]}, x=36: ${this._visibleTiles[18][36]}`;
|
||||
if (window.GameDebug) window.GameDebug.updateDebugDisplay(clientDebug);
|
||||
@@ -296,19 +283,15 @@ var GameState = {
|
||||
const newX = this._player.x + dx;
|
||||
const newY = this._player.y + dy;
|
||||
|
||||
// Basic bounds checking
|
||||
if (newX < 0 || newY < 0 || newX >= screenWidth || newY >= screenHeight) {
|
||||
console.warn("Attempted to move player outside of bounds:", newX, newY);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the new position is a wall (1) or out of map bounds
|
||||
|
||||
// Tile value 1 is wall; undefined is off-map. Both block movement.
|
||||
if (this._map[newY] && this._map[newY][newX] !== undefined && this._map[newY][newX] !== 1) {
|
||||
// Move the player
|
||||
this._player.x = newX;
|
||||
this._player.y = newY;
|
||||
|
||||
// Update visibility and redraw
|
||||
this.updateExploredTiles();
|
||||
return true;
|
||||
} else {
|
||||
@@ -316,9 +299,8 @@ var GameState = {
|
||||
}
|
||||
},
|
||||
|
||||
// Debug functions
|
||||
// Debug helpers: console output here is intentional.
|
||||
debugPrintMap: function(screenWidth, screenHeight) {
|
||||
// Map debugging - preserve console.log for debug function
|
||||
console.log("Current game map:");
|
||||
for (let y = 0; y < screenHeight; y++) {
|
||||
let row = "";
|
||||
@@ -334,7 +316,6 @@ var GameState = {
|
||||
},
|
||||
|
||||
debugTileMapping: function() {
|
||||
// Tile mapping debugging - preserve console.log for debug function
|
||||
const testValues = [0, 1, undefined, null];
|
||||
for (const val of testValues) {
|
||||
const char = (val === 1) ? '#' : "\'";
|
||||
@@ -344,36 +325,33 @@ var GameState = {
|
||||
}
|
||||
},
|
||||
|
||||
// Compute field of view for the player (LOCAL TESTING ONLY - server handles FOV)
|
||||
// Local FOV, used only when running without a server; in normal play the server owns FOV.
|
||||
// This definition shadows the earlier computeFieldOfView on the same object literal.
|
||||
computeFieldOfView: function() {
|
||||
if (!this._player) {
|
||||
console.warn("Cannot compute FOV: player not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
// Warning for local FOV computation (keep for debugging)
|
||||
// console.log("⚠️ LOCAL FOV COMPUTATION (should only be used for testing without server)");
|
||||
|
||||
const oldVisibleTiles = JSON.parse(JSON.stringify(this._visibleTiles));
|
||||
this._visibleTiles = {};
|
||||
|
||||
const fov = new ROT.FOV.PreciseShadowcasting(function(x, y) {
|
||||
const tile = this._map[y] && this._map[y][x];
|
||||
// Check for both legacy (0) and new ("'1", "'2") floor tiles
|
||||
// Floor is encoded either as 0 or as the tile ids "'1" / "'2".
|
||||
return tile === 0 || tile === "'1" || tile === "'2";
|
||||
}.bind(this));
|
||||
|
||||
fov.compute(this._player.x, this._player.y, 6, function(x, y, r, visibility) {
|
||||
if (!this._visibleTiles[y]) this._visibleTiles[y] = {};
|
||||
this._visibleTiles[y][x] = this._map[y] && this._map[y][x] !== undefined ? this._map[y][x] : 0;
|
||||
|
||||
// Mark as explored
|
||||
|
||||
if (!this._exploredTiles[y]) this._exploredTiles[y] = {};
|
||||
this._exploredTiles[y][x] = this._map[y] && this._map[y][x] !== undefined ? this._map[y][x] : 0;
|
||||
}.bind(this));
|
||||
},
|
||||
|
||||
// Get game state data for rendering
|
||||
// Snapshot consumed by the render kit and the legacy render engine.
|
||||
getGameStateForRender: function() {
|
||||
const renderState = {
|
||||
map: this._map,
|
||||
@@ -394,7 +372,7 @@ var GameState = {
|
||||
return renderState;
|
||||
},
|
||||
|
||||
// Move player and update game state
|
||||
// This definition shadows the earlier movePlayer on the same object literal.
|
||||
movePlayer: function(dx, dy, screenWidth, screenHeight) {
|
||||
if (!this._player) {
|
||||
console.warn("Cannot move player: player not initialized");
|
||||
@@ -404,23 +382,17 @@ var GameState = {
|
||||
const newX = this._player.x + dx;
|
||||
const newY = this._player.y + dy;
|
||||
|
||||
// Check bounds
|
||||
if (newX < 0 || newX >= screenWidth || newY < 0 || newY >= screenHeight) {
|
||||
// console.log("Player move blocked: out of bounds");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the new position is passable
|
||||
// Tile value 1 is wall and blocks movement.
|
||||
if (this._map[newY] && this._map[newY][newX] === 1) {
|
||||
// console.log("Player move blocked: wall at", newX, newY);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Update player position
|
||||
this._player.x = newX;
|
||||
this._player.y = newY;
|
||||
|
||||
// console.log("Player moved to:", this._player.x, this._player.y);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
// 🎭 + this title) so it doesn't read like a mode/role button.
|
||||
label.textContent = (info && info.label ? info.label : 'CHARACTER').toUpperCase();
|
||||
btn.title = 'Create / customize your character'
|
||||
+ (info && info.label ? ' — currently ' + info.label : '')
|
||||
+ (info && info.label ? ': currently ' + info.label : '')
|
||||
+ (info && info.premium ? ' (premium)' : '');
|
||||
},
|
||||
|
||||
@@ -207,7 +207,7 @@
|
||||
var px = viewport.screenX * cell + cell / 2;
|
||||
var py = viewport.screenY * cell + cell / 2;
|
||||
root.FX.renderLighting(px, py, cell);
|
||||
} catch (_) { /* FX is purely additive — never let it break the avatar */ }
|
||||
} catch (_) { /* FX is purely additive: never let it break the avatar */ }
|
||||
},
|
||||
|
||||
drawLegendIcon: function (tileX, tileY, opts) {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// FXOverlay — dependency-free 2D-canvas FX layer for the FREE ROT.js dungeon (Pipeline A).
|
||||
// FXOverlay: dependency-free 2D-canvas FX layer for the FREE ROT.js dungeon (Pipeline A).
|
||||
// Ports the premium FancyRenderer look (warm flickering torch light, screen-edge vignette,
|
||||
// drifting-ember particle field, transient sparkles/bursts, full-screen color flash and a
|
||||
// decaying screen shake) using plain <canvas> radial gradients instead of Pixi.
|
||||
//
|
||||
// Exposes a single global `window.FX`. EVERY method is a no-op when there is no
|
||||
// #game-display / ROT base canvas present (so match.html & tavern.html — which never
|
||||
// include this file — and any early-boot call are safe). It draws onto its OWN
|
||||
// #game-display / ROT base canvas present (so match.html & tavern.html, which never
|
||||
// include this file, and any early-boot call are safe). It draws onto its OWN
|
||||
// absolutely-positioned canvas layered just above the avatar overlay (z-index 7,
|
||||
// pointer-events:none) and reuses the same overlay-sync math as
|
||||
// SinglePlayerAvatar._syncOverlay. It runs its own requestAnimationFrame loop for
|
||||
// flicker/embers/particles, and self-suspends (cancelAnimationFrame — no leaks) once the
|
||||
// flicker/embers/particles, and self-suspends (cancelAnimationFrame: no leaks) once the
|
||||
// scene is idle; stop() hard-cancels it. Coordinates are in base-canvas pixel space.
|
||||
(function (root) {
|
||||
'use strict';
|
||||
@@ -116,7 +116,7 @@
|
||||
},
|
||||
|
||||
// Returns the 2D context of a correctly sized/positioned overlay, or null
|
||||
// (which makes every public method a no-op — keeps other pages unaffected).
|
||||
// (which makes every public method a no-op: keeps other pages unaffected).
|
||||
_ensure: function () {
|
||||
if (!doc) return null;
|
||||
var base = this._resolveBase();
|
||||
@@ -396,7 +396,7 @@
|
||||
this._raf = root.requestAnimationFrame(this._frameBound);
|
||||
} else {
|
||||
// Idle (game over / title, nothing animating): suspend the loop
|
||||
// entirely — no lingering rAF, no leak. A future call restarts it.
|
||||
// entirely: no lingering rAF, no leak. A future call restarts it.
|
||||
this._running = false;
|
||||
this._raf = 0;
|
||||
this._active = false;
|
||||
|
||||
@@ -268,7 +268,7 @@ var RenderEngine = {
|
||||
// because canDrawPlayer returned true, but the overlay draw may still fail.
|
||||
if (!drew) {
|
||||
try { display.draw(centerX, centerY, playerTile, "#FFF", "transparent"); }
|
||||
catch (_) { /* ignore — next frame will retry */ }
|
||||
catch (_) { /* ignore: next frame will retry */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ var RenderEngine = {
|
||||
var _cellW = (window.options && window.options.tileWidth) || 32;
|
||||
var _cellH = (window.options && window.options.tileHeight) || _cellW;
|
||||
window.FX.renderLighting(centerX * _cellW + _cellW / 2, centerY * _cellH + _cellH / 2, _cellW);
|
||||
} catch (_) { /* FX is purely cosmetic — never block the dungeon render */ }
|
||||
} catch (_) { /* FX is purely cosmetic: never block the dungeon render */ }
|
||||
}
|
||||
|
||||
// Render message at bottom if present
|
||||
|
||||
@@ -158,7 +158,7 @@ var ScreenManager = {
|
||||
? GameTiles.getTreasureTile()
|
||||
: (this._cryptoType === 'XMR' ? '$M' : '$W');
|
||||
// Every row uses a uniform icon slot at `centerX` and a single fixed text column, so the
|
||||
// icons share a left edge and all four descriptions line up — regardless of whether the
|
||||
// icons share a left edge and all four descriptions line up, regardless of whether the
|
||||
// icon is a 1-cell glyph (>, ~, $) or the 2-cell player avatar sprite.
|
||||
const legendItems = [
|
||||
{ tile: "@2", text: "This is you", role: "player" },
|
||||
@@ -175,8 +175,8 @@ var ScreenManager = {
|
||||
const centerX = Math.floor(this._screenWidth / 2) - 10;
|
||||
const textX = centerX + ICON_COLS + 1; // one fixed text column for every row
|
||||
for (let item of legendItems) {
|
||||
// Draw EVERY icon (incl. the player's original hero tile) as a grid tile — no avatar
|
||||
// overlay — so all four share a left edge + baseline and the hero is the plain tile.
|
||||
// Draw EVERY icon (incl. the player's original hero tile) as a grid tile, no avatar
|
||||
// overlay, so all four share a left edge + baseline and the hero is the plain tile.
|
||||
const glyph = window.options?.tileMap?.hasOwnProperty(item.tile) ? item.tile : "?";
|
||||
const gx = centerX + Math.floor((ICON_COLS - (glyph.length || 1)) / 2);
|
||||
display.draw(gx, y, glyph, "transparent", "transparent");
|
||||
|
||||
@@ -144,7 +144,7 @@ var WaitingScreenAnimator = {
|
||||
},
|
||||
|
||||
// JUICE: a one-shot particle burst at a grid cell (treasure grab / escape / death). Purely
|
||||
// additive over window.FX — no-op if FX is absent — and tile-agnostic, so it works identically
|
||||
// additive over window.FX, no-op if FX is absent, and tile-agnostic, so it works identically
|
||||
// in every pack (it draws particles, never tiles). `spread` in grid cells, `count` particles.
|
||||
_burst: function(gridX, gridY, color, count, screenWidth, screenHeight, spread) {
|
||||
if (!window.FX || !window.FX.sparkle) return;
|
||||
@@ -182,7 +182,7 @@ var WaitingScreenAnimator = {
|
||||
}
|
||||
|
||||
// Fixed-position animated text. The label reflects the ACTUAL wait state, not this function's
|
||||
// historical name — the two animations were wired to the opposite labels. This branch runs
|
||||
// historical name: the two animations were wired to the opposite labels. This branch runs
|
||||
// while genuinely awaiting a payment.
|
||||
let y = Math.floor(screenHeight / 2) - 8;
|
||||
let baseText = (typeof Game !== 'undefined' && Game._awaitingPayment) ? 'Awaiting payment' : 'Awaiting next block';
|
||||
@@ -275,7 +275,7 @@ var WaitingScreenAnimator = {
|
||||
const anim = this._waitingAnimation;
|
||||
|
||||
// JUICE: a SUBTLE, tight sparkle when the hero breaks for the exit with the loot. No red
|
||||
// "caught" burst — keep it a small glint, not an explosion near the torches.
|
||||
// "caught" burst: keep it a small glint, not an explosion near the torches.
|
||||
if (this._epicPhase !== anim.phase) {
|
||||
if (anim.phase === 'escape') this._burst(anim.playerX, anim.playerY, 'rgba(255,220,90,0.9)', 6, screenWidth, screenHeight, 0.7);
|
||||
this._epicPhase = anim.phase;
|
||||
@@ -414,7 +414,7 @@ var WaitingScreenAnimator = {
|
||||
|
||||
// Fixed-position animated text. This treasure-hunt animation runs for the block-QUEUE wait
|
||||
// (see drawAnimatedWaitingScreen routing), so show the block label unless we're genuinely
|
||||
// awaiting a payment. (Function name is historical — do not trust it for the label.)
|
||||
// awaiting a payment. (Function name is historical: do not trust it for the label.)
|
||||
let y = Math.floor(screenHeight / 2) - 8;
|
||||
const dots = ".".repeat((Math.floor(time / 500) % 4));
|
||||
const paddedDots = dots.padEnd(3, " ");
|
||||
@@ -498,7 +498,7 @@ var WaitingScreenAnimator = {
|
||||
this.updatePaymentAnimation();
|
||||
|
||||
// JUICE: a SUBTLE, tight one-shot sparkle on the treasure grab / clean escape. Kept small and
|
||||
// low-spread so it reads as a glint on the hero, not an explosion — and never sprays onto the
|
||||
// low-spread so it reads as a glint on the hero, not an explosion, and never sprays onto the
|
||||
// wall torches. No red "death" burst (the animation's own red shockwave already covers that).
|
||||
if (this._payPhase !== anim.phase) {
|
||||
if (anim.phase === 'grab') this._burst(anim.treasureX, anim.treasureY, 'rgba(255,220,90,0.9)', 6, screenWidth, screenHeight, 0.6);
|
||||
|
||||
@@ -4,11 +4,10 @@
|
||||
|
||||
// Is a blocking dialog on screen?
|
||||
//
|
||||
// The document-level Enter shortcut submits a RANKED (paid-intent) entry, and it used to fire
|
||||
// regardless of what was on top — including while the entry-choice modal or the payment modal was
|
||||
// open. Since those modals advertise "ENTER ▸" on their own cards, one Enter silently submitted an
|
||||
// entry the player had not chosen, and the click that followed submitted a second one. Anything
|
||||
// that takes over the screen must swallow the shortcut.
|
||||
// The document-level Enter shortcut submits a ranked (paid-intent) entry. The entry-choice and
|
||||
// payment modals advertise "ENTER" on their own cards, so anything that takes over the screen must
|
||||
// swallow the shortcut; otherwise Enter submits an entry the player has not chosen and the
|
||||
// following click submits a second one.
|
||||
function modalIsOpen() {
|
||||
if (document.getElementById('entryChoiceOverlay')) return true;
|
||||
var overlays = document.querySelectorAll('.modal-overlay');
|
||||
@@ -33,7 +32,7 @@ const InputHandler = {
|
||||
_lastMoveTime: 0,
|
||||
_moveCooldown: 100, // Minimum 100ms between moves
|
||||
_pendingMove: null,
|
||||
_initialized: false, // Flag to prevent multiple initializations
|
||||
_initialized: false, // Guards against repeated init() calls binding duplicate handlers
|
||||
|
||||
init: function() {
|
||||
if (this._initialized) {
|
||||
@@ -51,12 +50,11 @@ const InputHandler = {
|
||||
setupChatForm: function() {
|
||||
const MAX_CHAT_LENGTH = 200;
|
||||
|
||||
// Character counter update
|
||||
$('#chatInput').on('input', function() {
|
||||
const len = $(this).val().length;
|
||||
const counter = $('#charCounter');
|
||||
counter.text(len + '/' + MAX_CHAT_LENGTH);
|
||||
// Change color when approaching limit
|
||||
// Red at the limit, amber within the last 20%.
|
||||
if (len >= MAX_CHAT_LENGTH) {
|
||||
counter.css('color', '#f66');
|
||||
} else if (len >= MAX_CHAT_LENGTH * 0.8) {
|
||||
@@ -70,11 +68,9 @@ const InputHandler = {
|
||||
e.preventDefault();
|
||||
var msg = $('#chatInput').val().trim();
|
||||
|
||||
// Don't send empty messages
|
||||
if (!msg) return false;
|
||||
|
||||
// If the message is "enter", always send it to server
|
||||
// The server will handle queueing logic and timing
|
||||
// "enter" always goes to the server, which owns queueing logic and timing.
|
||||
if (msg.toLowerCase() === 'enter') {
|
||||
if (typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) {
|
||||
if (!SocketHandlers.emitFairGameStart('join_queue')) return false;
|
||||
@@ -82,15 +78,11 @@ const InputHandler = {
|
||||
socket.emit('chat message', msg);
|
||||
}
|
||||
|
||||
// Don't automatically switch to waiting screen - let the server control screen state
|
||||
// The server will send appropriate events (payment_created, waiting_status, etc.)
|
||||
// to control what the user sees
|
||||
|
||||
// Add visual feedback that request was sent
|
||||
// The server controls screen state via payment_created, waiting_status and similar
|
||||
// events, so this path does not switch to the waiting screen itself.
|
||||
$('#messages').append($('<li style="color: #0f0;">').text("🔑 Processing game entry request..."));
|
||||
UI.scrollChat();
|
||||
} else {
|
||||
// Send other chat messages normally
|
||||
socket.emit('chat message', msg);
|
||||
}
|
||||
|
||||
@@ -99,24 +91,22 @@ const InputHandler = {
|
||||
return false;
|
||||
});
|
||||
|
||||
// Set initial focus to chat input
|
||||
$('#chatInput').focus();
|
||||
UI.updateFocusIndicator();
|
||||
},
|
||||
|
||||
setupFocusHandlers: function() {
|
||||
// Add focus/blur listeners for visual indicator
|
||||
// Focus/blur drive the visual focus indicator.
|
||||
$('#chatInput').on('focus', UI.updateFocusIndicator).on('blur', UI.updateFocusIndicator);
|
||||
$('#game-display').on('focus', UI.updateFocusIndicator).on('blur', UI.updateFocusIndicator);
|
||||
|
||||
// Make game display focusable
|
||||
// tabindex -1 makes the game display programmatically focusable without entering tab order.
|
||||
$('#game-display').attr('tabindex', '-1');
|
||||
},
|
||||
|
||||
setupKeyboardControls: function() {
|
||||
const self = this; // Store reference to InputHandler for use in event handler
|
||||
const self = this; // The keydown handler runs with its own `this`, so capture InputHandler.
|
||||
$(document).on('keydown', function(e) {
|
||||
// Handle ESC to leave spectate mode
|
||||
if (e.key === 'Escape' && typeof SocketHandlers !== 'undefined' && SocketHandlers._spectatorMode) {
|
||||
e.preventDefault();
|
||||
if (window.socket) {
|
||||
@@ -127,9 +117,8 @@ const InputHandler = {
|
||||
|
||||
if (document.activeElement === $('#game-display')[0]) {
|
||||
if (Game && Game._gameActive) {
|
||||
// Block movement input if spectating
|
||||
// Spectators drive no character, so movement keys are ignored.
|
||||
if (typeof Game !== 'undefined' && Game._isSpectating) {
|
||||
// In spectator mode - ignore movement keys
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -146,17 +135,16 @@ const InputHandler = {
|
||||
}
|
||||
|
||||
if (moved && direction) {
|
||||
e.preventDefault(); // Prevent page scrolling
|
||||
|
||||
// Implement movement throttling to prevent rapid-fire movement
|
||||
e.preventDefault(); // Arrow keys would otherwise scroll the page.
|
||||
|
||||
// Moves are throttled to one per _moveCooldown; a move arriving inside the
|
||||
// window replaces any pending one so only the latest direction is sent.
|
||||
const now = Date.now();
|
||||
if (now - self._lastMoveTime >= self._moveCooldown) {
|
||||
// Send move immediately if enough time has passed
|
||||
socket.emit('player_move', { direction });
|
||||
self._lastMoveTime = now;
|
||||
self._pendingMove = null;
|
||||
} else {
|
||||
// Queue the move to be sent after cooldown
|
||||
self._pendingMove = { direction };
|
||||
const timeToWait = self._moveCooldown - (now - self._lastMoveTime);
|
||||
setTimeout(() => {
|
||||
@@ -169,10 +157,9 @@ const InputHandler = {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Game display has focus but game is not active (welcome screen)
|
||||
// Only handle animation toggle
|
||||
// Game display has focus with no active game (welcome/waiting screen): the only
|
||||
// key handled is the animation toggle.
|
||||
if (e.key === 'A' || e.key === 'a') {
|
||||
// Toggle animation on waiting screen
|
||||
e.preventDefault();
|
||||
if (typeof ScreenManager !== 'undefined') {
|
||||
ScreenManager.toggleAnimation();
|
||||
@@ -181,9 +168,8 @@ const InputHandler = {
|
||||
}
|
||||
} else if (e.key === 'Enter' && document.activeElement !== $('#chatInput')[0]
|
||||
&& !modalIsOpen() && !typingInAField()) {
|
||||
// If Enter is pressed and chat is not focused
|
||||
if (typeof Game !== 'undefined' && !Game._gameActive) {
|
||||
// On welcome screen - start the game
|
||||
// Enter on the welcome screen requests a game start.
|
||||
e.preventDefault();
|
||||
|
||||
const isDebugMode = window.location.hostname === 'localhost' ||
|
||||
@@ -197,17 +183,18 @@ const InputHandler = {
|
||||
}
|
||||
|
||||
if (isDebugMode || ScreenManager.canEnterGame()) {
|
||||
// Check if we should show waiting screen
|
||||
const addressRequired = typeof SocketHandlers !== 'undefined' && SocketHandlers.payoutAddressRequired();
|
||||
const hasAddress = typeof SocketHandlers !== 'undefined' && SocketHandlers._hasPayoutAddress;
|
||||
const canAfford = typeof SocketHandlers !== 'undefined' && SocketHandlers.canAffordGame();
|
||||
const isFree = typeof SocketHandlers !== 'undefined' && SocketHandlers._gameMode === 'FREE';
|
||||
|
||||
// Only show waiting screen if address is set (if required) and they can afford it (if credits)
|
||||
// Free play always waits; otherwise the player needs an affordable entry and,
|
||||
// where required, a payout address on file.
|
||||
const shouldShowWaiting = isFree || (canAfford && (!addressRequired || hasAddress));
|
||||
|
||||
if (shouldShowWaiting && typeof ScreenManager !== 'undefined' && ScreenManager.drawWaitingScreen) {
|
||||
// Optimistically set awaiting payment if needed
|
||||
// Paid entries show the awaiting-payment state up front, except when
|
||||
// credits mode already has enough credits to cover the game.
|
||||
const isPaidCredits = typeof SocketHandlers !== 'undefined' && SocketHandlers._gameMode === 'PAID_CREDITS';
|
||||
const hasEnoughCredits = typeof SocketHandlers !== 'undefined' && SocketHandlers._creditsBalance >= (SocketHandlers._creditsPerGame || 1);
|
||||
|
||||
@@ -223,11 +210,10 @@ const InputHandler = {
|
||||
UI.scrollChat();
|
||||
}
|
||||
|
||||
// Add visual feedback to chat
|
||||
$('#messages').append($('<li style="color: #0f0;">').text("🔑 Game start requested..."));
|
||||
UI.scrollChat();
|
||||
} else {
|
||||
// Game is active or in other state - focus chat
|
||||
// With a game active, Enter moves focus to chat.
|
||||
$('#chatInput').focus();
|
||||
UI.updateFocusIndicator();
|
||||
}
|
||||
@@ -236,12 +222,11 @@ const InputHandler = {
|
||||
},
|
||||
|
||||
setupClickHandlers: function() {
|
||||
// Add click handling for the HTML START button
|
||||
$('#startButton').click(function(e) {
|
||||
// Show the wait-vs-drop-in TIMING choice whenever the game would start instantly (no
|
||||
// upfront payment): free play (this instance allows it), a FREE instance, or paid-credits
|
||||
// with credits in hand. The chosen payment method flows through auto_start / join_queue
|
||||
// unchanged — this modal only picks WHEN you enter.
|
||||
// The wait-vs-drop-in timing choice appears whenever the game would start instantly with
|
||||
// no upfront payment: free play enabled, a FREE instance, or paid-credits with credits in
|
||||
// hand. The modal only picks when you enter; the payment method still flows through
|
||||
// auto_start / join_queue unchanged.
|
||||
if (typeof SocketHandlers !== 'undefined') {
|
||||
var mode = SocketHandlers._gameMode;
|
||||
var freeAvailable = SocketHandlers._freePlayEnabled || mode === 'FREE';
|
||||
@@ -253,27 +238,25 @@ const InputHandler = {
|
||||
}
|
||||
}
|
||||
|
||||
// Default: attempt immediate start (payment-required modes go through their own flow)
|
||||
// Otherwise attempt an immediate start; payment-required modes go through their own flow.
|
||||
if (typeof SocketHandlers !== 'undefined' && SocketHandlers.emitFairGameStart) {
|
||||
if (!SocketHandlers.emitFairGameStart('auto_start')) return;
|
||||
} else {
|
||||
socket.emit('auto_start');
|
||||
}
|
||||
|
||||
// Check if we should show waiting screen
|
||||
const addressRequired = typeof SocketHandlers !== 'undefined' && SocketHandlers.payoutAddressRequired();
|
||||
const hasAddress = typeof SocketHandlers !== 'undefined' && SocketHandlers._hasPayoutAddress;
|
||||
const canAfford = typeof SocketHandlers !== 'undefined' && SocketHandlers.canAffordGame();
|
||||
const isFree = typeof SocketHandlers !== 'undefined' && SocketHandlers._gameMode === 'FREE';
|
||||
|
||||
// We only show waiting screen if:
|
||||
// 1. It's free mode
|
||||
// 2. We have address (if required) AND (it's direct pay OR we have credits)
|
||||
// Free play always waits; otherwise the player needs an affordable entry and, where
|
||||
// required, a payout address on file.
|
||||
const shouldShowWaiting = isFree || (canAfford && (!addressRequired || hasAddress));
|
||||
|
||||
if (shouldShowWaiting) {
|
||||
// If in a paid mode but no payment detected yet, optimistically set awaiting payment
|
||||
// UNLESS it is credits mode and we already have enough credits
|
||||
// Paid modes with no payment detected yet show the awaiting-payment state, except
|
||||
// when credits mode already has enough credits to cover the game.
|
||||
const isPaidCredits = typeof SocketHandlers !== 'undefined' && SocketHandlers._gameMode === 'PAID_CREDITS';
|
||||
const hasEnoughCredits = typeof SocketHandlers !== 'undefined' && SocketHandlers._creditsBalance >= (SocketHandlers._creditsPerGame || 1);
|
||||
|
||||
@@ -287,7 +270,7 @@ const InputHandler = {
|
||||
ScreenManager.drawWaitingScreen();
|
||||
}
|
||||
} else {
|
||||
// If not showing waiting screen, add helpful message to chat if missing address
|
||||
// A missing payout address is the actionable case, so name it in chat.
|
||||
if (addressRequired && !hasAddress) {
|
||||
$('#messages').append($('<li style="color: #ffa500;">').text("⚠️ Please set a payout address using the button below before starting."));
|
||||
UI.scrollChat();
|
||||
@@ -304,7 +287,6 @@ const InputHandler = {
|
||||
if ($panel.length && $panel.is(':visible')) {
|
||||
$panel.hide();
|
||||
} else {
|
||||
// Request fresh game list and show panel
|
||||
if (window.socket) {
|
||||
socket.emit('get_active_games', { page: 1, pageSize: 20 });
|
||||
}
|
||||
@@ -314,24 +296,21 @@ const InputHandler = {
|
||||
}
|
||||
});
|
||||
|
||||
// Add click handling for the animation toggle button
|
||||
$('#animationToggleButton').click(function(e) {
|
||||
|
||||
|
||||
if (typeof ScreenManager !== 'undefined' && ScreenManager.toggleAnimation) {
|
||||
ScreenManager.toggleAnimation();
|
||||
// Redraw the waiting screen to reflect the change
|
||||
// The waiting screen is drawn once, so it needs a redraw to pick up the new setting.
|
||||
if (typeof ScreenManager.drawWaitingScreen === 'function') {
|
||||
ScreenManager.drawWaitingScreen();
|
||||
}
|
||||
}
|
||||
|
||||
// Add visual feedback to chat
|
||||
|
||||
const status = ScreenManager._animationEnabled ? "enabled" : "disabled";
|
||||
$('#messages').append($('<li style="color: #aa0;">').text(`🎬 Animation ${status}`));
|
||||
UI.scrollChat();
|
||||
});
|
||||
|
||||
// Address management button
|
||||
$('#manageAddressButton').click(function(e) {
|
||||
e.preventDefault();
|
||||
if (window.socket) {
|
||||
@@ -358,10 +337,9 @@ const InputHandler = {
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure InputHandler is available globally
|
||||
if (typeof window !== 'undefined') {
|
||||
window.InputHandler = InputHandler;
|
||||
}
|
||||
|
||||
// Note: InputHandler.init() is called from index.html after DOM ready
|
||||
// to ensure proper initialization order with other modules
|
||||
// index.html calls InputHandler.init() after DOM ready, once the modules it reaches into
|
||||
// (UI, Game, ScreenManager, SocketHandlers) are defined.
|
||||
|
||||
@@ -164,7 +164,7 @@ const SmirkAuth = {
|
||||
// Sign directly. If this origin doesn't yet hold the Nostr scope, the wallet throws
|
||||
// NOT_AUTHORIZED (no popup); grant it once via getNostrPublicKey() and retry. Signing
|
||||
// first means a RETURNING user (scope already granted) gets a SINGLE approval instead of
|
||||
// two. The wallet still asks per-signature for kind 27235 — that can't be silenced.
|
||||
// two. The wallet still asks per-signature for kind 27235; that can't be silenced.
|
||||
let signed;
|
||||
try {
|
||||
signed = await window.smirk.signNostrEvent(evt);
|
||||
@@ -267,7 +267,7 @@ const SmirkAuth = {
|
||||
}
|
||||
|
||||
try {
|
||||
// /status is session-gated (BOLA fix) — send the session token so a linked user
|
||||
// /status is session-gated (BOLA fix): send the session token so a linked user
|
||||
// is recognised; without it the endpoint returns 401 and we fall through to unlinked.
|
||||
var token = '';
|
||||
try { token = localStorage.getItem('wownerogue_token') || ''; } catch (_) { token = ''; }
|
||||
@@ -366,7 +366,7 @@ const SmirkAuth = {
|
||||
this._updateButton(btn, 'error'); // button label becomes "Retry Smirk Connect"
|
||||
|
||||
// A locked wallet is an action, not a red error. Current Smirk builds auto-open the
|
||||
// unlock popup on connect()/signNostrEvent(); older builds throw LOCKED — so guide the
|
||||
// unlock popup on connect()/signNostrEvent(); older builds throw LOCKED, so guide the
|
||||
// user to unlock and retry (the Retry button re-invokes connect(), which pops the
|
||||
// unlock screen on the updated build).
|
||||
const locked = (err && err.code === 'LOCKED') || /is locked/i.test((err && err.message) || '');
|
||||
|
||||
+118
-196
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
// AsciiRenderer — draws a Scene as a monospace glyph grid on a 2D canvas.
|
||||
// AsciiRenderer: draws a Scene as a monospace glyph grid on a 2D canvas.
|
||||
// The always-available fallback tier: tiny, legible, accessible.
|
||||
(function (root) {
|
||||
'use strict';
|
||||
@@ -111,7 +111,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Camera focus — centre the SP camera on the player cell (same convention as the tiled
|
||||
// Camera focus: centre the SP camera on the player cell (same convention as the tiled
|
||||
// renderer). Sticky: only update when the player is present, so switching TO ascii mid-game
|
||||
// doesn't leave focusPoint null (which pinned the whole grid to the corner → black screen).
|
||||
var focusFallback = null;
|
||||
|
||||
@@ -45,10 +45,10 @@
|
||||
data = data || {};
|
||||
var prev = RK.entitlements || {};
|
||||
|
||||
// Consume the server-authoritative catalog (migration 024). This retires the hardcoded
|
||||
// client pack list as the source of truth: operator-added packs appear, and existing packs
|
||||
// pick up server definitions (label/projection/tier). Merged (not replaced) so any client
|
||||
// fallback a render mode relies on is preserved if the operator's catalog omits it.
|
||||
// The server catalog is authoritative over the client pack list: operator-added packs
|
||||
// appear, and existing packs pick up server definitions (label/projection/tier). Merged
|
||||
// rather than replaced so a client fallback a render mode relies on survives an operator
|
||||
// catalog that omits it.
|
||||
if (Array.isArray(data.catalog) && data.catalog.length) {
|
||||
var merged = {};
|
||||
for (var k in RK.PACKS) merged[k] = RK.PACKS[k];
|
||||
@@ -130,15 +130,15 @@
|
||||
};
|
||||
|
||||
RK.THEMES = {
|
||||
// FREE baseline pack — the game's original bare tiles. Only the essentials are mapped
|
||||
// (floor/wall/door/torch/features); furniture kinds (bar/keg/table/chair/…) are absent, so
|
||||
// they fall back to floor and the free tavern reads plain — the intended "reason to unlock".
|
||||
// Free baseline pack: the game's bare tiles. Only the essentials are mapped
|
||||
// (floor/wall/door/torch/features); furniture kinds (bar/keg/table/chair, ...) are absent,
|
||||
// so they fall back to floor and the free tavern reads plain, which is the reason to unlock.
|
||||
'original': {
|
||||
id: 'original',
|
||||
label: 'Original Tiles',
|
||||
// The game's ORIGINAL tileset (html/tiles.png, 32px, no spacing) — the exact tiles the
|
||||
// ROT instructions + waiting screens use, so the free pack IS the classic look. Grid
|
||||
// coords = the legacy pixel coords / 32 (see options.js tileMap).
|
||||
// html/tiles.png, 32px, no spacing: the same tiles the ROT instructions and waiting
|
||||
// screens use, so the free pack carries the classic look. Grid coords are the legacy
|
||||
// pixel coords divided by 32 (see options.js tileMap).
|
||||
tileset: { url: 'tiles.png', tile: 32, spacing: 0 },
|
||||
tiles: {
|
||||
floor: [0, 0],
|
||||
@@ -149,58 +149,57 @@
|
||||
exit: [1, 0],
|
||||
treasure: [30, 0], // $W gold coin
|
||||
torch: [32, 1],
|
||||
// Classic entities too (the tiled renderer prefers a pack's player/monster tile over
|
||||
// the sprite when present) — the blue-robe @ hero and the green ~ goblin.
|
||||
// The tiled renderer prefers a pack's player/monster tile over the sprite when
|
||||
// present: the blue-robe @ hero and the green ~ goblin.
|
||||
player: [2, 0],
|
||||
monster: [31, 1]
|
||||
// No furniture kinds → tavern furniture falls back to floor (bare, as intended).
|
||||
// No furniture kinds, so tavern furniture falls back to floor.
|
||||
}
|
||||
},
|
||||
'roguelike-interior': {
|
||||
id: 'roguelike-interior',
|
||||
label: 'Kenney Roguelike Interior',
|
||||
tileset: { url: 'assets/kenney/roguelikeSheet.png', tile: 16, spacing: 1 },
|
||||
// Default tile picks from the sheet metadata ( roguelikeSheet_meta.json).
|
||||
// Default tile picks from the sheet metadata (roguelikeSheet_meta.json).
|
||||
// The tile-picker overlay can override these per-user via localStorage.
|
||||
// Each kind maps to a single [col,row] on the sheet (cols = 57). Per-cell "variant"
|
||||
// arrays were tried but mixing wood/stone/plank tiles per cell read as noise, so we
|
||||
// keep one coherent tile per kind. (TileRenderer still accepts an array here if a
|
||||
// future theme wants subtle, same-material variation.)
|
||||
// Each kind maps to a single [col,row] on the sheet (cols = 57). One coherent tile per
|
||||
// kind: mixing wood/stone/plank variants per cell reads as noise. TileRenderer still
|
||||
// accepts an array here for a theme wanting subtle, same-material variation.
|
||||
tiles: {
|
||||
floor: [5, 2], // 119 — wood floor
|
||||
floor2: [6, 2], // 120 — stone floor (accent)
|
||||
wall: [13, 12], // 697 — stone masonry
|
||||
floor: [5, 2], // 119 wood floor
|
||||
floor2: [6, 2], // 120 stone floor (accent)
|
||||
wall: [13, 12], // 697 stone masonry
|
||||
// Tavern furnishings (coords from roguelikeSheet_meta.json labels: index -> [i%57, i/57]).
|
||||
window: [42, 2], // 156 — window (in the wall)
|
||||
bar: [29, 0], // 29 — bar counter
|
||||
keg: [23, 0], // 23 — barrel / beer keg (behind the bar)
|
||||
shelf: [30, 1], // 87 — shelf of bottles (behind the bar)
|
||||
table: [20, 3], // 191 — table (191 reads wooden; 312 rendered green)
|
||||
chair: [19, 3], // 190 — chair
|
||||
barrel: [23, 0], // 23 — barrel (decor)
|
||||
crate: [25, 0], // 25 — crate (decor)
|
||||
rug: [11, 16], // 923 — rug
|
||||
door: [37, 9], // 550 — door
|
||||
window: [42, 2], // 156 window (in the wall)
|
||||
bar: [29, 0], // 29 bar counter
|
||||
keg: [23, 0], // 23 barrel / beer keg (behind the bar)
|
||||
shelf: [30, 1], // 87 shelf of bottles (behind the bar)
|
||||
table: [20, 3], // 191 table, reads wooden; 312 renders green
|
||||
chair: [19, 3], // 190 chair
|
||||
barrel: [23, 0], // 23 barrel (decor)
|
||||
crate: [25, 0], // 25 crate (decor)
|
||||
rug: [11, 16], // 923 rug
|
||||
door: [37, 9], // 550 door
|
||||
// Dungeon features drawn as real tiles instead of monospace glyphs.
|
||||
entrance: [37, 9], // 550 — door
|
||||
exit: [37, 9], // 550 — door
|
||||
treasure: [42, 15], // 897 — item
|
||||
torch: [50, 10] // 620 — rubble / prop
|
||||
entrance: [37, 9], // 550 door
|
||||
exit: [37, 9], // 550 door
|
||||
treasure: [42, 15], // 897 item
|
||||
torch: [50, 10] // 620 rubble / prop
|
||||
}
|
||||
},
|
||||
// Second topdown pack — same sheet, a DUNGEON palette (stone floors, barrels/crates instead
|
||||
// of the wood-interior furniture). A distinct look with zero new assets, and it's the
|
||||
// "interchangeable dungeon" style (the tiled renderer's lighting/shadows are the built-in FX).
|
||||
// Second topdown pack: same sheet, dungeon palette (stone floors, barrels/crates instead of
|
||||
// the wood-interior furniture). A distinct look with zero new assets; the tiled renderer's
|
||||
// lighting/shadows supply the FX.
|
||||
'roguelike-dungeon': {
|
||||
id: 'roguelike-dungeon',
|
||||
label: 'Roguelike Dungeon',
|
||||
tileset: { url: 'assets/kenney/roguelikeSheet.png', tile: 16, spacing: 1 },
|
||||
fx: true,
|
||||
tiles: {
|
||||
floor: [6, 2], // 120 — stone floor
|
||||
floor2: [13, 17], // 982 — plank accent
|
||||
wall: [13, 12], // 697 — masonry
|
||||
window: [42, 2], // 156 — window
|
||||
floor: [6, 2], // 120 stone floor
|
||||
floor2: [13, 17], // 982 plank accent
|
||||
wall: [13, 12], // 697 masonry
|
||||
window: [42, 2], // 156 window
|
||||
bar: [23, 0], // barrels (a dungeon has no bar)
|
||||
keg: [23, 0],
|
||||
shelf: [25, 0], // crate
|
||||
@@ -315,15 +314,15 @@
|
||||
tile: { w: 84, h: 42, imageW: 92, imageH: 184 },
|
||||
tiles: {
|
||||
// Floors / ground
|
||||
floor: 'assets/kenney/iso-dungeon/tiles/planks_S.png', // wood — tavern interior
|
||||
floor2: 'assets/kenney/iso-dungeon/tiles/stone_S.png', // stone — dungeon
|
||||
floor: 'assets/kenney/iso-dungeon/tiles/planks_S.png', // wood, tavern interior
|
||||
floor2: 'assets/kenney/iso-dungeon/tiles/stone_S.png', // stone, dungeon
|
||||
dirt: 'assets/kenney/iso-dungeon/tiles/dirt_S.png',
|
||||
rug: 'assets/kenney/iso-dungeon/tiles/planks_S.png',
|
||||
// Architecture (full-height tiles). Iso walls have a facing: a wall running along the
|
||||
// grid-x axis needs the _S rotation, one running along grid-y needs the perpendicular
|
||||
// _W rotation, and true corners get the corner tile. The renderer picks per-cell from
|
||||
// neighbours, choosing `<kind>Y` for y-running segments (that's what fixes the "thin
|
||||
// slabs" look on the top-left / bottom-right edges).
|
||||
// neighbours, choosing `<kind>Y` for y-running segments; without that the top-left and
|
||||
// bottom-right edges read as thin slabs.
|
||||
wall: 'assets/kenney/iso-dungeon/tiles/stoneWall_S.png',
|
||||
wallY: 'assets/kenney/iso-dungeon/tiles/stoneWall_W.png',
|
||||
wallCorner: 'assets/kenney/iso-dungeon/tiles/stoneWallCorner_S.png',
|
||||
@@ -332,15 +331,15 @@
|
||||
door: 'assets/kenney/iso-dungeon/tiles/stoneWallDoorOpen_S.png',
|
||||
doorY: 'assets/kenney/iso-dungeon/tiles/stoneWallDoorOpen_W.png',
|
||||
archway: 'assets/kenney/iso-dungeon/tiles/stoneWallArchway_S.png',
|
||||
// Floor variety — the renderer sprinkles these deterministically so the ground isn't a
|
||||
// Floor variety: the renderer sprinkles these deterministically so the ground isn't a
|
||||
// flat sea of identical planks.
|
||||
floorAlt: 'assets/kenney/iso-dungeon/tiles/planksBroken_S.png',
|
||||
floor2Alt: 'assets/kenney/iso-dungeon/tiles/stoneUneven_S.png',
|
||||
stoneTile: 'assets/kenney/iso-dungeon/tiles/stoneTile_S.png',
|
||||
column: 'assets/kenney/iso-dungeon/tiles/stoneColumnWood_S.png',
|
||||
stairs: 'assets/kenney/iso-dungeon/tiles/stairs_S.png',
|
||||
// Furniture props (drawn over a floor base) — each kind now has distinct art.
|
||||
bar: 'assets/kenney/iso-dungeon/tiles/barrelsStacked_S.png', // stacked barrels = the bar counter
|
||||
// Furniture props, drawn over a floor base; each kind has distinct art.
|
||||
bar: 'assets/kenney/iso-dungeon/tiles/barrelsStacked_S.png', // stacked barrels as the bar counter
|
||||
keg: 'assets/kenney/iso-dungeon/tiles/barrel_S.png',
|
||||
barrel: 'assets/kenney/iso-dungeon/tiles/barrel_S.png',
|
||||
table: 'assets/kenney/iso-dungeon/tiles/tableShortChairs_S.png', // real table + chairs
|
||||
@@ -350,11 +349,11 @@
|
||||
chest: 'assets/kenney/iso-dungeon/tiles/chestClosed_S.png',
|
||||
fallback: 'assets/kenney/iso-dungeon/tiles/planks_S.png'
|
||||
},
|
||||
// The Kenney files named Male_0..Male_7 are directional renders of the same body,
|
||||
// not different character classes. The customizer exposes one honest Iso body for now.
|
||||
// These sprites are angled CLOCKWISE from NE: 0=NE, 2=SE, 4=SW, 6=NW (verified against live
|
||||
// movement). In this iso projection a grid step moves visually up->NE, down->SW, left->NW,
|
||||
// right->SE, so each facing uses the sprite pointing that way — face the way you move.
|
||||
// The Kenney files named Male_0..Male_7 are directional renders of the same body, not
|
||||
// different character classes, so the customizer exposes one Iso body. The sprites are
|
||||
// angled clockwise from NE: 0=NE, 2=SE, 4=SW, 6=NW. In this iso projection a grid step
|
||||
// moves visually up->NE, down->SW, left->NW, right->SE, so each facing uses the sprite
|
||||
// pointing that way and the character faces its direction of travel.
|
||||
character: isoMale(3), // idle faces roughly toward the camera (S)
|
||||
directions: {
|
||||
up: isoMale(0), // NE (up-right)
|
||||
@@ -368,10 +367,10 @@
|
||||
}
|
||||
};
|
||||
|
||||
// Second iso pack — Kenney Isometric Medieval Town (a coherent, DIFFERENT environment). Strong on
|
||||
// architecture (floor/walls/door/window/banner); no medieval furniture at this scale, so furniture
|
||||
// kinds fall back to floor for now. Reuses the dungeon-pack characters. Its tiles are shorter
|
||||
// (210x244) than the dungeon set, so its own tile geometry.
|
||||
// Second iso pack: Kenney Isometric Medieval Town, a separate environment. Strong on
|
||||
// architecture (floor/walls/door/window/banner); it has no medieval furniture at this scale, so
|
||||
// furniture kinds fall back to floor. Reuses the dungeon-pack characters. Its tiles are shorter
|
||||
// (210x244) than the dungeon set, hence its own tile geometry.
|
||||
RK.isoMedievalAssets = {
|
||||
pack: 'iso-medieval',
|
||||
avatar: 'char-villager',
|
||||
@@ -408,10 +407,10 @@
|
||||
}
|
||||
};
|
||||
|
||||
// Register the built-in RENDER packs in the pack registry (packRegistry.js) so iso/3D become
|
||||
// multi-pack like topdown themes already are. Each maps to its catalog id; the active pack per
|
||||
// projection is entitlement-gated + user-selectable. Adding a pack later = registerPack(...) +
|
||||
// assets + a catalog row. Backward-compatible: one pack per projection resolves to these.
|
||||
// Register the built-in render packs in the pack registry (packRegistry.js) so iso and 3D are
|
||||
// multi-pack like topdown themes. Each maps to its catalog id; the active pack per projection
|
||||
// is entitlement-gated and user-selectable. Adding a pack takes registerPack(...), assets, and
|
||||
// a catalog row. With one pack per projection, resolution falls through to these.
|
||||
if (RK.registerPack) {
|
||||
RK.registerPack({ id: 'original', label: 'Original Tiles', projection: 'topdown', kind: 'tiles', assets: RK.THEMES['original'] });
|
||||
RK.registerPack({ id: 'roguelike-interior', label: 'Roguelike Interior', projection: 'topdown', kind: 'tiles', assets: RK.THEMES['roguelike-interior'] });
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// TilesetAtlas — loads a tileset image and serves individual tiles to either a 2D canvas
|
||||
// TilesetAtlas: loads a tileset image and serves individual tiles to either a 2D canvas
|
||||
// (TileRenderer) or PixiJS (FancyRenderer). Source-agnostic: any pack's sheet loads through it.
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
// A player who never customized arrives as avatar 'default' (or an unknown id), which is not
|
||||
// a char/skin/model and would fall to the grey circle. On the TAVERN canvas path, coerce it
|
||||
// to a real Kenney character so nobody is a grey dot in a room of people. But NOT on
|
||||
// 'legacy-rot' (the single-player dungeon) — there RenderEngine falls back to the original @
|
||||
// 'legacy-rot' (the single-player dungeon): there RenderEngine falls back to the original @
|
||||
// hero TILE, which is what an un-customized player expects (grey circle was a canvas-only
|
||||
// problem). iso/3d keep their own fallbacks below.
|
||||
if (ap && projection !== 'iso' && projection !== '3d' && projection !== 'legacy-rot') {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
var frame = Math.floor((now || 0) / FRAME_MS) % FRAMES;
|
||||
var sx = frame * FW;
|
||||
// The cat occupies only ~40% of its 50x50 frame (lots of transparent padding), so scale
|
||||
// the frame well past the cell and centre it — the visible cat then reads about tile-sized.
|
||||
// the frame well past the cell and centre it; the visible cat then reads about tile-sized.
|
||||
var size = cell * 2.7;
|
||||
var dx = e.x * cell + (cell - size) / 2;
|
||||
var dy = e.y * cell + (cell - size) / 2 + cell * 0.28; // centred, nudged down onto the tile
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// ChainAmbience — the live chain, drifting around the edges of a stage.
|
||||
// ChainAmbience: the live chain, drifting around the edges of a stage.
|
||||
//
|
||||
// The Tavern sits on top of a real blockchain, and the game's whole clock is "when does the next
|
||||
// block land". This makes that visible without putting a dashboard on screen: fragments of the top
|
||||
// block hash fade in around the border and drift away, a corner readout tracks the height, and a
|
||||
// new block sends a ripple around the frame.
|
||||
// Fragments of the top block hash fade in around the border and drift away, a corner readout
|
||||
// tracks the height, and a new block sends a ripple around the frame.
|
||||
//
|
||||
// Purely decorative. It reads the public `blockheight` broadcast and never influences gameplay, so
|
||||
// a missing or stale tip just means fewer motes.
|
||||
@@ -85,11 +83,10 @@
|
||||
var self = this;
|
||||
this.timer = root.setInterval(function () { self._spawn(); }, this.spawnMs);
|
||||
|
||||
// A hidden tab pauses CSS animations, so `animationend` never fires and nothing is ever
|
||||
// removed — while the spawn interval keeps ticking. Left alone, a tavern tab parked in the
|
||||
// background accrues motes indefinitely and then plays a pile of stale ones all at once when
|
||||
// the player comes back. Spawning is skipped while hidden (below) and the backlog is dropped
|
||||
// here; these are decorations, so discarding them costs nothing.
|
||||
// A hidden tab pauses CSS animations, so `animationend` never fires and motes are never
|
||||
// removed. Spawning is skipped while hidden and any backlog is dropped here, otherwise a
|
||||
// backgrounded tab accrues motes indefinitely and replays them all at once on return.
|
||||
// These are decorations, so discarding them costs nothing.
|
||||
this._onVisibility = function () {
|
||||
if (!self.el || !self.doc.hidden) return;
|
||||
self._clearMotes();
|
||||
@@ -121,9 +118,9 @@
|
||||
|
||||
ChainAmbience.prototype._spawn = function (burst) {
|
||||
if (!this.el || reducedMotion()) return;
|
||||
if (this.doc.hidden) return; // paused animations would never clean themselves up
|
||||
// Count the DOM, not a counter: a missed animationend must not permanently inflate the tally
|
||||
// and silently stop all further spawning.
|
||||
if (this.doc.hidden) return; // paused animations never clean themselves up
|
||||
// Count the DOM, not `this.motes`: a missed animationend would permanently inflate the
|
||||
// tally and silently stop all further spawning.
|
||||
if (!burst && this.el.querySelectorAll('.rk-cm').length >= this.maxMotes) return;
|
||||
var text = this._fragment();
|
||||
if (!text) return;
|
||||
@@ -132,8 +129,8 @@
|
||||
m.className = 'rk-cm rk-drift';
|
||||
m.textContent = text;
|
||||
|
||||
// Hug the border: pick an edge, then a position along it. The middle of the stage is where
|
||||
// the game is — the chain lives in the margins.
|
||||
// Hug the border: pick an edge, then a position along it. The middle of the stage belongs
|
||||
// to the game, so the chain lives in the margins.
|
||||
var edge = Math.floor(Math.random() * 4);
|
||||
var along = 6 + Math.random() * 84;
|
||||
var dx = 0, dy = 0;
|
||||
|
||||
@@ -725,7 +725,7 @@
|
||||
}
|
||||
|
||||
// Sprite tinting (cloth / skin / hair / gear colour) is disabled for the top-down
|
||||
// Tiles view — characters render in native Kenney art. Only equipment PIECE selection
|
||||
// Tiles view: characters render in native Kenney art. Only equipment PIECE selection
|
||||
// remains here; the '@' colour for ASCII mode is still pickable in the ascii/colour
|
||||
// branch above.
|
||||
sectionTitle('Equipment', editSection);
|
||||
@@ -791,7 +791,7 @@
|
||||
}
|
||||
|
||||
function buildAvatarGrid() {
|
||||
// Don't auto-switch the avatar when the render mode changes — only an explicit
|
||||
// Don't auto-switch the avatar when the render mode changes; only an explicit
|
||||
// click on a different avatar card should change the saved identity. If the current
|
||||
// avatar isn't native to this projection, it stays selected (the resolver handles
|
||||
// fallback rendering) so browsing render modes never silently mutates the identity.
|
||||
|
||||
@@ -229,7 +229,7 @@
|
||||
// Sprite recoloring (skin / hair / cloth-base / gear tinting) is intentionally disabled:
|
||||
// characters and equipment render in their native Kenney art. Returning null makes every
|
||||
// caller (drawCharTileCanvas, charTileTexture) fall through to the untinted sprite. The
|
||||
// ASCII '@' colour is unaffected — it uses colors.base via avatarVisuals.tintColorFor,
|
||||
// ASCII '@' colour is unaffected: it uses colors.base via avatarVisuals.tintColorFor,
|
||||
// a separate path. To restore tinting, delete this early return.
|
||||
return null;
|
||||
var a = RK.charAtlas();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Designed room — loads an imported Tiled room (from tmx_import.py): layered tile-index grids
|
||||
// Designed room: loads an imported Tiled room (from tmx_import.py): layered tile-index grids
|
||||
// + a tileset, and draws the layers (back-to-front) beneath the avatars. The server uses the
|
||||
// same room's walkability/dims for collision, so visuals and movement match.
|
||||
(function (root) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// FancyRenderer — WebGL tier via PixiJS (v7). Beyond flat shapes: a real blurred bloom on
|
||||
// entities, warm flickering lights along the bar, drifting dust motes, and a vignette — all
|
||||
// FancyRenderer: WebGL tier via PixiJS (v7). Beyond flat shapes: a real blurred bloom on
|
||||
// entities, warm flickering lights along the bar, drifting dust motes, and a vignette, all
|
||||
// programmatic (no art assets). Requires a global PIXI (loaded from CDN, with @pixi/unsafe-eval
|
||||
// so shaders compile under a strict CSP); the factory falls back to tiled if PIXI is missing.
|
||||
(function (root) {
|
||||
@@ -77,7 +77,7 @@
|
||||
FancyRenderer.prototype._buildTiles = function (scene) {
|
||||
this.tileLayer.removeChildren();
|
||||
var cell = this.cell;
|
||||
// A designed room is loaded — draw its layered tiles as sprites from the room atlas.
|
||||
// A designed room is loaded: draw its layered tiles as sprites from the room atlas.
|
||||
if (window.RK && RK.roomReady && RK.roomReady()) {
|
||||
var d = RK.activeRoom.desc, a = RK.activeRoom.atlas;
|
||||
for (var li = 0; li < d.layerOrder.length; li++) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// fxLayer.js — the shared "juice" engine (RK.fx). Pure-canvas animated FX (no WebGL, no shaders,
|
||||
// fxLayer.js: the shared "juice" engine (RK.fx). Pure-canvas animated FX (no WebGL, no shaders,
|
||||
// CSP-safe) used by BOTH the top-down tiled renderer and the iso renderer so a scene lights up the
|
||||
// same way in either projection: dancing torch/hearth flame + warm glow, and pulsing hazard tiles
|
||||
// (lava / poison / spikes) for the dungeon. Everything is a function of `now` (ms) so callers just
|
||||
// keep a RAF loop alive and pass the clock — no per-particle state to thread through.
|
||||
// keep a RAF loop alive and pass the clock: no per-particle state to thread through.
|
||||
(function (root) {
|
||||
'use strict';
|
||||
var RK = root.RK = root.RK || {};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// IsoRenderer — canvas isometric projection using Kenney's Isometric Miniature Dungeon pack.
|
||||
// IsoRenderer: canvas isometric projection using Kenney's Isometric Miniature Dungeon pack.
|
||||
// It draws the same Scene as the top-down renderers, preserving the shared Room contract.
|
||||
(function (root) {
|
||||
'use strict';
|
||||
@@ -75,7 +75,8 @@
|
||||
};
|
||||
|
||||
// Map a dungeon FEATURE entity (entrance/exit/treasure/item) to a pack tile: stairs for the
|
||||
// entrance/exit, a chest for treasure/items. Null if the pack has no fitting tile (→ glyph).
|
||||
// entrance/exit, a chest for treasure/items. Null if the pack has no fitting tile, which falls
|
||||
// back to a glyph.
|
||||
IsoRenderer.prototype._featureTileUrl = function (e) {
|
||||
var tiles = this.assets.tiles || {};
|
||||
var c = e.char;
|
||||
@@ -84,8 +85,8 @@
|
||||
return null;
|
||||
};
|
||||
|
||||
// A stroked iso diamond outline — used to flag the floor cells the player can actually step to,
|
||||
// so ambiguous wall gaps are never mistaken for openings.
|
||||
// Stroked iso diamond outline, used to flag the floor cells the player can actually step to so
|
||||
// ambiguous wall gaps are never mistaken for openings.
|
||||
IsoRenderer.prototype._diamondOutline = function (cx, cy, color) {
|
||||
var ctx = this.ctx, hw = this.tileW / 2, hh = this.tileH / 2;
|
||||
ctx.save();
|
||||
@@ -109,8 +110,8 @@
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
// A flat iso diamond in the legend colour — the placeholder a tile draws while its pack image
|
||||
// loads (or if an image is missing), so the view shows the dungeon shape instead of going black.
|
||||
// Flat iso diamond in the legend colour: the placeholder a tile draws while its pack image loads
|
||||
// (or if an image is missing), so the view shows the dungeon shape instead of going black.
|
||||
IsoRenderer.prototype._diamond = function (cx, cy, color) {
|
||||
var ctx = this.ctx, hw = this.tileW / 2, hh = this.tileH / 2;
|
||||
ctx.beginPath();
|
||||
@@ -132,13 +133,13 @@
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
// Warm pool of light cast onto the scene by a torch / the player's lantern / a beacon. Drawn
|
||||
// additively after the tiles so it lights the floor, the props AND the characters standing in
|
||||
// Warm pool of light cast onto the scene by a torch, the player's lantern or a beacon. Drawn
|
||||
// additively after the tiles so it lights the floor, the props and the characters standing in
|
||||
// it, which is what separates a lit room from a flat sprite collage.
|
||||
IsoRenderer.prototype._lightPool = function (cx, cy, radius, inner, outer) {
|
||||
var ctx = this.ctx;
|
||||
// A non-finite emitter would throw out of createRadialGradient and abandon the frame
|
||||
// half-drawn. Lighting is decoration — skip the bad pool, keep the dungeon.
|
||||
// A non-finite emitter throws out of createRadialGradient and abandons the frame half-drawn.
|
||||
// Lighting is decoration, so skip the bad pool and keep the dungeon.
|
||||
if (!isFinite(cx) || !isFinite(cy) || !isFinite(radius) || radius <= 0) return;
|
||||
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
||||
g.addColorStop(0, inner);
|
||||
@@ -177,7 +178,7 @@
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
// Crevice darkening on floor cells that touch a wall — a cheap ambient-occlusion cue. The
|
||||
// Crevice darkening on floor cells that touch a wall: a cheap ambient-occlusion cue. The
|
||||
// gradient is clear in the middle of the cell and dark at its rim, so tiles tucked into a corner
|
||||
// sit in shadow instead of looking like stickers laid on top of the ground.
|
||||
IsoRenderer.prototype._wallAO = function (cx, cy, strength) {
|
||||
@@ -195,8 +196,8 @@
|
||||
ctx.restore();
|
||||
};
|
||||
|
||||
// Warm radial vignette — an ambient tavern glow that darkens the edges and lifts the centre,
|
||||
// the "juice" that makes the flat canvas feel lit rather than pasted.
|
||||
// Warm radial vignette: an ambient tavern glow that darkens the edges and lifts the centre, so
|
||||
// the flat canvas feels lit rather than pasted.
|
||||
IsoRenderer.prototype._vignette = function () {
|
||||
var ctx = this.ctx, w = this.canvas.width, h = this.canvas.height;
|
||||
var g = ctx.createRadialGradient(w / 2, h * 0.46, Math.min(w, h) * 0.18,
|
||||
@@ -265,12 +266,10 @@
|
||||
return cv;
|
||||
};
|
||||
|
||||
// Darkened copy of a tile image, cached at quantized levels.
|
||||
//
|
||||
// Fog-of-war used to dim tiles with globalAlpha, which made remembered walls TRANSLUCENT — you
|
||||
// could see the tiles behind them, so the dungeon read as a haze of overlapping ghosts instead
|
||||
// of solid stone in the dark. Multiplying the sprite's own pixels toward black keeps every tile
|
||||
// opaque and just unlit, which is what "you remember this room, it isn't lit" should look like.
|
||||
// Darkened copy of a tile image, cached at quantized levels. Fog-of-war multiplies the sprite's
|
||||
// own pixels toward black rather than dimming with globalAlpha: alpha makes remembered walls
|
||||
// translucent, so the tiles behind them show through and the dungeon reads as a haze of
|
||||
// overlapping ghosts. Shading keeps every tile opaque and merely unlit.
|
||||
IsoRenderer.prototype._shadedImage = function (img, level) {
|
||||
if (!img) return img;
|
||||
var step = Math.max(0, Math.min(5, Math.round(level * 5)));
|
||||
@@ -284,7 +283,7 @@
|
||||
var ctx = cv.getContext('2d');
|
||||
ctx.drawImage(img, 0, 0);
|
||||
ctx.globalCompositeOperation = 'source-atop';
|
||||
// A touch of blue in the shadow rather than flat black — unlit stone reads cold.
|
||||
// A touch of blue in the shadow rather than flat black, so unlit stone reads cold.
|
||||
ctx.fillStyle = 'rgba(6,10,20,' + (1 - step / 5).toFixed(3) + ')';
|
||||
ctx.fillRect(0, 0, cv.width, cv.height);
|
||||
ctx.globalCompositeOperation = 'source-over';
|
||||
@@ -307,7 +306,7 @@
|
||||
return tiles[kind] || tiles.fallback || tiles.floor;
|
||||
};
|
||||
|
||||
// Deterministic per-cell hash (no RNG — stable across frames/reloads).
|
||||
// Deterministic per-cell hash: no RNG, so it is stable across frames and reloads.
|
||||
function cellHash(x, y) {
|
||||
var h = (x * 73856093) ^ (y * 19349663);
|
||||
h = (h ^ (h >>> 13)) >>> 0;
|
||||
@@ -324,8 +323,8 @@
|
||||
|
||||
// Iso walls have a facing. A segment running along grid-x uses the base (_S) rotation; one
|
||||
// running along grid-y uses the perpendicular (`<kind>Y`, _W) rotation; a true corner (both
|
||||
// axes present) uses the corner tile. This is what makes enclosing walls read as continuous
|
||||
// faces instead of thin disconnected slabs on the y-running edges.
|
||||
// axes present) uses the corner tile. Without the per-axis rotation, enclosing walls read as
|
||||
// thin disconnected slabs on the y-running edges instead of continuous faces.
|
||||
IsoRenderer.prototype._wallVariant = function (scene, x, y, kind) {
|
||||
var tiles = this.assets.tiles || {};
|
||||
var xRun = this._isWall(scene, x - 1, y) || this._isWall(scene, x + 1, y);
|
||||
@@ -361,9 +360,8 @@
|
||||
var key = e.id || 'anon';
|
||||
var st = this.last[key] || (this.last[key] = { x: e.x, y: e.y, t: 0, facing: 'down' });
|
||||
if (st.x !== e.x || st.y !== e.y) {
|
||||
// Infer facing from the actual movement delta — the SP game doesn't send player.facing
|
||||
// (only the tavern does), so without this the character always faced 'down' (SW) no matter
|
||||
// which way it moved. Explicit e.facing (tavern) still wins below.
|
||||
// Facing is inferred from the movement delta because the SP game does not send
|
||||
// player.facing; only the tavern does. Explicit e.facing (tavern) wins below.
|
||||
var ddx = e.x - st.x, ddy = e.y - st.y;
|
||||
if (Math.abs(ddx) >= Math.abs(ddy)) st.facing = ddx > 0 ? 'right' : 'left';
|
||||
else st.facing = ddy > 0 ? 'down' : 'up';
|
||||
@@ -386,10 +384,10 @@
|
||||
var originY = margin + this.imageH;
|
||||
var wantW = Math.ceil((scene.cols + scene.rows) * this.tileW / 2 + margin * 2 + this.tileW * 2);
|
||||
var wantH = Math.ceil((scene.cols + scene.rows) * this.tileH / 2 + this.imageH + margin * 2);
|
||||
// Only ASSIGN width/height when they actually change. Assigning either one reallocates and
|
||||
// Assign width/height only when they actually change. Assigning either one reallocates and
|
||||
// clears the whole backing buffer, and this renderer re-runs every animation frame (the
|
||||
// walkable pulse and the flames keep `_animating` set) — so the old unconditional assignment
|
||||
// was throwing away and re-allocating a multi-megapixel canvas 60 times a second.
|
||||
// walkable pulse and the flames keep `_animating` set), so an unconditional assignment
|
||||
// re-allocates a multi-megapixel canvas 60 times a second.
|
||||
if (this.canvas.width !== wantW || this.canvas.height !== wantH) {
|
||||
this.canvas.width = wantW;
|
||||
this.canvas.height = wantH;
|
||||
@@ -415,8 +413,8 @@
|
||||
if (PROP[kind]) {
|
||||
items.push({ type: 'prop', kind: kind, x: x, y: y, sx: p.x, sy: p.y, depth: x + y + 0.25 });
|
||||
}
|
||||
// Fire fixtures (torch/hearth) & hazard tiles (lava/poison/spikes): a floor tile is
|
||||
// the base (drawn above via fallback); RK.fx paints the animated flame / pulse.
|
||||
// Fire fixtures (torch/hearth) and hazard tiles (lava/poison/spikes): a floor tile is
|
||||
// the base, drawn above via fallback; RK.fx paints the animated flame or pulse.
|
||||
if (def && (def.fx === 'fire' || def.hazard)) {
|
||||
items.push({ type: 'fx', def: def, x: x, y: y, sx: p.x, sy: p.y, depth: x + y + 0.3 });
|
||||
}
|
||||
@@ -426,21 +424,21 @@
|
||||
var e = scene.entities[i];
|
||||
p = this._project(e.x, e.y, originX, originY);
|
||||
items.push({ type: 'entity', e: e, sx: p.x, sy: p.y, depth: e.x + e.y + 0.55 });
|
||||
// Sticky camera target (keep last if the player is momentarily absent). Multiplayer
|
||||
// scenes explicitly mark one racer as cameraTarget; never let a later rival steal it.
|
||||
// Sticky camera target: the last value is kept if the player is momentarily absent.
|
||||
// Multiplayer scenes mark one racer as cameraTarget so a later rival cannot steal it.
|
||||
if (e.you || e.cameraTarget) { this.focusPoint = { x: p.x, y: p.y + this.tileH }; this._plx = e.x; this._ply = e.y; }
|
||||
}
|
||||
items.sort(function (a, b) { return a.depth === b.depth ? (a.y || 0) - (b.y || 0) : a.depth - b.depth; });
|
||||
|
||||
var now = Date.now();
|
||||
// Light emitters collected while drawing, applied additively in one pass afterwards so the
|
||||
// pools land on the floor, the props AND the characters standing in them.
|
||||
// pools land on the floor, the props and the characters standing in them.
|
||||
var lights = [];
|
||||
for (i = 0; i < items.length; i++) {
|
||||
var it = items[i];
|
||||
if (it.type === 'tile') {
|
||||
// FOG OF WAR: never render unexplored cells. Without this they fell back to the floor
|
||||
// tile (`_tileUrl('dark')` → tiles.floor) and revealed the ENTIRE map from move 0.
|
||||
// Fog of war: unexplored cells are never rendered. They would otherwise fall back to
|
||||
// the floor tile (`_tileUrl('dark')` resolves to tiles.floor) and reveal the whole map.
|
||||
if (it.kind === 'dark') continue;
|
||||
var tileKind = PROP[it.kind] ? 'floor' : it.kind;
|
||||
if (WALLISH[tileKind]) tileKind = this._wallVariant(scene, it.x, it.y, tileKind);
|
||||
@@ -453,11 +451,11 @@
|
||||
it.x >= this._plx && it.y >= this._ply &&
|
||||
(it.x + it.y) > (this._plx + this._ply) &&
|
||||
((it.x - this._plx) + (it.y - this._ply)) <= 3;
|
||||
// Fog fade: dim explored-but-distant tiles toward the (near-black) background so the
|
||||
// remembered area melts into the dark with distance — parity with the 2D modes.
|
||||
// Fog fade: dim explored-but-distant tiles toward the near-black background so the
|
||||
// remembered area melts into the dark with distance, matching the 2D modes.
|
||||
var lb = (scene.lightGrid && scene.lightGrid[it.y] && scene.lightGrid[it.y][it.x] != null) ? scene.lightGrid[it.y][it.x] : 1;
|
||||
// Cutaway walls stay ALPHA-faded (you need to see the floor through them). Everything
|
||||
// else is shaded opaque, so distant stone is dark stone rather than a ghost.
|
||||
// Cutaway walls stay alpha-faded so the floor shows through them. Everything else is
|
||||
// shaded opaque, so distant stone is dark stone rather than a ghost.
|
||||
ctx.globalAlpha = cut ? 0.28 : 1;
|
||||
if (rec && rec.ready) {
|
||||
var img = cut ? rec.img : this._shadedImage(rec.img, Math.max(0.16, lb));
|
||||
@@ -466,11 +464,11 @@
|
||||
ctx.globalAlpha = cut ? 0.28 : Math.max(0.16, lb);
|
||||
this._diamond(it.sx, it.sy + this.tileH * 0.5, (legend[it.kind] && legend[it.kind].color) || '#3a4048');
|
||||
}
|
||||
// Walkable-floor pip: mark every visible floor cell with a soft dot so the WALKABLE
|
||||
// ground is explicit everywhere. A dark gap with no pip is unambiguously NOT floor —
|
||||
// that's what stops iso wall-gaps/voids from reading as holes you can walk into.
|
||||
// Walkable-floor pip: every visible floor cell gets a soft dot so walkable ground is
|
||||
// explicit everywhere. A dark gap with no pip is unambiguously not floor, which stops
|
||||
// iso wall gaps and voids from reading as holes you can walk into.
|
||||
if (it.kind === 'floor' || it.kind === 'floor2') {
|
||||
// Crevice shadow where this floor cell abuts stone. Cheap AO — the more walls
|
||||
// Crevice shadow where this floor cell abuts stone. Cheap AO: the more walls
|
||||
// around it, the deeper the cell sits in shadow.
|
||||
var adj = (this._isWall(scene, it.x - 1, it.y) ? 1 : 0) + (this._isWall(scene, it.x + 1, it.y) ? 1 : 0) +
|
||||
(this._isWall(scene, it.x, it.y - 1) ? 1 : 0) + (this._isWall(scene, it.x, it.y + 1) ? 1 : 0);
|
||||
@@ -513,10 +511,9 @@
|
||||
});
|
||||
}
|
||||
} else if (it.type === 'entity') {
|
||||
// Dungeon FEATURES/ITEMS (entrance/exit/treasure/items) are tiles/glyphs, NOT avatars.
|
||||
// The iso renderer is shared with the tavern (where every entity is an avatar); without
|
||||
// this branch the entrance was drawn as a male character — the "copy of the player at
|
||||
// the start" the map origin showed.
|
||||
// Dungeon features and items (entrance/exit/treasure/items) draw as tiles or glyphs,
|
||||
// not avatars. The iso renderer is shared with the tavern, where every entity is an
|
||||
// avatar, so without this branch the entrance draws as a character sprite.
|
||||
if (it.e.kind === 'feature' || it.e.kind === 'item') {
|
||||
var furl = this._featureTileUrl(it.e);
|
||||
var frec = furl && this._load(furl);
|
||||
@@ -524,14 +521,14 @@
|
||||
else this._glyph(it.sx, it.sy + this.tileH * 0.4, it.e.char || '?', it.e.color || '#fff');
|
||||
// Objectives beacon. Stairs and treasure are the only two things in the dungeon
|
||||
// worth crossing a room for, so they get a slow breathing glow in their own
|
||||
// colour — findable from across a dark hall without leaking the layout.
|
||||
// colour: findable from across a dark hall without leaking the layout.
|
||||
var beaconRGB = it.e.char === '<' ? '63,185,80'
|
||||
: it.e.char === '>' ? '210,153,34'
|
||||
: it.e.char && it.e.char.charAt(0) === '$' ? '251,191,36' : null;
|
||||
if (beaconRGB) {
|
||||
this._animating = true;
|
||||
// NB: entity items carry the cell on `it.e`, not `it.x` (that is only set on
|
||||
// tile items) — reading it.x here produced a NaN radius and threw.
|
||||
// Entity items carry the cell on `it.e`; `it.x` is set only on tile items, so
|
||||
// reading it here would give a NaN radius.
|
||||
var breathe = 0.55 + Math.sin(now / 620 + it.e.x) * 0.45;
|
||||
lights.push({
|
||||
x: it.sx, y: it.sy + this.tileH, r: this.tileW * (1.1 + breathe * 0.35),
|
||||
@@ -546,8 +543,8 @@
|
||||
rec = this._load(frame && frame.url);
|
||||
if (rec && rec.ready) {
|
||||
var ch = frame.character || this.assets.character || {};
|
||||
// Monsters use the same character art — tint them RED so they don't read as a
|
||||
// second player following you around.
|
||||
// Monsters use the same character art, so they are tinted red to avoid reading as
|
||||
// a second player following you around.
|
||||
var tint = it.e.kind === 'monster' ? '#f85149' : this._skinTintFor(frame.visual);
|
||||
this._drawSpriteImage(this._tintedImage(rec.img, tint), it.sx, it.sy + this.tileH * 0.95, ch.imageH || 92);
|
||||
} else {
|
||||
@@ -573,9 +570,8 @@
|
||||
}
|
||||
|
||||
// Walkability affordance: the iso wall tiles leave ambiguous gaps that read as doorways, so
|
||||
// flag the floor cells the player can actually step to (orthogonally adjacent, walkable +
|
||||
// explored) with a soft pulsing outline — a reliable "you can go here" cue independent of how
|
||||
// the walls render.
|
||||
// the floor cells the player can actually step to (orthogonally adjacent, walkable and
|
||||
// explored) get a soft pulsing outline. That cue holds regardless of how the walls render.
|
||||
if (this._plx != null) {
|
||||
var pulse = 0.30 + Math.sin(now / 320) * 0.18;
|
||||
var nbrs = [[0, -1], [0, 1], [-1, 0], [1, 0]];
|
||||
@@ -605,9 +601,9 @@
|
||||
var L = lights[lg];
|
||||
this._lightPool(L.x, L.y, L.r, L.inner, L.outer);
|
||||
}
|
||||
// Colour-temperature separation: everything the player can currently SEE is warm torchlight;
|
||||
// the explored-but-remembered parts get a cold blue wash. Alpha alone made memory read as
|
||||
// "far away"; the temperature shift makes it read as "remembered", which is what it is.
|
||||
// Colour-temperature separation: everything the player can currently see is warm torchlight,
|
||||
// while explored-but-remembered parts get a cold blue wash. Alpha alone reads as "far away";
|
||||
// the temperature shift reads as "remembered".
|
||||
this._memoryWash(scene, originX, originY);
|
||||
|
||||
this._vignette();
|
||||
@@ -621,16 +617,16 @@
|
||||
var ctx = this.ctx, hw = this.tileW / 2, hh = this.tileH / 2;
|
||||
ctx.save();
|
||||
ctx.globalCompositeOperation = 'source-atop';
|
||||
// A DARK blue. A mid-tone blue at 20% actually lifted the already-darkened memory tiles
|
||||
// brighter than the torchlit centre, inverting the whole depth hierarchy — remembered rooms
|
||||
// glowed and the room you were standing in receded.
|
||||
// The wash must be a dark blue. A mid-tone blue lifts the already-darkened memory tiles
|
||||
// brighter than the torchlit centre, inverting the depth hierarchy so remembered rooms glow
|
||||
// and the room the player stands in recedes.
|
||||
ctx.fillStyle = 'rgba(16,28,52,1)';
|
||||
for (var y = 0; y < scene.rows; y++) {
|
||||
for (var x = 0; x < scene.cols; x++) {
|
||||
var kind = scene.grid[y] && scene.grid[y][x];
|
||||
if (!kind || kind === 'dark') continue;
|
||||
var lb = (light[y] && light[y][x] != null) ? light[y][x] : 1;
|
||||
if (lb >= 0.3) continue; // currently lit — leave it warm
|
||||
if (lb >= 0.3) continue; // currently lit, so leave it warm
|
||||
var p = this._project(x, y, originX, originY), cy = p.y + hh;
|
||||
ctx.globalAlpha = 0.45 * (1 - lb / 0.3);
|
||||
ctx.beginPath();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Pack registry — the engine behind "add more 2D tilesets / iso packs / 3D skins as products".
|
||||
// Pack registry: the engine behind "add more 2D tilesets / iso packs / 3D skins as products".
|
||||
//
|
||||
// A PACK is a rich, self-contained render environment for a projection (topdown | iso | 3d). Because
|
||||
// every scene (tavern OR dungeon) is kind-based (floor/wall/table/torch/monster…), the SAME pack
|
||||
// renders both — packs are interchangeable across town and dungeon. Each pack maps 1:1 to a catalog
|
||||
// renders both; packs are interchangeable across town and dungeon. Each pack maps 1:1 to a catalog
|
||||
// entry (id/tier/threshold), so it's a monetizable product gated by entitlements.
|
||||
//
|
||||
// This module makes packs MULTI (like RK.THEMES already is for topdown) and adds active-pack
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
return true;
|
||||
};
|
||||
|
||||
// Render tiers. `premium` marks modes intended to be unlocked with credits — a cosmetic
|
||||
// Render tiers. `premium` marks modes intended to be unlocked with credits: a cosmetic
|
||||
// entitlement gated by Operator Policy, the same pattern as avatar unlocks. NOTE: rendering
|
||||
// is client-side, so this is a soft/honour gate for cosmetics, not a hard security boundary.
|
||||
// Modes are TECHNIQUES (how a scene is drawn). Packs are the SETS/styles within a projection
|
||||
// (chosen via the pack picker) — so "Fancy" (tiles + FX) is a topdown PACK, not a mode. A
|
||||
// (chosen via the pack picker), so "Fancy" (tiles + FX) is a topdown PACK, not a mode. A
|
||||
// premium mode unlocks once the user has ANY unlocked pack for its projection.
|
||||
RK.RENDER_MODES = [
|
||||
{ id: 'tiles', label: 'Tiled', premium: false, projection: 'topdown' },
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
RK.entitlements = RK.entitlements || { premium: false, level: 'free', packs: {} };
|
||||
// Per-session QA bypass for premium render-mode gating (Fancy/Iso/3D). Default OFF. A tester
|
||||
// opts IN for their own browser only via ?unlock=1 (persisted) — nobody else is affected:
|
||||
// opts IN for their own browser only via ?unlock=1 (persisted); nobody else is affected:
|
||||
// ?unlock=1 -> on (sticky) ?unlock=0 -> off
|
||||
RK.RENDER_MODE_TEST_UNLOCKS = false;
|
||||
try {
|
||||
|
||||
@@ -9,21 +9,21 @@
|
||||
// Tile kinds and how each renderer treats them (glyph for ASCII, colour for tiled/fancy).
|
||||
// `over` names the ground tile an object sits on, so the renderer composites the object over it
|
||||
// (its transparent areas show the floor/wall, not the dark canvas). Ground tiles (wall/floor/rug)
|
||||
// have no `over` — they fill the cell.
|
||||
// have no `over`; they fill the cell.
|
||||
var TAVERN_LEGEND = {
|
||||
wall: { char: '#', color: '#3a3f4b', solid: true },
|
||||
window: { char: 'W', color: '#4a6a8a', solid: true, over: 'wall' },
|
||||
floor: { char: '·', color: '#6b4d33', solid: false }, // warm wood, not the old dark green
|
||||
floor: { char: '·', color: '#6b4d33', solid: false }, // warm wood
|
||||
rug: { char: 'r', color: '#7a3b39', solid: false, over: 'floor' },
|
||||
bar: { char: '=', color: '#6a4a2a', solid: true, over: 'floor' },
|
||||
keg: { char: 'k', color: '#5a4028', solid: true, over: 'floor' },
|
||||
shelf: { char: 'h', color: '#4a3a28', solid: true, over: 'floor' },
|
||||
table: { char: 'T', color: '#6a4a2a', solid: true, over: 'floor' },
|
||||
chair: { char: 'c', color: '#5a4028', solid: false, over: 'floor' }, // walkable — stand on it to "sit"
|
||||
chair: { char: 'c', color: '#5a4028', solid: false, over: 'floor' }, // walkable: stand on it to "sit"
|
||||
barrel: { char: 'B', color: '#5a4028', solid: true, over: 'floor' },
|
||||
crate: { char: 'C', color: '#7a5a38', solid: true, over: 'floor' },
|
||||
door: { char: 'D', color: '#3fb950', solid: false, over: 'floor' },
|
||||
// Fire fixtures — the base tile is floor; RK.fx paints the animated flame + glow on top.
|
||||
// Fire fixtures: the base tile is floor; RK.fx paints the animated flame + glow on top.
|
||||
torch: { char: 'i', color: '#d29922', solid: true, over: 'floor', fx: 'fire', fxScale: 0.34 },
|
||||
hearth: { char: 'F', color: '#e0742a', solid: true, over: 'floor', fx: 'fire', fxScale: 0.7 }
|
||||
};
|
||||
@@ -95,7 +95,7 @@
|
||||
// ---- Dungeon game-state adapter ------------------------------------------------
|
||||
// Converts a game state (from the server's game_start/game_update/spectator_update) into a
|
||||
// renderer-agnostic Scene, so the render kit can draw the dungeon just like the tavern.
|
||||
// This is the bridge for milestone R1 (main game → render kit) and T2b (tavern spectator).
|
||||
// Used by both the main game and the tavern spectator view.
|
||||
|
||||
var DUNGEON_LEGEND = {
|
||||
wall: { char: '#', color: '#2a2f38', solid: true },
|
||||
@@ -107,8 +107,8 @@
|
||||
// The generator only places torches on WALL cells adjacent to floor, so the fixture is
|
||||
// wall-mounted: base on `wall`, draw the torch sprite over it, then RK.fx animates the flame.
|
||||
torch: { char: 'i', color: '#d29922', solid: false, over: 'wall', fx: 'fire', fxScale: 0.34 },
|
||||
// Hazard zones. The base tile stays walkable/ground; RK.fx paints the pulsing overlay. These
|
||||
// render as soon as the dungeon generator emits their chars (L/P/^) in the tile stream.
|
||||
// Hazard zones. The base tile stays walkable/ground; RK.fx paints the pulsing overlay.
|
||||
// Keyed off the dungeon generator's chars (L/P/^) in the tile stream.
|
||||
lava: { char: '≈', color: '#7a2a0e', solid: false, hazard: 'lava' },
|
||||
poison: { char: '≈', color: '#1c4a24', solid: false, hazard: 'poison' },
|
||||
spikes: { char: '^', color: '#3a3f48', solid: false, hazard: 'spikes' },
|
||||
@@ -130,7 +130,7 @@ function dungeonTileKind(ch) {
|
||||
return 'floor';
|
||||
}
|
||||
|
||||
// Grid dimensions from array grids OR sparse {y:{x:v}} maps (the single-player client uses the
|
||||
// Grid dimensions from array grids or sparse {y:{x:v}} maps (the single-player client uses the
|
||||
// latter). Returns the max seen index + 1 across every supplied map.
|
||||
function maxDims(maps) {
|
||||
var rows = 0, cols = 0;
|
||||
@@ -156,8 +156,8 @@ function maxDims(maps) {
|
||||
}
|
||||
|
||||
function normalizeGameStateOpts(opts) {
|
||||
// Older multiplayer callers passed a socket id directly. Keep that path working while the
|
||||
// public API moves to an options object (`{ viewerId, playerAppearance, cryptoType }`).
|
||||
// Accepts either a bare socket id (multiplayer callers) or an options object
|
||||
// (`{ viewerId, playerAppearance, cryptoType }`).
|
||||
if (typeof opts === 'string') return { viewerId: opts };
|
||||
return (opts && typeof opts === 'object') ? opts : {};
|
||||
}
|
||||
@@ -188,7 +188,7 @@ function pickCameraPlayer(players, state, opts) {
|
||||
p = players[i];
|
||||
if (p && state.winnerId && p.id === state.winnerId) return p;
|
||||
}
|
||||
// Spectators follow a live contender by default, then gracefully fall back to the first racer.
|
||||
// Spectators follow a live contender by default, then fall back to the first racer.
|
||||
for (i = 0; i < players.length; i++) {
|
||||
p = players[i];
|
||||
if (p && p.alive !== false && !p.finished) return p;
|
||||
@@ -219,7 +219,7 @@ function sceneFromGameState(state, opts) {
|
||||
var visible = state.visibleTiles || {};
|
||||
var explored = state.exploredTiles || {};
|
||||
var lighting = state.lighting || {};
|
||||
// Array grids expose .length; the SP client uses sparse {y:{x:v}} maps — fall back to the max
|
||||
// Array grids expose .length; the SP client uses sparse {y:{x:v}} maps, so fall back to the max
|
||||
// seen index. Explicit dungeonRows/Cols (server) always win.
|
||||
var dims = maxDims([visible, explored, state.map]);
|
||||
var rows = state.dungeonRows || visible.length || dims.rows;
|
||||
@@ -228,8 +228,8 @@ function sceneFromGameState(state, opts) {
|
||||
var matchPlayers = Array.isArray(state.players) ? state.players : [];
|
||||
var cameraPlayer = pickCameraPlayer(matchPlayers, state, opts);
|
||||
|
||||
// Player cell — explored MEMORY fades to black with distance from here (see below). Multiplayer
|
||||
// states use `players[]`, while the original single-player protocol uses singular `player`.
|
||||
// Player cell. Explored memory fades to black with distance from here. Multiplayer states use
|
||||
// `players[]`; the single-player protocol uses singular `player`.
|
||||
var _pl = state.player || cameraPlayer || {};
|
||||
var _plx = typeof _pl.x === 'number' ? _pl.x : (cols / 2);
|
||||
var _ply = typeof _pl.y === 'number' ? _pl.y : (rows / 2);
|
||||
@@ -256,9 +256,9 @@ function sceneFromGameState(state, opts) {
|
||||
var la = (lighting[y] && lighting[y][x]) || 0;
|
||||
lrow.push(Math.max(0.15, 1 - Math.min(la, 0.8)));
|
||||
} else {
|
||||
// Explored MEMORY (seen before, not currently visible): dim, and fading toward black
|
||||
// with distance from the player, so the remembered area melts into the dark instead
|
||||
// of forming a hard-edged grey block that outlines the map's shape/edge (a FoW leak).
|
||||
// Explored memory (seen before, not currently visible): dim, fading toward black with
|
||||
// distance from the player. A flat dim value would form a hard-edged grey block that
|
||||
// outlines the map's shape, leaking information through the fog of war.
|
||||
row.push(dungeonTileKind(ch));
|
||||
var _dx = x - _plx, _dy = y - _ply;
|
||||
lrow.push(Math.max(0, 0.26 - Math.sqrt(_dx * _dx + _dy * _dy) * 0.019));
|
||||
@@ -272,11 +272,11 @@ function sceneFromGameState(state, opts) {
|
||||
|
||||
// Fog of war: a cell is `seen` once explored (grid isn't 'dark'); `inView` only while currently
|
||||
// visible. Features (exit/treasure/entrance) show once discovered; the monster only shows while
|
||||
// in view — otherwise the exit stairs leak through the fog before you've found them.
|
||||
// in view, so its position does not leak through the fog.
|
||||
function seen(x, y) { return !!(grid[y] && grid[y][x] && grid[y][x] !== 'dark'); }
|
||||
function inView(x, y) { return !!(visible[y] && visible[y][x] !== undefined); }
|
||||
|
||||
// Entrance / exit / treasure as entities (so they layer above tiles) — only once explored.
|
||||
// Entrance / exit / treasure as entities (so they layer above tiles), only once explored.
|
||||
// Solo uses `[x,y]`; match state uses `{x,y}` for treasure, so accept both point shapes.
|
||||
var entrance = pointOf(state.entrance);
|
||||
var exit = pointOf(state.exit);
|
||||
@@ -292,7 +292,7 @@ function sceneFromGameState(state, opts) {
|
||||
entities.push({ id: 'treasure', x: treasure.x, y: treasure.y, kind: 'feature', char: tChar, color: '#fbbf24', label: null });
|
||||
}
|
||||
|
||||
// Items — only once explored.
|
||||
// Items, only once explored.
|
||||
if (state.items) {
|
||||
for (var key in state.items) {
|
||||
if (state.items.hasOwnProperty(key)) {
|
||||
@@ -304,7 +304,7 @@ function sceneFromGameState(state, opts) {
|
||||
}
|
||||
}
|
||||
|
||||
// Monster — only while it's actually in view.
|
||||
// Monster, only while it is in view.
|
||||
if (state.monster && inView(state.monster.x, state.monster.y)) {
|
||||
entities.push({
|
||||
id: 'monster', x: state.monster.x, y: state.monster.y,
|
||||
@@ -331,13 +331,13 @@ function sceneFromGameState(state, opts) {
|
||||
hasTreasure: !!rp.hasTreasure, placement: rp.placement == null ? null : rp.placement
|
||||
});
|
||||
}
|
||||
// Original single-player protocol.
|
||||
// Single-player protocol.
|
||||
} else if (state.player) {
|
||||
var playerEntity = {
|
||||
id: 'player', x: state.player.x, y: state.player.y,
|
||||
kind: 'player', char: '@', color: '#9aa4b2',
|
||||
// No bogus default: the SP server doesn't send facing, so leave it null and let the iso/3D
|
||||
// renderers INFER facing from movement. A hardcoded 'down' made them always face down/SW.
|
||||
// The SP server does not send facing. Leaving it null lets the iso/3D renderers infer
|
||||
// facing from movement; any hardcoded default would pin the sprite to one direction.
|
||||
facing: state.player.facing || null, label: null,
|
||||
you: !state.isSpectating, cameraTarget: true
|
||||
};
|
||||
@@ -369,9 +369,9 @@ function sceneFromGameState(state, opts) {
|
||||
|
||||
root.RK = root.RK || {};
|
||||
root.RK.scene = api;
|
||||
// Convenience aliases on RK directly — several callers (matchClient, tavern spectator, the SP
|
||||
// render bridge) reference RK.sceneFromGameState / RK.sceneFromTavern; without these they were
|
||||
// silently undefined (guarded → no-op). Expose them so those paths actually render.
|
||||
// Convenience aliases on RK directly. matchClient, the tavern spectator and the SP render bridge
|
||||
// reference RK.sceneFromGameState / RK.sceneFromTavern; without these they are undefined and
|
||||
// those guarded call sites silently render nothing.
|
||||
root.RK.sceneFromGameState = root.RK.sceneFromGameState || sceneFromGameState;
|
||||
if (api.sceneFromTavern) root.RK.sceneFromTavern = root.RK.sceneFromTavern || api.sceneFromTavern;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// Character skins for the render kit. A skin is an animated sprite sheet (rows = facing
|
||||
// directions, cols = animation frames) produced by the sprite pipeline
|
||||
// (~/src/docs/animated-sprite-pipeline.md). Sheets are HEAVY and PREMIUM, so they are:
|
||||
// Character skins for the render kit. A skin is an animated sprite sheet: rows are facing
|
||||
// directions, columns are animation frames. Sheets are large and premium, so they are:
|
||||
// - lazy-loaded (only fetched when an entity actually uses the skin, in a sprite-capable mode),
|
||||
// - gated by entitlement (RK.entitlements.premium),
|
||||
// - delivered as WebP.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// SP game render bridge (RK.SPGame) — renders the single-player DUNGEON through the render kit
|
||||
// (Tiled / ASCII / Iso / 3D + unlocked packs) instead of the legacy ROT display, so a pack you
|
||||
// bought applies in actual gameplay, not just the tavern. It mounts a renderer in a dedicated host
|
||||
// SP game render bridge (RK.SPGame): renders the single-player dungeon through the render kit
|
||||
// (Tiled / ASCII / Iso / 3D + unlocked packs) rather than the legacy ROT display, so a purchased
|
||||
// pack applies in gameplay and not only in the tavern. It mounts a renderer in a dedicated host
|
||||
// inside #game-display and hides the legacy ROT canvases while a game is live; the retro splash /
|
||||
// win / lose text screens still use ROT. If anything here is unavailable it returns false and the
|
||||
// caller falls back to the legacy RenderEngine, so the live game can never hard-break.
|
||||
// win / lose text screens still use ROT. When anything here is unavailable it returns false and the
|
||||
// caller falls back to the legacy RenderEngine, so a live game can never hard-break.
|
||||
(function (root) {
|
||||
'use strict';
|
||||
var RK = root.RK = root.RK || {};
|
||||
@@ -29,9 +29,8 @@
|
||||
return doc().activeElement === gd;
|
||||
};
|
||||
|
||||
// A dedicated, SELF-SIZING child host so the RK canvas doesn't collide with the ROT canvas and
|
||||
// doesn't depend on the ROT canvas for its dimensions (it's an aspect-ratio viewport the camera
|
||||
// transform is clipped to).
|
||||
// A dedicated, self-sizing child host so the RK canvas neither collides with the ROT canvas nor
|
||||
// depends on it for dimensions; it is the viewport the camera transform is clipped to.
|
||||
function rkHost() {
|
||||
var gd = gameDisplay();
|
||||
if (!gd) return null;
|
||||
@@ -39,12 +38,12 @@
|
||||
if (!host) {
|
||||
host = doc().createElement('div');
|
||||
host.id = 'rk-game-host';
|
||||
// FILL #game-display exactly (inset:0). #game-display is a definite, grid-bounded height
|
||||
// (the .container is height:100vh; overflow:hidden), so filling it gives the camera
|
||||
// reliable clientWidth/clientHeight every frame. The earlier `min(78vh)` + translate(-50%)
|
||||
// centering could resolve to a wrong/transient size, stranding the view in a top band.
|
||||
// Pure-black background so the area BEYOND the map edge is the SAME black as an
|
||||
// unexplored ('dark') in-bounds tile — otherwise the shade difference outlines the map.
|
||||
// inset:0 fills #game-display exactly. #game-display is a definite, grid-bounded height
|
||||
// (.container is height:100vh; overflow:hidden), so filling it gives the camera reliable
|
||||
// clientWidth/clientHeight every frame. Height caps plus translate(-50%) centering resolve
|
||||
// to a wrong or transient size and strand the view in a top band, so avoid them.
|
||||
// Pure-black background keeps the area beyond the map edge the same black as an
|
||||
// unexplored ('dark') in-bounds tile; any shade difference outlines the map.
|
||||
host.style.cssText = 'display:none; position:absolute; inset:0;' +
|
||||
' overflow:hidden; background:#000; z-index:6; touch-action:none; cursor:grab;';
|
||||
gd.appendChild(host);
|
||||
@@ -131,8 +130,8 @@
|
||||
if (!host || !RK.createRenderer) return null;
|
||||
var mode = SP.mode();
|
||||
// 3D needs THREE, lazy-loaded from the pinned same-origin vendor route (or the explicitly
|
||||
// enabled CDN fallback). The sync createRenderer() returns Tiled until it is ready; remount
|
||||
// as real 3D after loading so the screen never goes blank.
|
||||
// enabled CDN fallback). The sync createRenderer() returns Tiled until it is ready, so the
|
||||
// renderer is remounted as real 3D once loading finishes and the screen never goes blank.
|
||||
if (mode === '3d' && RK.ensureThree && RK.threeReady && !RK.threeReady()) {
|
||||
if (!SP._threePending) {
|
||||
SP._threePending = true;
|
||||
@@ -146,17 +145,17 @@
|
||||
}
|
||||
}
|
||||
SP._renderer = RK.createRenderer(mode, host, { cell: 24 });
|
||||
// The camera owns the canvas transform (centre on the player), so position it absolutely
|
||||
// and let it overflow — do NOT use RK.attachZoom (it fights this transform).
|
||||
// The camera owns the canvas transform (centre on the player), so the canvas is positioned
|
||||
// absolutely and allowed to overflow. RK.attachZoom fights this transform; do not use it.
|
||||
if (SP._renderer && SP._renderer.canvas) {
|
||||
var c = SP._renderer.canvas;
|
||||
c.style.position = 'absolute'; c.style.top = '0'; c.style.left = '0';
|
||||
// Beat the `.rotdis canvas { max-width/height:100%; object-fit:contain }` rules (meant for
|
||||
// the legacy ROT canvas) so the render-kit buffer maps 1:1 to the element and the CAMERA
|
||||
// transform is the SOLE controller of scale/position. WITHOUT the max-height/object-fit
|
||||
// override, a canvas TALLER than the host got object-fit-shrunk and the camera translate —
|
||||
// computed in buffer space — pushed the player far off-screen (iso went fully BLACK; tiled's
|
||||
// shorter canvas usually fit the host so it escaped the bug).
|
||||
// Overrides the `.rotdis canvas { max-width/height:100%; object-fit:contain }` rules meant
|
||||
// for the legacy ROT canvas, so the render-kit buffer maps 1:1 to the element and the
|
||||
// camera transform is the sole controller of scale and position. Without these overrides a
|
||||
// canvas taller than the host is object-fit-shrunk while the camera translate is computed
|
||||
// in buffer space, pushing the player off-screen (iso canvases are tall enough to hit this;
|
||||
// tiled canvases usually fit the host).
|
||||
c.style.setProperty('max-width', 'none', 'important');
|
||||
c.style.setProperty('max-height', 'none', 'important');
|
||||
c.style.setProperty('object-fit', 'fill', 'important');
|
||||
@@ -166,9 +165,9 @@
|
||||
host._rkZoomBound = true;
|
||||
host.addEventListener('wheel', function (ev) {
|
||||
ev.preventDefault();
|
||||
// Base the step on the CURRENT effective zoom (mode-aware default if not yet set),
|
||||
// NOT a hardcoded 1.7 — otherwise the first scroll on iso (default 1.0) jumped up to
|
||||
// 1.7 and BOTH directions read as "zoom in".
|
||||
// The step is based on the current effective zoom (mode-aware default when unset).
|
||||
// A hardcoded base makes the first scroll in a mode with a different default jump to
|
||||
// that base, so both directions read as "zoom in".
|
||||
var base = (SP._zoom != null) ? SP._zoom : SP._defaultZoom();
|
||||
SP._setZoom(base * Math.exp(-ev.deltaY * 0.0015));
|
||||
}, { passive: false });
|
||||
@@ -208,14 +207,14 @@
|
||||
return 1.7;
|
||||
};
|
||||
|
||||
// Centre the (whole-scene) canvas on the player via a CSS transform, clipped to the host —
|
||||
// renderer-agnostic (works for tiled / iso / 3d, each of which reports its own focusPoint).
|
||||
// Centre the whole-scene canvas on the player via a CSS transform, clipped to the host. This is
|
||||
// renderer-agnostic: tiled, iso and 3d each report their own focusPoint.
|
||||
SP._applyCamera = function () {
|
||||
var r = SP._renderer;
|
||||
var host = doc() && doc().getElementById('rk-game-host');
|
||||
if (!r || !r.canvas || !host) return;
|
||||
// The 3D renderer fills the host itself (setSize) and its OWN THREE camera follows the
|
||||
// player — so DON'T apply a CSS transform (it would double-transform the WebGL view).
|
||||
// The 3D renderer fills the host itself (setSize) and its own THREE camera follows the
|
||||
// player, so it takes no CSS transform; one would double-transform the WebGL view.
|
||||
if (r.name === '3d') {
|
||||
if (r.setZoom) r.setZoom((SP._zoom != null) ? SP._zoom : SP._defaultZoom());
|
||||
r.canvas.style.transform = 'none';
|
||||
@@ -225,10 +224,10 @@
|
||||
}
|
||||
var scale = (SP._zoom != null) ? SP._zoom : SP._defaultZoom();
|
||||
var fp = r.focusPoint;
|
||||
// ROBUSTNESS: if the renderer hasn't reported a focus this frame — a race at game start, or a
|
||||
// camera tick reading a just-(re)created renderer instance before its first render — derive the
|
||||
// focus straight from the last known player cell (tiled coords). Without this the camera falls
|
||||
// back to a scale-only transform that pins the whole level to the top-left corner.
|
||||
// A renderer can lack a focus for a frame: at game start, or when a camera tick reads a
|
||||
// just-created renderer instance before its first render. In that case the focus is derived
|
||||
// from the last known player cell (tiled coords); otherwise the camera falls back to a
|
||||
// scale-only transform that pins the whole level to the top-left corner.
|
||||
if (!fp && (r.name === 'tiles' || r.name === 'ascii') && SP._lastState && SP._lastState.player &&
|
||||
typeof SP._lastState.player.x === 'number') {
|
||||
var _c = r.cell || 24;
|
||||
@@ -238,8 +237,8 @@
|
||||
if (!fp) { r.canvas.style.transform = 'scale(' + scale + ')'; return; }
|
||||
var w = host.clientWidth || host.offsetWidth || 640, h = host.clientHeight || host.offsetHeight || 400;
|
||||
var tx = w / 2 - fp.x * scale, ty = h / 2 - fp.y * scale;
|
||||
// Smooth follow: glide toward the target so single steps aren't jerky; SNAP on a big jump
|
||||
// (level-change teleport / a stale-then-updated focus) rather than slowly panning the map.
|
||||
// Smooth follow: glide toward the target so single steps aren't jerky, but snap on a big jump
|
||||
// (level-change teleport, or a stale focus that then updates) rather than panning the map.
|
||||
if (SP._camX == null || Math.abs(tx - SP._camX) > w || Math.abs(ty - SP._camY) > h) {
|
||||
SP._camX = tx; SP._camY = ty;
|
||||
} else {
|
||||
@@ -272,8 +271,8 @@
|
||||
return true;
|
||||
};
|
||||
|
||||
// Re-apply the camera every frame while live — robust against the renderer resizing its canvas
|
||||
// or a transient focusPoint, which was making the view snap to the corner mid-run.
|
||||
// Re-applies the camera every frame while live, so a renderer resizing its canvas or a transient
|
||||
// focusPoint cannot leave the view snapped to a corner mid-run.
|
||||
SP._startCameraLoop = function () {
|
||||
if (SP._camRaf != null) return;
|
||||
function tick() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// TavernLife — client-side NPCs (bartender, patrons, cat) + speech bubbles that make the
|
||||
// TavernLife: client-side NPCs (bartender, patrons, cat) + speech bubbles that make the
|
||||
// tavern feel alive. NPCs are decorative: they wander, sit, and chat. They're client-side
|
||||
// only (no server round-trips), so each client sees its own lively room — the real players
|
||||
// only (no server round-trips), so each client sees its own lively room; the real players
|
||||
// come from the server snapshot. Speech bubbles pop above any occupant (NPC or real player)
|
||||
// when they speak.
|
||||
(function (root) {
|
||||
@@ -26,9 +26,9 @@
|
||||
// ---- Speech lines -----------------------------------------------------------
|
||||
var BARTENDER_LINES = [
|
||||
"What'll it be, traveler?", "Ale's fresh today.", "Tab's full, friend.",
|
||||
"Watch the monsters out there.", "Block's coming — hurry back.",
|
||||
"Watch the monsters out there.", "Block's coming, hurry back.",
|
||||
"I've seen braver than you not return.", "House special: liquid courage.",
|
||||
"No credit — coin on the barrel.", "Heard someone found treasure last block.",
|
||||
"No credit: coin on the barrel.", "Heard someone found treasure last block.",
|
||||
"The dungeon's hungry tonight.", "Keep your wits about you.", "One ale, coming up."
|
||||
];
|
||||
var PATRON_LINES = [
|
||||
@@ -40,7 +40,7 @@
|
||||
"You new here?", "Don't go in unprepared.", "The exit was so close..."
|
||||
];
|
||||
var CAT_LINES = ["meow", "mrrrow", "prrrrp", "hisss", "...", "mew"];
|
||||
var WELCOME_LINES = ["Welcome!", "Pull up a stool.", "New face — nice.", "Come in, sit down."];
|
||||
var WELCOME_LINES = ["Welcome!", "Pull up a stool.", "New face, nice.", "Come in, sit down."];
|
||||
|
||||
function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; }
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
return { minY: 3, maxY: rows - 2, minX: 1, maxX: cols - 2 };
|
||||
}
|
||||
function catArea(cols, rows) {
|
||||
// The customer floor (in front of the bar) — not behind the counter, not the walls.
|
||||
// The customer floor (in front of the bar): not behind the counter, not the walls.
|
||||
return { minY: 4, maxY: rows - 2, minX: 1, maxX: cols - 2 };
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@
|
||||
}));
|
||||
}
|
||||
|
||||
// A tavern cat — a random pixel cat from the Pet Cats Pack that wanders the floor.
|
||||
// A tavern cat: a random pixel cat from the Pet Cats Pack that wanders the floor.
|
||||
var catSpot = findFloor(4, rows - 4);
|
||||
var cat = makeNPC('cat', 'cat', null, catSpot.x, catSpot.y, {
|
||||
label: null, color: '#e8a060', role: 'cat', roamArea: catArea(cols, rows)
|
||||
@@ -281,7 +281,7 @@
|
||||
else if (stepY < 0) npc.facing = 'up';
|
||||
else if (stepY > 0) npc.facing = 'down';
|
||||
} else {
|
||||
// Blocked — pick a new target.
|
||||
// Blocked: pick a new target.
|
||||
this._pickTarget(npc, scene);
|
||||
}
|
||||
// Cat moves faster; bartender slower.
|
||||
@@ -350,7 +350,7 @@
|
||||
y: originY + (e.x + e.y) * tileH / 2
|
||||
};
|
||||
} else if (mode === '3d') {
|
||||
// 3D renderer uses Three.js — we can't easily get screen coords.
|
||||
// 3D renderer uses Three.js; we can't easily get screen coords.
|
||||
// Skip speech bubbles for 3D mode.
|
||||
return null;
|
||||
} else {
|
||||
@@ -396,7 +396,7 @@
|
||||
}
|
||||
};
|
||||
|
||||
// Main update — called every frame via rAF.
|
||||
// Main update: called every frame via rAF.
|
||||
TavernLife.prototype.update = function (scene, cell, mode) {
|
||||
if (!scene || !this._spawned) return;
|
||||
var now = Date.now();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ThreeRenderer — a lit, textured 3D projection of the shared Scene. It uses generated GLB avatars
|
||||
// when available and falls back to low-poly pieces so the mode remains usable during asset work.
|
||||
// ThreeRenderer: a lit, textured 3D projection of the shared Scene. It uses GLB avatars when
|
||||
// available and falls back to low-poly pieces so the mode stays usable without them.
|
||||
//
|
||||
// The level is drawn with three InstancedMeshes (walls / ground / props) so a 70x35 dungeon costs
|
||||
// three draw calls instead of ~2500 individual meshes, and per-instance colour carries the same
|
||||
@@ -16,8 +16,8 @@
|
||||
return fallback || null;
|
||||
}
|
||||
|
||||
// Deterministic PRNG — generated textures are byte-identical every session, so the dungeon
|
||||
// doesn't subtly re-skin itself on reload.
|
||||
// Deterministic PRNG: generated textures are byte-identical every session, so the dungeon
|
||||
// does not subtly re-skin itself on reload.
|
||||
function lcg(seed) {
|
||||
var s = seed >>> 0;
|
||||
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
|
||||
@@ -55,7 +55,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Irregular flagstones — a broken grout grid plus grain, so a large floor never shows an
|
||||
// Irregular flagstones: a broken grout grid plus grain, so a large floor never shows an
|
||||
// obvious repeat.
|
||||
function groundCanvas(size) {
|
||||
return makeCanvas(size, function (c, s) {
|
||||
@@ -115,7 +115,7 @@
|
||||
};
|
||||
var HAZARD_GLOW = { lava: 0.85, poison: 0.5, spikes: 0.12 };
|
||||
// Ground tints. The scene legend colours are tuned for flat 2D fills and read almost black in a
|
||||
// lit 3D scene, so wall/floor use material-appropriate values and everything else falls back to
|
||||
// lit 3D scene, so wall/floor use material-appropriate values; everything else falls back to
|
||||
// the legend.
|
||||
var KIND_TINT = {
|
||||
wall: '#a49a8b', window: '#7d94a8', door: '#9c7a4a', archway: '#a49a8b',
|
||||
@@ -153,9 +153,9 @@
|
||||
// Fog-of-war parity with the 2D modes: the camera follows the player, so distance fog fades
|
||||
// explored tiles to black away from you, and _buildTiles skips unexplored ('dark') tiles so
|
||||
// they never render. Together: unexplored = hidden, explored fades with distance, no map leak.
|
||||
// Fog is measured from the CAMERA, which sits ~17 units back along the isometric offset —
|
||||
// not from the player. A band tuned as if it started at the player put the player themselves
|
||||
// a quarter of the way into fog and crushed the whole scene to near-black.
|
||||
// The fog band is measured from the CAMERA, which sits ~17 units back along the isometric
|
||||
// offset, not from the player. Tuning it as if it started at the player puts the player a
|
||||
// quarter of the way into fog and crushes the scene to near-black.
|
||||
this.scene.fog = new T.Fog(0x05060a, 21, 46);
|
||||
this.camera = new T.OrthographicCamera(-10, 10, 10, -10, 0.1, 120);
|
||||
this.camera.position.set(9, 11, 9);
|
||||
@@ -180,7 +180,7 @@
|
||||
var hemi = new T.HemisphereLight(0x9fb4d8, 0x161a24, 0.5);
|
||||
this.scene.add(hemi);
|
||||
this.keyLight = new T.DirectionalLight(0xffd9a8, 2.3);
|
||||
this.keyLight.position.set(10, 8.5, 4); // low sun — long, readable wall shadows
|
||||
this.keyLight.position.set(10, 8.5, 4); // low sun: long, readable wall shadows
|
||||
this.keyLight.castShadow = true;
|
||||
this.keyLight.shadow.mapSize.set(1024, 1024);
|
||||
this.keyLight.shadow.camera.near = 1;
|
||||
@@ -250,7 +250,7 @@
|
||||
map: groundMap, bumpMap: groundBump, bumpScale: 0.35, roughness: 0.96, metalness: 0.0
|
||||
});
|
||||
this.propMat = new T.MeshStandardMaterial({ roughness: 0.8, metalness: 0.05 });
|
||||
// Hazards/fire glow on their own so they stay legible in an unlit corridor.
|
||||
// Hazards/fire glow on their own so they stay legible in an unlit corridor.
|
||||
this.glowMat = new T.MeshStandardMaterial({
|
||||
roughness: 0.55, metalness: 0.0, emissive: new T.Color(0xffffff), emissiveIntensity: 0.9
|
||||
});
|
||||
@@ -282,16 +282,16 @@
|
||||
this.scene.add(this.motes);
|
||||
};
|
||||
|
||||
// Fill the host and frame a fixed "screenful" around the player. The camera FOLLOWS the player
|
||||
// Fill the host and frame a fixed "screenful" around the player. The camera follows the player
|
||||
// (see _animate) instead of framing the whole level, so 3D behaves like the other modes. Only
|
||||
// does work when the host size actually changed (cheap to call every frame).
|
||||
// does work when the host size actually changed, so it is cheap to call every frame.
|
||||
ThreeRenderer.prototype._fitToHost = function () {
|
||||
var host = this.host;
|
||||
var w = (host && (host.clientWidth || host.offsetWidth)) || 640;
|
||||
var h = (host && (host.clientHeight || host.offsetHeight)) || 400;
|
||||
if (w === this._cw && h === this._ch) return;
|
||||
this._cw = w; this._ch = h;
|
||||
this.renderer.setSize(w, h, true); // updateStyle:true → the canvas CSS-fills the host
|
||||
this.renderer.setSize(w, h, true); // updateStyle:true so the canvas CSS-fills the host
|
||||
var aspect = w / h, span = 6.6 / (this.zoom || 1); // fixed screenful, user-adjustable zoom
|
||||
this.camera.left = -span * aspect;
|
||||
this.camera.right = span * aspect;
|
||||
@@ -308,9 +308,9 @@
|
||||
this._fitToHost();
|
||||
};
|
||||
|
||||
// `attachCamera` reports drag pan in viewport pixels. Keep that contract for 3D and convert the
|
||||
// `attachCamera` reports drag pan in viewport pixels. 3D keeps that contract and converts the
|
||||
// offset to ground-plane world units in _animate(), where the followed player's current smoothed
|
||||
// position is available. Bound it to one viewport so a stray gesture cannot lose the dungeon.
|
||||
// position is available. The bound of one viewport keeps a stray gesture from losing the dungeon.
|
||||
ThreeRenderer.prototype.setPan = function (x, y) {
|
||||
var w = (this.host && (this.host.clientWidth || this.host.offsetWidth)) || 640;
|
||||
var h = (this.host && (this.host.clientHeight || this.host.offsetHeight)) || 400;
|
||||
@@ -336,8 +336,8 @@
|
||||
});
|
||||
};
|
||||
|
||||
// One InstancedMesh per bucket, grown geometrically. Reusing the mesh across rebuilds is what
|
||||
// keeps a fog-of-war update from re-allocating the whole level every time the player steps.
|
||||
// One InstancedMesh per bucket, grown geometrically. Reusing the mesh across rebuilds keeps a
|
||||
// fog-of-war update from re-allocating the whole level every time the player steps.
|
||||
ThreeRenderer.prototype._bucket = function (name, geom, mat, needed) {
|
||||
var T = this.THREE;
|
||||
var b = this.buckets[name];
|
||||
@@ -374,13 +374,13 @@
|
||||
for (y = 0; y < scene.rows; y++) {
|
||||
for (x = 0; x < scene.cols; x++) {
|
||||
kind = scene.grid[y] && scene.grid[y][x];
|
||||
if (!kind || kind === 'dark') continue; // unexplored — never render it (fog-of-war)
|
||||
if (!kind || kind === 'dark') continue; // unexplored: never rendered (fog-of-war)
|
||||
var def = legend[kind] || {};
|
||||
var lb = (light && light[y] && light[y][x] != null) ? light[y][x] : 1;
|
||||
// Unlike the 2D modes — where lightGrid IS the lighting — 3D has real point lights
|
||||
// doing the falloff, so applying the grid at full strength to the albedo as well
|
||||
// double-darkened every surface into near-black. Compress it into a gentle range and
|
||||
// let the lamps shape the scene.
|
||||
// In the 2D modes lightGrid IS the lighting; here real point lights do the falloff.
|
||||
// Applying the grid at full strength to the albedo as well would double-darken every
|
||||
// surface into near-black, so it is compressed into a gentle range and the lamps
|
||||
// shape the scene.
|
||||
var b = 0.38 + 0.62 * Math.max(0, Math.min(1, lb));
|
||||
var tint = KIND_TINT[kind] || def.color || '#6d6558';
|
||||
var cell = { x: x - cx, z: y - cz, gx: x, gy: y, b: b, tint: tint, kind: kind };
|
||||
@@ -427,7 +427,7 @@
|
||||
p.set(cell.x, h / 2, cell.z);
|
||||
m.compose(p, q, s);
|
||||
b.mesh.setMatrixAt(i, m);
|
||||
// Hazards keep near-full brightness — a lava pool you can barely see is a trap, not a
|
||||
// Hazards keep near-full brightness: a lava pool you can barely see is a trap, not a
|
||||
// difficulty curve.
|
||||
var hb = cell.hazard ? Math.max(0.7, cell.b) : cell.b;
|
||||
c.set(colorNum(cell.tint)).multiplyScalar(hb);
|
||||
@@ -438,7 +438,7 @@
|
||||
if (b.mesh.instanceColor) b.mesh.instanceColor.needsUpdate = true;
|
||||
};
|
||||
|
||||
// Walls are rewritten whenever the player's cell changes so the cutaway can follow them: walls
|
||||
// Walls are rewritten whenever the player's cell changes so the cutaway follows them: walls
|
||||
// sitting between the isometric camera (+x/+z) and the player are squashed and darkened, the 3D
|
||||
// equivalent of the iso renderer's fade. Without it the near wall of every corridor hides you.
|
||||
ThreeRenderer.prototype._writeWalls = function () {
|
||||
@@ -467,9 +467,9 @@
|
||||
if (b.mesh.instanceColor) b.mesh.instanceColor.needsUpdate = true;
|
||||
};
|
||||
|
||||
// Braziers on the torch walls. Only the cells that newly appeared get geometry — the tile
|
||||
// buckets are rewritten on every move (that is how remembered tiles keep dimming), and building
|
||||
// a fresh mesh per torch per step would churn the GPU for no visual gain.
|
||||
// Braziers on the torch walls. Only newly appeared cells get geometry: the tile buckets are
|
||||
// rewritten on every move (that is how remembered tiles keep dimming), and building a fresh mesh
|
||||
// per torch per step would churn the GPU for no visual gain.
|
||||
ThreeRenderer.prototype._syncFixtures = function () {
|
||||
var T = this.THREE;
|
||||
this._flames = this._flames || {};
|
||||
@@ -550,9 +550,8 @@
|
||||
return g;
|
||||
};
|
||||
|
||||
// Dungeon FEATURES/ITEMS (entrance/exit/treasure/pickups) are set dressing, not avatars. Before
|
||||
// this branch existed every one of them was drawn as a humanoid capsule, so the entrance looked
|
||||
// like a second player standing at the map origin.
|
||||
// Dungeon features and items (entrance/exit/treasure/pickups) are set dressing, not avatars, so
|
||||
// they get their own geometry rather than the humanoid capsule an avatar would draw.
|
||||
ThreeRenderer.prototype._makeFeature = function (e) {
|
||||
var T = this.THREE;
|
||||
var g = new T.Group();
|
||||
@@ -771,10 +770,10 @@
|
||||
if (pcell) { this._plx = pcell.x; this._ply = pcell.y; }
|
||||
|
||||
// Rewrite the level every render. `render` is called on game updates (a keypress), not per
|
||||
// frame, and the buckets are three reused InstancedMeshes — so this is a few thousand matrix
|
||||
// frame, and the buckets are three reused InstancedMeshes, so this is a few thousand matrix
|
||||
// writes, not an allocation. It has to be unconditional: `lightGrid` re-shades remembered
|
||||
// tiles on EVERY step, so keying the rebuild on the grid alone froze the fog-of-war fade at
|
||||
// whatever it was when a cell was first seen.
|
||||
// tiles on every step, so keying the rebuild on the grid alone would freeze the fog-of-war
|
||||
// fade at whatever it was when a cell was first seen.
|
||||
this._buildTiles(scene);
|
||||
|
||||
var seen = {}, cx = (scene.cols - 1) / 2, cz = (scene.rows - 1) / 2;
|
||||
@@ -866,7 +865,7 @@
|
||||
}
|
||||
this._playAction(o, moving ? 'run' : 'idle');
|
||||
// Face the way you move. Prefer explicit facing (tavern); else infer from the world delta
|
||||
// (the SP game doesn't send player.facing). world x=grid x, world z=grid y.
|
||||
// (the SP game does not send player.facing). world x=grid x, world z=grid y.
|
||||
var face = ent.e && ent.e.facing;
|
||||
if (!face && moving) { face = Math.abs(dx) >= Math.abs(dz) ? (dx > 0 ? 'right' : 'left') : (dz > 0 ? 'down' : 'up'); ent._face = face; }
|
||||
face = face || ent._face;
|
||||
@@ -877,7 +876,7 @@
|
||||
}
|
||||
// Player-follow camera: keep the fixed iso offset but re-target the player's world position
|
||||
// each frame. The player mesh itself lerps, so the camera glides smoothly with it.
|
||||
this._fitToHost(); // pick up any host resize
|
||||
this._fitToHost(); // picks up any host resize
|
||||
var pcam = null;
|
||||
for (var pid in this.entities) {
|
||||
var pen = this.entities[pid];
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
// Tile-picker — a dev overlay to map a theme's tileset cells to game tile-kinds by CLICKING.
|
||||
// Pick a kind (floor/wall/bar/table), click the matching cell in the sheet; it's saved to
|
||||
// localStorage and the tavern re-renders live. Works for any pack, so no coordinate-guessing.
|
||||
(function (root) {
|
||||
'use strict';
|
||||
var RK = root.RK = root.RK || {};
|
||||
|
||||
RK.openTilePicker = function (onChange) {
|
||||
var themeId = RK.activeThemeId;
|
||||
var atlas = RK.themeAtlas(themeId);
|
||||
if (!atlas) return;
|
||||
atlas.onReady(function () { build(themeId, atlas, onChange); });
|
||||
};
|
||||
|
||||
function build(themeId, atlas, onChange) {
|
||||
var SCALE = 2;
|
||||
var stride = atlas.tile + atlas.spacing;
|
||||
|
||||
var wrap = document.createElement('div');
|
||||
wrap.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.82);z-index:9999;overflow:auto;padding:16px;font-family:ui-monospace,monospace;color:#d7dbe0;';
|
||||
|
||||
var bar = document.createElement('div');
|
||||
bar.style.cssText = 'display:flex;gap:8px;align-items:center;margin-bottom:8px;flex-wrap:wrap;';
|
||||
var active = RK.TILE_KINDS[0];
|
||||
var status = document.createElement('span');
|
||||
status.style.cssText = 'font-size:13px;color:#9cf;';
|
||||
|
||||
RK.TILE_KINDS.forEach(function (k) {
|
||||
var b = document.createElement('button');
|
||||
b.textContent = k; b.dataset.kind = k;
|
||||
b.style.cssText = 'font:inherit;padding:6px 10px;border:1px solid #2a313a;border-radius:4px;background:#0a0c0f;color:#d7dbe0;cursor:pointer;';
|
||||
b.onclick = function () { active = k; refresh(); status.textContent = 'Click the tile for: ' + k; };
|
||||
bar.appendChild(b);
|
||||
});
|
||||
var done = document.createElement('button');
|
||||
done.textContent = 'Done';
|
||||
done.style.cssText = 'font:inherit;padding:6px 12px;border:0;border-radius:4px;background:#3fb950;color:#08130a;cursor:pointer;font-weight:600;margin-left:auto;';
|
||||
done.onclick = function () { document.body.removeChild(wrap); };
|
||||
bar.appendChild(status); bar.appendChild(done);
|
||||
function refresh() {
|
||||
Array.prototype.forEach.call(bar.querySelectorAll('button[data-kind]'), function (b) {
|
||||
b.style.outline = b.dataset.kind === active ? '2px solid #3fb950' : 'none';
|
||||
});
|
||||
}
|
||||
wrap.appendChild(bar);
|
||||
|
||||
var cv = document.createElement('canvas');
|
||||
cv.width = atlas.img.width * SCALE; cv.height = atlas.img.height * SCALE;
|
||||
cv.style.cssText = 'image-rendering:pixelated;border:1px solid #2a313a;background:#111;cursor:crosshair;';
|
||||
var ctx = cv.getContext('2d');
|
||||
|
||||
function draw() {
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||
ctx.drawImage(atlas.img, 0, 0, cv.width, cv.height);
|
||||
ctx.strokeStyle = 'rgba(63,185,80,0.22)'; ctx.lineWidth = 1;
|
||||
for (var c = 0; c <= atlas.cols; c++) { ctx.beginPath(); ctx.moveTo(c * stride * SCALE, 0); ctx.lineTo(c * stride * SCALE, cv.height); ctx.stroke(); }
|
||||
for (var r = 0; r <= atlas.rows; r++) { ctx.beginPath(); ctx.moveTo(0, r * stride * SCALE); ctx.lineTo(cv.width, r * stride * SCALE); ctx.stroke(); }
|
||||
var map = RK.tileMap(themeId);
|
||||
RK.TILE_KINDS.forEach(function (k) {
|
||||
var co = map[k]; if (!co) return;
|
||||
ctx.strokeStyle = '#ffb800'; ctx.lineWidth = 2;
|
||||
ctx.strokeRect(co[0] * stride * SCALE, co[1] * stride * SCALE, atlas.tile * SCALE, atlas.tile * SCALE);
|
||||
ctx.fillStyle = '#ffb800'; ctx.font = 'bold 11px monospace';
|
||||
ctx.fillText(k[0].toUpperCase(), co[0] * stride * SCALE + 2, co[1] * stride * SCALE + 11);
|
||||
});
|
||||
}
|
||||
cv.onclick = function (e) {
|
||||
var rect = cv.getBoundingClientRect();
|
||||
var col = Math.floor(((e.clientX - rect.left) / SCALE) / stride);
|
||||
var row = Math.floor(((e.clientY - rect.top) / SCALE) / stride);
|
||||
if (col < 0 || row < 0 || col >= atlas.cols || row >= atlas.rows) return;
|
||||
RK.setTile(themeId, active, col, row);
|
||||
draw();
|
||||
if (onChange) onChange();
|
||||
};
|
||||
|
||||
wrap.appendChild(cv);
|
||||
document.body.appendChild(wrap);
|
||||
refresh(); status.textContent = 'Click the tile for: ' + active; draw();
|
||||
}
|
||||
})(window);
|
||||
@@ -1,4 +1,4 @@
|
||||
// TileRenderer — draws a Scene as tiled sprites + entity sprites on a 2D canvas.
|
||||
// TileRenderer: draws a Scene as tiled sprites + entity sprites on a 2D canvas.
|
||||
// The default tier. Pulls tiles from the theme atlas (Kenney roguelikeSheet) when loaded,
|
||||
// with a flat-colour fallback for any unmapped kind. Adds torch-lit dungeon atmosphere:
|
||||
// per-cell tile variation, drop shadows, a warm torch light on the player + edge vignette,
|
||||
@@ -90,10 +90,9 @@
|
||||
var tmap = (window.RK && RK.tileMap) ? RK.tileMap() : {};
|
||||
var useAtlas = !!(atlas && atlas.ready);
|
||||
|
||||
// The atlas image loads async. If you switch INTO this renderer (e.g. entering the tavern
|
||||
// in Tiled mode) before it's ready, the first paint uses the flat-colour fallback — which
|
||||
// looks like plain blocks. Without a re-render it stays stuck there until the next scene
|
||||
// update. Hook the atlas so we repaint the moment it's ready.
|
||||
// The atlas image loads async. Switching into this renderer before it is ready paints the
|
||||
// flat-colour fallback, and nothing repaints until the next scene update. Hooking onReady
|
||||
// forces a repaint as soon as the atlas is available.
|
||||
if (atlas && !atlas.ready && !this._atlasHooked && typeof atlas.onReady === 'function') {
|
||||
this._atlasHooked = true;
|
||||
var self = this;
|
||||
@@ -101,7 +100,7 @@
|
||||
}
|
||||
|
||||
if (!scene.isDungeon && window.RK && RK.roomReady && RK.roomReady()) {
|
||||
// A designed room (imported .tmx) is loaded — draw its layered tiles.
|
||||
// A designed room (imported .tmx) is loaded: draw its layered tiles.
|
||||
RK.drawRoomCanvas(ctx, cell);
|
||||
} else {
|
||||
for (var y = 0; y < scene.rows; y++) {
|
||||
@@ -109,9 +108,11 @@
|
||||
var kind = scene.grid[y][x];
|
||||
var px = x * cell, py = y * cell;
|
||||
var def = scene.legend[kind] || { color: '#333' };
|
||||
// Fire fixtures (torch/hearth) & hazard tiles (lava/poison/spikes) draw only
|
||||
// their ground base here; RK.fx paints the animated flame / pulse over the
|
||||
// snapshot in the flicker loop so they actually move. Skip the solid colour block.
|
||||
// Fire fixtures (torch/hearth) and hazard tiles (lava/poison/spikes) draw only
|
||||
// their ground base here (a wall for a dungeon torch); RK.fx paints the animated
|
||||
// flame / pulse over the snapshot in the flicker loop so they move. The pack's
|
||||
// torch SPRITE is deliberately skipped: several sheets place it off-center or
|
||||
// rotated, so a procedural flame on a plain base reads correctly in every pack.
|
||||
if (def.fx === 'fire' || def.hazard) {
|
||||
var baseKind = def.over || 'floor';
|
||||
var bco = useAtlas ? pickVariant(tmap[baseKind], x, y) : null;
|
||||
@@ -121,20 +122,15 @@
|
||||
ctx.fillStyle = bdef.color || def.color;
|
||||
ctx.fillRect(px, py, cell, cell);
|
||||
}
|
||||
// Fire fixtures draw ONLY their base (wall for a dungeon torch) here; RK.fx
|
||||
// paints the animated flame + warm glow centered on the cell in the flicker
|
||||
// loop. We deliberately skip the pack's torch SPRITE — several sheets place it
|
||||
// off-center / rotated, which read as misaligned; the procedural flame on a wall
|
||||
// base is a clean wall-mounted torch that looks right in every pack.
|
||||
applyCellLight(ctx, scene, x, y, px, py, cell);
|
||||
continue;
|
||||
}
|
||||
var coord = useAtlas ? pickVariant(tmap[kind], x, y) : null;
|
||||
if (coord) {
|
||||
// Object tiles (furniture, windows, torches, treasure…) are transparent
|
||||
// around the object. Legend `over` names the ground they sit on (floor/wall);
|
||||
// draw that first so the transparency shows the room, not the dark canvas.
|
||||
// Pipeline-level: any scene can declare it, not a tavern one-off.
|
||||
// Object tiles (furniture, windows, torches, treasure) are transparent around
|
||||
// the object. Legend `over` names the ground they sit on (floor/wall); drawing
|
||||
// that first makes the transparency show the room, not the dark canvas. Any
|
||||
// scene can declare `over`; it is not a tavern-only path.
|
||||
if (def.over && tmap[def.over]) {
|
||||
var base = pickVariant(tmap[def.over], x, y);
|
||||
if (base) atlas.draw(ctx, base[0], base[1], px, py, cell);
|
||||
@@ -177,7 +173,7 @@
|
||||
var now = Date.now();
|
||||
for (var i = 0; i < scene.entities.length; i++) {
|
||||
var e = scene.entities[i];
|
||||
// Dungeon features (entrance / exit / treasure) — real tiles, glyph fallback.
|
||||
// Dungeon features (entrance / exit / treasure): real tiles, glyph fallback.
|
||||
if (e.kind === 'feature') {
|
||||
var fpx = e.x * cell, fpy = e.y * cell;
|
||||
var fc = useAtlas ? pickVariant(featureCoord(tmap, e), e.x, e.y) : null;
|
||||
@@ -200,7 +196,6 @@
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Dungeon items.
|
||||
if (e.kind === 'item') {
|
||||
var ipx = e.x * cell, ipy = e.y * cell;
|
||||
var ic = useAtlas ? pickVariant(tmap.treasure, e.x, e.y) : null;
|
||||
@@ -212,12 +207,10 @@
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Dungeon monster — draw the Goblin character sprite (same path the tavern draws
|
||||
// characters through), falling back to a red orb only if the sprite is unavailable.
|
||||
if (e.kind === 'monster') {
|
||||
drawEntityShadow(ctx, e.x * cell + cell / 2, e.y * cell + cell * 0.9, cell * 0.32, cell * 0.13);
|
||||
// Prefer the active pack's monster TILE (e.g. the Original pack's ~ goblin) so a
|
||||
// classic tile pack renders classic; else the character sprite; else a red orb.
|
||||
// Preference order: the active pack's monster TILE, so a classic tile pack renders
|
||||
// classic; then the Goblin character sprite; then a red orb.
|
||||
var mCoord = useAtlas ? pickVariant(tmap.monster, e.x, e.y) : null;
|
||||
if (mCoord) { atlas.draw(ctx, mCoord[0], mCoord[1], e.x * cell, e.y * cell, cell); continue; }
|
||||
var mAvatar = e.avatar || 'char-goblin';
|
||||
@@ -236,15 +229,15 @@
|
||||
ctx.stroke();
|
||||
continue;
|
||||
}
|
||||
// Tavern cat — a standalone animated sprite (Pet Cats Pack), not a char-atlas avatar.
|
||||
// Tavern cat: a standalone animated sprite (Pet Cats Pack), not a char-atlas avatar.
|
||||
if (e.kind === 'cat') {
|
||||
drawEntityShadow(ctx, e.x * cell + cell / 2, e.y * cell + cell * 0.9, cell * 0.28, cell * 0.11);
|
||||
if (window.RK && RK.drawCatCanvas && RK.drawCatCanvas(ctx, e, cell, now)) continue;
|
||||
}
|
||||
// Character-like entity: soft ground shadow, then the sprite (or fallback circle).
|
||||
drawEntityShadow(ctx, e.x * cell + cell / 2, e.y * cell + cell * 0.92, cell * 0.34, cell * 0.14);
|
||||
// In the DUNGEON, prefer the active pack's player TILE (Original pack's @ hero) over the
|
||||
// sprite so a classic pack is fully classic. The tavern keeps character sprites.
|
||||
// In the dungeon the active pack's player TILE wins over the character sprite so a
|
||||
// classic pack is fully classic. The tavern keeps character sprites.
|
||||
if (scene.isDungeon && (e.you || e.kind === 'player') && useAtlas) {
|
||||
var pCoord = pickVariant(tmap.player, e.x, e.y);
|
||||
if (pCoord) { atlas.draw(ctx, pCoord[0], pCoord[1], e.x * cell, e.y * cell, cell); continue; }
|
||||
@@ -293,9 +286,9 @@
|
||||
|
||||
// Snapshot the base scene, then composite the torch light + vignette on top, and keep
|
||||
// an animation loop running so the light flickers and embers drift between renders.
|
||||
// Where the player is on this canvas — the SP camera centers on it. Sticky: keep the last
|
||||
// point if the player is momentarily absent (level transition), so the view never snaps to
|
||||
// the corner.
|
||||
// focusPoint is where the player is on this canvas; the SP camera centers on it. It is
|
||||
// sticky: while the player is momentarily absent (level transition) the last point stands,
|
||||
// so the view never snaps to the corner.
|
||||
var _fp = findPlayer(scene);
|
||||
if (_fp) this.focusPoint = { x: (_fp.x + 0.5) * cell, y: (_fp.y + 0.5) * cell };
|
||||
|
||||
@@ -375,8 +368,8 @@
|
||||
};
|
||||
|
||||
TileRenderer.prototype._ensureEmbers = function (scene) {
|
||||
// Embers belong to the dungeon torch the player carries — never in the tavern (or any
|
||||
// non-dungeon scene), where they'd make the "you" avatar look like a flaming dot.
|
||||
// Embers belong to the dungeon torch the player carries, never to the tavern or any other
|
||||
// non-dungeon scene, where they would make the "you" avatar look like a flaming dot.
|
||||
if (!scene || !scene.isDungeon) { this._embers = []; return; }
|
||||
var p = findPlayer(scene);
|
||||
if (!p) { this._embers = []; return; }
|
||||
@@ -414,14 +407,15 @@
|
||||
}
|
||||
};
|
||||
|
||||
// Warm torch light on the player cell + drifting embers + an edge vignette. Ported from
|
||||
// the FancyRenderer recipe to flat 2D canvas (no Pixi, no shaders).
|
||||
// Warm torch light on the player cell + drifting embers + an edge vignette, on flat 2D
|
||||
// canvas (no Pixi, no shaders).
|
||||
TileRenderer.prototype._composite = function (scene) {
|
||||
var ctx = this.ctx;
|
||||
if (!ctx) return;
|
||||
var w = this.canvas.width, h = this.canvas.height, cell = this.cell;
|
||||
// The warm torch light + embers are a DUNGEON effect (the player carries the torch). In the
|
||||
// tavern there's no torch, so skip it — otherwise the "you" cell glows like a flaming dot.
|
||||
// The warm torch light + embers are a dungeon effect (the player carries the torch). The
|
||||
// tavern has no torch, so it is skipped there; otherwise the "you" cell glows like a
|
||||
// flaming dot.
|
||||
var p = (scene && scene.isDungeon) ? findPlayer(scene) : null;
|
||||
if (p) {
|
||||
var cx = p.x * cell + cell / 2, cy = p.y * cell + cell / 2;
|
||||
@@ -437,7 +431,6 @@
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, rad, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
// Embers.
|
||||
for (var i = 0; i < this._embers.length; i++) {
|
||||
var m = this._embers[i];
|
||||
var a = Math.max(0, Math.min(1, m.life / m.max)) * 0.6;
|
||||
@@ -463,7 +456,7 @@
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.restore();
|
||||
|
||||
// Animated fire fixtures + hazard tiles — drawn over the vignette so their light punches
|
||||
// Animated fire fixtures + hazard tiles draw over the vignette so their light punches
|
||||
// through the dark edges rather than being dimmed by it.
|
||||
var em = this._emitters;
|
||||
if (em && em.length && root.RK && RK.fx) {
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
// signed by this session's authenticated npub, then relays it to nostr under that npub.
|
||||
//
|
||||
// Returns a Promise<boolean>: true if it signed + emitted, false if Smirk signing is
|
||||
// unavailable or declined — in which case the caller falls back to the normal unsigned send.
|
||||
// unavailable or declined, in which case the caller falls back to the normal unsigned send.
|
||||
// Feature-detected end to end, so it's a no-op (always false) until the page has a connected
|
||||
// Smirk wallet; wiring it into a send site is therefore always safe.
|
||||
// Sign an event, granting the Nostr scope on demand. Signing FIRST means a returning user
|
||||
// (scope already granted for this origin — e.g. from the game's NIP-98 login) gets a single
|
||||
// (scope already granted for this origin, e.g. from the game's NIP-98 login) gets a single
|
||||
// approval; only a first-time origin hits NOT_AUTHORIZED and needs the one-time scope grant.
|
||||
// Same pattern as SmirkAuth (html/js/network/smirkAuth.js).
|
||||
function signWithScope(smirk, tmpl) {
|
||||
|
||||
@@ -66,7 +66,7 @@ const HelpModal = {
|
||||
$container.html(`
|
||||
<div style="padding:8px 10px; background:#0d3320; border:1px solid #4ade80; border-radius:6px;">
|
||||
<strong style="color:#4ade80;">🆓 Free to play</strong><br>
|
||||
<span>No payment needed — just escape the dungeon.</span>
|
||||
<span>No payment needed: just escape the dungeon.</span>
|
||||
</div>
|
||||
`);
|
||||
return;
|
||||
@@ -86,13 +86,13 @@ const HelpModal = {
|
||||
const payoutsOn = !!(config.directPayoutsEnabled || config.creditsPayoutsEnabled);
|
||||
|
||||
const buy = [];
|
||||
if (directOn) buy.push(`<strong>1 credit</strong> — ${price} ${currency}`);
|
||||
if (directOn) buy.push(`<strong>1 credit</strong>: ${price} ${currency}`);
|
||||
if (creditsOn && packages.length > 0) {
|
||||
const bundles = packages.map(pkg => {
|
||||
const bonus = pkg.bonus > 0 ? ` +${pkg.bonus}` : '';
|
||||
return `${pkg.credits}${bonus} for ${pkg.priceFormatted}`;
|
||||
}).join(' · ');
|
||||
buy.push(`<strong>Bundles</strong> — ${bundles} ${currency}`);
|
||||
buy.push(`<strong>Bundles</strong>: ${bundles} ${currency}`);
|
||||
}
|
||||
|
||||
// Reward line is config-driven so it stays honest on prestige (no-payout) deployments.
|
||||
@@ -100,7 +100,7 @@ const HelpModal = {
|
||||
if (payoutsOn) {
|
||||
reward = '<span style="color:#4ade80;">💰 Exact escape and treasure rewards are shown before entry and locked for that run.</span>';
|
||||
} else {
|
||||
reward = `<span style="color:#fbbf24;">🏅 No crypto payout — escape for the win; top scores enter the <strong>Hall of Champions</strong>.</span>`;
|
||||
reward = `<span style="color:#fbbf24;">🏅 No crypto payout: escape for the win; top scores enter the <strong>Hall of Champions</strong>.</span>`;
|
||||
}
|
||||
|
||||
const freeLine = config.freePlayEnabled
|
||||
@@ -115,7 +115,7 @@ const HelpModal = {
|
||||
${buy.map(b => ' • ' + b).join('<br>')}
|
||||
</div>
|
||||
<div style="font-size:0.82em; color:#9aa4b2; margin-bottom:6px;">
|
||||
Spend a credit to enter — jump in immediately, or on the next block. Block timing
|
||||
Spend a credit to enter: jump in immediately, or on the next block. Block timing
|
||||
is random, so your average wait is the same either way.
|
||||
</div>
|
||||
<div style="font-size:0.85em;">${reward}</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ const PaymentUI = {
|
||||
$('#payment-ui').on('click', '.mode-option', function() {
|
||||
// The "Buy Selected Package" button ALSO carries .mode-option (for styling) but has its
|
||||
// own handler below; without this guard it double-fired here with NO data attrs, defaulting
|
||||
// to single_game — so buying a 10-credit pack ALSO created a spurious 1-credit invoice.
|
||||
// to single_game, so buying a 10-credit pack ALSO created a spurious 1-credit invoice.
|
||||
if (this.id === 'buy-credits-btn') return;
|
||||
const mode = $(this).data('mode');
|
||||
const action = $(this).data('action');
|
||||
@@ -163,7 +163,7 @@ const PaymentUI = {
|
||||
render: function() {
|
||||
if (!this.config) return;
|
||||
|
||||
// Display label (sXMR on stagenet) — distinct from cryptoType, which formatPrice
|
||||
// Display label (sXMR on stagenet): distinct from cryptoType, which formatPrice
|
||||
// still uses for decimals.
|
||||
const currency = this.config.currencyLabel || this.config.cryptoType || 'WOW';
|
||||
const hasCredits = this.userCredits >= (this.config.creditsPerGame || 1);
|
||||
@@ -185,7 +185,7 @@ const PaymentUI = {
|
||||
$container.append(
|
||||
'<div style="margin-bottom:14px;padding:10px;background:rgba(245,158,11,0.08);border:1px solid #92400e;border-radius:6px;font-size:0.85em;line-height:1.5;">' +
|
||||
'🏅 <strong style="color:#fbbf24;">Pay</strong> (' + this.escapeHtml(paidEntryCopy) + ') to put your high scores in the <strong style="color:#fbbf24;">Hall of Champions</strong>.<br>' +
|
||||
'🪙 <strong style="color:#8ab4f8;">Play free</strong> — high scores go to the <strong style="color:#8ab4f8;">Pleb leaderboard</strong> instead.' +
|
||||
'🪙 <strong style="color:#8ab4f8;">Play free</strong>: high scores go to the <strong style="color:#8ab4f8;">Pleb leaderboard</strong> instead.' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
@@ -366,7 +366,7 @@ const PaymentUI = {
|
||||
// alongside paid (freePlayEnabled). Free games go to the Pleb leaderboard.
|
||||
if (this.config.freePlayEnabled) {
|
||||
const note = this.config.paymentsEnabled
|
||||
? '🪙 Free — high scores go to the Pleb leaderboard'
|
||||
? '🪙 Free: high scores go to the Pleb leaderboard'
|
||||
: 'No payments required';
|
||||
$container.append(`
|
||||
<button class="mode-option" data-mode="FREE" data-action="free"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Public stats strip — light "social proof" shown in the status area: games and escapes in
|
||||
* Public stats strip: light "social proof" shown in the status area: games and escapes in
|
||||
* the last 24h, and total paid out (only where payouts are enabled). Polls /api/stats, which
|
||||
* is cached server-side (10s), so a 30s poll is cheap. Online-player count is intentionally
|
||||
* NOT shown here — it already updates live via the 'user_count' socket event.
|
||||
* NOT shown here; it already updates live via the 'user_count' socket event.
|
||||
*/
|
||||
(function () {
|
||||
var EL_ID = 'publicStats';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Homepage render picker — render mode (Tiled / ASCII / Iso / 3D) + tile-pack selector for the
|
||||
// Homepage render picker: render mode (Tiled / ASCII / Iso / 3D) + tile-pack selector for the
|
||||
// single-player dungeon, gated by entitlements. Free players see Tiled(Original) + ASCII; the credit
|
||||
// ladder unlocks the richer packs, iso, and 3D. Rebuilds itself whenever entitlements change.
|
||||
(function (root) {
|
||||
@@ -54,8 +54,8 @@
|
||||
b.className = 'rp-btn' + (m.id === current ? ' active' : '') + (usable ? '' : ' locked');
|
||||
b.textContent = m.label + (!availability.runtimeAvailable ? ' ⛔' : (m.premium && !usable ? ' 🔒' : ''));
|
||||
b.title = !availability.runtimeAvailable
|
||||
? (m.label + ' — unavailable on this server (optional renderer dependency disabled)')
|
||||
: (usable ? m.label : (m.label + ' — unlock with credits'));
|
||||
? (m.label + ': unavailable on this server (optional renderer dependency disabled)')
|
||||
: (usable ? m.label : (m.label + ': unlock with credits'));
|
||||
b.setAttribute('aria-disabled', usable ? 'false' : 'true');
|
||||
b.onclick = function () {
|
||||
if (!usable) return;
|
||||
@@ -68,7 +68,7 @@
|
||||
});
|
||||
h.appendChild(modeRow);
|
||||
|
||||
// Pack row — only when the current projection has more than one UNLOCKED pack to pick from.
|
||||
// Pack row: only when the current projection has more than one UNLOCKED pack to pick from.
|
||||
var proj = projForMode(current);
|
||||
var packs = (proj && RK.unlockedPacks) ? RK.unlockedPacks(proj) : [];
|
||||
if (packs.length > 1) {
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Wownerogue — Multiplayer Arena</title>
|
||||
<title>Wownerogue: Multiplayer Arena</title>
|
||||
<link rel="stylesheet" href="styles/commerce-consent.css">
|
||||
<style>
|
||||
@font-face { font-family: PixelOperator; src: url('styles/fonts/pixel-operator/PixelOperator.ttf') format('truetype'); font-display: swap; }
|
||||
|
||||
+12
-12
@@ -14,7 +14,7 @@
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
DESIGN TOKENS — "dungeon console" palette
|
||||
DESIGN TOKENS: "dungeon console" palette
|
||||
Single source of truth for the torch-lit dungeon aesthetic. Hard-coded hex
|
||||
throughout the sheet is refactored to reference these incrementally.
|
||||
========================================================================= */
|
||||
@@ -76,7 +76,7 @@ body {
|
||||
width: 100%;
|
||||
max-width: 100vw;
|
||||
box-sizing: border-box;
|
||||
/* A few px of right gutter so the play area isn't flush to the viewport edge — otherwise
|
||||
/* A few px of right gutter so the play area isn't flush to the viewport edge; otherwise
|
||||
overflow:hidden clips the yellow focus outline on the right side. */
|
||||
padding-right: 6px;
|
||||
overflow: hidden;
|
||||
@@ -99,7 +99,7 @@ body {
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
/* Status area for block height display — the "control console" column */
|
||||
/* Status area for block height display: the "control console" column */
|
||||
.status {
|
||||
grid-area: status;
|
||||
position: relative;
|
||||
@@ -114,7 +114,7 @@ body {
|
||||
justify-content: flex-start;
|
||||
border-right: 1px solid var(--rule);
|
||||
box-shadow: inset 0 0 32px rgba(0, 0, 0, 0.45);
|
||||
overflow-y: auto; /* it now only spans the game row — scroll if the controls are tall */
|
||||
overflow-y: auto; /* it now only spans the game row, so it scrolls if the controls are tall */
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ body {
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
CONTROL BAR — primary action buttons (#startButton, #watchGamesButton,
|
||||
CONTROL BAR: primary action buttons (#startButton, #watchGamesButton,
|
||||
#tavernButton, #characterButton, #shopButton, #animationToggleButton).
|
||||
Presentation lives here; ids + click handlers stay untouched in the HTML.
|
||||
Each variant only sets three custom props (accent / panel / label).
|
||||
@@ -185,7 +185,7 @@ body {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Variants — colours preserved from the original inline styles. */
|
||||
/* Variants: colours preserved from the original inline styles. */
|
||||
.ctrl-btn--start { --btn-panel: #0a5c0a; --btn-accent: var(--neon); --btn-label: #ff0; --btn-glow: var(--glow-neon); }
|
||||
.ctrl-btn--watch { --btn-panel: #053655; --btn-accent: #0af; --btn-label: #0af; --btn-glow: var(--glow-cyan); }
|
||||
.ctrl-btn--tavern { --btn-panel: #2a0a3a; --btn-accent: #a855f7; --btn-label: #d8b4fe; --btn-glow: var(--glow-purple); }
|
||||
@@ -197,7 +197,7 @@ body {
|
||||
.ctrl-btn--start { animation: torch-glow 3.4s ease-in-out infinite; }
|
||||
.ctrl-btn--start:hover { animation: none; }
|
||||
|
||||
/* Sidebar group headers (Loadout / Explore / Progress) — the redesigned menu grouping. */
|
||||
/* Sidebar group headers (Loadout / Explore / Progress): the redesigned menu grouping. */
|
||||
.menu-grp-h {
|
||||
font-size: 10px; letter-spacing: 2px; text-transform: uppercase;
|
||||
color: #7a8a5f; font-weight: 700; margin: 12px 0 5px; padding: 9px 0 0 2px;
|
||||
@@ -205,7 +205,7 @@ body {
|
||||
}
|
||||
|
||||
/* =========================================================================
|
||||
HUD TOOLBAR — secondary "chip" controls (audio toggle + help / history /
|
||||
HUD TOOLBAR: secondary "chip" controls (audio toggle + help / history /
|
||||
scores / manage-address). Small, bordered, glow-on-hover.
|
||||
========================================================================= */
|
||||
.hud-toolbar {
|
||||
@@ -687,7 +687,7 @@ li.debug {
|
||||
.win-toast strong { color: var(--good); }
|
||||
|
||||
/* =========================================================================
|
||||
Motion — tasteful transitions & keyframes (all respect reduced-motion)
|
||||
Motion: tasteful transitions & keyframes (all respect reduced-motion)
|
||||
========================================================================= */
|
||||
@keyframes torch-flicker {
|
||||
0%, 100% { opacity: 0.8; }
|
||||
@@ -743,7 +743,7 @@ li.debug {
|
||||
/* Stack the desktop 3-column grid into a single column.
|
||||
Rows: header / status / game / CHAT(flex) / input / footer.
|
||||
The game row is `auto` (sized by the canvas's own aspect ratio at full width)
|
||||
and the CHAT row takes the leftover space (1fr) instead — so the play area fills
|
||||
and the CHAT row takes the leftover space (1fr) instead: so the play area fills
|
||||
the screen width and chat gets whatever height remains. */
|
||||
.container {
|
||||
grid-template-areas:
|
||||
@@ -807,7 +807,7 @@ li.debug {
|
||||
/* Toolbar spans the full row when the console wraps. */
|
||||
.hud-toolbar { flex-basis: 100%; margin-top: 6px; }
|
||||
|
||||
/* Fixed-width modals would overflow tiny screens — make them fit */
|
||||
/* Fixed-width modals would overflow tiny screens: make them fit */
|
||||
#payment-ui, #history-panel, #leaderboard-panel {
|
||||
min-width: 0 !important;
|
||||
width: 94vw !important;
|
||||
@@ -819,7 +819,7 @@ li.debug {
|
||||
|
||||
}
|
||||
|
||||
/* On-screen D-pad — styling defined unconditionally; shown on small screens (media query
|
||||
/* On-screen D-pad: styling defined unconditionally; shown on small screens (media query
|
||||
above) OR any touch device (body.touch set by touchControls.js for landscape/tablets). */
|
||||
@media (max-width: 820px) { #touch-dpad { display: grid; } }
|
||||
body.touch #touch-dpad { display: grid; }
|
||||
|
||||
+6
-6
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Wownerogue — Tavern</title>
|
||||
<title>Wownerogue: Tavern</title>
|
||||
<link rel="stylesheet" href="styles/commerce-consent.css">
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script src="js/legal/commerceConsent.js"></script>
|
||||
@@ -220,7 +220,7 @@
|
||||
var spectatorDpad = document.getElementById('spectatorDpad');
|
||||
var leaveSpectateBtn = document.getElementById('leaveSpectateBtn');
|
||||
|
||||
// Character customization — persisted appearance identity, chosen before entering.
|
||||
// Character customization: persisted appearance identity, chosen before entering.
|
||||
var customizeBtn = document.getElementById('customizeBtn');
|
||||
var appearancePreview = document.getElementById('appearancePreview');
|
||||
var selectedAppearance = ((RK.loadIdentity && RK.loadIdentity()) || {}).appearance || RK.normalizeAppearance('default');
|
||||
@@ -375,7 +375,7 @@
|
||||
var active = activeMode;
|
||||
setStatus(active === mode
|
||||
? 'Render mode: ' + active
|
||||
: '“' + mode + '” unavailable here — using “' + active + '”.');
|
||||
: '“' + mode + '” unavailable here, using “' + active + '”.');
|
||||
});
|
||||
}
|
||||
function buildModebar() {
|
||||
@@ -398,7 +398,7 @@
|
||||
function projForMode(mode) {
|
||||
if (mode === 'iso') return 'iso';
|
||||
if (mode === '3d') return '3d';
|
||||
if (mode === 'ascii') return null; // ASCII draws glyphs — it consumes no tile pack
|
||||
if (mode === 'ascii') return null; // ASCII draws glyphs; it consumes no tile pack
|
||||
return 'topdown';
|
||||
}
|
||||
|
||||
@@ -555,7 +555,7 @@
|
||||
//
|
||||
// This used to key off `m.socketId`, which the chat provider deliberately stopped
|
||||
// sending (it publishes a short `publicId` instead so the raw socket id never leaves the
|
||||
// server) — so the bubble branch simply never ran and nobody in the tavern ever spoke
|
||||
// server), so the bubble branch simply never ran and nobody in the tavern ever spoke
|
||||
// visibly. Resolve the speaker from the occupant list by display name instead, which
|
||||
// needs no additional identity on the wire.
|
||||
if (tavernLife) {
|
||||
@@ -576,7 +576,7 @@
|
||||
socket.on('tavern_notice', function (n) {
|
||||
if (n && n.message) addSystemLine(n.message);
|
||||
});
|
||||
// Global chat backlog sent on join — render it so the tavern shows history, not a blank log.
|
||||
// Global chat backlog sent on join: render it so the tavern shows history, not a blank log.
|
||||
socket.on('chat_history', function (payload) {
|
||||
var msgs = payload && payload.messages;
|
||||
if (!msgs || !msgs.length) return;
|
||||
|
||||
@@ -6,15 +6,27 @@ Run with Blender, not plain Python:
|
||||
|
||||
blender --background --python scripts/build_kenney_3d_characters.py
|
||||
|
||||
The source FBX files remain in the local Kenney bundle. Outputs are written under
|
||||
html/assets/generated/ (gitignored) for CDN/local delivery.
|
||||
The source FBX files remain in your local Kenney bundle. Outputs are written under
|
||||
html/assets/generated/ (gitignored) for local delivery.
|
||||
|
||||
Point KENNEY_ASSETS_DIR at the "Animated Characters 1" directory of your own copy of the
|
||||
Kenney Game Assets bundle, for example:
|
||||
|
||||
KENNEY_ASSETS_DIR="$HOME/assets/Kenney Game Assets All-in-1 1.1.0/3D assets/Animated Characters 1" \
|
||||
blender --background --python scripts/build_kenney_3d_characters.py
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import bpy
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
KENNEY = Path("/home/jw/Drawings/assets/Kenney Game Assets All-in-1 1.1.0/3D assets/Animated Characters 1")
|
||||
_kenney_dir = os.environ.get("KENNEY_ASSETS_DIR")
|
||||
if not _kenney_dir:
|
||||
raise SystemExit(
|
||||
"Set KENNEY_ASSETS_DIR to the 'Animated Characters 1' directory of your Kenney bundle."
|
||||
)
|
||||
KENNEY = Path(_kenney_dir).expanduser()
|
||||
OUT = ROOT / "html/assets/generated/3d/kenney-animated-characters"
|
||||
|
||||
MODEL = KENNEY / "Model/characterMedium.fbx"
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
# Parallel Monero stagenet wallet candidate
|
||||
|
||||
> **STATUS: HISTORICAL NON-MUTATING DESIGN RECORD.** It is not an installation procedure. Such
|
||||
> Software production wallet preparation/promotion is owned by the separately reviewed,
|
||||
> default-closed roles in `~/src/such-fleet`; this document and its sample unit authorize nothing.
|
||||
>
|
||||
> The read-only preflight and disposable fixture test in this change are executable. The candidate
|
||||
> unit is an inert, manual-only template. There is intentionally no password-capture, wallet-copy,
|
||||
> credential-bundle, installer, activation, or cutover program yet. Do not improvise those steps
|
||||
> from shell commands. No in-place destructive migration is shipped with this design; deleting an
|
||||
> abandoned draft does not make the parallel candidate production-deployable.
|
||||
|
||||
This is the smaller alternative to replacing the current wallet in place. It is fixed to the
|
||||
Monero **stagenet** wallet used by `monerogue.app`. It has no mainnet or Wownero mode.
|
||||
|
||||
The safety property is simple: the current wallet, unit, and application stay exactly as they are.
|
||||
A stopped, consistent copy is opened under a new dedicated identity on a different loopback port.
|
||||
Nothing can use that copy until one complete directory containing the wallet, protected
|
||||
credentials, configuration, and readiness marker has been validated and atomically published.
|
||||
|
||||
## What is safe to run now
|
||||
|
||||
The local fixture uses only synthetic bytes below a temporary directory:
|
||||
|
||||
```bash
|
||||
python3 scripts/deploy/monero-stagenet-parallel-fixture-test.py
|
||||
```
|
||||
|
||||
The fixed-path preflight is read-only. It deliberately does not read unit text, process argv,
|
||||
wallet bytes, keys, passwords, or mnemonics. After independent review, it can check the current
|
||||
legacy RPC:
|
||||
|
||||
```bash
|
||||
sudo python3 scripts/deploy/monero-stagenet-parallel-preflight.py legacy
|
||||
```
|
||||
|
||||
Its `candidate-prereqs` scope additionally requires the new system identity, free candidate ports,
|
||||
an unused candidate path, the root-owned candidate binary, and enough local space. Provisioning
|
||||
that identity is a future approved change; the preflight does not create it.
|
||||
|
||||
Neither command proves that secret capture, bundle creation, activation, backup recovery, or an
|
||||
application cutover is safe. The service template must not be installed yet.
|
||||
|
||||
## Fixed boundary
|
||||
|
||||
| Purpose | Fixed value |
|
||||
| --- | --- |
|
||||
| Legacy unit | `monero-wallet-rpc.service` |
|
||||
| Legacy wallet | `/home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4/test` + `.keys` |
|
||||
| Legacy RPC | `127.0.0.1:38083` |
|
||||
| Network/daemon | Monero stagenet / `127.0.0.1:38081` |
|
||||
| Candidate identity | `monerogue-wallet-candidate:monerogue-wallet-candidate` |
|
||||
| Staging generation | `/var/lib/.monerogue-stagenet-wallet-candidate.staging` |
|
||||
| Published generation | `/var/lib/monerogue-stagenet-wallet-candidate` |
|
||||
| Candidate RPC | `127.0.0.1:38084`, Digest authentication required |
|
||||
| Offline verification RPC | `127.0.0.1:39084`, temporary only |
|
||||
| Candidate unit | `monerogue-stagenet-wallet-candidate.service`, manual only |
|
||||
|
||||
The final bundle must be one directory on the same filesystem as its staging generation:
|
||||
|
||||
```text
|
||||
/var/lib/monerogue-stagenet-wallet-candidate/ root:root 0711
|
||||
├── READY root:root 0600, written last
|
||||
├── STATE.json root:root 0600, non-secret phase/address metadata
|
||||
├── expected-address root:root 0600, public address only
|
||||
├── wallet/ candidate:candidate 0700
|
||||
│ ├── stagenet candidate:candidate 0600
|
||||
│ └── stagenet.keys candidate:candidate 0600
|
||||
├── config/ root:candidate 0710
|
||||
│ └── wallet-rpc.conf root:candidate 0640
|
||||
├── secrets/ root:root 0711
|
||||
│ ├── wallet-password root:candidate 0640
|
||||
│ └── rpc-auth.netrc root:root 0600
|
||||
└── integration/ root:root 0711
|
||||
└── candidate-app.env root:monerogue 0640
|
||||
```
|
||||
|
||||
Execute-only directory traversal permits each identity to open its one reviewed fixed path without
|
||||
listing the directory. No file may be a symlink or have a link count other than one. The `READY`
|
||||
marker contains only version, stagenet, `127.0.0.1:38084`, the public address, and a
|
||||
`manual-only` flag—never a password, hash of a seed, private key, or wallet bytes.
|
||||
|
||||
## Required staged procedure
|
||||
|
||||
These are acceptance requirements for small future helpers, not commands to run today.
|
||||
|
||||
### 1. Read-only inventory
|
||||
|
||||
The preflight must pass immediately before a maintenance window. Separately record the public
|
||||
wallet address without recording credentials. Confirm the current application and wallet remain
|
||||
healthy and that `38084` and `39084` have no listener.
|
||||
|
||||
The preflight intentionally does not inspect `/proc/<pid>/cmdline`, because the current process
|
||||
contains a password. That work belongs only in the bounded capture helper described next.
|
||||
|
||||
### 2. Capture the already-exposed wallet password
|
||||
|
||||
A future fixed-path helper may read the password only from the validated live process. It must:
|
||||
|
||||
- run as root with `RLIMIT_CORE=0`, `PR_SET_DUMPABLE=0`, `umask 077`, and no tracing/logging;
|
||||
- obtain only `MainPID` from systemd, then validate PID start time, `/proc/<pid>/exe`, UID, cgroup,
|
||||
and the sole PID-owned `127.0.0.1:38083` listener before and after capture;
|
||||
- read NUL-delimited `/proc/<pid>/cmdline` directly, never systemd unit text or `ExecStart` output;
|
||||
- accept exactly one bounded `--password VALUE` or `--password=VALUE`, reject duplicates,
|
||||
`--password-file`, NUL/newline data, or a process change;
|
||||
- write only a new `root:root 0600` file below the unpublished staging generation using
|
||||
`openat(O_NOFOLLOW|O_CREAT|O_EXCL)`, `fsync`, and retained directory descriptors;
|
||||
- print only success/refusal, never arguments, password length, content, hash, or exception data;
|
||||
- zero mutable buffers before exit; and
|
||||
- leave an incomplete stage inert and refuse automatic overwrite or cleanup after any failure.
|
||||
|
||||
The helper needs executable fixture coverage for both argv forms, duplicates, process replacement,
|
||||
short writes, fsync failures, interruption, and restart with every possible partial file/state. A
|
||||
static source test is not sufficient.
|
||||
|
||||
### 3. One bounded stopped-wallet copy window
|
||||
|
||||
Only a narrow wrapper may stop the legacy unit. It must acquire a fixed root-only lock and install
|
||||
an `EXIT`, signal, and error trap that always attempts to restart the exact legacy unit. It must not
|
||||
read or alter the unit.
|
||||
|
||||
After stop, require `MainPID=0`, no listener on `38083`, and no other process descriptor open on
|
||||
either source inode. Open every source/path component with retained directory descriptors and
|
||||
`O_NOFOLLOW`; require `jw:jw 0600`, a single link, and stable inode/size/timestamps. Copy wallet and
|
||||
keys with `O_EXCL` temporary files, `fsync`, byte-compare through retained descriptors, publish each
|
||||
inside the unpublished stage with `renameat2(RENAME_NOREPLACE)`, and fsync its directory.
|
||||
|
||||
The wrapper must restart the legacy unit whether the copy succeeds, fails, or is interrupted. A
|
||||
successful window is not complete until the restarted RPC has the same PID ownership/listener
|
||||
properties and returns the exact public address captured before stop. At no point may a helper
|
||||
move, rename, chmod, chown, truncate, erase, or unlink the legacy wallet, keys, directory, or unit.
|
||||
|
||||
### 4. Build and verify one unused bundle
|
||||
|
||||
Generate a new RPC credential in a non-dumpable process. The wallet password and RPC credential
|
||||
must be distinct. Keep all copies in the one unpublished generation; no `/etc` credential file or
|
||||
application drop-in is needed.
|
||||
|
||||
The protected wallet config must contain exactly these non-secret semantics plus one Digest login:
|
||||
|
||||
```text
|
||||
stagenet=1
|
||||
wallet-file=/var/lib/monerogue-stagenet-wallet-candidate/wallet/stagenet
|
||||
password-file=/var/lib/monerogue-stagenet-wallet-candidate/secrets/wallet-password
|
||||
rpc-bind-port=38084
|
||||
rpc-bind-ip=127.0.0.1
|
||||
daemon-address=127.0.0.1:38081
|
||||
rpc-login=monerogue-candidate:<generated-secret>
|
||||
non-interactive=1
|
||||
```
|
||||
|
||||
Before publication, open the copied wallet offline on temporary port `39084` as the candidate UID,
|
||||
with supplementary groups and all capabilities cleared, `no_new_privs`, core dumps disabled, and
|
||||
logs directed to `/dev/null`. Prove all of the following without printing response data:
|
||||
|
||||
- the verifier PID owns the sole exact loopback listener;
|
||||
- unauthenticated JSON-RPC returns HTTP 401;
|
||||
- Digest-authenticated `get_address` returns HTTP 200 and the captured stagenet address; and
|
||||
- termination removes the verifier and listener cleanly.
|
||||
|
||||
Then verify every directory/file owner, mode, type, and link count. Write and fsync `STATE.json`,
|
||||
write `READY` **last**, fsync every file and directory, atomically rename the whole staging
|
||||
directory to the unused final name with `RENAME_NOREPLACE`, and fsync `/var/lib`. A crash before the
|
||||
single directory rename leaves no final candidate. An existing final candidate is never replaced.
|
||||
|
||||
### 5. Manual candidate validation
|
||||
|
||||
Only after an independent reviewer accepts the helper and crash evidence may the inert service
|
||||
template be installed as `/etc/systemd/system/monerogue-stagenet-wallet-candidate.service`. It has
|
||||
no `[Install]` section, no boot target, no restart loop, no relationship to `monerogue.service`, and
|
||||
requires the exact readiness marker.
|
||||
|
||||
Start it manually, then prove systemd MainPID/executable/UID/cgroup agreement, the sole
|
||||
`127.0.0.1:38084` listener owned by that PID, HTTP 401 without auth, HTTP 200 with the protected
|
||||
netrc, and the sealed public address. The legacy RPC and live app must remain healthy throughout.
|
||||
|
||||
### 6. Candidate application canary
|
||||
|
||||
The bundle may contain one `root:monerogue 0640` environment override with candidate endpoint and
|
||||
Digest credentials. It must also force:
|
||||
|
||||
```text
|
||||
HOST=127.0.0.1
|
||||
PORT=3103
|
||||
PAYMENTS_ENABLED=false
|
||||
PAYOUTS_ENABLED=false
|
||||
DIRECT_PAYMENT_ENABLED=false
|
||||
DIRECT_PAYOUTS_ENABLED=false
|
||||
CREDITS_PAYOUTS_ENABLED=false
|
||||
MATCH_ENABLED=false
|
||||
MATCH_CRYPTO_RACE_ENABLED=false
|
||||
MATCH_PAYOUTS_ENABLED=false
|
||||
```
|
||||
|
||||
Run the exact release only against a fresh, isolated canary database. Do not attach port 3103 to
|
||||
the NPM/router, public ingress, production database, payment intake, payout dispatchers, or the
|
||||
live service. Readiness and a non-money wallet query are the only allowed checks. Stop both canary
|
||||
app and candidate wallet afterward.
|
||||
|
||||
Promotion of the production app from `38083` to `38084` is explicitly out of scope. It requires a
|
||||
separate reviewed maintenance change and rollback plan.
|
||||
|
||||
## Rollback and partial failures
|
||||
|
||||
There is no destructive wallet rollback because this path never alters the legacy artifacts.
|
||||
|
||||
- During the copy window, always restart and validate the legacy unit from the trap.
|
||||
- Before candidate activation, leave any partial staging generation root-only and inert. Do not
|
||||
delete, merge, resume, or overwrite it automatically.
|
||||
- After candidate activation, stop the candidate unit and prove port `38084` is closed. Restart and
|
||||
validate the legacy unit if needed. The live application still points to `38083`.
|
||||
- Preserve the unused candidate bundle for incident review; cleanup is a later exact-target,
|
||||
separately approved operation.
|
||||
|
||||
If the legacy unit cannot restart or its address differs, keep the application stopped/ingress
|
||||
held under the existing incident runbook. Do not attempt an app cutover to rescue the ceremony.
|
||||
|
||||
## GO criteria
|
||||
|
||||
All are mandatory before any host mutation beyond provisioning the unused candidate identity:
|
||||
|
||||
1. Small fixed-path capture, copy-window, bundle, and validation helpers exist; no general source,
|
||||
destination, network, unit, port, user, or overwrite arguments exist.
|
||||
2. Disposable tests inject failure before/after every create, write, chmod/chown, file fsync,
|
||||
directory fsync, state write, readiness write, stop, start, address check, and final rename.
|
||||
3. Kill/restart tests prove every partial stage remains inert and every stopped-wallet failure
|
||||
makes a bounded restart attempt without touching legacy bytes or metadata.
|
||||
4. Symlink, hardlink, inode replacement, open-writer, wrong UID/cgroup/PID, wildcard/IPv6 listener,
|
||||
port collision, duplicate password option, and existing-final fixtures all fail closed.
|
||||
5. A reviewer verifies secrets never enter argv, environment, stdout/stderr, journald, unit text,
|
||||
tracebacks, Git, ordinary docs, or the non-secret readiness marker.
|
||||
6. The restored wallet backup plus its password has been opened offline and address-matched, and
|
||||
mnemonic/off-host recovery custody is resolved separately. A `.keys` file alone is not mnemonic
|
||||
custody evidence.
|
||||
7. The complete process succeeds, rolls back, and succeeds again on an isolated stagenet host with
|
||||
synthetic/unfunded material before touching the funded stagenet wallet.
|
||||
8. An independent reviewer records GO against an immutable release hash.
|
||||
|
||||
Until every item passes, the production-deployable answer is **no**. Only the read-only preflight
|
||||
and disposable fixture test are approved by this document; even the candidate service template is
|
||||
not approved for installation.
|
||||
@@ -1,306 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Disposable fixture proof for the parallel-wallet filesystem contract.
|
||||
|
||||
This test uses only synthetic bytes below a fresh TemporaryDirectory. It never imports or invokes
|
||||
the host preflight, systemd, a wallet binary, a wallet file, or a credential. Run directly with:
|
||||
|
||||
python3 scripts/deploy/monero-stagenet-parallel-fixture-test.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import errno
|
||||
import os
|
||||
import pathlib
|
||||
import secrets
|
||||
import stat
|
||||
import tempfile
|
||||
import unittest
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
O_DIRECTORY = getattr(os, "O_DIRECTORY", 0)
|
||||
O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
|
||||
RENAME_NOREPLACE = 1
|
||||
LIBC = ctypes.CDLL(None, use_errno=True)
|
||||
|
||||
|
||||
class Refusal(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def rename_noreplace(source_fd: int, source: str, destination_fd: int, destination: str) -> None:
|
||||
result = LIBC.renameat2(
|
||||
ctypes.c_int(source_fd),
|
||||
ctypes.c_char_p(os.fsencode(source)),
|
||||
ctypes.c_int(destination_fd),
|
||||
ctypes.c_char_p(os.fsencode(destination)),
|
||||
ctypes.c_uint(RENAME_NOREPLACE),
|
||||
)
|
||||
if result != 0:
|
||||
problem = ctypes.get_errno()
|
||||
raise OSError(problem, os.strerror(problem), destination)
|
||||
|
||||
|
||||
def open_directory_nofollow(path: pathlib.Path) -> int:
|
||||
if not path.is_absolute() or pathlib.Path(os.path.normpath(path)) != path:
|
||||
raise Refusal("path is not absolute and normalized")
|
||||
descriptor = os.open("/", os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW)
|
||||
try:
|
||||
for component in path.parts[1:]:
|
||||
following = os.open(
|
||||
component,
|
||||
os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW,
|
||||
dir_fd=descriptor,
|
||||
)
|
||||
os.close(descriptor)
|
||||
descriptor = following
|
||||
return descriptor
|
||||
except Exception:
|
||||
os.close(descriptor)
|
||||
raise
|
||||
|
||||
|
||||
def file_identity(details: os.stat_result) -> tuple[int, ...]:
|
||||
return (
|
||||
details.st_dev,
|
||||
details.st_ino,
|
||||
details.st_uid,
|
||||
details.st_gid,
|
||||
stat.S_IMODE(details.st_mode),
|
||||
details.st_nlink,
|
||||
details.st_size,
|
||||
details.st_mtime_ns,
|
||||
details.st_ctime_ns,
|
||||
)
|
||||
|
||||
|
||||
def open_source(directory_fd: int, name: str) -> tuple[int, tuple[int, ...]]:
|
||||
descriptor = os.open(name, os.O_RDONLY | O_NOFOLLOW, dir_fd=directory_fd)
|
||||
details = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(details.st_mode) or details.st_nlink != 1:
|
||||
os.close(descriptor)
|
||||
raise Refusal("source is not a single-linked regular file")
|
||||
if stat.S_IMODE(details.st_mode) != 0o600:
|
||||
os.close(descriptor)
|
||||
raise Refusal("source mode differs from the contract")
|
||||
return descriptor, file_identity(details)
|
||||
|
||||
|
||||
def copy_one(
|
||||
source_fd: int,
|
||||
expected: tuple[int, ...],
|
||||
destination_fd: int,
|
||||
name: str,
|
||||
after_copy: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
temporary = f".{name}.pending.{secrets.token_hex(8)}"
|
||||
output = os.open(
|
||||
temporary,
|
||||
os.O_RDWR | os.O_CREAT | os.O_EXCL | O_NOFOLLOW,
|
||||
0o600,
|
||||
dir_fd=destination_fd,
|
||||
)
|
||||
published = False
|
||||
try:
|
||||
os.lseek(source_fd, 0, os.SEEK_SET)
|
||||
while True:
|
||||
block = os.read(source_fd, 1024 * 1024)
|
||||
if not block:
|
||||
break
|
||||
view = memoryview(block)
|
||||
while view:
|
||||
written = os.write(output, view)
|
||||
if written <= 0:
|
||||
raise OSError("short synthetic copy write")
|
||||
view = view[written:]
|
||||
os.fsync(output)
|
||||
if after_copy is not None:
|
||||
after_copy()
|
||||
if file_identity(os.fstat(source_fd)) != expected:
|
||||
raise Refusal("source metadata changed during copy")
|
||||
os.lseek(source_fd, 0, os.SEEK_SET)
|
||||
os.lseek(output, 0, os.SEEK_SET)
|
||||
while True:
|
||||
source_block = os.read(source_fd, 1024 * 1024)
|
||||
output_block = os.read(output, 1024 * 1024)
|
||||
if source_block != output_block:
|
||||
raise Refusal("copy differs from retained source")
|
||||
if not source_block:
|
||||
break
|
||||
os.close(output)
|
||||
output = -1
|
||||
rename_noreplace(destination_fd, temporary, destination_fd, name)
|
||||
published = True
|
||||
os.fsync(destination_fd)
|
||||
finally:
|
||||
if output >= 0:
|
||||
os.close(output)
|
||||
if not published:
|
||||
try:
|
||||
os.unlink(temporary, dir_fd=destination_fd)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def copy_pair(source: pathlib.Path, wallet_destination: pathlib.Path) -> None:
|
||||
source_fd = open_directory_nofollow(source)
|
||||
destination_fd = open_directory_nofollow(wallet_destination)
|
||||
try:
|
||||
opened: list[tuple[int, tuple[int, ...], str]] = []
|
||||
try:
|
||||
for source_name, destination_name in (
|
||||
("test", "stagenet"),
|
||||
("test.keys", "stagenet.keys"),
|
||||
):
|
||||
descriptor, expected = open_source(source_fd, source_name)
|
||||
opened.append((descriptor, expected, destination_name))
|
||||
for descriptor, expected, destination_name in opened:
|
||||
copy_one(descriptor, expected, destination_fd, destination_name)
|
||||
finally:
|
||||
for descriptor, _, _ in opened:
|
||||
os.close(descriptor)
|
||||
finally:
|
||||
os.close(source_fd)
|
||||
os.close(destination_fd)
|
||||
|
||||
|
||||
def require_ready(stage: pathlib.Path) -> None:
|
||||
descriptor = open_directory_nofollow(stage)
|
||||
try:
|
||||
marker = os.open("READY", os.O_RDONLY | O_NOFOLLOW, dir_fd=descriptor)
|
||||
try:
|
||||
details = os.fstat(marker)
|
||||
if (
|
||||
not stat.S_ISREG(details.st_mode)
|
||||
or details.st_nlink != 1
|
||||
or stat.S_IMODE(details.st_mode) != 0o600
|
||||
):
|
||||
raise Refusal("readiness marker metadata is invalid")
|
||||
finally:
|
||||
os.close(marker)
|
||||
except FileNotFoundError:
|
||||
raise Refusal("readiness marker is absent")
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def publish(stage: pathlib.Path, final: pathlib.Path) -> None:
|
||||
require_ready(stage)
|
||||
if stage.parent != final.parent:
|
||||
raise Refusal("publication must be one same-filesystem directory rename")
|
||||
parent = open_directory_nofollow(stage.parent)
|
||||
try:
|
||||
rename_noreplace(parent, stage.name, parent, final.name)
|
||||
os.fsync(parent)
|
||||
finally:
|
||||
os.close(parent)
|
||||
|
||||
|
||||
class BundleFixture(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="monero-parallel-fixture-")
|
||||
self.root = pathlib.Path(self.temporary.name)
|
||||
self.source = self.root / "legacy"
|
||||
self.stage = self.root / ".candidate.staging"
|
||||
self.final = self.root / "candidate"
|
||||
self.source.mkdir(mode=0o700)
|
||||
self.stage.mkdir(mode=0o700)
|
||||
(self.stage / "wallet").mkdir(mode=0o700)
|
||||
for name, content in (("test", b"synthetic-wallet\0bytes"), ("test.keys", b"synthetic-key\0bytes")):
|
||||
path = self.source / name
|
||||
path.write_bytes(content)
|
||||
path.chmod(0o600)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.temporary.cleanup()
|
||||
|
||||
def ready(self) -> None:
|
||||
marker = self.stage / "READY"
|
||||
descriptor = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL | O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
os.write(descriptor, b"network=stagenet\nrpc_bind=127.0.0.1:38084\n")
|
||||
os.fchmod(descriptor, 0o600)
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
stage_fd = open_directory_nofollow(self.stage)
|
||||
try:
|
||||
os.fsync(stage_fd)
|
||||
finally:
|
||||
os.close(stage_fd)
|
||||
|
||||
def test_partial_stage_is_not_a_candidate(self) -> None:
|
||||
copy_pair(self.source, self.stage / "wallet")
|
||||
with self.assertRaises(Refusal):
|
||||
publish(self.stage, self.final)
|
||||
self.assertFalse(self.final.exists())
|
||||
self.assertTrue(self.stage.exists())
|
||||
|
||||
def test_ready_bundle_is_one_atomic_nonreplacing_publish(self) -> None:
|
||||
copy_pair(self.source, self.stage / "wallet")
|
||||
self.ready()
|
||||
publish(self.stage, self.final)
|
||||
self.assertFalse(self.stage.exists())
|
||||
self.assertEqual((self.final / "wallet" / "stagenet").read_bytes(), b"synthetic-wallet\0bytes")
|
||||
self.assertEqual((self.final / "wallet" / "stagenet.keys").read_bytes(), b"synthetic-key\0bytes")
|
||||
|
||||
def test_existing_final_is_never_replaced(self) -> None:
|
||||
self.ready()
|
||||
self.final.mkdir(mode=0o700)
|
||||
sentinel = self.final / "sentinel"
|
||||
sentinel.write_text("older generation", encoding="ascii")
|
||||
with self.assertRaises(OSError) as problem:
|
||||
publish(self.stage, self.final)
|
||||
self.assertEqual(problem.exception.errno, errno.EEXIST)
|
||||
self.assertEqual(sentinel.read_text(encoding="ascii"), "older generation")
|
||||
self.assertTrue(self.stage.exists())
|
||||
|
||||
def test_symlink_component_is_refused(self) -> None:
|
||||
linked = self.root / "linked-source"
|
||||
linked.symlink_to(self.source, target_is_directory=True)
|
||||
with self.assertRaises(OSError):
|
||||
copy_pair(linked, self.stage / "wallet")
|
||||
|
||||
def test_hard_linked_key_is_refused(self) -> None:
|
||||
os.link(self.source / "test.keys", self.root / "second-link")
|
||||
with self.assertRaises(Refusal):
|
||||
copy_pair(self.source, self.stage / "wallet")
|
||||
self.assertFalse((self.stage / "wallet" / "stagenet.keys").exists())
|
||||
|
||||
def test_source_change_is_detected_before_publication(self) -> None:
|
||||
source_fd = open_directory_nofollow(self.source)
|
||||
destination_fd = open_directory_nofollow(self.stage / "wallet")
|
||||
descriptor, expected = open_source(source_fd, "test")
|
||||
try:
|
||||
def mutate() -> None:
|
||||
with open(self.source / "test", "ab") as output:
|
||||
output.write(b"changed")
|
||||
|
||||
with self.assertRaises(Refusal):
|
||||
copy_one(descriptor, expected, destination_fd, "stagenet", mutate)
|
||||
self.assertFalse((self.stage / "wallet" / "stagenet").exists())
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
os.close(source_fd)
|
||||
os.close(destination_fd)
|
||||
|
||||
def test_marker_contains_no_synthetic_secret(self) -> None:
|
||||
self.ready()
|
||||
self.assertNotIn(b"synthetic-key", (self.stage / "READY").read_bytes())
|
||||
self.assertNotIn(b"synthetic-wallet", (self.stage / "READY").read_bytes())
|
||||
|
||||
def test_linked_readiness_marker_is_refused(self) -> None:
|
||||
external = self.root / "external-marker"
|
||||
external.write_text("network=stagenet\n", encoding="ascii")
|
||||
external.chmod(0o600)
|
||||
os.link(external, self.stage / "READY")
|
||||
with self.assertRaises(Refusal):
|
||||
publish(self.stage, self.final)
|
||||
self.assertFalse(self.final.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.umask(0o077)
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,374 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read-only preflight for a parallel Monero stagenet wallet-RPC candidate.
|
||||
|
||||
This script does not read systemd unit text, process arguments, wallet bytes, keys, passwords, or
|
||||
mnemonics. It does not create, modify, move, or delete anything. It validates only public RPC
|
||||
identity, process/listener identity, path metadata, capacity, and inert candidate prerequisites.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
import grp
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import pwd
|
||||
import resource
|
||||
import socket
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
LEGACY_UNIT = "monero-wallet-rpc.service"
|
||||
LEGACY_USER = "jw"
|
||||
LEGACY_GROUP = "jw"
|
||||
LEGACY_BINARY = pathlib.Path("/usr/bin/monero-wallet-rpc")
|
||||
LEGACY_DIR = pathlib.Path("/home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4")
|
||||
LEGACY_WALLET = LEGACY_DIR / "test"
|
||||
LEGACY_KEYS = LEGACY_DIR / "test.keys"
|
||||
LEGACY_RPC_PORT = 38083
|
||||
LEGACY_RPC_URL = "http://127.0.0.1:38083/json_rpc"
|
||||
|
||||
CANDIDATE_UNIT = "monerogue-stagenet-wallet-candidate.service"
|
||||
CANDIDATE_USER = "monerogue-wallet-candidate"
|
||||
CANDIDATE_GROUP = "monerogue-wallet-candidate"
|
||||
CANDIDATE_HOME = pathlib.Path("/var/lib/monerogue-stagenet-wallet-candidate")
|
||||
CANDIDATE_STAGE = pathlib.Path("/var/lib/.monerogue-stagenet-wallet-candidate.staging")
|
||||
CANDIDATE_BINARY = pathlib.Path("/usr/bin/monero-wallet-rpc")
|
||||
CANDIDATE_RPC_PORT = 38084
|
||||
VERIFY_RPC_PORT = 39084
|
||||
|
||||
ADDRESS_REQUEST = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": "parallel-preflight",
|
||||
"method": "get_address",
|
||||
"params": {"account_index": 0},
|
||||
}
|
||||
O_DIRECTORY = getattr(os, "O_DIRECTORY", 0)
|
||||
O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0)
|
||||
LIBC = ctypes.CDLL(None, use_errno=True)
|
||||
|
||||
|
||||
class Refusal(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def refuse(message: str) -> None:
|
||||
raise Refusal(message)
|
||||
|
||||
|
||||
def harden_process() -> None:
|
||||
os.umask(0o077)
|
||||
resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
|
||||
try:
|
||||
if LIBC.prctl(4, 0, 0, 0, 0) != 0: # PR_SET_DUMPABLE
|
||||
refuse("could not disable process dumps")
|
||||
except AttributeError:
|
||||
refuse("could not disable process dumps")
|
||||
|
||||
|
||||
def identity(user: str, group: str) -> tuple[int, int]:
|
||||
try:
|
||||
return pwd.getpwnam(user).pw_uid, grp.getgrnam(group).gr_gid
|
||||
except KeyError:
|
||||
refuse(f"required identity is missing: {user}:{group}")
|
||||
|
||||
|
||||
def open_directory_nofollow(path: pathlib.Path) -> int:
|
||||
if not path.is_absolute() or pathlib.Path(os.path.normpath(path)) != path:
|
||||
refuse("path is not absolute and normalized")
|
||||
descriptor = os.open("/", os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW)
|
||||
try:
|
||||
for component in path.parts[1:]:
|
||||
following = os.open(
|
||||
component,
|
||||
os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW,
|
||||
dir_fd=descriptor,
|
||||
)
|
||||
os.close(descriptor)
|
||||
descriptor = following
|
||||
if not stat.S_ISDIR(os.fstat(descriptor).st_mode):
|
||||
refuse("opened path is not a directory")
|
||||
return descriptor
|
||||
except Exception:
|
||||
os.close(descriptor)
|
||||
raise
|
||||
|
||||
|
||||
def require_directory(path: pathlib.Path, user: str, group: str, mode: int) -> os.stat_result:
|
||||
try:
|
||||
descriptor = open_directory_nofollow(path)
|
||||
except (FileNotFoundError, NotADirectoryError, OSError):
|
||||
refuse(f"required real directory is unavailable: {path}")
|
||||
try:
|
||||
details = os.fstat(descriptor)
|
||||
expected = (*identity(user, group), mode)
|
||||
actual = (details.st_uid, details.st_gid, stat.S_IMODE(details.st_mode))
|
||||
if actual != expected:
|
||||
refuse(f"directory metadata mismatch: {path}")
|
||||
return details
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def require_regular(path: pathlib.Path, user: str, group: str, mode: int) -> os.stat_result:
|
||||
parent = open_directory_nofollow(path.parent)
|
||||
try:
|
||||
try:
|
||||
descriptor = os.open(path.name, os.O_RDONLY | O_NOFOLLOW, dir_fd=parent)
|
||||
except OSError:
|
||||
refuse(f"required real file is unavailable: {path}")
|
||||
try:
|
||||
details = os.fstat(descriptor)
|
||||
expected = (*identity(user, group), mode)
|
||||
actual = (details.st_uid, details.st_gid, stat.S_IMODE(details.st_mode))
|
||||
if not stat.S_ISREG(details.st_mode) or details.st_nlink != 1 or actual != expected:
|
||||
refuse(f"file metadata mismatch or hard link refused: {path}")
|
||||
if details.st_size <= 0:
|
||||
refuse(f"required file is empty: {path}")
|
||||
return details
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
finally:
|
||||
os.close(parent)
|
||||
|
||||
|
||||
def systemctl(*arguments: str) -> subprocess.CompletedProcess[bytes]:
|
||||
try:
|
||||
return subprocess.run(
|
||||
["/usr/bin/systemctl", *arguments],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
timeout=20,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
refuse("systemd state check failed")
|
||||
|
||||
|
||||
def main_pid(unit: str) -> int:
|
||||
result = systemctl("show", unit, "--property=MainPID", "--value")
|
||||
if result.returncode != 0:
|
||||
refuse("systemd MainPID check failed")
|
||||
try:
|
||||
return int(result.stdout.strip())
|
||||
except ValueError:
|
||||
refuse("systemd returned an invalid MainPID")
|
||||
|
||||
|
||||
def process_uid(pid: int) -> int:
|
||||
try:
|
||||
with open(f"/proc/{pid}/status", "r", encoding="ascii") as source:
|
||||
for line in source:
|
||||
if line.startswith("Uid:"):
|
||||
values = line.split()[1:]
|
||||
if len(values) == 4 and len(set(values)) == 1:
|
||||
return int(values[0])
|
||||
except (OSError, UnicodeDecodeError, ValueError):
|
||||
pass
|
||||
refuse("legacy process UID is unavailable")
|
||||
|
||||
|
||||
def process_start_time(pid: int) -> str:
|
||||
try:
|
||||
raw = pathlib.Path(f"/proc/{pid}/stat").read_text(encoding="ascii")
|
||||
fields = raw[raw.rfind(")") + 2 :].split()
|
||||
return fields[19]
|
||||
except (OSError, UnicodeDecodeError, IndexError):
|
||||
refuse("legacy process start time is unavailable")
|
||||
|
||||
|
||||
def process_has_unit_cgroup(pid: int, unit: str) -> bool:
|
||||
try:
|
||||
lines = pathlib.Path(f"/proc/{pid}/cgroup").read_text(encoding="ascii").splitlines()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
refuse("legacy process cgroup is unavailable")
|
||||
return any(line.rsplit(":", 1)[-1].endswith(f"/{unit}") for line in lines)
|
||||
|
||||
|
||||
def parse_listener_table(path: str, port: int, ipv6: bool) -> list[tuple[str, int]]:
|
||||
found: list[tuple[str, int]] = []
|
||||
try:
|
||||
with open(path, "r", encoding="ascii") as source:
|
||||
next(source, None)
|
||||
for line in source:
|
||||
fields = line.split()
|
||||
if len(fields) < 10 or fields[3] != "0A":
|
||||
continue
|
||||
address_hex, port_hex = fields[1].split(":")
|
||||
if int(port_hex, 16) != port:
|
||||
continue
|
||||
if ipv6:
|
||||
address = socket.inet_ntop(socket.AF_INET6, bytes.fromhex(address_hex))
|
||||
else:
|
||||
address = socket.inet_ntoa(bytes.fromhex(address_hex)[::-1])
|
||||
found.append((address, int(fields[9])))
|
||||
except (OSError, UnicodeDecodeError, ValueError, IndexError):
|
||||
refuse("kernel listener state is unavailable")
|
||||
return found
|
||||
|
||||
|
||||
def listeners(port: int) -> list[tuple[str, int]]:
|
||||
return parse_listener_table("/proc/net/tcp", port, False) + parse_listener_table(
|
||||
"/proc/net/tcp6", port, True
|
||||
)
|
||||
|
||||
|
||||
def process_owns_socket(pid: int, inode: int) -> bool:
|
||||
expected = f"socket:[{inode}]"
|
||||
try:
|
||||
for entry in os.scandir(f"/proc/{pid}/fd"):
|
||||
try:
|
||||
if os.readlink(entry.path) == expected:
|
||||
return True
|
||||
except OSError:
|
||||
continue
|
||||
except OSError:
|
||||
refuse("legacy process descriptors are unavailable")
|
||||
return False
|
||||
|
||||
|
||||
def require_exact_listener(pid: int, port: int) -> None:
|
||||
active = listeners(port)
|
||||
if len(active) != 1 or active[0][0] != "127.0.0.1":
|
||||
refuse(f"port {port} is not one exact IPv4 loopback listener")
|
||||
if not process_owns_socket(pid, active[0][1]):
|
||||
refuse(f"port {port} is not owned by the expected process")
|
||||
|
||||
|
||||
def require_unused_port(port: int) -> None:
|
||||
if listeners(port):
|
||||
refuse(f"candidate port {port} is already in use")
|
||||
|
||||
|
||||
def validate_public_address() -> None:
|
||||
body = json.dumps(ADDRESS_REQUEST, separators=(",", ":")).encode("ascii")
|
||||
request = urllib.request.Request(
|
||||
LEGACY_RPC_URL,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
# Never honor ambient proxy settings for a loopback identity check.
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
with opener.open(request, timeout=5) as response:
|
||||
if response.status != 200:
|
||||
refuse("legacy wallet RPC returned an unexpected status")
|
||||
raw = response.read(1024 * 1024 + 1)
|
||||
except (urllib.error.URLError, TimeoutError, OSError, http.client.HTTPException):
|
||||
refuse("legacy wallet RPC public address check failed")
|
||||
if len(raw) > 1024 * 1024:
|
||||
refuse("legacy wallet RPC response is oversized")
|
||||
try:
|
||||
payload = json.loads(raw.decode("utf-8"))
|
||||
address = payload["result"]["address"]
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError):
|
||||
refuse("legacy wallet RPC returned an invalid public address response")
|
||||
alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
||||
if (
|
||||
not isinstance(address, str)
|
||||
or len(address) != 95
|
||||
or not address.startswith("5")
|
||||
or any(character not in alphabet for character in address)
|
||||
):
|
||||
refuse("legacy wallet RPC address is not a primary stagenet address")
|
||||
|
||||
|
||||
def validate_legacy() -> tuple[os.stat_result, os.stat_result]:
|
||||
if os.geteuid() != 0:
|
||||
refuse("run as root so process ownership checks cannot be hidden")
|
||||
if systemctl("is-active", "--quiet", LEGACY_UNIT).returncode != 0:
|
||||
refuse("legacy wallet service must be active")
|
||||
pid = main_pid(LEGACY_UNIT)
|
||||
if pid <= 1:
|
||||
refuse("legacy wallet service has no stable MainPID")
|
||||
started = process_start_time(pid)
|
||||
expected_uid, _ = identity(LEGACY_USER, LEGACY_GROUP)
|
||||
if process_uid(pid) != expected_uid:
|
||||
refuse("legacy wallet process UID is unexpected")
|
||||
try:
|
||||
executable = pathlib.Path(os.readlink(f"/proc/{pid}/exe"))
|
||||
except OSError:
|
||||
refuse("legacy wallet process executable is unavailable")
|
||||
if executable != LEGACY_BINARY:
|
||||
refuse("legacy wallet process executable is unexpected")
|
||||
if not process_has_unit_cgroup(pid, LEGACY_UNIT):
|
||||
refuse("legacy wallet process cgroup is unexpected")
|
||||
require_exact_listener(pid, LEGACY_RPC_PORT)
|
||||
require_directory(LEGACY_DIR, LEGACY_USER, LEGACY_GROUP, 0o775)
|
||||
wallet = require_regular(LEGACY_WALLET, LEGACY_USER, LEGACY_GROUP, 0o600)
|
||||
keys = require_regular(LEGACY_KEYS, LEGACY_USER, LEGACY_GROUP, 0o600)
|
||||
validate_public_address()
|
||||
if main_pid(LEGACY_UNIT) != pid or process_start_time(pid) != started:
|
||||
refuse("legacy wallet process changed during preflight")
|
||||
require_exact_listener(pid, LEGACY_RPC_PORT)
|
||||
return wallet, keys
|
||||
|
||||
|
||||
def validate_candidate_prerequisites(wallet: os.stat_result, keys: os.stat_result) -> None:
|
||||
try:
|
||||
record = pwd.getpwnam(CANDIDATE_USER)
|
||||
group = grp.getgrnam(CANDIDATE_GROUP)
|
||||
except KeyError:
|
||||
refuse("parallel candidate service identity is not provisioned")
|
||||
if (
|
||||
record.pw_uid >= 1000
|
||||
or record.pw_gid != group.gr_gid
|
||||
or os.getgrouplist(CANDIDATE_USER, record.pw_gid) != [record.pw_gid]
|
||||
or record.pw_dir != os.fspath(CANDIDATE_HOME)
|
||||
or record.pw_shell not in ("/usr/sbin/nologin", "/bin/false")
|
||||
):
|
||||
refuse("parallel candidate identity is not a dedicated system account")
|
||||
require_regular(CANDIDATE_BINARY, "root", "root", 0o755)
|
||||
require_directory(CANDIDATE_HOME.parent, "root", "root", 0o755)
|
||||
if os.path.lexists(CANDIDATE_STAGE) or os.path.lexists(CANDIDATE_HOME):
|
||||
refuse("candidate staging/final path already exists; do not overwrite it")
|
||||
if systemctl("is-active", "--quiet", CANDIDATE_UNIT).returncode == 0:
|
||||
refuse("candidate unit must be inactive")
|
||||
require_unused_port(CANDIDATE_RPC_PORT)
|
||||
require_unused_port(VERIFY_RPC_PORT)
|
||||
capacity = os.statvfs(CANDIDATE_HOME.parent)
|
||||
available = capacity.f_bavail * capacity.f_frsize
|
||||
required = wallet.st_size + keys.st_size + 64 * 1024 * 1024
|
||||
if available < required:
|
||||
refuse("insufficient space for a non-replacing candidate wallet copy")
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Read-only Monero stagenet parallel preflight")
|
||||
parser.add_argument(
|
||||
"scope",
|
||||
choices=("legacy", "candidate-prereqs"),
|
||||
help="legacy checks the current RPC; candidate-prereqs also checks inert new-path gates",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
options = parse_args(sys.argv[1:] if argv is None else argv)
|
||||
harden_process()
|
||||
wallet, keys = validate_legacy()
|
||||
if options.scope == "candidate-prereqs":
|
||||
validate_candidate_prerequisites(wallet, keys)
|
||||
print(f"PASSED: read-only {options.scope} preflight")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except Refusal as problem:
|
||||
print(f"REFUSED: {problem}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
except Exception:
|
||||
# Do not render process or RPC data in an unexpected exception.
|
||||
print("REFUSED: unexpected read-only preflight failure", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -1,35 +1,46 @@
|
||||
# Monero STAGENET wallet RPC for the monerogue.app instance.
|
||||
# Mirrors wownero-wallet-rpc.service. Install to /etc/systemd/system/monero-wallet-rpc.service
|
||||
# Monero STAGENET wallet RPC for the monerogue instance.
|
||||
# Install to /etc/systemd/system/monero-wallet-rpc.service.
|
||||
#
|
||||
# Wallet: /home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4/test (the stagenet wallet with coins)
|
||||
# Daemon: monerod --stagenet RPC on 127.0.0.1:38081 (already running)
|
||||
# RPC: binds :38083 (free)
|
||||
#
|
||||
# IMPORTANT: replace REPLACE_WITH_WALLET_PASSWORD below with the wallet's real password
|
||||
# (do NOT commit the real password). Consider --wallet-dir + --rpc-login for production,
|
||||
# but this mirrors the existing wownero setup for parity.
|
||||
# Wallet credentials belong in /etc/monerogue/wallet-rpc.conf (root:monerogue-wallet 0640), never in
|
||||
# this unit: anything passed on the command line is readable from /proc by any local user. See
|
||||
# monero-wallet-rpc.conf.example.
|
||||
[Unit]
|
||||
Description=Monero Wallet RPC (Stagenet) - monerogue
|
||||
After=network-online.target monerod.service
|
||||
Wants=network-online.target
|
||||
Wants=monerod.service
|
||||
|
||||
[Service]
|
||||
User=jw
|
||||
Group=jw
|
||||
User=monerogue-wallet
|
||||
Group=monerogue-wallet
|
||||
Type=simple
|
||||
|
||||
ExecStart=/usr/bin/monero-wallet-rpc \
|
||||
--stagenet \
|
||||
--wallet-file /home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4/test \
|
||||
--password "REPLACE_WITH_WALLET_PASSWORD" \
|
||||
--rpc-bind-port 38083 \
|
||||
--daemon-address 127.0.0.1:38081 \
|
||||
--non-interactive \
|
||||
--rpc-bind-ip 127.0.0.1 \
|
||||
--disable-rpc-login
|
||||
ExecStart=/usr/bin/monero-wallet-rpc --config-file /etc/monerogue/wallet-rpc.conf
|
||||
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
TimeoutStopSec=60
|
||||
UMask=0077
|
||||
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
ProcSubset=pid
|
||||
RestrictSUIDSGID=yes
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
LockPersonality=yes
|
||||
RestrictNamespaces=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
SystemCallArchitectures=native
|
||||
ReadWritePaths=/var/lib/monerogue-wallet
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# Manual-only, parallel Monero STAGENET wallet-RPC candidate.
|
||||
#
|
||||
# REVIEW STATUS: INERT TEMPLATE. Do not install until the bundle producer and its crash-injection
|
||||
# evidence satisfy README-monero-stagenet-parallel-candidate.md. This unit intentionally has no
|
||||
# installation stanza, so it cannot be enabled into a boot target.
|
||||
[Unit]
|
||||
Description=Monerogue parallel wallet RPC candidate (stagenet, manual only)
|
||||
After=network-online.target monerod.service
|
||||
Wants=network-online.target monerod.service
|
||||
ConditionPathExists=/var/lib/monerogue-stagenet-wallet-candidate/READY
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=monerogue-wallet-candidate
|
||||
Group=monerogue-wallet-candidate
|
||||
SupplementaryGroups=
|
||||
WorkingDirectory=/var/lib/monerogue-stagenet-wallet-candidate/wallet
|
||||
|
||||
# The protected config fixes stagenet, wallet/password paths, daemon 127.0.0.1:38081,
|
||||
# RPC 127.0.0.1:38084, and Digest rpc-login. No secret belongs in this unit or argv.
|
||||
ExecStart=/usr/bin/monero-wallet-rpc --config-file /var/lib/monerogue-stagenet-wallet-candidate/config/wallet-rpc.conf --log-file /dev/null
|
||||
|
||||
# A candidate must be restarted deliberately and revalidated; it is not a production dependency.
|
||||
Restart=no
|
||||
TimeoutStopSec=60
|
||||
UMask=0077
|
||||
LimitCORE=0
|
||||
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
PrivateUsers=no
|
||||
RemoveIPC=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectClock=yes
|
||||
ProtectHostname=yes
|
||||
ProtectProc=invisible
|
||||
ProcSubset=pid
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictRealtime=yes
|
||||
CapabilityBoundingSet=
|
||||
AmbientCapabilities=
|
||||
LockPersonality=yes
|
||||
MemoryDenyWriteExecute=yes
|
||||
KeyringMode=private
|
||||
RestrictNamespaces=yes
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
SystemCallArchitectures=native
|
||||
ReadWritePaths=/var/lib/monerogue-stagenet-wallet-candidate/wallet
|
||||
@@ -1,372 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Synthetic-only tests for the fixed wallet keyfile recovery proof primitives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPT = pathlib.Path(__file__).with_name("wallet-keyfile-recovery-proof.py")
|
||||
SPEC = importlib.util.spec_from_file_location("wallet_keyfile_recovery_proof", SCRIPT)
|
||||
if SPEC is None or SPEC.loader is None: # pragma: no cover
|
||||
raise RuntimeError("fixture import unavailable")
|
||||
proof = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = proof
|
||||
SPEC.loader.exec_module(proof)
|
||||
|
||||
|
||||
def synthetic_profile(stagenet: bool = True):
|
||||
return proof.WalletProfile(
|
||||
name="synthetic",
|
||||
unit="synthetic.service",
|
||||
user="synthetic",
|
||||
group="synthetic",
|
||||
binary=pathlib.Path("/fixed/bin/wallet-rpc"),
|
||||
live_wallet=pathlib.Path("/fixed/live/wallet"),
|
||||
live_rpc_port=38083,
|
||||
live_bind_address="127.0.0.1",
|
||||
snapshot_wallet=pathlib.Path("/fixed/snapshot.wallet"),
|
||||
snapshot_keys=pathlib.Path("/fixed/snapshot.keys"),
|
||||
proof_port=39084,
|
||||
stagenet=stagenet,
|
||||
address_kind="xmr-stagenet" if stagenet else "wow-mainnet",
|
||||
proof_directory=pathlib.Path("/fixed/proof"),
|
||||
)
|
||||
|
||||
|
||||
def cmdline(profile, *, wallet=None, passwords=(b"synthetic-wallet-password",), extra=()):
|
||||
arguments = [os.fsencode(profile.binary)]
|
||||
if profile.stagenet:
|
||||
arguments.append(b"--stagenet")
|
||||
arguments.extend((b"--wallet-file", os.fsencode(wallet or profile.live_wallet)))
|
||||
for password in passwords:
|
||||
arguments.extend((b"--password", password))
|
||||
arguments.extend((b"--rpc-bind-port", str(profile.live_rpc_port).encode("ascii")))
|
||||
arguments.extend(extra)
|
||||
raw = bytearray(b"\0".join(arguments) + b"\0")
|
||||
return raw, len(raw)
|
||||
|
||||
|
||||
class CmdlineContractFixture(unittest.TestCase):
|
||||
def test_exact_fixed_argv_extracts_only_the_inline_password_and_wipes_input(self):
|
||||
profile = synthetic_profile()
|
||||
raw, length = cmdline(profile)
|
||||
secret = proof.extract_inline_wallet_password(raw, length, profile)
|
||||
try:
|
||||
self.assertEqual(secret, b"synthetic-wallet-password")
|
||||
self.assertFalse(any(raw))
|
||||
finally:
|
||||
proof.wipe_buffer(secret)
|
||||
|
||||
def test_wrong_wallet_path_is_refused_and_input_is_wiped(self):
|
||||
profile = synthetic_profile()
|
||||
raw, length = cmdline(profile, wallet=pathlib.Path("/wrong/wallet"))
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.extract_inline_wallet_password(raw, length, profile)
|
||||
self.assertFalse(any(raw))
|
||||
|
||||
def test_duplicate_password_is_refused(self):
|
||||
profile = synthetic_profile()
|
||||
raw, length = cmdline(profile, passwords=(b"one", b"two"))
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.extract_inline_wallet_password(raw, length, profile)
|
||||
self.assertFalse(any(raw))
|
||||
|
||||
def test_password_file_or_wrong_network_is_refused(self):
|
||||
profile = synthetic_profile()
|
||||
for indirect in (
|
||||
(b"--password-file", b"/not-accepted"),
|
||||
(b"--password-file=/not-accepted",),
|
||||
(b"--wallet-dir=/not-accepted",),
|
||||
):
|
||||
raw, length = cmdline(profile, extra=indirect)
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.extract_inline_wallet_password(raw, length, profile)
|
||||
self.assertFalse(any(raw))
|
||||
|
||||
mainnet = synthetic_profile(stagenet=False)
|
||||
for wrong_network in ((b"--stagenet",), (b"--stagenet=1",), (b"--testnet=1",)):
|
||||
raw, length = cmdline(mainnet, extra=wrong_network)
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.extract_inline_wallet_password(raw, length, mainnet)
|
||||
self.assertFalse(any(raw))
|
||||
|
||||
|
||||
class SnapshotCopyFixture(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temporary = tempfile.TemporaryDirectory(prefix="wallet-recovery-fixture-")
|
||||
self.root = pathlib.Path(self.temporary.name)
|
||||
self.source = self.root / "source"
|
||||
self.destination = self.root / "destination"
|
||||
self.source.mkdir(mode=0o700)
|
||||
self.destination.mkdir(mode=0o700)
|
||||
self.wallet = self.source / "snapshot.wallet"
|
||||
self.keys = self.source / "snapshot.keys"
|
||||
self.wallet.write_bytes(b"synthetic encrypted wallet cache\0bytes")
|
||||
self.keys.write_bytes(b"synthetic encrypted keyfile\0bytes")
|
||||
self.wallet.chmod(0o600)
|
||||
self.keys.chmod(0o600)
|
||||
|
||||
def tearDown(self):
|
||||
self.temporary.cleanup()
|
||||
|
||||
def copy(self, after_first_copy=None):
|
||||
proof.copy_snapshot_pair(
|
||||
self.source,
|
||||
self.wallet.name,
|
||||
self.keys.name,
|
||||
self.destination,
|
||||
os.getuid(),
|
||||
os.getgid(),
|
||||
after_first_copy,
|
||||
)
|
||||
|
||||
def test_copy_is_byte_equal_private_and_source_is_unchanged(self):
|
||||
wallet_before = self.wallet.read_bytes()
|
||||
keys_before = self.keys.read_bytes()
|
||||
self.copy()
|
||||
self.assertEqual((self.destination / "wallet").read_bytes(), wallet_before)
|
||||
self.assertEqual((self.destination / "wallet.keys").read_bytes(), keys_before)
|
||||
self.assertEqual(self.wallet.read_bytes(), wallet_before)
|
||||
self.assertEqual(self.keys.read_bytes(), keys_before)
|
||||
self.assertEqual((self.destination / "wallet").stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual((self.destination / "wallet.keys").stat().st_mode & 0o777, 0o600)
|
||||
|
||||
def test_hard_linked_source_is_refused(self):
|
||||
os.link(self.keys, self.root / "second-key-link")
|
||||
with self.assertRaises(proof.Refusal):
|
||||
self.copy()
|
||||
self.assertFalse((self.destination / "wallet").exists())
|
||||
|
||||
def test_symlink_source_component_is_refused(self):
|
||||
linked = self.root / "linked-source"
|
||||
linked.symlink_to(self.source, target_is_directory=True)
|
||||
with self.assertRaises((OSError, proof.Refusal)):
|
||||
proof.copy_snapshot_pair(
|
||||
linked,
|
||||
self.wallet.name,
|
||||
self.keys.name,
|
||||
self.destination,
|
||||
os.getuid(),
|
||||
os.getgid(),
|
||||
)
|
||||
|
||||
def test_source_change_during_copy_is_refused_and_partial_copy_removed(self):
|
||||
def mutate():
|
||||
with self.wallet.open("ab") as output:
|
||||
output.write(b"changed")
|
||||
|
||||
with self.assertRaises(proof.Refusal):
|
||||
self.copy(mutate)
|
||||
self.assertFalse((self.destination / "wallet").exists())
|
||||
self.assertFalse((self.destination / "wallet.keys").exists())
|
||||
|
||||
|
||||
class CleanupAndProtocolFixture(unittest.TestCase):
|
||||
def test_protection_failure_wipes_the_synthetic_secret(self):
|
||||
value = bytearray(b"synthetic-secret")
|
||||
with mock.patch.object(proof.LIBC, "mlock", return_value=-1):
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.protect_buffer(value)
|
||||
self.assertFalse(any(value))
|
||||
|
||||
def test_cleanup_never_follows_a_link_outside_its_exact_directory(self):
|
||||
with tempfile.TemporaryDirectory(prefix="wallet-cleanup-fixture-") as temporary:
|
||||
root = pathlib.Path(temporary)
|
||||
proof_dir = root / "proof"
|
||||
proof_dir.mkdir(mode=0o700)
|
||||
secret = proof_dir / "wallet-password"
|
||||
secret.write_bytes(b"synthetic secret")
|
||||
secret.chmod(0o600)
|
||||
external = root / "external"
|
||||
external.write_bytes(b"must survive")
|
||||
(proof_dir / "linked").symlink_to(external)
|
||||
details = proof_dir.stat()
|
||||
|
||||
self.assertFalse(
|
||||
proof.secure_remove_proof_directory(
|
||||
proof_dir, (details.st_dev, details.st_ino)
|
||||
)
|
||||
)
|
||||
self.assertFalse(proof_dir.exists())
|
||||
self.assertEqual(external.read_bytes(), b"must survive")
|
||||
|
||||
def test_cleanup_never_overwrites_an_external_hard_link(self):
|
||||
with tempfile.TemporaryDirectory(prefix="wallet-hardlink-fixture-") as temporary:
|
||||
root = pathlib.Path(temporary)
|
||||
proof_dir = root / "proof"
|
||||
proof_dir.mkdir(mode=0o700)
|
||||
external = root / "external"
|
||||
external.write_bytes(b"must survive")
|
||||
os.link(external, proof_dir / "linked")
|
||||
details = proof_dir.stat()
|
||||
|
||||
self.assertFalse(
|
||||
proof.secure_remove_proof_directory(
|
||||
proof_dir, (details.st_dev, details.st_ino)
|
||||
)
|
||||
)
|
||||
self.assertFalse(proof_dir.exists())
|
||||
self.assertEqual(external.read_bytes(), b"must survive")
|
||||
|
||||
def test_fixed_address_shapes_reject_cross_network_values(self):
|
||||
xmr = synthetic_profile()
|
||||
wow = synthetic_profile(stagenet=False)
|
||||
xmr_address = "5" + "1" * 94
|
||||
wow_address = "Wo" + "1" * 95
|
||||
self.assertTrue(proof.network_shaped_address(xmr_address, xmr))
|
||||
self.assertTrue(proof.network_shaped_address(wow_address, wow))
|
||||
self.assertFalse(proof.network_shaped_address(wow_address, xmr))
|
||||
self.assertFalse(proof.network_shaped_address(xmr_address, wow))
|
||||
|
||||
def test_digest_header_does_not_embed_the_ephemeral_rpc_password(self):
|
||||
challenge = 'Digest realm="wallet-rpc", nonce="abcdef", qop="auth", algorithm=MD5'
|
||||
rpc_secret = bytearray(b"a" * 64)
|
||||
authorization = proof.digest_authorization(challenge, rpc_secret)
|
||||
self.assertTrue(authorization.startswith("Digest "))
|
||||
self.assertNotIn(rpc_secret.decode("ascii"), authorization)
|
||||
|
||||
def test_digest_requires_auth_qop_but_accepts_a_spaced_offer(self):
|
||||
parsed = proof.parse_digest_challenge(
|
||||
'Digest realm="wallet-rpc", nonce="abcdef", qop="auth-int, auth"'
|
||||
)
|
||||
self.assertEqual(parsed["nonce"], "abcdef")
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.parse_digest_challenge(
|
||||
'Digest realm="wallet-rpc", nonce="abcdef", qop="auth-int"'
|
||||
)
|
||||
|
||||
def test_candidate_files_use_password_file_loopback_offline_contract(self):
|
||||
with tempfile.TemporaryDirectory(prefix="wallet-config-fixture-") as temporary:
|
||||
profile = dataclasses.replace(
|
||||
synthetic_profile(), proof_directory=pathlib.Path(temporary)
|
||||
)
|
||||
profile.candidate_directory.mkdir(mode=0o700)
|
||||
written = {}
|
||||
|
||||
def capture(_directory_fd, name, value):
|
||||
written[name] = bytes(value)
|
||||
|
||||
with mock.patch.object(proof, "write_new_file", side_effect=capture):
|
||||
rpc_secret = proof.prepare_candidate_files(
|
||||
profile, bytearray(b"synthetic-wallet-password")
|
||||
)
|
||||
try:
|
||||
config = written["wallet-rpc.conf"]
|
||||
self.assertEqual(
|
||||
written["wallet-password"], b"synthetic-wallet-password\n"
|
||||
)
|
||||
self.assertIn(
|
||||
b"wallet-file="
|
||||
+ os.fsencode(profile.candidate_directory / "wallet")
|
||||
+ b"\n",
|
||||
config,
|
||||
)
|
||||
self.assertIn(
|
||||
b"password-file="
|
||||
+ os.fsencode(profile.candidate_directory / "wallet-password")
|
||||
+ b"\n",
|
||||
config,
|
||||
)
|
||||
self.assertIn(b"rpc-bind-ip=127.0.0.1\n", config)
|
||||
self.assertIn(b"rpc-bind-port=39084\n", config)
|
||||
self.assertIn(b"non-interactive=1\n", config)
|
||||
self.assertIn(b"rpc-login=recovery-proof:" + bytes(rpc_secret), config)
|
||||
self.assertIn(b"stagenet=1\n", config)
|
||||
self.assertNotIn(b"daemon-address", config)
|
||||
finally:
|
||||
proof.wipe_buffer(rpc_secret, True)
|
||||
|
||||
def test_fixed_message_signature_must_verify_as_spend_capability(self):
|
||||
profile = synthetic_profile()
|
||||
requests = []
|
||||
signature = "SigV1" + "1" * 80
|
||||
|
||||
def answer(_profile, _challenge, _rpc_secret, request_body, nonce_count):
|
||||
request = json.loads(bytes(request_body).decode("ascii"))
|
||||
requests.append((request, nonce_count))
|
||||
if request["method"] == "sign":
|
||||
return json.dumps({"result": {"signature": signature}}).encode()
|
||||
return json.dumps(
|
||||
{"result": {"good": True, "signature_type": "spend"}}
|
||||
).encode()
|
||||
|
||||
with mock.patch.object(proof, "authenticated_candidate_rpc", side_effect=answer):
|
||||
proof.prove_spend_capability(
|
||||
profile,
|
||||
'Digest realm="wallet-rpc", nonce="abcdef", qop="auth"',
|
||||
bytearray(b"synthetic-rpc-secret"),
|
||||
"5" + "1" * 94,
|
||||
)
|
||||
|
||||
self.assertEqual([request["method"] for request, _ in requests], ["sign", "verify"])
|
||||
self.assertEqual([nonce_count for _, nonce_count in requests], [2, 3])
|
||||
self.assertEqual(requests[0][0]["params"], {"data": proof.SIGN_MESSAGE})
|
||||
self.assertEqual(requests[1][0]["params"]["signature"], signature)
|
||||
|
||||
def test_missing_spend_signature_is_refused(self):
|
||||
with mock.patch.object(
|
||||
proof,
|
||||
"authenticated_candidate_rpc",
|
||||
return_value=b'{"error":{"message":"watch-only"}}',
|
||||
):
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.prove_spend_capability(
|
||||
synthetic_profile(),
|
||||
'Digest realm="wallet-rpc", nonce="abcdef", qop="auth"',
|
||||
bytearray(b"synthetic-rpc-secret"),
|
||||
"5" + "1" * 94,
|
||||
)
|
||||
|
||||
def test_verified_view_signature_is_not_spend_capability(self):
|
||||
signature = "SigV1" + "1" * 80
|
||||
responses = [
|
||||
json.dumps({"result": {"signature": signature}}).encode(),
|
||||
b'{"result":{"good":true,"signature_type":"view"}}',
|
||||
]
|
||||
with mock.patch.object(
|
||||
proof, "authenticated_candidate_rpc", side_effect=responses
|
||||
):
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.prove_spend_capability(
|
||||
synthetic_profile(),
|
||||
'Digest realm="wallet-rpc", nonce="abcdef", qop="auth"',
|
||||
bytearray(b"synthetic-rpc-secret"),
|
||||
"5" + "1" * 94,
|
||||
)
|
||||
|
||||
def test_child_drops_to_exact_ids_with_no_supplementary_groups(self):
|
||||
with (
|
||||
mock.patch.object(proof.os, "setgroups") as setgroups,
|
||||
mock.patch.object(proof.os, "setgid") as setgid,
|
||||
mock.patch.object(proof.os, "setuid") as setuid,
|
||||
mock.patch.object(proof.os, "getresuid", return_value=(123, 123, 123)),
|
||||
mock.patch.object(proof.os, "getresgid", return_value=(456, 456, 456)),
|
||||
mock.patch.object(proof.os, "getgroups", return_value=[]),
|
||||
mock.patch.object(proof.os, "umask"),
|
||||
mock.patch.object(proof.resource, "setrlimit"),
|
||||
mock.patch.object(proof.LIBC, "prctl", return_value=0) as prctl,
|
||||
):
|
||||
proof.child_hardening(123, 456)
|
||||
setgroups.assert_called_once_with([])
|
||||
setgid.assert_called_once_with(456)
|
||||
setuid.assert_called_once_with(123)
|
||||
self.assertEqual(prctl.call_count, 3)
|
||||
|
||||
def test_listener_collision_is_refused(self):
|
||||
with mock.patch.object(proof, "listeners", return_value=[("127.0.0.1", 42)]):
|
||||
with self.assertRaises(proof.Refusal):
|
||||
proof.require_unused_port(39084)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
os.umask(0o077)
|
||||
unittest.main(verbosity=2)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* splitOutputs.js — Split wallet outputs for concurrent payout capacity.
|
||||
* splitOutputs.js: split wallet outputs for concurrent payout capacity.
|
||||
*
|
||||
* Wownero/Monero lock change outputs for several blocks after spending.
|
||||
* Pre-splitting one large output into many smaller ones allows the wallet
|
||||
@@ -20,7 +20,7 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
|
||||
// Parse .env manually — no external dependencies needed
|
||||
// Parse .env manually so the script needs no external dependencies.
|
||||
function loadEnv() {
|
||||
const envPath = path.resolve(__dirname, '..', 'src', '.env');
|
||||
try {
|
||||
@@ -35,7 +35,7 @@ function loadEnv() {
|
||||
if (!process.env[key]) process.env[key] = val;
|
||||
}
|
||||
} catch (e) {
|
||||
// .env not found — that's fine, user can pass --endpoint
|
||||
// .env not found. The caller can pass --endpoint instead.
|
||||
}
|
||||
}
|
||||
loadEnv();
|
||||
@@ -76,7 +76,7 @@ function parseArgs() {
|
||||
case '--help':
|
||||
case '-h':
|
||||
console.log(`
|
||||
splitOutputs.js — Split wallet outputs for concurrent payout capacity.
|
||||
splitOutputs.js: split wallet outputs for concurrent payout capacity.
|
||||
|
||||
Usage: node scripts/splitOutputs.js [options]
|
||||
|
||||
|
||||
+8
-8
@@ -378,14 +378,14 @@ PRIMARY_RPC_ENDPOINT=http://127.0.0.1:34568
|
||||
FALLBACK_RPC_ENDPOINT=http://127.0.0.1:34568
|
||||
|
||||
# OPTIONAL: additional daemons, comma or whitespace separated, in PREFERENCE ORDER.
|
||||
# PRIMARY/FALLBACK above are appended to this list and the whole thing is deduplicated —
|
||||
# PRIMARY/FALLBACK above are appended to this list and the whole thing is deduplicated:
|
||||
# note that the two defaults are the SAME host, so out of the box there is exactly one node
|
||||
# and no redundancy at all.
|
||||
#
|
||||
# A fallback only helps if it can fail independently. A second daemon in the same rack (or the
|
||||
# same office) shares the outage that takes the first one down, so it buys nothing; a node
|
||||
# reachable over a separate path — a home node across the mesh, or a public/community node — is
|
||||
# what actually adds availability.
|
||||
# same office) shares the outage that takes the first one down, so it buys nothing. What actually
|
||||
# adds availability is a node reachable over a separate path: a home node across the mesh, or a
|
||||
# public/community node.
|
||||
#
|
||||
# Every endpoint must still pass the chain-identity check before it may answer, so listing a
|
||||
# remote node cannot silently serve a different chain's data. For FREE / CREDITS instances the
|
||||
@@ -393,14 +393,14 @@ FALLBACK_RPC_ENDPOINT=http://127.0.0.1:34568
|
||||
# taking PAYMENTS, prefer daemons you control: block height feeds match fairness and entropy, and
|
||||
# an untrusted node is an untrusted input to those.
|
||||
#
|
||||
# RPC_ENDPOINTS=http://127.0.0.1:34568,http://10.42.1.40:34568
|
||||
# RPC_ENDPOINTS=http://127.0.0.1:34568,http://192.0.2.40:34568
|
||||
#
|
||||
# How long to stay on a backup before re-testing the preferred node (ms, minimum 1000).
|
||||
# RPC_PREFERRED_RETRY_MS=60000
|
||||
#
|
||||
# When NO endpoint answers, the server does not guess: entries are refused with a clear message
|
||||
# and nothing is charged. There is intentionally no wallet failover — two wallet-rpc processes
|
||||
# over one wallet file can spend the same outputs — so a wallet outage declines paid intake while
|
||||
# and nothing is charged. There is intentionally no wallet failover: two wallet-rpc processes
|
||||
# over one wallet file can spend the same outputs. So a wallet outage declines paid intake while
|
||||
# credits and free play keep working.
|
||||
|
||||
# How often to poll for new blocks (milliseconds)
|
||||
@@ -445,7 +445,7 @@ RPC_POLL_INTERVAL=2000
|
||||
# TRUST_PROXY_HOPS=1 # NPM directly in front of Node
|
||||
|
||||
# Anti-instant-death grace (ms) before a block-timeout can end a game. This is NOT a
|
||||
# fairness floor — random block timing is the core mechanic. It only prevents the
|
||||
# fairness floor: random block timing is the core mechanic. It only prevents the
|
||||
# degenerate case where a block lands the instant a game starts (before the dungeon
|
||||
# renders / before the first move is possible). Default 2000 (2s); set 0 to disable.
|
||||
# GAME_START_GRACE_MS=2000
|
||||
|
||||
@@ -80,13 +80,13 @@ PRIMARY_RPC_ENDPOINT=http://127.0.0.1:34568
|
||||
FALLBACK_RPC_ENDPOINT=http://127.0.0.1:34568
|
||||
|
||||
# OPTIONAL: additional daemons, comma or whitespace separated, in PREFERENCE ORDER.
|
||||
# PRIMARY/FALLBACK above are appended to this list and the whole thing is deduplicated —
|
||||
# PRIMARY/FALLBACK above are appended to this list and the whole thing is deduplicated:
|
||||
# note that the two defaults are the SAME host, so out of the box there is exactly one node
|
||||
# and no redundancy at all.
|
||||
#
|
||||
# A fallback only helps if it can fail independently. A second daemon in the same rack (or the
|
||||
# same office) shares the outage that takes the first one down, so it buys nothing; a node
|
||||
# reachable over a separate path — a home node across the mesh, or a public/community node — is
|
||||
# reachable over a separate path, a home node across the mesh, or a public/community node, is
|
||||
# what actually adds availability.
|
||||
#
|
||||
# Every endpoint must still pass the chain-identity check before it may answer, so listing a
|
||||
@@ -95,15 +95,15 @@ FALLBACK_RPC_ENDPOINT=http://127.0.0.1:34568
|
||||
# taking PAYMENTS, prefer daemons you control: block height feeds match fairness and entropy, and
|
||||
# an untrusted node is an untrusted input to those.
|
||||
#
|
||||
# RPC_ENDPOINTS=http://127.0.0.1:34568,http://10.42.1.40:34568
|
||||
# RPC_ENDPOINTS=http://127.0.0.1:34568,http://192.0.2.40:34568
|
||||
#
|
||||
# How long to stay on a backup before re-testing the preferred node (ms, minimum 1000).
|
||||
# RPC_PREFERRED_RETRY_MS=60000
|
||||
#
|
||||
# When NO endpoint answers, the server does not guess: entries are refused with a clear message
|
||||
# and nothing is charged. There is intentionally no wallet failover — two wallet-rpc processes
|
||||
# over one wallet file can spend the same outputs — so a wallet outage declines paid intake while
|
||||
# credits and free play keep working.
|
||||
# and nothing is charged. There is intentionally no wallet failover: two wallet-rpc processes
|
||||
# over one wallet file can spend the same outputs. A wallet outage therefore declines paid intake
|
||||
# while credits and free play keep working.
|
||||
RPC_POLL_INTERVAL=2000
|
||||
|
||||
DIFFICULTY_PRESET=normal
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
/**
|
||||
* ChainProfile registry — the 10× foundation stone for multi-chain (Pillar 3).
|
||||
* ChainProfile registry: the 10× foundation stone for multi-chain (Pillar 3).
|
||||
*
|
||||
* A single, data-driven source for everything chain-specific that ISN'T an RPC call: money units
|
||||
* (decimals / atomic divisor), the block-timing model (mean block time — which difficulty must
|
||||
* (decimals / atomic divisor), the block-timing model (mean block time, which difficulty must
|
||||
* scale to), the adapter family (which wallet/daemon adapter implements it), and the payment-URI
|
||||
* scheme. The adapters (daemon/wallet clients, address validators) plug in per family and read
|
||||
* their params from here.
|
||||
*
|
||||
* The set is exactly what the Smirk wallet supports — btc, ltc, xmr, wow, grin (see
|
||||
* smirk-monorepo packages/core/src/types.ts AssetType) — all proof-of-work / high-variance, so the
|
||||
* "a random block ends your run" mechanic is fair on every one. Adding a chain = one entry here
|
||||
* The set is exactly what the Smirk wallet supports: btc, ltc, xmr, wow, grin (see
|
||||
* smirk-monorepo packages/core/src/types.ts AssetType). All are proof-of-work / high-variance, so
|
||||
* the "a random block ends your run" mechanic is fair on every one. Adding a chain = one entry here
|
||||
* plus a family adapter, never a fork.
|
||||
*/
|
||||
|
||||
@@ -54,7 +54,7 @@ function uriSchemeFor(cryptoType) {
|
||||
return getProfile(cryptoType).uriScheme;
|
||||
}
|
||||
|
||||
/** Atomic divisor as a BigInt (10 ** decimals) — safe for ETH-18-scale amounts (none here, but correct). */
|
||||
/** Atomic divisor as a BigInt (10 ** decimals): safe for ETH-18-scale amounts (none here, but correct). */
|
||||
function atomicDivisor(cryptoType) {
|
||||
return 10n ** BigInt(decimalsFor(cryptoType));
|
||||
}
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
/**
|
||||
* Configuration Persistence
|
||||
*
|
||||
* Provides a mechanism to persist and reload payment configuration changes
|
||||
* without requiring a full server restart. Configuration is stored in the
|
||||
* database and merged with environment variables on load.
|
||||
*
|
||||
* Priority order (highest to lowest):
|
||||
* 1. Database overrides (hot-reloadable)
|
||||
* 2. Environment variables (.env)
|
||||
* 3. Default values
|
||||
*/
|
||||
|
||||
const { normalizeError } = require('../utils/errors');
|
||||
|
||||
// Allowed configuration keys that can be persisted
|
||||
const ALLOWED_KEYS = new Set([
|
||||
// Direct mode
|
||||
'DIRECT_GAME_PRICE',
|
||||
'DIRECT_PAYOUT_ESCAPE',
|
||||
'DIRECT_PAYOUT_TREASURE',
|
||||
'DIRECT_PAYOUTS_ENABLED',
|
||||
|
||||
// Credits mode
|
||||
'CREDITS_PER_GAME',
|
||||
'CREDITS_PACKAGES',
|
||||
'CREDITS_PAYOUT_BASE',
|
||||
'CREDITS_PAYOUT_ESCAPE',
|
||||
'CREDITS_PAYOUT_TREASURE',
|
||||
'CREDITS_PAYOUTS_ENABLED',
|
||||
|
||||
// Limits
|
||||
'MAX_GAMES_PER_HOUR',
|
||||
'MAX_PAYOUTS_PER_DAY',
|
||||
'GAME_COOLDOWN_SECONDS',
|
||||
'PAYOUT_MIN_AMOUNT',
|
||||
'PAYOUT_MAX_PER_GAME',
|
||||
|
||||
// Difficulty
|
||||
'DIFFICULTY_PRESET'
|
||||
]);
|
||||
|
||||
class ConfigPersistence {
|
||||
constructor({ db, debugManager }) {
|
||||
this.db = db;
|
||||
this.debugManager = debugManager;
|
||||
this._cache = new Map();
|
||||
this._initialized = false;
|
||||
this._listeners = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the persistence layer, creating table if needed
|
||||
*/
|
||||
async initialize() {
|
||||
if (this._initialized) return;
|
||||
|
||||
try {
|
||||
// Create config table if it doesn't exist
|
||||
await this.db.query(`
|
||||
CREATE TABLE IF NOT EXISTS config_overrides (
|
||||
key VARCHAR(100) PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_by VARCHAR(100) DEFAULT 'system'
|
||||
)
|
||||
`);
|
||||
|
||||
// Load existing overrides into cache
|
||||
await this._loadFromDatabase();
|
||||
|
||||
this._initialized = true;
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`[ConfigPersistence] Initialized with ${this._cache.size} overrides`);
|
||||
}
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error, 'Failed to initialize config persistence');
|
||||
console.error('[ConfigPersistence] Initialization error:', normalized.message);
|
||||
// Don't throw - system should work without persistence
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all overrides from database into cache
|
||||
*/
|
||||
async _loadFromDatabase() {
|
||||
try {
|
||||
const result = await this.db.query('SELECT key, value FROM config_overrides');
|
||||
this._cache.clear();
|
||||
for (const row of result.rows) {
|
||||
if (ALLOWED_KEYS.has(row.key)) {
|
||||
this._cache.set(row.key, row.value);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error, 'Failed to load config from database');
|
||||
console.error('[ConfigPersistence] Load error:', normalized.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a configuration value with priority: DB override > env > default
|
||||
* @param {string} key - Configuration key
|
||||
* @param {*} defaultValue - Default value if not found
|
||||
* @returns {string|null} Configuration value
|
||||
*/
|
||||
get(key, defaultValue = null) {
|
||||
// Check database override first
|
||||
if (this._cache.has(key)) {
|
||||
return this._cache.get(key);
|
||||
}
|
||||
|
||||
// Fall back to environment variable
|
||||
if (process.env[key] !== undefined) {
|
||||
return process.env[key];
|
||||
}
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a numeric configuration value
|
||||
* @param {string} key - Configuration key
|
||||
* @param {number} defaultValue - Default value
|
||||
* @returns {number} Parsed numeric value
|
||||
*/
|
||||
getNumber(key, defaultValue = 0) {
|
||||
const value = this.get(key);
|
||||
if (value === null) return defaultValue;
|
||||
const parsed = parseFloat(value);
|
||||
return isNaN(parsed) ? defaultValue : parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a boolean configuration value
|
||||
* @param {string} key - Configuration key
|
||||
* @param {boolean} defaultValue - Default value
|
||||
* @returns {boolean} Parsed boolean value
|
||||
*/
|
||||
getBoolean(key, defaultValue = false) {
|
||||
const value = this.get(key);
|
||||
if (value === null) return defaultValue;
|
||||
return value === 'true' || value === '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a JSON configuration value
|
||||
* @param {string} key - Configuration key
|
||||
* @param {*} defaultValue - Default value
|
||||
* @returns {*} Parsed JSON value
|
||||
*/
|
||||
getJSON(key, defaultValue = null) {
|
||||
const value = this.get(key);
|
||||
if (value === null) return defaultValue;
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a configuration override (persisted to database)
|
||||
* @param {string} key - Configuration key
|
||||
* @param {*} value - Value to set
|
||||
* @param {string} updatedBy - Who made the change
|
||||
* @returns {boolean} Success
|
||||
*/
|
||||
async set(key, value, updatedBy = 'admin') {
|
||||
if (!ALLOWED_KEYS.has(key)) {
|
||||
console.warn(`[ConfigPersistence] Attempted to set non-allowed key: ${key}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const stringValue = typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
|
||||
await this.db.query(`
|
||||
INSERT INTO config_overrides (key, value, updated_at, updated_by)
|
||||
VALUES ($1, $2, NOW(), $3)
|
||||
ON CONFLICT (key) DO UPDATE SET
|
||||
value = EXCLUDED.value,
|
||||
updated_at = NOW(),
|
||||
updated_by = EXCLUDED.updated_by
|
||||
`, [key, stringValue, updatedBy]);
|
||||
|
||||
// Update cache
|
||||
this._cache.set(key, stringValue);
|
||||
|
||||
// Notify listeners
|
||||
this._notifyListeners(key, stringValue);
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`[ConfigPersistence] Set ${key} = ${stringValue.substring(0, 50)}... by ${updatedBy}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error, 'Failed to set config value');
|
||||
console.error('[ConfigPersistence] Set error:', normalized.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a configuration override (reverts to env/default)
|
||||
* @param {string} key - Configuration key
|
||||
* @returns {boolean} Success
|
||||
*/
|
||||
async remove(key) {
|
||||
try {
|
||||
await this.db.query('DELETE FROM config_overrides WHERE key = $1', [key]);
|
||||
this._cache.delete(key);
|
||||
this._notifyListeners(key, null);
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`[ConfigPersistence] Removed override for ${key}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error, 'Failed to remove config value');
|
||||
console.error('[ConfigPersistence] Remove error:', normalized.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all current overrides
|
||||
* @returns {Object} Key-value pairs of all overrides
|
||||
*/
|
||||
getAllOverrides() {
|
||||
return Object.fromEntries(this._cache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload configuration from database
|
||||
* Call this to pick up external changes
|
||||
*/
|
||||
async reload() {
|
||||
await this._loadFromDatabase();
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`[ConfigPersistence] Reloaded ${this._cache.size} overrides`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a listener for configuration changes
|
||||
* @param {Function} callback - (key, value) => void
|
||||
* @returns {Function} Unsubscribe function
|
||||
*/
|
||||
onConfigChange(callback) {
|
||||
this._listeners.add(callback);
|
||||
return () => this._listeners.delete(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all listeners of a configuration change
|
||||
*/
|
||||
_notifyListeners(key, value) {
|
||||
for (const listener of this._listeners) {
|
||||
try {
|
||||
listener(key, value);
|
||||
} catch (e) {
|
||||
console.error('[ConfigPersistence] Listener error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration snapshot for display/API
|
||||
* @returns {Object} Current effective configuration
|
||||
*/
|
||||
getSnapshot() {
|
||||
const snapshot = {};
|
||||
for (const key of ALLOWED_KEYS) {
|
||||
snapshot[key] = {
|
||||
value: this.get(key),
|
||||
source: this._cache.has(key) ? 'database' : (process.env[key] ? 'environment' : 'default'),
|
||||
hasOverride: this._cache.has(key)
|
||||
};
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ConfigPersistence;
|
||||
@@ -120,11 +120,10 @@ class EnvironmentValidator {
|
||||
//
|
||||
// Every fail-closed gate in the app (chain/wallet identity verification, secret-strength
|
||||
// checks, the simulated-block prohibition, HSTS, the readiness identity requirement) tests
|
||||
// for the literal string 'production'. DebugManager, meanwhile, defaults an UNSET NODE_ENV
|
||||
// *to* production — so it suppresses console logging and reports IS_PRODUCTION while every
|
||||
// safety gate silently reads false. A missing or misspelled NODE_ENV therefore produced a
|
||||
// quiet, worst-of-both-worlds deployment: hardening off, diagnostics off, no warning. Rather
|
||||
// than reconcile nine call sites, make the ambiguous state impossible to boot.
|
||||
// for the literal string 'production'. DebugManager instead treats an unset NODE_ENV as
|
||||
// production, so it suppresses console logging and reports IS_PRODUCTION while every safety
|
||||
// gate reads false. Rejecting a missing or unrecognized value at startup makes that
|
||||
// ambiguous state unbootable rather than requiring agreement across all those call sites.
|
||||
const RECOGNIZED_ENVS = ['production', 'development', 'debug', 'test'];
|
||||
const declaredEnv = typeof env.NODE_ENV === 'string' ? env.NODE_ENV.trim() : '';
|
||||
if (!declaredEnv) {
|
||||
|
||||
@@ -51,8 +51,8 @@ const PROFILES = Object.freeze({
|
||||
legalPolicyVersion: '2026-07-23-v2',
|
||||
termsEffectiveDate: '2026-07-23',
|
||||
scopeNotice: 'Such Software operates monerogue.app only with direct-entry Monero stagenet test coins. Its single-player 2×/3× outcomes are test gambling mechanics; purchased-credit entry is disabled, and no real-money or mainnet gambling is offered.',
|
||||
commerceSummary: 'NO REAL VALUE — monerogue.app uses direct-entry Monero stagenet test coins only. Single-player qualifying outcomes pay 2× or 3× in test coins. Purchased-credit entry is off. Never send mainnet XMR.',
|
||||
noRealValueNotice: 'NO REAL VALUE — Monero stagenet coins are test data, not money, deposits, redeemable prizes, or a promise of value. Never send mainnet XMR to this service.'
|
||||
commerceSummary: 'NO REAL VALUE: monerogue.app uses direct-entry Monero stagenet test coins only. Single-player qualifying outcomes pay 2× or 3× in test coins. Purchased-credit entry is off. Never send mainnet XMR.',
|
||||
noRealValueNotice: 'NO REAL VALUE: Monero stagenet coins are test data, not money, deposits, redeemable prizes, or a promise of value. Never send mainnet XMR to this service.'
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ const path = require('path');
|
||||
class QueryValidator {
|
||||
isSafe(text, params) {
|
||||
// Check if using parameterized query properly
|
||||
// Count unique placeholders ($1, $2, etc.) — PostgreSQL allows reusing $1 multiple times
|
||||
// Count unique placeholders ($1, $2, etc.): PostgreSQL allows reusing $1 multiple times
|
||||
const placeholders = text.match(/\$\d+/g) || [];
|
||||
const uniquePlaceholders = new Set(placeholders);
|
||||
const placeholderCount = uniquePlaceholders.size;
|
||||
|
||||
+16
-25
@@ -1,18 +1,16 @@
|
||||
const Game = require('../game/game'); // Ensure Game is required
|
||||
const Game = require('../game/game');
|
||||
|
||||
// Environment-based console logging control
|
||||
// Verbose logging is limited to debug and development environments.
|
||||
const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development';
|
||||
|
||||
// User class definition
|
||||
class User {
|
||||
constructor(id, address) {
|
||||
this.id = id; // This is the socket.id
|
||||
this.id = id; // socket.id
|
||||
this.address = address;
|
||||
this.currentGame = null; // Reference to current game instance
|
||||
this.blockRec = 0; // Block record for game entry timing
|
||||
this.clientId = null; // To store socket.client.id if different
|
||||
|
||||
// User statistics
|
||||
this.currentGame = null;
|
||||
this.blockRec = 0; // Block height used for game entry timing
|
||||
this.clientId = null; // socket.client.id, a distinct value from socket.id
|
||||
|
||||
this.stats = {
|
||||
gamesPlayed: 0,
|
||||
gamesWon: 0,
|
||||
@@ -22,8 +20,8 @@ class User {
|
||||
treasuresFound: 0,
|
||||
monstersDefeated: 0
|
||||
};
|
||||
|
||||
// Add user to the registry upon creation
|
||||
|
||||
// Registration happens in the constructor so every User is reachable by socket id.
|
||||
userRegistry.set(id, this);
|
||||
if (CONSOLE_LOGGING) {
|
||||
console.log(`User created and registered: ${id}`);
|
||||
@@ -43,7 +41,8 @@ class User {
|
||||
}
|
||||
|
||||
/**
|
||||
* Game ended - update user statistics
|
||||
* Record the end of a game and fold its results into the user statistics.
|
||||
* Does nothing when the user has no current game.
|
||||
* @param {string} result - 'won', 'lost', or 'abandoned'
|
||||
* @param {number} score - Final score
|
||||
* @param {object} gameStats - Additional game statistics
|
||||
@@ -51,8 +50,7 @@ class User {
|
||||
endGame(result, score = 0, gameStats = {}) {
|
||||
if (this.currentGame) {
|
||||
this.currentGame = null;
|
||||
|
||||
// Update statistics
|
||||
|
||||
if (result === 'won') {
|
||||
this.stats.gamesWon++;
|
||||
} else if (result === 'lost') {
|
||||
@@ -78,31 +76,24 @@ class User {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user statistics
|
||||
* @returns {object} A copy of the stats, so callers cannot mutate the user's own counters.
|
||||
*/
|
||||
getStats() {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's current game status
|
||||
*/
|
||||
isInGame() {
|
||||
return this.currentGame !== null;
|
||||
}
|
||||
|
||||
// ... other User methods ...
|
||||
}
|
||||
|
||||
// User registry (maps socketId to User objects)
|
||||
// Maps socket.id to User. Declared after the class body; the constructor only reads it at call time,
|
||||
// which is always after this binding is initialised.
|
||||
const userRegistry = new Map();
|
||||
|
||||
// ... (rest of the file, e.g., getUserBySocketId, removeUser, module.exports)
|
||||
// Ensure these functions and module.exports are correctly placed relative to the class definition.
|
||||
// For example:
|
||||
module.exports = {
|
||||
User,
|
||||
getUserBySocketId: (socketId) => userRegistry.get(socketId),
|
||||
removeUser: (socketId) => userRegistry.delete(socketId),
|
||||
getAllUsers: () => Array.from(userRegistry.values()) // If you need to iterate over users
|
||||
getAllUsers: () => Array.from(userRegistry.values())
|
||||
};
|
||||
|
||||
@@ -149,7 +149,7 @@ class DebugManager {
|
||||
this.onNewBlock(currentHeight);
|
||||
|
||||
this.lastProductionBlockHeight = currentHeight;
|
||||
// Refresh the cosmetic tip (hash/difficulty) only on an actual new block — this
|
||||
// Refresh the cosmetic tip (hash/difficulty) only on an actual new block: this
|
||||
// is decoration for the Tavern, not something worth an extra RPC every 2s.
|
||||
this._refreshChainTip(currentHeight);
|
||||
}
|
||||
|
||||
@@ -17,12 +17,12 @@
|
||||
* - Grin (GRIN): ~1 minute
|
||||
* - Monero (XMR): ~2 minutes
|
||||
* - Litecoin (LTC): ~2.5 minutes
|
||||
* - Wownero (WOW): ~5 minutes (measured 5.01 min/block over 1000 blocks — an earlier "correction"
|
||||
* to 2 min was WRONG; WOW is a SLOW chain, closer to BTC than XMR for calibration purposes)
|
||||
* - Wownero (WOW): ~5 minutes (5.01 min/block over 1000 blocks; for calibration WOW is a slow
|
||||
* chain, closer to BTC than to XMR)
|
||||
* - Bitcoin (BTC): ~10 minutes
|
||||
*
|
||||
* Slow chains (WOW/BTC) can't get a timer-driven house edge from a single dungeon without it
|
||||
* becoming an unplayable slog → they want MULTI-LEVEL depth. See NETWORK_TUNING below.
|
||||
* Slow chains (WOW/BTC) cannot get a timer-driven house edge from a single dungeon without it
|
||||
* becoming an unplayable slog, so they use multi-level depth instead. See NETWORK_TUNING below.
|
||||
*/
|
||||
|
||||
const DIFFICULTY_PRESETS = {
|
||||
@@ -50,7 +50,7 @@ const DIFFICULTY_PRESETS = {
|
||||
targetHouseWinRate: 0.3 // 30% house wins (player-friendly)
|
||||
},
|
||||
|
||||
// Normal - balanced baseline (~2 min blocks, i.e. XMR; WOW is actually ~5 min — see header)
|
||||
// Normal - balanced baseline, calibrated for ~2 min blocks (XMR)
|
||||
normal: {
|
||||
dungeon: {
|
||||
width: 45,
|
||||
@@ -101,23 +101,23 @@ const DIFFICULTY_PRESETS = {
|
||||
// Casino - high house edge for real money
|
||||
casino: {
|
||||
dungeon: {
|
||||
width: 70, // Large dungeon (was 60)
|
||||
height: 35, // (was 30)
|
||||
roomWidthRange: [3, 4], // Small rooms (was [3, 5])
|
||||
width: 70, // Large dungeon
|
||||
height: 35,
|
||||
roomWidthRange: [3, 4], // Small rooms
|
||||
roomHeightRange: [3, 4],
|
||||
corridorLengthRange: [6, 15], // Long corridors (was [5, 12])
|
||||
dugPercentage: 0.10 // Very tight (was 0.12)
|
||||
corridorLengthRange: [6, 15], // Long corridors
|
||||
dugPercentage: 0.10 // Very tight
|
||||
},
|
||||
monster: {
|
||||
startDistanceFromPlayer: 0.30, // Close to player (was 0.35)
|
||||
startDistanceFromPlayer: 0.30, // Close to player
|
||||
movesPerPlayerMove: 1.0, // Monster matches player speed
|
||||
chaseAggressiveness: 0.97, // Nearly always chases (was 0.95)
|
||||
visionRange: 22, // Excellent vision (was 18)
|
||||
chaseAggressiveness: 0.97, // Nearly always chases
|
||||
visionRange: 22, // Excellent vision
|
||||
respawnOnDeath: false
|
||||
},
|
||||
treasure: {
|
||||
roomPositionRatio: 0.20, // Early room (was 0.25)
|
||||
distanceFromExitRatio: 0.85 // Very far from exit (was 0.8)
|
||||
roomPositionRatio: 0.20, // Early room
|
||||
distanceFromExitRatio: 0.85 // Very far from exit
|
||||
},
|
||||
targetHouseWinRate: 0.70 // 70% house wins
|
||||
}
|
||||
@@ -147,24 +147,21 @@ function getDifficultyPreset(cryptoType = 'WOW', overridePreset = null) {
|
||||
return { ...DIFFICULTY_PRESETS.casino, presetName: 'casino' };
|
||||
}
|
||||
|
||||
// Per-network tuning so cryptoType shapes difficulty (it used to be a dead parameter). The pacing
|
||||
// lever is LEVELS (multi-level depth ∝ block time): a run descends `levels` normal-sized dungeons,
|
||||
// so cumulative run length makes the block-timer bite on slow chains WITHOUT a giant single map or
|
||||
// a cheating-fast monster (the sim proved both fail — size self-cancels, and a 2.2× monster still
|
||||
// caps WOW ~64%). Each level is base-sized with a FAIR monster; the edge comes from racing the block
|
||||
// across the whole descent. Levels ≈ blockTime / ~75s (one normal level), clamped [1, 10].
|
||||
// GRIN 1m → 1 XMR 2m → 2 LTC 2.5m → 2 WOW 5m → 4 BTC 10m → 8
|
||||
// NOTE: the exact per-network level counts are sim starting points (calibration re-run is pending
|
||||
// the sim's multi-level support). Operator env overrides (DUNGEON_LEVELS, DUNGEON_*, MONSTER_*) win.
|
||||
// Kill: NETWORK_TUNING_DISABLED=true.
|
||||
// Level counts trimmed from the first sim estimate — 4-8 levels in a single block deadline is too
|
||||
// punishing (you rarely clear them all before the block). Kept modest until a real multi-level
|
||||
// calibration run + the descent UX are in. Reach-exit-to-win stays intuitive with fewer levels.
|
||||
// Per-network tuning: cryptoType shapes difficulty through the level count, which scales with block
|
||||
// time. A run descends `levels` normal-sized dungeons, so cumulative run length is what makes the
|
||||
// block timer bite on slow chains. Dungeon size and monster speed are deliberately NOT the lever:
|
||||
// size self-cancels (a bigger map moves the exit and the monster equally far away), and even a 2.2x
|
||||
// monster caps WOW near a 64% house win rate. Each level is base-sized with a fair monster; the edge
|
||||
// comes from racing the block across the whole descent.
|
||||
// Counts stay modest (well under blockTime / ~75s per level) because 4-8 levels inside one block
|
||||
// deadline is rarely clearable, and reach-the-exit-to-win reads as intuitive only at low depth.
|
||||
// Operator env overrides (DUNGEON_LEVELS, DUNGEON_*, MONSTER_*) take precedence.
|
||||
// Set NETWORK_TUNING_DISABLED=true to bypass this table entirely.
|
||||
const NETWORK_TUNING = {
|
||||
GRIN: { levels: 1 }, // ~1 min blocks
|
||||
XMR: { levels: 1 }, // ~2 min
|
||||
LTC: { levels: 2 }, // ~2.5 min
|
||||
WOW: { levels: 2 }, // ~5 min (measured)
|
||||
WOW: { levels: 2 }, // ~5 min
|
||||
BTC: { levels: 3 } // ~10 min
|
||||
};
|
||||
|
||||
@@ -178,7 +175,7 @@ function applyNetworkTuning(preset, cryptoType) {
|
||||
|
||||
/**
|
||||
* Merge difficulty config with custom overrides.
|
||||
* Precedence (low→high): preset → per-network tuning → env vars → explicit customOverrides.
|
||||
* Precedence (lowest to highest): preset, per-network tuning, env vars, explicit customOverrides.
|
||||
*/
|
||||
function getDifficultyConfig(cryptoType = 'WOW', customOverrides = {}) {
|
||||
const preset = applyNetworkTuning(getDifficultyPreset(cryptoType, customOverrides.preset), cryptoType);
|
||||
|
||||
+38
-66
@@ -46,7 +46,7 @@ class Game {
|
||||
this.moveCount = 0;
|
||||
this.startedAt = Date.now();
|
||||
|
||||
// Provably fair: Generate game proof before anything else
|
||||
// The fairness proof is built first; the seed it commits to drives all later generation.
|
||||
this.gameProof = createGameProof(
|
||||
this.id,
|
||||
fairnessProof?.clientSeed ?? optionClientSeed ?? '',
|
||||
@@ -58,14 +58,11 @@ class Game {
|
||||
this.seededRNG = createSeededRNG(this.gameProof.seed);
|
||||
this.seedInt = seedToInt(this.gameProof.seed);
|
||||
|
||||
// Get default configuration from DungeonGenerator
|
||||
const dungeonConfig = DungeonGenerator.getConfig();
|
||||
|
||||
// Determine dimensions - use provided options or defaults
|
||||
|
||||
const width = dungeonOptions.width || dungeonConfig.DEFAULT_WIDTH;
|
||||
const height = dungeonOptions.height || dungeonConfig.DEFAULT_HEIGHT;
|
||||
|
||||
// Merge game configuration
|
||||
this.gameConfig = {
|
||||
width: width,
|
||||
height: height,
|
||||
@@ -165,18 +162,6 @@ class Game {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Static factory method to create a legacy-sized game
|
||||
*/
|
||||
static createLegacyGame(socketId, user, options = {}) {
|
||||
const dungeonConfig = DungeonGenerator.getConfig();
|
||||
const legacyOptions = {
|
||||
width: dungeonConfig.LEGACY_WIDTH,
|
||||
height: dungeonConfig.LEGACY_HEIGHT,
|
||||
...options
|
||||
};
|
||||
return new Game(socketId, user, legacyOptions);
|
||||
}
|
||||
|
||||
initializeGame() {
|
||||
if (CONSOLE_LOGGING) {
|
||||
@@ -187,16 +172,14 @@ class Game {
|
||||
throw new Error(`Invalid game dimensions: width=${this.gameConfig.width}, height=${this.gameConfig.height}`);
|
||||
}
|
||||
|
||||
// Set game dimensions from config
|
||||
this.width = this.gameConfig.width;
|
||||
this.height = this.gameConfig.height;
|
||||
|
||||
// Get difficulty settings
|
||||
|
||||
const difficultyConfig = getDifficultyConfig(this.gameConfig.cryptoType || process.env.CRYPTO_TYPE || 'WOW');
|
||||
this.difficultyConfig = difficultyConfig;
|
||||
|
||||
// Multi-level descent: a run spans `maxDepth` levels (a pacing knob ∝ block time, from the
|
||||
// per-network tuning). Reaching a non-final exit descends to a fresh level; only the final
|
||||
// Multi-level descent: a run spans `maxDepth` levels, a pacing knob scaled to block time by
|
||||
// the per-network tuning. Reaching a non-final exit descends to a fresh level; only the final
|
||||
// level's exit escapes. Defaults to 1 (single level) when no tuning is present.
|
||||
this.depth = 1;
|
||||
this.maxDepth = Math.max(1, parseInt(difficultyConfig.levels, 10) || 1);
|
||||
@@ -207,25 +190,23 @@ class Game {
|
||||
console.log(`[Game.initializeGame] Levels this run: ${this.maxDepth}`);
|
||||
}
|
||||
|
||||
// Initialize game state
|
||||
this.gameState = 'waiting';
|
||||
this.startBlock = null;
|
||||
this.player = new Player();
|
||||
this.monsterMoveAccumulator = 0; // For fractional monster moves
|
||||
this.monsterMoveAccumulator = 0; // Carries fractional monster moves between turns.
|
||||
this.fee = 0;
|
||||
|
||||
// Generate the first level (also creates the monster + FOV for it).
|
||||
// Also creates the monster and FOV for the level.
|
||||
this._generateLevel(1);
|
||||
|
||||
// Set game state to active
|
||||
this.gameState = 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the dungeon for a given level and place the player + a fresh monster on it. The layout
|
||||
* is deterministic from the committed seed via a per-level seed (level 1 = master seed, so
|
||||
* single-level games are unchanged; deeper levels salt it), keeping the whole descent verifiable.
|
||||
* Treasure lives only in the vault (the final level) — intermediate levels are a race to the stairs.
|
||||
* is deterministic from the committed seed via a per-level seed (level 1 uses the master seed;
|
||||
* deeper levels salt it), keeping the whole descent verifiable. Treasure lives only in the vault
|
||||
* (the final level); intermediate levels are a race to the stairs.
|
||||
* @param {number} depth - 1-based level index
|
||||
*/
|
||||
_generateLevel(depth) {
|
||||
@@ -238,15 +219,14 @@ class Game {
|
||||
seedInt: isFirst ? this.seedInt : seedToInt(seed)
|
||||
});
|
||||
|
||||
// Only the final level holds the treasure — descend all the way for the reward.
|
||||
// Only the final level holds the treasure, so the reward requires a full descent.
|
||||
if (depth < this.maxDepth) {
|
||||
dungeon.treasure = null;
|
||||
}
|
||||
this.dungeon = dungeon;
|
||||
|
||||
// Once the constructor has committed its all-level manifest, assert every actually played
|
||||
// descent still matches it. This turns accidental generator drift within a live process into
|
||||
// a hard failure rather than silently serving an unverifiable paid layout.
|
||||
// Every played descent must match the all-level manifest the constructor committed. Generator
|
||||
// drift within a live process fails hard rather than serving an unverifiable paid layout.
|
||||
const expected = this.gameProof.layoutFingerprints?.find(item => item.depth === depth);
|
||||
if (expected) {
|
||||
const actual = DungeonGenerator.layoutFingerprint(dungeon, expected.fingerprintVersion);
|
||||
@@ -255,7 +235,6 @@ class Game {
|
||||
}
|
||||
}
|
||||
|
||||
// Place player at the entrance.
|
||||
if (dungeon.entrance) {
|
||||
this.player.moveTo(dungeon.entrance[0], dungeon.entrance[1]);
|
||||
}
|
||||
@@ -268,12 +247,11 @@ class Game {
|
||||
const center = dungeon.rooms[idx].getCenter();
|
||||
this.monster.moveTo(center[0], center[1]);
|
||||
}
|
||||
// ROT.Digger can legitimately produce <=2 rooms on the 30x15 easy profile. The old branch
|
||||
// then left the monster at its constructor default (0,0), normally a wall, producing an
|
||||
// effectively monsterless run. Also defend against a malformed room center. Choose the
|
||||
// farthest passable non-objective tile deterministically; this consumes no RNG and dungeon
|
||||
// generation/layout fingerprints remain byte-identical (monster execution is explicitly
|
||||
// outside the published layout proof).
|
||||
// ROT.Digger can legitimately produce <=2 rooms on the 30x15 easy profile, and a room center
|
||||
// can be malformed; either case leaves the monster at its constructor default (0,0), normally
|
||||
// a wall, giving an effectively monsterless run. The fallback picks the farthest passable
|
||||
// non-objective tile deterministically. It consumes no RNG, so dungeon generation and layout
|
||||
// fingerprints are unaffected; monster placement sits outside the published layout proof.
|
||||
const primaryFloor = this.gameConfig.primaryFloor || "'1";
|
||||
const secondaryFloor = this.gameConfig.secondaryFloor || "'2";
|
||||
const monsterTilePassable = (x, y) => {
|
||||
@@ -304,7 +282,7 @@ class Game {
|
||||
this.monster.moveTo(fallback[0], fallback[1]);
|
||||
}
|
||||
|
||||
// Reset lighting + FOV for the new level (a fresh descent is unexplored).
|
||||
// A fresh descent is unexplored, so lighting and FOV reset with the level.
|
||||
this.lightingAndFov = new LightingAndFov(dungeon.map, dungeon.torches);
|
||||
this._fovInstance = LightingAndFov.initializeFOV(dungeon.map, this.gameConfig);
|
||||
const fovRadius = this.gameConfig.fovRadius || 10;
|
||||
@@ -329,7 +307,7 @@ class Game {
|
||||
console.log(`Move: (${this.player.x},${this.player.y}) -> (${newX},${newY})`);
|
||||
}
|
||||
|
||||
// Check if the move is valid (not into a wall and within map bounds)
|
||||
// A move is valid only onto a floor tile inside the map bounds.
|
||||
const primaryFloor = this.gameConfig.primaryFloor || "'1";
|
||||
const secondaryFloor = this.gameConfig.secondaryFloor || "'2";
|
||||
|
||||
@@ -344,10 +322,9 @@ class Game {
|
||||
console.log(`Player moved to ${newX},${newY} in game for socket ${this.socketId}`);
|
||||
}
|
||||
|
||||
// Update FOV after moving
|
||||
this.updateFOV();
|
||||
|
||||
// Check if player walked into monster (death by collision)
|
||||
|
||||
// Walking into the monster is a collision death.
|
||||
if (this.monster && this.monster.x === newX && this.monster.y === newY) {
|
||||
this.gameState = 'lost';
|
||||
if (CONSOLE_LOGGING) {
|
||||
@@ -363,10 +340,9 @@ class Game {
|
||||
};
|
||||
}
|
||||
|
||||
// Check for game events like finding treasure or exit
|
||||
if (this.dungeon.exit && this.player.isAt(this.dungeon.exit[0], this.dungeon.exit[1])) {
|
||||
// Not at the bottom yet → take the stairs down to a fresh level (NOT a win). The new level
|
||||
// is generated here, so the returned state (and the next getState) already reflects it.
|
||||
// Above the bottom level the exit is stairs down, not a win. The new level is generated
|
||||
// here, so the returned state and the next getState already reflect it.
|
||||
if (this.depth < this.maxDepth) {
|
||||
this._descend();
|
||||
return {
|
||||
@@ -404,14 +380,12 @@ class Game {
|
||||
return { status: 'idle' };
|
||||
}
|
||||
|
||||
// Get monster movement rate from difficulty config
|
||||
const movesPerPlayerMove = this.difficultyConfig?.monster?.movesPerPlayerMove ?? 1.0;
|
||||
const chaseAggressiveness = this.difficultyConfig?.monster?.chaseAggressiveness ?? 0.8;
|
||||
|
||||
// Accumulate fractional moves
|
||||
// Fractional rates accumulate until they buy at least one whole step.
|
||||
this.monsterMoveAccumulator = (this.monsterMoveAccumulator || 0) + movesPerPlayerMove;
|
||||
|
||||
// Only move if accumulator >= 1
|
||||
|
||||
while (this.monsterMoveAccumulator >= 1) {
|
||||
this.monsterMoveAccumulator -= 1;
|
||||
|
||||
@@ -420,15 +394,14 @@ class Game {
|
||||
if (this.seededRNG() < chaseAggressiveness) {
|
||||
this.monster.moveTowardPlayer(this.player, this.dungeon, this.seededRNG);
|
||||
} else {
|
||||
// Random move - still try to move, just not toward player
|
||||
// A null target still moves the monster, just not toward the player.
|
||||
this.monster.moveTowardPlayer(null, this.dungeon, this.seededRNG);
|
||||
}
|
||||
|
||||
// Collision check after EACH sub-step (Phase 3.3). With movesPerPlayerMove > 1
|
||||
// (e.g. the casino preset's 1.5x speed) the monster takes multiple steps per turn;
|
||||
// checking only after the loop let it step onto the player's tile and back off
|
||||
// between checks, "phasing through" the player and missing the catch (a player-
|
||||
// favorable bug). Checking each sub-step closes that gap.
|
||||
// Collision is checked after each sub-step. With movesPerPlayerMove > 1 (the casino preset
|
||||
// runs 1.5x) the monster takes several steps per turn, and a check only after the loop
|
||||
// would let it step onto the player's tile and off again between checks, phasing through
|
||||
// the player without catching them.
|
||||
if (this.monster.hasCaughtPlayer(this.player)) {
|
||||
this.gameState = 'lost';
|
||||
return {
|
||||
@@ -470,12 +443,11 @@ class Game {
|
||||
/**
|
||||
* Sticky record of which dungeon features the player has actually laid eyes on.
|
||||
*
|
||||
* Fog of war used to be enforced ONLY in the browser: the server put the absolute entrance, exit
|
||||
* and treasure coordinates — plus the monster's exact position — into every `game_start` and
|
||||
* `game_update`, and the client renderer simply declined to draw them. Anyone reading the socket
|
||||
* frame in devtools could walk straight to the treasure and the exit from move 0, which in a
|
||||
* paid mode is a direct attack on the payout. Discovery is sticky (matching the client's
|
||||
* "explored memory") so a feature you have found stays on your map after you walk away.
|
||||
* Fog of war is enforced server-side: undiscovered entrance, exit and treasure coordinates never
|
||||
* enter a `game_start` or `game_update` frame. Client-side concealment alone is not enough,
|
||||
* because reading the socket frame reveals the treasure and exit from move 0, which in a paid
|
||||
* mode is a direct attack on the payout. Discovery is sticky, matching the client's explored
|
||||
* memory, so a feature stays on the map once found.
|
||||
*/
|
||||
_updateDiscovered() {
|
||||
if (!this._discovered) this._discovered = { entrance: false, exit: false, treasure: false };
|
||||
@@ -491,8 +463,8 @@ class Game {
|
||||
const state = {
|
||||
gameState: this.gameState,
|
||||
player: this.player.getState(),
|
||||
// The monster is concealed the same way the client conceals it — only while in view. Sending
|
||||
// it always let a player track the hunter through walls.
|
||||
// The monster ships only while in view, matching the client's concealment. Sending it
|
||||
// unconditionally would let a player track the hunter through walls.
|
||||
monster: (this.monster && this._isVisible(this.monster)) ? this.monster.getState() : null,
|
||||
visibleTiles: { ...this.visibleTiles },
|
||||
lighting: this.calculateLighting(),
|
||||
|
||||
+14
-25
@@ -50,17 +50,16 @@ class GameManager {
|
||||
/**
|
||||
* Create a new game for a user
|
||||
* @param {Object} user - User object
|
||||
* @param {string} gameType - Type of game ('standard', 'legacy')
|
||||
* @param {Object} options - Additional game options
|
||||
* @returns {Object} Created game instance
|
||||
*/
|
||||
async createGameForUser(user, gameType = 'standard', options = {}) {
|
||||
async createGameForUser(user, options = {}) {
|
||||
if (this._admissionClosed) {
|
||||
const error = new Error('Game admission is closed for server shutdown');
|
||||
error.code = 'SERVER_SHUTTING_DOWN';
|
||||
throw error;
|
||||
}
|
||||
const creation = this._createGameForUser(user, gameType, options);
|
||||
const creation = this._createGameForUser(user, options);
|
||||
this._creationsInFlight.add(creation);
|
||||
try {
|
||||
return await creation;
|
||||
@@ -69,7 +68,7 @@ class GameManager {
|
||||
}
|
||||
}
|
||||
|
||||
async _createGameForUser(user, gameType = 'standard', options = {}) {
|
||||
async _createGameForUser(user, options = {}) {
|
||||
// Settlement-pending games intentionally remain in activeGames. This is the last-line
|
||||
// guard against starting/charging a replacement run while the previous terminal result
|
||||
// is not yet durable (and also closes ordinary double-start races between handlers).
|
||||
@@ -77,13 +76,7 @@ class GameManager {
|
||||
throw new Error('User already has an active or settlement-pending game');
|
||||
}
|
||||
|
||||
let game;
|
||||
|
||||
if (gameType === 'legacy') {
|
||||
game = Game.createLegacyGame(user.id, user, options);
|
||||
} else {
|
||||
game = Game.createStandardGame(user.id, user, options);
|
||||
}
|
||||
const game = Game.createStandardGame(user.id, user, options);
|
||||
|
||||
// Persist before exposing the game or charging entry. Paid-start processing updates this
|
||||
// exact row; allowing play to continue after a failed INSERT can consume a payment/credit
|
||||
@@ -98,7 +91,7 @@ class GameManager {
|
||||
this.activeGames.set(user.id, game);
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`[createGameForUser] Created ${gameType} game ${game.id} for user ${user.id} (dbId: ${game.dbId || 'none'})`);
|
||||
console.log(`[createGameForUser] Created game ${game.id} for user ${user.id} (dbId: ${game.dbId || 'none'})`);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
@@ -464,11 +457,9 @@ class GameManager {
|
||||
const gameStates = new Map();
|
||||
|
||||
for (const [socketId, game] of this.activeGames.entries()) {
|
||||
// Count by game type (if available)
|
||||
const type = game.type || 'unknown';
|
||||
gameTypes.set(type, (gameTypes.get(type) || 0) + 1);
|
||||
|
||||
// Count by game state
|
||||
|
||||
const state = game.gameState || 'active';
|
||||
gameStates.set(state, (gameStates.get(state) || 0) + 1);
|
||||
}
|
||||
@@ -583,23 +574,22 @@ class GameManager {
|
||||
]);
|
||||
game.dbId = result.rows[0]?.id || null;
|
||||
|
||||
// Stamp the stable DB users.id onto the in-memory game object so
|
||||
// suspend/restore and completeGame can key on it WITHOUT any socket_id
|
||||
// lookup (socket ids are volatile across reconnects). game.userId is the
|
||||
// canonical field; game.dbUserId is kept as an alias for the disconnect/
|
||||
// suspend path that already reads it.
|
||||
// Stamps the stable users.id onto the in-memory game so suspend/restore and
|
||||
// completeGame key on it rather than on socket_id, which is volatile across
|
||||
// reconnects. game.userId is canonical; game.dbUserId is an alias read by the
|
||||
// disconnect/suspend path.
|
||||
const stableUserId = result.rows[0]?.user_id ?? null;
|
||||
if (stableUserId != null) {
|
||||
game.userId = stableUserId;
|
||||
if (game.dbUserId == null) game.dbUserId = stableUserId;
|
||||
}
|
||||
|
||||
// Entry block height captured at creation so the suspend path can persist
|
||||
// it (blockRec) and the reconnect timeout logic stays consistent.
|
||||
// Entry block height, captured at creation so the suspend path persists it and
|
||||
// the reconnect timeout logic stays consistent.
|
||||
game.blockRec = blockHeight;
|
||||
|
||||
// Reference to the DB pool so restoreGame can keep games.socket_id aligned
|
||||
// to the reconnecting socket without needing its own DB handle.
|
||||
// DB pool handle so restoreGame can keep games.socket_id aligned to the
|
||||
// reconnecting socket without holding its own handle.
|
||||
game.db = db;
|
||||
} catch (err) {
|
||||
console.error('Game insert failed:', err.message);
|
||||
@@ -630,7 +620,6 @@ class GameManager {
|
||||
WHERE dungeon_seed = $6 AND socket_id = $7
|
||||
`, [status, outcome, game.player.hasTreasure, moves, durationSeconds, game.id, socketId, score]);
|
||||
|
||||
// Update user's high score if this is a new personal best
|
||||
if (score > 0) {
|
||||
await db.query(`
|
||||
UPDATE users SET high_score = GREATEST(COALESCE(high_score, 0), $1)
|
||||
|
||||
+99
-110
@@ -26,9 +26,9 @@ const { isSmirkEnabled } = require('../auth/smirkPolicy');
|
||||
const { reservePayoutCapacity } = require('../services/payoutAdmissionService');
|
||||
const { getOperatedProductProfile } = require('../config/operatedProductProfiles');
|
||||
|
||||
// A wallet "not enough (unlocked) money" error is raised BEFORE the tx is broadcast, so it
|
||||
// is SAFE to retry (no double-pay risk) — unlike an ambiguous post-broadcast failure. Monero
|
||||
// locks spent outputs (incl. change) for ~10 blocks, so this is the expected error when all
|
||||
// A wallet "not enough (unlocked) money" error is raised BEFORE the tx is broadcast, so it is
|
||||
// safe to retry (no double-pay risk), unlike an ambiguous post-broadcast failure. Monero locks
|
||||
// spent outputs (including change) for ~10 blocks, so this is the expected error when all
|
||||
// outputs are temporarily locked.
|
||||
function payoutErrorText(error, depth = 0) {
|
||||
if (depth > 4 || error == null) return '';
|
||||
@@ -41,9 +41,9 @@ function payoutErrorText(error, depth = 0) {
|
||||
/**
|
||||
* A batch that wallet-rpc legitimately broadcast as MORE THAN ONE transaction.
|
||||
*
|
||||
* Not an error in the usual sense — the money is out. It is raised so the existing quarantine path
|
||||
* records the batch for attribution review while carrying the transaction hashes with it, instead
|
||||
* of the previous behaviour of discarding them and leaving the rows with tx_hash NULL.
|
||||
* Not an error in the usual sense: the money is out. It is raised so the quarantine path records
|
||||
* the batch for attribution review while carrying the transaction hashes with it, rather than
|
||||
* leaving the rows with tx_hash NULL.
|
||||
*/
|
||||
class BatchSplitEvidence extends Error {
|
||||
constructor(hashes, totalFee) {
|
||||
@@ -75,10 +75,10 @@ class GameModeManager {
|
||||
this.debugManager = debugManager;
|
||||
this.paymentConfigManager = paymentConfigManager || null;
|
||||
|
||||
// Modular payment providers (Pillar 3). Defaults to a registry whose only member is the
|
||||
// native Monero/Wownero provider wrapping this walletService — so with no gateway env set
|
||||
// every chain routes to the existing wallet-RPC path and behavior is unchanged. Injectable
|
||||
// for tests. See src/payments/providers/index.js + the btcpay-infra-topology memo.
|
||||
// Modular payment providers. Defaults to a registry whose only member is the native
|
||||
// Monero/Wownero provider wrapping this walletService, so with no gateway env set every
|
||||
// chain routes to the wallet-RPC path. Injectable for tests.
|
||||
// See src/payments/providers/index.js.
|
||||
this.paymentProviders = paymentProviders || buildProviderRegistry({ walletService: walletRPCService });
|
||||
|
||||
this.cryptoType = process.env.CRYPTO_TYPE || 'XMR';
|
||||
@@ -139,8 +139,8 @@ class GameModeManager {
|
||||
return inferCurrencyDecimalsForSymbol(symbol);
|
||||
}
|
||||
|
||||
// Product name (Monerogue/Wownerogue) and network-aware currency label (sXMR on
|
||||
// stagenet, XMR on mainnet, WOW for Wownero) — derived so they always track config.
|
||||
// Product name (Monerogue/Wownerogue) and network-aware currency label (sXMR on stagenet,
|
||||
// XMR on mainnet, WOW for Wownero), derived so they always track config.
|
||||
get gameName() { return gameNameFor(this.cryptoType); }
|
||||
get currencyLabel() { return currencyLabelFor(this.cryptoType, this.network); }
|
||||
|
||||
@@ -753,9 +753,9 @@ class GameModeManager {
|
||||
? (multipliers.escapeWithTreasure ?? multipliers.escape ?? 0)
|
||||
: (multipliers.escape ?? 0);
|
||||
|
||||
// Exact integer math: base (atomic units) * decimal multiplier via BigInt, then
|
||||
// narrow back to a number only when exactly representable. Avoids float precision
|
||||
// loss on large atomic amounts (the old `Math.round(base * multiplier)`).
|
||||
// Exact integer math: base (atomic units) * decimal multiplier via BigInt, then narrow
|
||||
// back to a number only when exactly representable. Float arithmetic would lose
|
||||
// precision on large atomic amounts.
|
||||
const amount = money.toSafe(money.mulByDecimal(base, multiplier));
|
||||
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
@@ -768,17 +768,17 @@ class GameModeManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a confirmed credits package payment - add credits to user
|
||||
* SECURITY: Uses atomic check-and-update to prevent double-crediting on server restart
|
||||
* Apply a confirmed product payment: credits, premium tier, race entries and pack grants.
|
||||
* SECURITY: uses atomic check-and-update so a restart cannot double-credit.
|
||||
* @param {string} socketId - Socket ID of the user
|
||||
* @param {number} paymentId - Payment record ID
|
||||
* @param {object} packageInfo - Package info (credits, bonus, etc.)
|
||||
* @param {object} productInfo - Catalog product info (credits, bonus, grants)
|
||||
* @returns {object} Result with success, creditsAdded, newBalance, alreadyProcessed
|
||||
*/
|
||||
async processProductPaymentConfirmation(socketId, paymentId, productInfo = null, receivedAmount = null, receipts = []) {
|
||||
try {
|
||||
// Look up user from payment record's user_id (stable across socket reconnects)
|
||||
// This avoids the bug where socket ID changes if user refreshes during confirmation
|
||||
// Resolve the user from the payment record's user_id, which is stable across socket
|
||||
// reconnects: a refresh during confirmation changes the socket ID.
|
||||
const paymentLookup = await this.db.query(`
|
||||
SELECT user_id, description, status, payment_type, product_id, product_grants,
|
||||
received_amount, expected_amount, provider_id, provider_invoice_id,
|
||||
@@ -865,8 +865,8 @@ class GameModeManager {
|
||||
throw new Error(`Race-entry product ${productId} lacks confirmed per-ticket payout backing`);
|
||||
}
|
||||
|
||||
// CRITICAL: Wrap all three operations in a transaction.
|
||||
// If any step fails, the payment stays 'pending' and can be recovered.
|
||||
// Every grant runs in one transaction: if any step fails the payment stays 'pending'
|
||||
// and can be recovered.
|
||||
const result = await this.db.withTransaction(async (client) => {
|
||||
// Lock and finalize the durable product promise before inserting append-only
|
||||
// receipt evidence. New invoices already contain this snapshot; this also gives
|
||||
@@ -1087,11 +1087,10 @@ class GameModeManager {
|
||||
|
||||
/**
|
||||
* Record a direct/single_game entry as "buy 1 credit and immediately spend it on this game":
|
||||
* the balance nets to zero but total_credits_purchased advances, so a direct payment unlocks the
|
||||
* SAME tier/threshold cosmetics as buying credits (the first step of unifying everything to
|
||||
* credits). Idempotent by the caller — the single_game confirmation runs once per payment via
|
||||
* its status='pending' -> 'confirmed' guard. Returns { totalCreditsPurchased, balance,
|
||||
* entitlements } or null.
|
||||
* the balance nets to zero but total_credits_purchased advances, so a direct payment unlocks
|
||||
* the same tier/threshold cosmetics as buying credits. Idempotency is the caller's: the
|
||||
* single_game confirmation runs once per payment via its status='pending' -> 'confirmed'
|
||||
* guard. Returns { totalCreditsPurchased, balance, entitlements } or null.
|
||||
*/
|
||||
async recordDirectEntryPurchase(socketId) {
|
||||
try {
|
||||
@@ -1307,7 +1306,7 @@ class GameModeManager {
|
||||
|
||||
const options = [];
|
||||
|
||||
// Free play (Pleb board, no payout) — offered alongside paid options when enabled.
|
||||
// Free play (Pleb board, no payout) is offered alongside paid options when enabled.
|
||||
if (this.freePlayEnabled && this.paymentsEnabled) {
|
||||
options.push({
|
||||
type: 'play_free',
|
||||
@@ -1405,7 +1404,7 @@ class GameModeManager {
|
||||
};
|
||||
}
|
||||
|
||||
// Option 2: User has confirmed single_game payment
|
||||
// Otherwise an unclaimed confirmed single_game payment admits the player.
|
||||
const pendingPayment = await this.db.query(`
|
||||
SELECT * FROM payments
|
||||
WHERE user_id = $1 AND status = 'confirmed'
|
||||
@@ -1887,10 +1886,10 @@ class GameModeManager {
|
||||
return this._financialRecoveryPendingResult();
|
||||
}
|
||||
const snap = this._computePayoutSnapshot('PAID_SINGLE');
|
||||
// M3: claiming a confirmed single_game payment must be atomic. Re-verify the payment
|
||||
// is still unclaimed and lock it FOR UPDATE inside the transaction, so two concurrent
|
||||
// starts can't both consume the same payment. The unique index on games.payment_id
|
||||
// (migration 023) is the final backstop; a 23505 there is treated as "already consumed".
|
||||
// Claiming a confirmed single_game payment must be atomic: the payment is re-verified as
|
||||
// unclaimed and locked FOR UPDATE inside the transaction so two concurrent starts cannot
|
||||
// both consume it. The unique index on games.payment_id (migration 023) is the final
|
||||
// backstop; a 23505 there means "already consumed".
|
||||
try {
|
||||
const result = await this.db.withTransaction(async (client) => {
|
||||
if (snap.eligible) {
|
||||
@@ -2035,9 +2034,6 @@ class GameModeManager {
|
||||
}
|
||||
}
|
||||
|
||||
// processGameCompletion() removed — dead code, never called.
|
||||
// Only completeGame() (below) is used for game completion/payouts.
|
||||
|
||||
/**
|
||||
* Create payment request
|
||||
*/
|
||||
@@ -2140,11 +2136,11 @@ class GameModeManager {
|
||||
`, [user.id]);
|
||||
const expiredAddresses = expiredResult.rows.map(r => r.subaddress);
|
||||
|
||||
// Create the payment request through the routed provider. The native provider (default
|
||||
// for every chain when no gateway env is set) delegates to walletService.createPaymentRequest,
|
||||
// so this is byte-for-byte the legacy path for the shipped single-chain config — same
|
||||
// subaddress, same walletService monitoring maps. A BTCPay/xmrcheckout/wowcheckout gateway
|
||||
// takes over only when the operator routes this.cryptoType to it.
|
||||
// Create the payment request through the routed provider. The native provider (the
|
||||
// default for every chain when no gateway env is set) delegates to
|
||||
// walletService.createPaymentRequest, giving the single-chain config the same
|
||||
// subaddress and the same walletService monitoring maps. A BTCPay/xmrcheckout/
|
||||
// wowcheckout gateway takes over only when the operator routes this.cryptoType to it.
|
||||
const provider = this.paymentProviders && this.paymentProviders.getProvider(this.cryptoType);
|
||||
let paymentResult;
|
||||
if (provider) {
|
||||
@@ -2232,19 +2228,15 @@ class GameModeManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create user record
|
||||
*/
|
||||
/**
|
||||
* Resolve the database user for a socket.
|
||||
*
|
||||
* IDENTITY (Phase 2.1): The stable identity is `users.id`, established from the
|
||||
* client's `anon_token` by SessionManager at connection time. This method resolves
|
||||
* through that session identity FIRST, so every money/credit/payout path operates on
|
||||
* the same stable row. It only falls back to the legacy `socket_id` lookup-or-create
|
||||
* when no session exists (e.g. SessionManager not wired, or a REST call for a socket
|
||||
* with no live session) — `socket_id` is mutable and non-unique and must never be the
|
||||
* primary money key.
|
||||
* The stable identity is `users.id`, established from the client's `anon_token` by
|
||||
* SessionManager at connection time. Resolution goes through that session identity first,
|
||||
* so every money/credit/payout path operates on the same stable row. The legacy `socket_id`
|
||||
* lookup-or-create is used only when no session exists (SessionManager not wired, or a REST
|
||||
* call for a socket with no live session): `socket_id` is mutable and non-unique, so it must
|
||||
* never be the primary money key.
|
||||
*/
|
||||
async getOrCreateUser(socketId, { create = true } = {}) {
|
||||
try {
|
||||
@@ -2280,22 +2272,22 @@ class GameModeManager {
|
||||
return userResult.rows[0];
|
||||
}
|
||||
|
||||
// Read-only callers (REST reads) pass { create: false }: never mint an orphan
|
||||
// row for a socket that has no user yet — return null and let them 404/handle it.
|
||||
// Read-only callers (REST reads) pass { create: false }: never mint an orphan row
|
||||
// for a socket that has no user yet. They get null and handle it themselves.
|
||||
if (!create) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. Last resort: create a new user. This only fires when no session was
|
||||
// established for the socket (abnormal in normal flow, since every connection
|
||||
// creates a session) — log it so orphan-row creation is visible.
|
||||
// 3. Last resort: create a new user. Every connection creates a session, so this
|
||||
// only fires when none was established for the socket; it is logged to make
|
||||
// orphan-row creation visible.
|
||||
userResult = await this.db.query(`
|
||||
INSERT INTO users (socket_id, ip_address)
|
||||
VALUES ($1, $2)
|
||||
RETURNING *
|
||||
`, [socketId, null]);
|
||||
|
||||
console.warn(`👤 Created new user without a session (socket ${socketId}) — no anon_token identity resolved.`);
|
||||
console.warn(`👤 Created new user without a session (socket ${socketId}): no anon_token identity resolved.`);
|
||||
return userResult.rows[0];
|
||||
|
||||
} catch (error) {
|
||||
@@ -2306,7 +2298,7 @@ class GameModeManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set user payout address
|
||||
* Validate a candidate payout address for the configured chain and network.
|
||||
*/
|
||||
async validatePayoutAddress(payoutAddress) {
|
||||
const address = typeof payoutAddress === 'string' ? payoutAddress.trim() : '';
|
||||
@@ -2342,13 +2334,14 @@ class GameModeManager {
|
||||
: (addressText ? '[redacted]' : '[cleared]');
|
||||
console.log(`💰 Set payout address for user ${user.id}: ${redactedAddress}`);
|
||||
|
||||
// Reconcile any claimable match winnings this user earned with no address on file.
|
||||
// A match winner without a payout address is recorded as a durable 'needs_review'
|
||||
// liability (reason 'match_winner_no_address', sentinel address 'PENDING_NO_ADDRESS');
|
||||
// now that a real address exists, convert those to sendable 'pending' payouts and kick
|
||||
// the batcher. The reason filter is deliberately narrow so this NEVER touches the
|
||||
// ambiguous-broadcast 'needs_review' rows from the single-payout path (which have a real
|
||||
// address and a different reason) — converting those could double-pay.
|
||||
// Reconcile any claimable winnings this user earned with no address on file. A winner
|
||||
// without a payout address is recorded as a durable 'needs_review' liability (reason
|
||||
// 'match_winner_no_address' or 'solo_winner_no_address', sentinel address
|
||||
// 'PENDING_NO_ADDRESS'); with a real address on file those become sendable 'pending'
|
||||
// payouts and the batcher is kicked. The reason filter is deliberately narrow so this
|
||||
// never touches the ambiguous-broadcast 'needs_review' rows from the payout paths
|
||||
// (which carry a real address and a different reason): converting those could
|
||||
// double-pay.
|
||||
if (typeof payoutAddress === 'string' && payoutAddress.trim().length > 0) {
|
||||
try {
|
||||
const reconciled = await this.db.query(`
|
||||
@@ -2413,7 +2406,6 @@ class GameModeManager {
|
||||
* Get game mode info for frontend
|
||||
*/
|
||||
getGameModeInfo() {
|
||||
// Debug: Log credits payout base value being sent
|
||||
if (this.debugManager?.CONSOLE_LOGGING) {
|
||||
console.log(`📤 getGameModeInfo: creditsPayoutBaseValue = ${this.creditsPayoutBaseValue} (${this.formatAtomic(this.creditsPayoutBaseValue)} ${this.cryptoType})`);
|
||||
}
|
||||
@@ -2888,12 +2880,12 @@ class GameModeManager {
|
||||
|
||||
if (pending.rows.length === 0) return;
|
||||
|
||||
// MONERO OUTPUT LOCKING: a spent output (incl. the change output) is locked for
|
||||
// ~10 blocks. If we don't have enough UNLOCKED balance to cover this batch right
|
||||
// now, DEFER it (revert to pending) instead of attempting a transfer that would
|
||||
// fail — the next batch interval and every new-block tick re-check, so it sends as
|
||||
// soon as outputs unlock. Batching already minimises how many change outputs we
|
||||
// create; this handles the "all outputs currently locked" window.
|
||||
// Monero output locking: a spent output (including change) is locked for ~10 blocks.
|
||||
// When the unlocked balance does not cover this batch, the rows are deferred (reverted
|
||||
// to pending) rather than attempting a transfer that would fail. The next batch
|
||||
// interval and every new-block tick re-check, so it sends as soon as outputs unlock.
|
||||
// Batching already minimises how many change outputs are created; this handles the
|
||||
// "all outputs currently locked" window.
|
||||
try {
|
||||
const totalNeeded = money.sum(pending.rows.map(p => p.amount)); // BigInt atomic
|
||||
const bal = await this.walletService.getBalance();
|
||||
@@ -2950,7 +2942,7 @@ class GameModeManager {
|
||||
const batchId = require('uuid').v4();
|
||||
|
||||
if (pending.rows.length === 1) {
|
||||
// Single payout — use processPayout for simpler flow
|
||||
// Single payout: processPayout is the simpler flow
|
||||
const p = pending.rows[0];
|
||||
let observedTxHash = null;
|
||||
try {
|
||||
@@ -2980,19 +2972,17 @@ class GameModeManager {
|
||||
});
|
||||
console.log(`💸 Single payout ${p.id} completed: ${txHash}`);
|
||||
} catch (err) {
|
||||
// Insufficient-unlocked-funds is a pre-broadcast error -> retry on the next
|
||||
// batch run (revert to pending). ANY OTHER error is ambiguous: processPayout
|
||||
// (transfer_split) may have broadcast on-chain even though the RPC response
|
||||
// errored, and this row has a null tx_hash so the retry service's blockchain
|
||||
// guard can't protect it — an auto-retry could DOUBLE-PAY. Mark 'needs_review'
|
||||
// (retry service skips it) + alert, mirroring the batch path below.
|
||||
// Once a valid hash was observed, broadcast is proven even if the following
|
||||
// DB transaction failed. Preserve that evidence and never return the row to
|
||||
// the automatic retry pool.
|
||||
// Provably pre-broadcast failures (transfer gate, identity check, address
|
||||
// validation — all strictly before transfer_split) are safe to retry: nothing
|
||||
// was sent, so returning them to 'pending' cannot double-pay. Only genuinely
|
||||
// ambiguous post-broadcast failures may quarantine the batch.
|
||||
// Retryable only when nothing can have been broadcast: insufficient unlocked
|
||||
// funds, or a failure flagged preBroadcast (transfer gate, identity check,
|
||||
// address validation, all strictly before transfer_split). Those revert to
|
||||
// 'pending' and cannot double-pay.
|
||||
// Any other error is ambiguous: transfer_split may have broadcast on-chain
|
||||
// even though the RPC response errored, and the row has a null tx_hash so the
|
||||
// retry service's blockchain guard cannot protect it. Those go to
|
||||
// 'needs_review' (the retry service skips it) plus an alert.
|
||||
// Once a valid hash is observed, broadcast is proven even if the following DB
|
||||
// transaction failed, so that evidence is preserved and the row never returns
|
||||
// to the automatic retry pool.
|
||||
const preBroadcast = !observedTxHash && err?.preBroadcast === true;
|
||||
const fundsIssue = !observedTxHash && isInsufficientFundsError(err);
|
||||
const retryable = fundsIssue || preBroadcast;
|
||||
@@ -3007,14 +2997,14 @@ class GameModeManager {
|
||||
).catch(() => {});
|
||||
if (!fundsIssue && this.alertService && typeof this.alertService.sendAlert === 'function') {
|
||||
this.alertService.sendAlert('single_payout_failed', {
|
||||
subject: '⚠️ Payout failed — manual review required',
|
||||
subject: '⚠️ Payout failed: manual review required',
|
||||
html: `<p>Single payout <b>${p.id}</b> failed and was marked <b>needs_review</b> to avoid a possible double-payout.</p>`
|
||||
+ `<p>Amount: ${p.amount} → ${p.payout_address}</p><p>Error: ${err.message}</p>`
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Multiple payouts — batch via transfer_split with multiple destinations
|
||||
// Multiple payouts: batch via transfer_split with multiple destinations
|
||||
const ids = pending.rows.map(p => p.id);
|
||||
let observedTxHash = null;
|
||||
try {
|
||||
@@ -3030,13 +3020,12 @@ class GameModeManager {
|
||||
throw new Error('Wallet returned ambiguous batch transaction-hash evidence');
|
||||
}
|
||||
if (hashes.length > 1) {
|
||||
// wallet-rpc SPLIT the send — which is exactly what transfer_split exists to
|
||||
// do when the destination set does not fit one transaction. The coins are
|
||||
// already broadcast, so this is not a failure; but the response does not say
|
||||
// which destination landed in which tx, so per-row attribution stays a human
|
||||
// decision (the operator's documented policy). What must NOT happen is what
|
||||
// used to: throwing here sent every row to needs_review with tx_hash NULL,
|
||||
// discarding the only evidence that the money left the wallet.
|
||||
// wallet-rpc split the send, which is what transfer_split does when the
|
||||
// destination set does not fit one transaction. The coins are already
|
||||
// broadcast, so this is not a failure; the response does not say which
|
||||
// destination landed in which tx, so per-row attribution stays a human
|
||||
// decision. BatchSplitEvidence carries the hashes into the quarantine
|
||||
// path so the evidence that the money left the wallet is preserved.
|
||||
observedTxHash = hashes[0];
|
||||
throw new BatchSplitEvidence(hashes, result.totalFee);
|
||||
}
|
||||
@@ -3047,9 +3036,8 @@ class GameModeManager {
|
||||
/ BigInt(pending.rows.length))
|
||||
: null;
|
||||
|
||||
// Mark the ENTIRE batch completed in a SINGLE transaction so we never
|
||||
// leave some rows completed and others stranded mid-batch (which the old
|
||||
// per-row loop did when the shared tx_hash hit the unique index).
|
||||
// The entire batch is marked completed in one transaction. A per-row loop
|
||||
// would strand rows mid-batch if the shared tx_hash hit the unique index.
|
||||
await this.db.withTransaction(async (client) => {
|
||||
await client.query(
|
||||
`UPDATE payouts SET tx_hash = $1, fee = $2, batch_id = $3, status = 'completed', processed_at = NOW() WHERE id = ANY($4)`,
|
||||
@@ -3066,18 +3054,19 @@ class GameModeManager {
|
||||
});
|
||||
console.log(`💸 Batch payout completed: ${pending.rows.length} payouts in tx ${txHash}`);
|
||||
} catch (err) {
|
||||
// An insufficient-unlocked-funds error is raised BEFORE broadcast (no tx
|
||||
// went out), so it's safe to retry: revert to 'pending' and let the next
|
||||
// run send it once outputs unlock — no operator alert needed (transient).
|
||||
// An insufficient-unlocked-funds error is raised before broadcast (no tx went
|
||||
// out), so the rows revert to 'pending' and the next run sends them once
|
||||
// outputs unlock. This is transient and raises no operator alert.
|
||||
//
|
||||
// ANY OTHER error is ambiguous: transfer_split may have broadcast on-chain
|
||||
// The same holds for failures flagged preBroadcast (transfer gate, identity
|
||||
// check, address validation, all strictly before transfer_split): nothing was
|
||||
// sent, so returning them to 'pending' cannot double-pay.
|
||||
//
|
||||
// Any other error is ambiguous: transfer_split may have broadcast on-chain
|
||||
// even though the RPC response errored. Those rows have no tx_hash, so the
|
||||
// retry service's blockchain guard can't protect them and an auto-retry
|
||||
// could DOUBLE-PAY. Mark 'needs_review' (retry service skips it) + alert.
|
||||
// Provably pre-broadcast failures (transfer gate, identity check, address
|
||||
// validation — all strictly before transfer_split) are safe to retry: nothing
|
||||
// was sent, so returning them to 'pending' cannot double-pay. Only genuinely
|
||||
// ambiguous post-broadcast failures may quarantine the batch.
|
||||
// retry service's blockchain guard cannot protect them and an auto-retry
|
||||
// could double-pay. They go to 'needs_review' (the retry service skips it)
|
||||
// plus an alert.
|
||||
const preBroadcast = !observedTxHash && err?.preBroadcast === true;
|
||||
const fundsIssue = !observedTxHash && isInsufficientFundsError(err);
|
||||
const retryable = fundsIssue || preBroadcast;
|
||||
@@ -3094,8 +3083,8 @@ class GameModeManager {
|
||||
const split = err instanceof BatchSplitEvidence;
|
||||
this.alertService.sendAlert('batch_payout_failed', {
|
||||
subject: split
|
||||
? '⚠️ Batch payout SPLIT — attribution review required'
|
||||
: '⚠️ Batch payout failed — manual review required',
|
||||
? '⚠️ Batch payout SPLIT: attribution review required'
|
||||
: '⚠️ Batch payout failed: manual review required',
|
||||
html: split
|
||||
? `<p>A batch of ${pending.rows.length} payout(s) was <b>broadcast as ${err.hashes.length} transactions</b>.`
|
||||
+ ` The funds have been sent; only the per-row attribution needs confirming.</p>`
|
||||
|
||||
@@ -14,7 +14,7 @@ const parseAtomicEnvValue = (val, fallback) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Delegates to the ChainProfile registry — one source for all chains (WOW 11, XMR 12, BTC/LTC 8,
|
||||
// Delegates to the ChainProfile registry: one source for all chains (WOW 11, XMR 12, BTC/LTC 8,
|
||||
// GRIN 9; unknown -> 12). Previously WOW/XMR were hardcoded and everything else silently got 12.
|
||||
const inferCurrencyDecimals = (symbol) => ChainProfile.decimalsFor(symbol);
|
||||
|
||||
|
||||
@@ -54,7 +54,6 @@ class LightingAndFov {
|
||||
const lightingData = {};
|
||||
const playerX = player.x;
|
||||
const playerY = player.y;
|
||||
// const maxLightDistance = 6; // Max distance light from a torch can reach // Old value
|
||||
const maxDarknessDistance = 8; // Distance at which light fully drops to max darkness
|
||||
const maxTorchInfluenceRadius = fovRadius + maxDarknessDistance;
|
||||
|
||||
@@ -63,11 +62,6 @@ class LightingAndFov {
|
||||
return distToPlayer < maxTorchInfluenceRadius;
|
||||
});
|
||||
|
||||
// console.log(`[CalculateLighting] Player at (${playerX},${playerY}). Found ${allTorches.length} total torches. Nearby torches (radius ${maxTorchInfluenceRadius}): ${nearbyTorches.length}`);
|
||||
|
||||
// let sampleAlpha1 = -1, sampleAlpha2 = -1, sampleAlpha3 = -1; // For logging flicker
|
||||
// let sampleAlpha4 = -1, sampleAlpha5 = -1; // Optional for extended logging
|
||||
|
||||
for (const yKey in visibleTiles) {
|
||||
const y = parseInt(yKey);
|
||||
lightingData[y] = {};
|
||||
@@ -76,7 +70,7 @@ class LightingAndFov {
|
||||
let minDistanceToTorch = Infinity;
|
||||
|
||||
if (nearbyTorches.length === 0) {
|
||||
minDistanceToTorch = maxTorchInfluenceRadius; // Effectively max darkness if no torches nearby
|
||||
minDistanceToTorch = maxTorchInfluenceRadius; // No torch nearby resolves to max darkness
|
||||
} else {
|
||||
for (const torch of nearbyTorches) {
|
||||
const distance = Math.max(Math.abs(x - torch[0]), Math.abs(y - torch[1]));
|
||||
@@ -89,37 +83,31 @@ class LightingAndFov {
|
||||
alpha = 0.0;
|
||||
} else if (minDistanceToTorch === 1) {
|
||||
alpha = 0.05 + Math.random() * 0.15; // Flicker: 0.05 - 0.20
|
||||
// if (sampleAlpha1 < 0) sampleAlpha1 = alpha;
|
||||
} else if (minDistanceToTorch === 2) {
|
||||
alpha = 0.15 + Math.random() * 0.15; // Flicker: 0.15 - 0.30
|
||||
// if (sampleAlpha2 < 0) sampleAlpha2 = alpha;
|
||||
} else if (minDistanceToTorch === 3) {
|
||||
alpha = 0.25 + Math.random() * 0.15; // Flicker: 0.25 - 0.40
|
||||
// if (sampleAlpha3 < 0) sampleAlpha3 = alpha;
|
||||
} else if (minDistanceToTorch === 4) {
|
||||
alpha = 0.35 + Math.random() * 0.15; // Flicker: 0.35 - 0.50
|
||||
// if (sampleAlpha4 < 0) sampleAlpha4 = alpha;
|
||||
} else if (minDistanceToTorch === 5) {
|
||||
alpha = 0.45 + Math.random() * 0.15; // Flicker: 0.45 - 0.60
|
||||
// if (sampleAlpha5 < 0) sampleAlpha5 = alpha;
|
||||
} else {
|
||||
// Stable falloff for distances > 5 up to maxDarknessDistance
|
||||
const falloffStartDistance = 5; // End of flicker zones
|
||||
const lightLevelAtFalloffStart = 0.6; // Max alpha from distance 5 flicker (0.45 + 0.15)
|
||||
const falloffStartDistance = 5; // End of the flicker zones
|
||||
const lightLevelAtFalloffStart = 0.6; // Max alpha of the distance-5 flicker (0.45 + 0.15)
|
||||
const maxDarknessAlpha = 0.9;
|
||||
|
||||
if (minDistanceToTorch >= maxDarknessDistance) {
|
||||
alpha = maxDarknessAlpha;
|
||||
} else { // minDistanceToTorch is between (falloffStartDistance + 1) and (maxDarknessDistance - 1)
|
||||
} else { // minDistanceToTorch lies between falloffStartDistance + 1 and maxDarknessDistance - 1
|
||||
let progress = (minDistanceToTorch - falloffStartDistance) / (maxDarknessDistance - falloffStartDistance);
|
||||
alpha = lightLevelAtFalloffStart + progress * (maxDarknessAlpha - lightLevelAtFalloffStart);
|
||||
}
|
||||
}
|
||||
alpha = Math.max(0.0, Math.min(0.9, alpha)); // Clamp alpha between 0.0 and 0.9
|
||||
alpha = Math.max(0.0, Math.min(0.9, alpha));
|
||||
lightingData[y][x] = alpha;
|
||||
}
|
||||
}
|
||||
// console.log(\`💡 Flicker samples (dist 1,2,3): \${sampleAlpha1.toFixed(3)}, \${sampleAlpha2.toFixed(3)}, \${sampleAlpha3.toFixed(3)}\`);
|
||||
return lightingData;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,9 +25,9 @@ class MovementManager {
|
||||
const game = this.activeGames.get(socketId);
|
||||
if (!game) return; // not in a game
|
||||
|
||||
// A terminal result stays in activeGames while its atomic DB completion is retried. Keeping
|
||||
// the entry blocks replacement games; this explicit guard prevents the retained Game object
|
||||
// from accepting moves (Game.movePlayer historically does not inspect gameState itself).
|
||||
// A terminal result stays in activeGames while its atomic DB completion is retried, which
|
||||
// blocks replacement games. Game.movePlayer does not inspect gameState, so this guard is what
|
||||
// stops the retained Game object from accepting further moves.
|
||||
if (game.settlementPending || game.settlementCommitted || game.gameState === 'ended') return;
|
||||
|
||||
const dir = moveData.direction;
|
||||
@@ -45,7 +45,7 @@ class MovementManager {
|
||||
moveResult = game.movePlayer(dx, dy);
|
||||
}
|
||||
|
||||
// Allow hook to perform monster movement or other side-effects BEFORE we snapshot state
|
||||
// The hook performs monster movement and other side-effects before the state snapshot.
|
||||
if (moveResult && moveResult.status === 'moved' && this.postMoveHook) {
|
||||
try {
|
||||
this.postMoveHook({ socketId, game, moveResult });
|
||||
@@ -54,7 +54,7 @@ class MovementManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Build state after player + potential monster move
|
||||
// State reflects both the player move and any monster move made by the hook.
|
||||
let state;
|
||||
if (typeof game.getState === 'function') {
|
||||
state = game.getState();
|
||||
@@ -72,8 +72,8 @@ class MovementManager {
|
||||
this.spectatorManager.broadcastToSpectators(game.id, state);
|
||||
}
|
||||
|
||||
// Handle special events from moveResult (escape / treasure / descend). Include depth for the
|
||||
// multi-level descend so the client can tell the player they took the stairs down (not a bug).
|
||||
// Special events from moveResult (escape / treasure / descend). Depth and maxDepth accompany a
|
||||
// multi-level descend so the client can tell the player they took the stairs down.
|
||||
if (moveResult && moveResult.event) {
|
||||
this.io.to(socketId).emit('game_event', {
|
||||
event: moveResult.event, depth: moveResult.depth, maxDepth: moveResult.maxDepth
|
||||
@@ -106,9 +106,8 @@ class MovementManager {
|
||||
/**
|
||||
* Drop the move-cooldown entry for a departed socket.
|
||||
*
|
||||
* `_lastMove` is keyed by the volatile socket.id and written on every accepted move, but was only
|
||||
* ever emptied by shutdown() — so a long-lived process accumulated one permanent entry per socket
|
||||
* that ever moved.
|
||||
* `_lastMove` is keyed by the volatile socket.id and written on every accepted move. Without this
|
||||
* call it is only emptied by shutdown(), leaving one permanent entry per socket that ever moved.
|
||||
*/
|
||||
forgetSocket(socketId) {
|
||||
this._lastMove.delete(socketId);
|
||||
|
||||
@@ -150,7 +150,7 @@ function seedToInt(seed) {
|
||||
* Derive a per-level seed for a multi-level run. Level 1 uses the master seed verbatim (so
|
||||
* single-level games stay byte-identical to pre-multi-level behaviour); deeper levels salt the
|
||||
* master seed with the depth. The whole descent is therefore reproducible from the one committed
|
||||
* seed — feed (masterSeed, depth) to `createSeededRNG`/`seedToInt` to regenerate any level.
|
||||
* seed: feed (masterSeed, depth) to `createSeededRNG`/`seedToInt` to regenerate any level.
|
||||
* @param {string} masterSeed - The game's committed seed
|
||||
* @param {number} depth - 1-based level index
|
||||
* @returns {string} The seed string for that level
|
||||
|
||||
Vendored
-138
@@ -1,138 +0,0 @@
|
||||
var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.ASSUME_ES5=!1;$jscomp.ASSUME_NO_NATIVE_MAP=!1;$jscomp.ASSUME_NO_NATIVE_SET=!1;$jscomp.defineProperty=$jscomp.ASSUME_ES5||"function"==typeof Object.defineProperties?Object.defineProperty:function(g,p,r){g!=Array.prototype&&g!=Object.prototype&&(g[p]=r.value)};$jscomp.getGlobal=function(g){return"undefined"!=typeof window&&window===g?g:"undefined"!=typeof global&&null!=global?global:g};$jscomp.global=$jscomp.getGlobal(this);
|
||||
$jscomp.polyfill=function(g,p,r,t){if(p){r=$jscomp.global;g=g.split(".");for(t=0;t<g.length-1;t++){var w=g[t];w in r||(r[w]={});r=r[w]}g=g[g.length-1];t=r[g];p=p(t);p!=t&&null!=p&&$jscomp.defineProperty(r,g,{configurable:!0,writable:!0,value:p})}};$jscomp.polyfill("Array.prototype.fill",function(g){return g?g:function(g,r,t){var p=this.length||0;0>r&&(r=Math.max(0,p+r));if(null==t||t>p)t=p;t=Number(t);0>t&&(t=Math.max(0,p+t));for(r=Number(r||0);r<t;r++)this[r]=g;return this}},"es6","es3");
|
||||
$jscomp.checkStringArgs=function(g,p,r){if(null==g)throw new TypeError("The 'this' value for String.prototype."+r+" must not be null or undefined");if(p instanceof RegExp)throw new TypeError("First argument to String.prototype."+r+" must not be a regular expression");return g+""};
|
||||
$jscomp.polyfill("String.prototype.repeat",function(g){return g?g:function(g){var p=$jscomp.checkStringArgs(this,null,"repeat");if(0>g||1342177279<g)throw new RangeError("Invalid count value");g|=0;for(var t="";g;)if(g&1&&(t+=p),g>>>=1)p+=p;return t}},"es6","es3");$jscomp.stringPadding=function(g,p){g=void 0!==g?String(g):" ";return 0<p&&g?g.repeat(Math.ceil(p/g.length)).substring(0,p):""};
|
||||
$jscomp.polyfill("String.prototype.padStart",function(g){return g?g:function(g,r){var p=$jscomp.checkStringArgs(this,null,"padStart");return $jscomp.stringPadding(r,g-p.length)+p}},"es8","es3");$jscomp.owns=function(g,p){return Object.prototype.hasOwnProperty.call(g,p)};$jscomp.assign="function"==typeof Object.assign?Object.assign:function(g,p){for(var r=1;r<arguments.length;r++){var t=arguments[r];if(t)for(var w in t)$jscomp.owns(t,w)&&(g[w]=t[w])}return g};
|
||||
$jscomp.polyfill("Object.assign",function(g){return g||$jscomp.assign},"es6","es3");$jscomp.SYMBOL_PREFIX="jscomp_symbol_";$jscomp.initSymbol=function(){$jscomp.initSymbol=function(){};$jscomp.global.Symbol||($jscomp.global.Symbol=$jscomp.Symbol)};$jscomp.Symbol=function(){var g=0;return function(p){return $jscomp.SYMBOL_PREFIX+(p||"")+g++}}();
|
||||
$jscomp.initSymbolIterator=function(){$jscomp.initSymbol();var g=$jscomp.global.Symbol.iterator;g||(g=$jscomp.global.Symbol.iterator=$jscomp.global.Symbol("iterator"));"function"!=typeof Array.prototype[g]&&$jscomp.defineProperty(Array.prototype,g,{configurable:!0,writable:!0,value:function(){return $jscomp.arrayIterator(this)}});$jscomp.initSymbolIterator=function(){}};
|
||||
$jscomp.initSymbolAsyncIterator=function(){$jscomp.initSymbol();var g=$jscomp.global.Symbol.asyncIterator;g||(g=$jscomp.global.Symbol.asyncIterator=$jscomp.global.Symbol("asyncIterator"));$jscomp.initSymbolAsyncIterator=function(){}};$jscomp.arrayIterator=function(g){var p=0;return $jscomp.iteratorPrototype(function(){return p<g.length?{done:!1,value:g[p++]}:{done:!0}})};
|
||||
$jscomp.iteratorPrototype=function(g){$jscomp.initSymbolIterator();g={next:g};g[$jscomp.global.Symbol.iterator]=function(){return this};return g};$jscomp.iteratorFromArray=function(g,p){$jscomp.initSymbolIterator();g instanceof String&&(g+="");var r=0,t={next:function(){if(r<g.length){var w=r++;return{value:p(w,g[w]),done:!1}}t.next=function(){return{done:!0,value:void 0}};return t.next()}};t[Symbol.iterator]=function(){return t};return t};
|
||||
$jscomp.polyfill("Array.prototype.keys",function(g){return g?g:function(){return $jscomp.iteratorFromArray(this,function(g){return g})}},"es6","es3");function _assertThisInitialized(g){if(void 0===g)throw new ReferenceError("this hasn't been initialised - super() hasn't been called");return g}function _inheritsLoose(g,p){g.prototype=Object.create(p.prototype);g.prototype.constructor=g;g.__proto__=p}
|
||||
(function(g,p){"object"===typeof exports&&"undefined"!==typeof module?p(exports):"function"===typeof define&&define.amd?define(["exports"],p):p(g.ROT={})})(this,function(g){function p(d,c){return(d%c+c)%c}function r(d,c,a){void 0===c&&(c=0);void 0===a&&(a=1);return d<c?c:d>a?a:d}function t(d){return d.charAt(0).toUpperCase()+d.substring(1)}function w(d){for(var c=arguments.length,a=Array(1<c?c-1:0),b=1;b<c;b++)a[b-1]=arguments[b];var e=w.map;return d.replace(/%(?:([a-z]+)|(?:{([^}]+)}))/gi,function(b,
|
||||
m,k,h){if("%"==d.charAt(h-1))return b.substring(1);if(!a.length)return b;k=(m||k).split(",");m=k.shift()||"";h=e[m.toLowerCase()];if(!h)return b;b=a.shift();b=b[h].apply(b,k);m=m.charAt(0);m!=m.toLowerCase()&&(b=t(b));return b})}function L(d){var c;if(d in Q)var a=Q[d];else{if("#"==d.charAt(0))if(a=(d.match(/[0-9a-f]/gi)||[]).map(function(b){return parseInt(b,16)}),3==a.length)a=a.map(function(b){return 17*b});else for(c=0;3>c;c++)a[c+1]+=16*a[c],a.splice(c,1);else a=(c=d.match(/rgb\(([0-9, ]+)\)/i))?
|
||||
c[1].split(/\s*,\s*/).map(function(b){return parseInt(b)}):[0,0,0];Q[d]=a}return a.slice()}function Y(d){for(var c=arguments.length,a=Array(1<c?c-1:0),b=1;b<c;b++)a[b-1]=arguments[b];for(c=0;3>c;c++)for(b=0;b<a.length;b++)d[c]+=a[b][c];return d}function Z(d,c,a){void 0===a&&(a=.5);for(var b=d.slice(),e=0;3>e;e++)b[e]=Math.round(b[e]+a*(c[e]-d[e]));return b}function aa(d,c,a){void 0===a&&(a=.5);d=R(d);c=R(c);for(var b=0;3>b;b++)d[b]+=a*(c[b]-d[b]);return ba(d)}function R(d){var c=d[0]/255,a=d[1]/255;
|
||||
d=d[2]/255;var b=Math.max(c,a,d),e=Math.min(c,a,d),f=0,m=(b+e)/2;if(b==e)e=0;else{var k=b-e;e=.5<m?k/(2-b-e):k/(b+e);switch(b){case c:f=(a-d)/k+(a<d?6:0);break;case a:f=(d-c)/k+2;break;case d:f=(c-a)/k+4}f/=6}return[f,e,m]}function S(d,c,a){0>a&&(a+=1);1<a&&--a;return a<1/6?d+6*(c-d)*a:.5>a?c:a<2/3?d+(c-d)*(2/3-a)*6:d}function ba(d){var c=d[2];if(0==d[1])return c=Math.round(255*c),[c,c,c];var a=d[1];a=.5>c?c*(1+a):c+a-c*a;var b=2*c-a;c=S(b,a,d[0]+1/3);var e=S(b,a,d[0]);d=S(b,a,d[0]-1/3);return[Math.round(255*
|
||||
c),Math.round(255*e),Math.round(255*d)]}function fa(d,c,a){var b=d.createShader(d.VERTEX_SHADER);d.shaderSource(b,c);d.compileShader(b);if(!d.getShaderParameter(b,d.COMPILE_STATUS))throw Error(d.getShaderInfoLog(b)||"");c=d.createShader(d.FRAGMENT_SHADER);d.shaderSource(c,a);d.compileShader(c);if(!d.getShaderParameter(c,d.COMPILE_STATUS))throw Error(d.getShaderInfoLog(c)||"");a=d.createProgram();d.attachShader(a,b);d.attachShader(a,c);d.linkProgram(a);if(!d.getProgramParameter(a,d.LINK_STATUS))throw Error(d.getProgramInfoLog(a)||
|
||||
"");return a}function ha(d){var c=new Float32Array([0,0,1,0,0,1,1,1]),a=d.createBuffer();d.bindBuffer(d.ARRAY_BUFFER,a);d.bufferData(d.ARRAY_BUFFER,c,d.STATIC_DRAW);d.enableVertexAttribArray(0);d.vertexAttribPointer(0,2,d.FLOAT,!1,0,0)}function M(d){if(!(d in T)){if("transparent"==d)var c=[0,0,0,0];else if(-1<d.indexOf("rgba")){c=(d.match(/[\d.]+/g)||[]).map(Number);for(var a=0;3>a;a++)c[a]/=255}else c=L(d).map(function(b){return b/255}),c.push(1);T[d]=c}return T[d]}function U(d){d=L(d);return 36*
|
||||
Math.floor(.0234375*d[0])+6*Math.floor(.0234375*d[1])+1*Math.floor(.0234375*d[2])+16}function V(d,c){var a=[],b=0;d.replace(ia,function(e,m,k,h){var f=d.substring(b,h);f.length&&a.push({type:0,value:f});a.push({type:"c"==m?2:3,value:k.trim()});b=h+e.length;return""});var e=d.substring(b);e.length&&a.push({type:0,value:e});return ja(a,c)}function ja(d,c){c||(c=Infinity);for(var a=0,b=0,e=-1;a<d.length;){var f=d[a];1==f.type&&(b=0,e=-1);if(0!=f.type)a++;else{for(;0==b&&" "==f.value.charAt(0);)f.value=
|
||||
f.value.substring(1);var m=f.value.indexOf("\n");if(-1!=m){f.value=N(d,a,m,!0);for(m=f.value.split("");m.length&&" "==m[m.length-1];)m.pop();f.value=m.join("")}if(f.value.length){if(b+f.value.length>c){for(m=-1;;){var k=f.value.indexOf(" ",m+1);if(-1==k)break;if(b+k>c)break;m=k}-1!=m?f.value=N(d,a,m,!0):-1!=e?(a=d[e],f=a.value.lastIndexOf(" "),a.value=N(d,e,f,!0),a=e):f.value=N(d,a,c-b,!1)}else b+=f.value.length,-1!=f.value.indexOf(" ")&&(e=a);a++}else d.splice(a,1)}}d.push({type:1});c=null;for(b=
|
||||
0;b<d.length;b++)switch(e=d[b],e.type){case 0:c=e;break;case 1:if(c){for(e=c.value.split("");e.length&&" "==e[e.length-1];)e.pop();c.value=e.join("")}c=null}d.pop();return d}function N(d,c,a,b){b={type:0,value:d[c].value.substring(a+(b?1:0))};d.splice(c+1,0,{type:1},b);return d[c].value.substring(0,a)}function ca(d,c,a){a[c[d+1]]=a[d];c[a[d]]=c[d+1];a[d]=d+1;c[d+1]=d}function da(d,c,a){a[c[d]]=a[d];c[a[d]]=c[d];a[d]=d;c[d]=d}var q=(new (function(){function d(){this._c=this._s2=this._s1=this._s0=this._seed=
|
||||
0}var c=d.prototype;c.getSeed=function(){return this._seed};c.setSeed=function(a){this._seed=a=1>a?1/a:a;this._s0=2.3283064365386963E-10*(a>>>0);a=69069*a+1>>>0;this._s1=2.3283064365386963E-10*a;this._s2=2.3283064365386963E-10*(69069*a+1>>>0);this._c=1;return this};c.getUniform=function(){var a=2091639*this._s0+2.3283064365386963E-10*this._c;this._s0=this._s1;this._s1=this._s2;this._c=a|0;return this._s2=a-this._c};c.getUniformInt=function(a,b){var e=Math.max(a,b);a=Math.min(a,b);return Math.floor(this.getUniform()*
|
||||
(e-a+1))+a};c.getNormal=function(a,b){void 0===a&&(a=0);void 0===b&&(b=1);do{var e=2*this.getUniform()-1;var f=2*this.getUniform()-1;f=e*e+f*f}while(1<f||0==f);return a+e*Math.sqrt(-2*Math.log(f)/f)*b};c.getPercentage=function(){return 1+Math.floor(100*this.getUniform())};c.getItem=function(a){return a.length?a[Math.floor(this.getUniform()*a.length)]:null};c.shuffle=function(a){var b=[];for(a=a.slice();a.length;){var e=a.indexOf(this.getItem(a));b.push(a.splice(e,1)[0])}return b};c.getWeightedValue=
|
||||
function(a){var b=0;for(f in a)b+=a[f];b*=this.getUniform();var e;var f=0;for(e in a)if(f+=a[e],b<f)break;return e};c.getState=function(){return[this._s0,this._s1,this._s2,this._c]};c.setState=function(a){this._s0=a[0];this._s1=a[1];this._s2=a[2];this._c=a[3];return this};c.clone=function(){return(new d).setState(this.getState())};return d}())).setSeed(Date.now()),I=function(){function d(){}var c=d.prototype;c.getContainer=function(){return null};c.setOptions=function(a){this._options=a};return d}(),
|
||||
J=function(d){function c(){var b=d.call(this)||this;b._ctx=document.createElement("canvas").getContext("2d");return b}_inheritsLoose(c,d);var a=c.prototype;a.schedule=function(b){requestAnimationFrame(b)};a.getContainer=function(){return this._ctx.canvas};a.setOptions=function(b){d.prototype.setOptions.call(this,b);b=(b.fontStyle?b.fontStyle+" ":"")+" "+b.fontSize+"px "+b.fontFamily;this._ctx.font=b;this._updateSize();this._ctx.font=b;this._ctx.textAlign="center";this._ctx.textBaseline="middle"};
|
||||
a.clear=function(){this._ctx.fillStyle=this._options.bg;this._ctx.fillRect(0,0,this._ctx.canvas.width,this._ctx.canvas.height)};a.eventToPosition=function(b,e){var f=this._ctx.canvas,a=f.getBoundingClientRect();b-=a.left;e-=a.top;b*=f.width/a.width;e*=f.height/a.height;return 0>b||0>e||b>=f.width||e>=f.height?[-1,-1]:this._normalizedEventToPosition(b,e)};return c}(I);w.map={s:"toString"};var ka=Object.freeze({mod:p,clamp:r,capitalize:t,format:w}),O=function(d){function c(){var b=d.call(this)||this;
|
||||
b._spacingX=0;b._spacingY=0;b._hexSize=0;return b}_inheritsLoose(c,d);var a=c.prototype;a.draw=function(b,e){var f=b[2],a=b[3],k=b[4];b=[(b[0]+1)*this._spacingX,b[1]*this._spacingY+this._hexSize];this._options.transpose&&b.reverse();e&&(this._ctx.fillStyle=k,this._fill(b[0],b[1]));if(f)for(this._ctx.fillStyle=a,e=[].concat(f),f=0;f<e.length;f++)this._ctx.fillText(e[f],b[0],Math.ceil(b[1]))};a.computeSize=function(b,e){this._options.transpose&&(b+=e,e=b-e,b-=e);return[Math.floor(b/this._spacingX)-
|
||||
1,Math.floor((e-2*this._hexSize)/this._spacingY+1)]};a.computeFontSize=function(b,e){this._options.transpose&&(b+=e,e=b-e,b-=e);b=Math.min(2*b/((this._options.width+1)*Math.sqrt(3))-1,e/(2+1.5*(this._options.height-1)));e=this._ctx.font;this._ctx.font="100px "+this._options.fontFamily;var f=Math.ceil(this._ctx.measureText("W").width);this._ctx.font=e;b=Math.floor(b)+1;return Math.ceil(2*b/(this._options.spacing*(1+f/100/Math.sqrt(3))))-1};a._normalizedEventToPosition=function(b,e){if(this._options.transpose){b+=
|
||||
e;e=b-e;b-=e;var f=this._ctx.canvas.width}else f=this._ctx.canvas.height;e=Math.floor(e/(f/this._options.height));p(e,2)?(b-=this._spacingX,b=1+2*Math.floor(b/(2*this._spacingX))):b=2*Math.floor(b/(2*this._spacingX));return[b,e]};a._fill=function(b,e){var f=this._hexSize,a=this._options.border,k=this._ctx;k.beginPath();this._options.transpose?(k.moveTo(b-f+a,e),k.lineTo(b-f/2+a,e+this._spacingX-a),k.lineTo(b+f/2-a,e+this._spacingX-a),k.lineTo(b+f-a,e),k.lineTo(b+f/2-a,e-this._spacingX+a),k.lineTo(b-
|
||||
f/2+a,e-this._spacingX+a),k.lineTo(b-f+a,e)):(k.moveTo(b,e-f+a),k.lineTo(b+this._spacingX-a,e-f/2+a),k.lineTo(b+this._spacingX-a,e+f/2-a),k.lineTo(b,e+f-a),k.lineTo(b-this._spacingX+a,e+f/2-a),k.lineTo(b-this._spacingX+a,e-f/2+a),k.lineTo(b,e-f+a));k.fill()};a._updateSize=function(){var b=this._options,e=Math.ceil(this._ctx.measureText("W").width);this._hexSize=Math.floor(b.spacing*(b.fontSize+e/Math.sqrt(3))/2);this._spacingX=this._hexSize*Math.sqrt(3)/2;this._spacingY=1.5*this._hexSize;if(b.transpose){e=
|
||||
"height";var f="width"}else e="width",f="height";this._ctx.canvas[e]=Math.ceil((b.width+1)*this._spacingX);this._ctx.canvas[f]=Math.ceil((b.height-1)*this._spacingY+2*this._hexSize)};return c}(J),F=function(d){function c(){var b=d.call(this)||this;b._spacingX=0;b._spacingY=0;b._canvasCache={};return b}_inheritsLoose(c,d);var a=c.prototype;a.setOptions=function(b){d.prototype.setOptions.call(this,b);this._canvasCache={}};a.draw=function(b,e){c.cache?this._drawWithCache(b):this._drawNoCache(b,e)};a._drawWithCache=
|
||||
function(b){var e=b[0],f=b[1],a=b[2],k=b[3],h=b[4];b=""+a+k+h;if(b in this._canvasCache)var c=this._canvasCache[b];else{var d=this._options.border;c=document.createElement("canvas");var g=c.getContext("2d");c.width=this._spacingX;c.height=this._spacingY;g.fillStyle=h;g.fillRect(d,d,c.width-d,c.height-d);if(a)for(g.fillStyle=k,g.font=this._ctx.font,g.textAlign="center",g.textBaseline="middle",a=[].concat(a),k=0;k<a.length;k++)g.fillText(a[k],this._spacingX/2,Math.ceil(this._spacingY/2));this._canvasCache[b]=
|
||||
c}this._ctx.drawImage(c,e*this._spacingX,f*this._spacingY)};a._drawNoCache=function(b,e){var f=b[0],a=b[1],k=b[2],h=b[3];b=b[4];e&&(e=this._options.border,this._ctx.fillStyle=b,this._ctx.fillRect(f*this._spacingX+e,a*this._spacingY+e,this._spacingX-e,this._spacingY-e));if(k)for(this._ctx.fillStyle=h,k=[].concat(k),h=0;h<k.length;h++)this._ctx.fillText(k[h],(f+.5)*this._spacingX,Math.ceil((a+.5)*this._spacingY))};a.computeSize=function(b,e){return[Math.floor(b/this._spacingX),Math.floor(e/this._spacingY)]};
|
||||
a.computeFontSize=function(b,e){b=Math.floor(b/this._options.width);e=Math.floor(e/this._options.height);var f=this._ctx.font;this._ctx.font="100px "+this._options.fontFamily;var a=Math.ceil(this._ctx.measureText("W").width);this._ctx.font=f;b=a/100*e/b;1<b&&(e=Math.floor(e/b));return Math.floor(e/this._options.spacing)};a._normalizedEventToPosition=function(b,e){return[Math.floor(b/this._spacingX),Math.floor(e/this._spacingY)]};a._updateSize=function(){var b=this._options,e=Math.ceil(this._ctx.measureText("W").width);
|
||||
this._spacingX=Math.ceil(b.spacing*e);this._spacingY=Math.ceil(b.spacing*b.fontSize);b.forceSquareRatio&&(this._spacingX=this._spacingY=Math.max(this._spacingX,this._spacingY));this._ctx.canvas.width=b.width*this._spacingX;this._ctx.canvas.height=b.height*this._spacingY};return c}(J);F.cache=!1;var B=function(d){function c(){var b=d.call(this)||this;b._colorCanvas=document.createElement("canvas");return b}_inheritsLoose(c,d);var a=c.prototype;a.draw=function(b,e){var f=b[0],a=b[1],k=b[2],h=b[3],c=
|
||||
b[4];b=this._options.tileWidth;var d=this._options.tileHeight;e&&(this._options.tileColorize?this._ctx.clearRect(f*b,a*d,b,d):(this._ctx.fillStyle=c,this._ctx.fillRect(f*b,a*d,b,d)));if(k)for(e=[].concat(k),h=[].concat(h),c=[].concat(c),k=0;k<e.length;k++){var g=this._options.tileMap[e[k]];if(!g)throw Error('Char "'+e[k]+'" not found in tileMap');if(this._options.tileColorize){var z=this._colorCanvas,y=z.getContext("2d");y.globalCompositeOperation="source-over";y.clearRect(0,0,b,d);var q=h[k],p=c[k];
|
||||
y.drawImage(this._options.tileSet,g[0],g[1],b,d,0,0,b,d);"transparent"!=q&&(y.fillStyle=q,y.globalCompositeOperation="source-atop",y.fillRect(0,0,b,d));"transparent"!=p&&(y.fillStyle=p,y.globalCompositeOperation="destination-over",y.fillRect(0,0,b,d));this._ctx.drawImage(z,f*b,a*d,b,d)}else this._ctx.drawImage(this._options.tileSet,g[0],g[1],b,d,f*b,a*d,b,d)}};a.computeSize=function(b,e){return[Math.floor(b/this._options.tileWidth),Math.floor(e/this._options.tileHeight)]};a.computeFontSize=function(){throw Error("Tile backend does not understand font size");
|
||||
};a._normalizedEventToPosition=function(b,e){return[Math.floor(b/this._options.tileWidth),Math.floor(e/this._options.tileHeight)]};a._updateSize=function(){var b=this._options;this._ctx.canvas.width=b.width*b.tileWidth;this._ctx.canvas.height=b.height*b.tileHeight;this._colorCanvas.width=b.tileWidth;this._colorCanvas.height=b.tileHeight};return c}(J),Q={black:[0,0,0],navy:[0,0,128],darkblue:[0,0,139],mediumblue:[0,0,205],blue:[0,0,255],darkgreen:[0,100,0],green:[0,128,0],teal:[0,128,128],darkcyan:[0,
|
||||
139,139],deepskyblue:[0,191,255],darkturquoise:[0,206,209],mediumspringgreen:[0,250,154],lime:[0,255,0],springgreen:[0,255,127],aqua:[0,255,255],cyan:[0,255,255],midnightblue:[25,25,112],dodgerblue:[30,144,255],forestgreen:[34,139,34],seagreen:[46,139,87],darkslategray:[47,79,79],darkslategrey:[47,79,79],limegreen:[50,205,50],mediumseagreen:[60,179,113],turquoise:[64,224,208],royalblue:[65,105,225],steelblue:[70,130,180],darkslateblue:[72,61,139],mediumturquoise:[72,209,204],indigo:[75,0,130],darkolivegreen:[85,
|
||||
107,47],cadetblue:[95,158,160],cornflowerblue:[100,149,237],mediumaquamarine:[102,205,170],dimgray:[105,105,105],dimgrey:[105,105,105],slateblue:[106,90,205],olivedrab:[107,142,35],slategray:[112,128,144],slategrey:[112,128,144],lightslategray:[119,136,153],lightslategrey:[119,136,153],mediumslateblue:[123,104,238],lawngreen:[124,252,0],chartreuse:[127,255,0],aquamarine:[127,255,212],maroon:[128,0,0],purple:[128,0,128],olive:[128,128,0],gray:[128,128,128],grey:[128,128,128],skyblue:[135,206,235],
|
||||
lightskyblue:[135,206,250],blueviolet:[138,43,226],darkred:[139,0,0],darkmagenta:[139,0,139],saddlebrown:[139,69,19],darkseagreen:[143,188,143],lightgreen:[144,238,144],mediumpurple:[147,112,216],darkviolet:[148,0,211],palegreen:[152,251,152],darkorchid:[153,50,204],yellowgreen:[154,205,50],sienna:[160,82,45],brown:[165,42,42],darkgray:[169,169,169],darkgrey:[169,169,169],lightblue:[173,216,230],greenyellow:[173,255,47],paleturquoise:[175,238,238],lightsteelblue:[176,196,222],powderblue:[176,224,
|
||||
230],firebrick:[178,34,34],darkgoldenrod:[184,134,11],mediumorchid:[186,85,211],rosybrown:[188,143,143],darkkhaki:[189,183,107],silver:[192,192,192],mediumvioletred:[199,21,133],indianred:[205,92,92],peru:[205,133,63],chocolate:[210,105,30],tan:[210,180,140],lightgray:[211,211,211],lightgrey:[211,211,211],palevioletred:[216,112,147],thistle:[216,191,216],orchid:[218,112,214],goldenrod:[218,165,32],crimson:[220,20,60],gainsboro:[220,220,220],plum:[221,160,221],burlywood:[222,184,135],lightcyan:[224,
|
||||
255,255],lavender:[230,230,250],darksalmon:[233,150,122],violet:[238,130,238],palegoldenrod:[238,232,170],lightcoral:[240,128,128],khaki:[240,230,140],aliceblue:[240,248,255],honeydew:[240,255,240],azure:[240,255,255],sandybrown:[244,164,96],wheat:[245,222,179],beige:[245,245,220],whitesmoke:[245,245,245],mintcream:[245,255,250],ghostwhite:[248,248,255],salmon:[250,128,114],antiquewhite:[250,235,215],linen:[250,240,230],lightgoldenrodyellow:[250,250,210],oldlace:[253,245,230],red:[255,0,0],fuchsia:[255,
|
||||
0,255],magenta:[255,0,255],deeppink:[255,20,147],orangered:[255,69,0],tomato:[255,99,71],hotpink:[255,105,180],coral:[255,127,80],darkorange:[255,140,0],lightsalmon:[255,160,122],orange:[255,165,0],lightpink:[255,182,193],pink:[255,192,203],gold:[255,215,0],peachpuff:[255,218,185],navajowhite:[255,222,173],moccasin:[255,228,181],bisque:[255,228,196],mistyrose:[255,228,225],blanchedalmond:[255,235,205],papayawhip:[255,239,213],lavenderblush:[255,240,245],seashell:[255,245,238],cornsilk:[255,248,220],
|
||||
lemonchiffon:[255,250,205],floralwhite:[255,250,240],snow:[255,250,250],yellow:[255,255,0],lightyellow:[255,255,224],ivory:[255,255,240],white:[255,255,255]};J=Object.freeze({fromString:L,add:function(d){for(var c=d.slice(),a=arguments.length,b=Array(1<a?a-1:0),e=1;e<a;e++)b[e-1]=arguments[e];for(a=0;3>a;a++)for(e=0;e<b.length;e++)c[a]+=b[e][a];return c},add_:Y,multiply:function(d){for(var c=d.slice(),a=arguments.length,b=Array(1<a?a-1:0),e=1;e<a;e++)b[e-1]=arguments[e];for(a=0;3>a;a++){for(e=0;e<
|
||||
b.length;e++)c[a]*=b[e][a]/255;c[a]=Math.round(c[a])}return c},multiply_:function(d){for(var c=arguments.length,a=Array(1<c?c-1:0),b=1;b<c;b++)a[b-1]=arguments[b];for(c=0;3>c;c++){for(b=0;b<a.length;b++)d[c]*=a[b][c]/255;d[c]=Math.round(d[c])}return d},interpolate:Z,lerp:Z,interpolateHSL:aa,lerpHSL:aa,randomize:function(d,c){c instanceof Array||(c=Math.round(q.getNormal(0,c)));d=d.slice();for(var a=0;3>a;a++)d[a]+=c instanceof Array?Math.round(q.getNormal(0,c[a])):c;return d},rgb2hsl:R,hsl2rgb:ba,
|
||||
toRGB:function(d){return"rgb("+d.map(function(c){return r(c,0,255)}).join(",")+")"},toHex:function(d){return"#"+d.map(function(c){return r(c,0,255).toString(16).padStart(2,"0")}).join("")}});var x=function(d){function c(){var b=d.call(this)||this;b._uniforms={};try{b._gl=b._initWebGL()}catch(e){alert(e.message)}return b}_inheritsLoose(c,d);c.isSupported=function(){return!!document.createElement("canvas").getContext("webgl2",{preserveDrawingBuffer:!0})};var a=c.prototype;a.schedule=function(b){requestAnimationFrame(b)};
|
||||
a.getContainer=function(){return this._gl.canvas};a.setOptions=function(b){var e=this;d.prototype.setOptions.call(this,b);this._updateSize();var f=this._options.tileSet;f&&"complete"in f&&!f.complete?f.addEventListener("load",function(){return e._updateTexture(f)}):this._updateTexture(f)};a.draw=function(b,e){var f=this._gl,a=this._options,k=b[0],h=b[1],c=b[2],d=b[3];b=b[4];f.scissor(k*a.tileWidth,f.canvas.height-(h+1)*a.tileHeight,a.tileWidth,a.tileHeight);e&&(a.tileColorize?f.clearColor(0,0,0,0):
|
||||
f.clearColor.apply(f,M(b)),f.clear(f.COLOR_BUFFER_BIT));if(c)for(e=[].concat(c),c=[].concat(b),d=[].concat(d),f.uniform2fv(this._uniforms.targetPosRel,[k,h]),k=0;k<e.length;k++){h=this._options.tileMap[e[k]];if(!h)throw Error('Char "'+e[k]+'" not found in tileMap');f.uniform1f(this._uniforms.colorize,a.tileColorize?1:0);f.uniform2fv(this._uniforms.tilesetPosAbs,h);a.tileColorize&&(f.uniform4fv(this._uniforms.tint,M(d[k])),f.uniform4fv(this._uniforms.bg,M(c[k])));f.drawArrays(f.TRIANGLE_STRIP,0,4)}};
|
||||
a.clear=function(){var b=this._gl;b.clearColor.apply(b,M(this._options.bg));b.scissor(0,0,b.canvas.width,b.canvas.height);b.clear(b.COLOR_BUFFER_BIT)};a.computeSize=function(b,e){return[Math.floor(b/this._options.tileWidth),Math.floor(e/this._options.tileHeight)]};a.computeFontSize=function(){throw Error("Tile backend does not understand font size");};a.eventToPosition=function(b,e){var f=this._gl.canvas,a=f.getBoundingClientRect();b-=a.left;e-=a.top;b*=f.width/a.width;e*=f.height/a.height;return 0>
|
||||
b||0>e||b>=f.width||e>=f.height?[-1,-1]:this._normalizedEventToPosition(b,e)};a._initWebGL=function(){var b=this,e=document.createElement("canvas").getContext("webgl2",{preserveDrawingBuffer:!0});window.gl=e;var f=fa(e,la,ma);e.useProgram(f);ha(e);na.forEach(function(a){return b._uniforms[a]=e.getUniformLocation(f,a)});this._program=f;e.enable(e.BLEND);e.blendFuncSeparate(e.SRC_ALPHA,e.ONE_MINUS_SRC_ALPHA,e.ONE,e.ONE_MINUS_SRC_ALPHA);e.enable(e.SCISSOR_TEST);return e};a._normalizedEventToPosition=
|
||||
function(b,e){return[Math.floor(b/this._options.tileWidth),Math.floor(e/this._options.tileHeight)]};a._updateSize=function(){var b=this._gl,e=this._options,a=[e.width*e.tileWidth,e.height*e.tileHeight];b.canvas.width=a[0];b.canvas.height=a[1];b.viewport(0,0,a[0],a[1]);b.uniform2fv(this._uniforms.tileSize,[e.tileWidth,e.tileHeight]);b.uniform2fv(this._uniforms.targetSize,a)};a._updateTexture=function(b){var e=this._gl,a=e.createTexture();e.bindTexture(e.TEXTURE_2D,a);e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MAG_FILTER,
|
||||
e.NEAREST);e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MIN_FILTER,e.NEAREST);e.texParameteri(e.TEXTURE_2D,e.TEXTURE_WRAP_S,e.REPEAT);e.texParameteri(e.TEXTURE_2D,e.TEXTURE_WRAP_T,e.REPEAT);e.pixelStorei(e.UNPACK_FLIP_Y_WEBGL,0);e.texImage2D(e.TEXTURE_2D,0,e.RGBA,e.RGBA,e.UNSIGNED_BYTE,b)};return c}(I),na="targetPosRel tilesetPosAbs tileSize targetSize colorize bg tint".split(" "),la="#version 300 es\n\nin vec2 tilePosRel;\nout vec2 tilesetPosPx;\n\nuniform vec2 tilesetPosAbs;\nuniform vec2 tileSize;\nuniform vec2 targetSize;\nuniform vec2 targetPosRel;\n\nvoid main() {\n\tvec2 targetPosPx = (targetPosRel + tilePosRel) * tileSize;\n\tvec2 targetPosNdc = ((targetPosPx / targetSize)-0.5)*2.0;\n\ttargetPosNdc.y *= -1.0;\n\n\tgl_Position = vec4(targetPosNdc, 0.0, 1.0);\n\ttilesetPosPx = tilesetPosAbs + tilePosRel * tileSize;\n}",
|
||||
ma="#version 300 es\nprecision highp float;\n\nin vec2 tilesetPosPx;\nout vec4 fragColor;\nuniform sampler2D image;\nuniform bool colorize;\nuniform vec4 bg;\nuniform vec4 tint;\n\nvoid main() {\n\tfragColor = vec4(0, 0, 0, 1);\n\n\tvec4 texel = texelFetch(image, ivec2(tilesetPosPx), 0);\n\n\tif (colorize) {\n\t\ttexel.rgb = tint.a * tint.rgb + (1.0-tint.a) * texel.rgb;\n\t\tfragColor.rgb = texel.a*texel.rgb + (1.0-texel.a)*bg.rgb;\n\t\tfragColor.a = texel.a + (1.0-texel.a)*bg.a;\n\t} else {\n\t\tfragColor = texel;\n\t}\n}",
|
||||
T={},u=function(d){function c(){var b=d.call(this)||this;b._offset=[0,0];b._cursor=[-1,-1];b._lastColor="";return b}_inheritsLoose(c,d);var a=c.prototype;a.schedule=function(b){setTimeout(b,1E3/60)};a.setOptions=function(b){d.prototype.setOptions.call(this,b);var e=[b.width,b.height];this._offset=this.computeSize().map(function(b,a){return Math.floor((b-e[a])/2)})};a.clear=function(){process.stdout.write("\u001b[0;48;5;"+U(this._options.bg)+"m\u001b[2J")};a.draw=function(b,e){var a=b[2],m=b[3],k=
|
||||
b[4],c=this._offset[0]+b[0],d=this._offset[1]+b[1];b=this.computeSize();if(!(0>c||c>=b[0]||0>d||d>=b[1])){if(c!==this._cursor[0]||d!==this._cursor[1])process.stdout.write("\u001b["+(d+1)+";"+(c+1)+"H"),this._cursor[0]=c,this._cursor[1]=d;e&&(a||(a=" "));a&&(e="\u001b[0;38;5;"+U(m)+";48;5;"+U(k)+"m",e!==this._lastColor&&(process.stdout.write(e),this._lastColor=e),a=[].concat(a),process.stdout.write(a[0]),this._cursor[0]++,this._cursor[0]>=b[0]&&(this._cursor[0]=0,this._cursor[1]++))}};a.computeFontSize=
|
||||
function(){throw Error("Terminal backend has no notion of font size");};a.eventToPosition=function(b,e){return[b,e]};a.computeSize=function(){return[process.stdout.columns,process.stdout.rows]};return c}(I),ia=/%([bc]){([^}]*)}/g;I=Object.freeze({TYPE_TEXT:0,TYPE_NEWLINE:1,TYPE_FG:2,TYPE_BG:3,measure:function(d,c){var a={width:0,height:1};d=V(d,c);for(var b=c=0;b<d.length;b++){var e=d[b];switch(e.type){case 0:c+=e.value.length;break;case 1:a.height++,a.width=Math.max(a.width,c),c=0}}a.width=Math.max(a.width,
|
||||
c);return a},tokenize:V});var v={4:[[0,-1],[1,0],[0,1],[-1,0]],8:[[0,-1],[1,-1],[1,0],[1,1],[0,1],[-1,1],[-1,0],[-1,-1]],6:[[-1,-1],[1,-1],[2,0],[1,1],[-1,1],[-2,0]]},oa={hex:O,rect:F,tile:B,"tile-gl":x,term:u},pa={width:80,height:25,transpose:!1,layout:"rect",fontSize:15,spacing:1,border:0,forceSquareRatio:!1,fontFamily:"monospace",fontStyle:"",fg:"#ccc",bg:"#000",tileWidth:32,tileHeight:32,tileMap:{},tileSet:null,tileColorize:!1},G=function(){function d(a){void 0===a&&(a={});this._data={};this._dirty=
|
||||
!1;this._options={};a=Object.assign({},pa,a);this.setOptions(a);this.DEBUG=this.DEBUG.bind(this);this._tick=this._tick.bind(this);this._backend.schedule(this._tick)}var c=d.prototype;c.DEBUG=function(a,b,e){var f=[this._options.bg,this._options.fg];this.draw(a,b,null,null,f[e%f.length])};c.clear=function(){this._data={};this._dirty=!0};c.setOptions=function(a){Object.assign(this._options,a);if(a.width||a.height||a.fontSize||a.fontFamily||a.spacing||a.layout)a.layout&&(this._backend=new oa[a.layout]),
|
||||
this._backend.setOptions(this._options),this._dirty=!0;return this};c.getOptions=function(){return this._options};c.getContainer=function(){return this._backend.getContainer()};c.computeSize=function(a,b){return this._backend.computeSize(a,b)};c.computeFontSize=function(a,b){return this._backend.computeFontSize(a,b)};c.computeTileSize=function(a,b){return[Math.floor(a/this._options.width),Math.floor(b/this._options.height)]};c.eventToPosition=function(a){if("touches"in a){var b=a.touches[0].clientX;
|
||||
a=a.touches[0].clientY}else b=a.clientX,a=a.clientY;return this._backend.eventToPosition(b,a)};c.draw=function(a,b,e,f,m){f||(f=this._options.fg);m||(m=this._options.bg);var k=a+","+b;this._data[k]=[a,b,e,f,m];!0!==this._dirty&&(this._dirty||(this._dirty={}),this._dirty[k]=!0)};c.drawText=function(a,b,e,f){var m=null,k=null,c=a,d=1;f||(f=this._options.width-a);for(e=V(e,f);e.length;)switch(f=e.shift(),f.type){case 0:for(var l,g=!1,z,y=!1,q=0;q<f.value.length;q++){l=f.value.charCodeAt(q);var p=f.value.charAt(q);
|
||||
z=65280<l&&65377>l||65500<l&&65512>l||65518<l;l=32==p.charCodeAt(0)||12288==p.charCodeAt(0);!y||z||l||c++;z&&!g&&c++;this.draw(c++,b,p,m,k);g=l;y=z}break;case 2:m=f.value||null;break;case 3:k=f.value||null;break;case 1:c=a,b++,d++}return d};c._tick=function(){this._backend.schedule(this._tick);if(this._dirty){if(!0===this._dirty){this._backend.clear();for(var a in this._data)this._draw(a,!1)}else for(var b in this._dirty)this._draw(b,!0);this._dirty=!1}};c._draw=function(a,b){a=this._data[a];a[4]!=
|
||||
this._options.bg&&(b=!0);this._backend.draw(a,b)};return d}();G.Rect=F;G.Hex=O;G.Tile=B;G.TileGL=x;G.Term=u;O=function(){function d(a){this._options={words:!1,order:3,prior:.001};Object.assign(this._options,a);this._suffix=this._boundary=String.fromCharCode(0);this._prefix=[];for(a=0;a<this._options.order;a++)this._prefix.push(this._boundary);this._priorValues={};this._priorValues[this._boundary]=this._options.prior;this._data={}}var c=d.prototype;c.clear=function(){this._data={};this._priorValues=
|
||||
{}};c.generate=function(){for(var a=[this._sample(this._prefix)];a[a.length-1]!=this._boundary;)a.push(this._sample(a));return this._join(a.slice(0,-1))};c.observe=function(a){a=this._split(a);for(var b=0;b<a.length;b++)this._priorValues[a[b]]=this._options.prior;a=this._prefix.concat(a).concat(this._suffix);for(b=this._options.order;b<a.length;b++)for(var e=a.slice(b-this._options.order,b),f=a[b],m=0;m<e.length;m++){var k=e.slice(m);this._observeEvent(k,f)}};c.getStats=function(){var a=[],b=Object.keys(this._priorValues).length;
|
||||
b--;a.push("distinct samples: "+b);b=Object.keys(this._data).length;var e=0,f;for(f in this._data)e+=Object.keys(this._data[f]).length;a.push("dictionary size (contexts): "+b);a.push("dictionary size (events): "+e);return a.join(", ")};c._split=function(a){return a.split(this._options.words?/\s+/:"")};c._join=function(a){return a.join(this._options.words?" ":"")};c._observeEvent=function(a,b){a=this._join(a);a in this._data||(this._data[a]={});a=this._data[a];b in a||(a[b]=0);a[b]++};c._sample=function(a){a=
|
||||
this._backoff(a);a=this._join(a);a=this._data[a];var b={};if(this._options.prior){for(var e in this._priorValues)b[e]=this._priorValues[e];for(var f in a)b[f]+=a[f]}else b=a;return q.getWeightedValue(b)};c._backoff=function(a){for(a.length>this._options.order?a=a.slice(-this._options.order):a.length<this._options.order&&(a=this._prefix.slice(0,this._options.order-a.length).concat(a));!(this._join(a)in this._data)&&0<a.length;)a=a.slice(1);return a};return d}();var ea=function(){function d(){this._time=
|
||||
0;this._events=[];this._eventTimes=[]}var c=d.prototype;c.getTime=function(){return this._time};c.clear=function(){this._events=[];this._eventTimes=[];return this};c.add=function(a,b){for(var e=this._events.length,f=0;f<this._eventTimes.length;f++)if(this._eventTimes[f]>b){e=f;break}this._events.splice(e,0,a);this._eventTimes.splice(e,0,b)};c.get=function(){if(!this._events.length)return null;var a=this._eventTimes.splice(0,1)[0];if(0<a){this._time+=a;for(var b=0;b<this._eventTimes.length;b++)this._eventTimes[b]-=
|
||||
a}return this._events.splice(0,1)[0]};c.getEventTime=function(a){a=this._events.indexOf(a);if(-1!=a)return this._eventTimes[a]};c.remove=function(a){a=this._events.indexOf(a);if(-1==a)return!1;this._remove(a);return!0};c._remove=function(a){this._events.splice(a,1);this._eventTimes.splice(a,1)};return d}();x=function(){function d(){this._queue=new ea;this._repeat=[];this._current=null}var c=d.prototype;c.getTime=function(){return this._queue.getTime()};c.add=function(a,b){b&&this._repeat.push(a);
|
||||
return this};c.getTimeOf=function(a){return this._queue.getEventTime(a)};c.clear=function(){this._queue.clear();this._repeat=[];this._current=null;return this};c.remove=function(a){var b=this._queue.remove(a),e=this._repeat.indexOf(a);-1!=e&&this._repeat.splice(e,1);this._current==a&&(this._current=null);return b};c.next=function(){return this._current=this._queue.get()};return d}();F=function(d){function c(){return d.apply(this,arguments)||this}_inheritsLoose(c,d);var a=c.prototype;a.add=function(b,
|
||||
e){this._queue.add(b,0);return d.prototype.add.call(this,b,e)};a.next=function(){null!==this._current&&-1!=this._repeat.indexOf(this._current)&&this._queue.add(this._current,0);return d.prototype.next.call(this)};return c}(x);B=function(d){function c(){return d.apply(this,arguments)||this}_inheritsLoose(c,d);var a=c.prototype;a.add=function(b,e,a){this._queue.add(b,void 0!==a?a:1/b.getSpeed());return d.prototype.add.call(this,b,e)};a.next=function(){this._current&&-1!=this._repeat.indexOf(this._current)&&
|
||||
this._queue.add(this._current,1/this._current.getSpeed());return d.prototype.next.call(this)};return c}(x);x=function(d){function c(){var b=d.call(this)||this;b._defaultDuration=1;b._duration=b._defaultDuration;return b}_inheritsLoose(c,d);var a=c.prototype;a.add=function(b,e,a){this._queue.add(b,a||this._defaultDuration);return d.prototype.add.call(this,b,e)};a.clear=function(){this._duration=this._defaultDuration;return d.prototype.clear.call(this)};a.remove=function(b){b==this._current&&(this._duration=
|
||||
this._defaultDuration);return d.prototype.remove.call(this,b)};a.next=function(){null!==this._current&&-1!=this._repeat.indexOf(this._current)&&(this._queue.add(this._current,this._duration||this._defaultDuration),this._duration=this._defaultDuration);return d.prototype.next.call(this)};a.setDuration=function(b){this._current&&(this._duration=b);return this};return c}(x);F={Simple:F,Speed:B,Action:x};u=function(){function d(c,a){void 0===a&&(a={});this._lightPasses=c;this._options=Object.assign({topology:8},
|
||||
a)}d.prototype._getCircle=function(c,a,b){var e=[];switch(this._options.topology){case 4:var f=1;var m=[0,1];var k=[v[8][7],v[8][1],v[8][3],v[8][5]];break;case 6:k=v[6];f=1;m=[-1,1];break;case 8:k=v[4];f=2;m=[-1,1];break;default:throw Error("Incorrect topology for FOV computation");}c+=m[0]*b;a+=m[1]*b;for(m=0;m<k.length;m++)for(var h=0;h<b*f;h++)e.push([c,a]),c+=k[m][0],a+=k[m][1];return e};return d}();B=function(d){function c(){return d.apply(this,arguments)||this}_inheritsLoose(c,d);var a=c.prototype;
|
||||
a.compute=function(b,e,a,m){m(b,e,0,1);if(this._lightPasses(b,e))for(var f=[],c,d,l,g,z,y=1;y<=a;y++)for(var q=this._getCircle(b,e,y),p=360/q.length,r=0;r<q.length;r++)if(l=q[r][0],g=q[r][1],c=p*(r-.5),d=c+p,z=!this._lightPasses(l,g),this._visibleCoords(Math.floor(c),Math.ceil(d),z,f)&&m(l,g,y,1),2==f.length&&0==f[0]&&360==f[1])return};a._visibleCoords=function(b,e,a,m){if(0>b)return e=this._visibleCoords(0,e,a,m),b=this._visibleCoords(360+b,360,a,m),e||b;for(var f=0;f<m.length&&m[f]<b;)f++;if(f==
|
||||
m.length)return a&&m.push(b,e),!0;var c=0;if(f%2){for(;f<m.length&&m[f]<e;)f++,c++;if(0==c)return!1;a&&(c%2?m.splice(f-c,c,e):m.splice(f-c,c))}else{for(;f<m.length&&m[f]<e;)f++,c++;if(b==m[f-c]&&1==c)return!1;a&&(c%2?m.splice(f-c,c,b):m.splice(f-c,c,b,e))}return!0};return c}(u);x=function(d){function c(){return d.apply(this,arguments)||this}_inheritsLoose(c,d);var a=c.prototype;a.compute=function(b,e,a,c){c(b,e,0,1);if(this._lightPasses(b,e))for(var f=[],m,d,l,g,z,q=1;q<=a;q++)for(var p=this._getCircle(b,
|
||||
e,q),r=p.length,t=0;t<r;t++)if(m=p[t][0],d=p[t][1],g=[t?2*t-1:2*r-1,2*r],z=[2*t+1,2*r],l=!this._lightPasses(m,d),(l=this._checkVisibility(g,z,l,f))&&c(m,d,q,l),2==f.length&&0==f[0][0]&&f[1][0]==f[1][1])return};a._checkVisibility=function(b,e,a,c){if(b[0]>e[0])return b=this._checkVisibility(b,[b[1],b[1]],a,c),e=this._checkVisibility([0,1],e,a,c),(b+e)/2;for(var f=0,m=!1;f<c.length;){var d=c[f];d=d[0]*b[1]-b[0]*d[1];if(0<=d){0!=d||f%2||(m=!0);break}f++}d=c.length;for(var l=!1;d--;){var g=c[d];g=e[0]*
|
||||
g[1]-g[0]*e[1];if(0<=g){0==g&&d%2&&(l=!0);break}}g=!0;f==d&&(m||l)?g=!1:m&&l&&f+1==d&&d%2?g=!1:f>d&&f%2&&(g=!1);if(!g)return 0;m=d-f+1;if(m%2)f%2?(d=c[f],d=(e[0]*d[1]-d[0]*e[1])/(d[1]*e[1]),a&&c.splice(f,m,e)):(d=c[d],d=(d[0]*b[1]-b[0]*d[1])/(b[1]*d[1]),a&&c.splice(f,m,b));else if(f%2)l=c[f],d=c[d],d=(d[0]*l[1]-l[0]*d[1])/(l[1]*d[1]),a&&c.splice(f,m);else return a&&c.splice(f,m,b,e),1;return d/((e[0]*b[1]-b[0]*e[1])/(b[1]*e[1]))};return c}(u);var C=[[-1,0,0,1],[0,-1,1,0],[0,-1,-1,0],[-1,0,0,-1],[1,
|
||||
0,0,-1],[0,1,-1,0],[0,1,1,0],[1,0,0,1]];u=function(d){function c(){return d.apply(this,arguments)||this}_inheritsLoose(c,d);var a=c.prototype;a.compute=function(b,e,a,c){c(b,e,0,1);for(var f=0;f<C.length;f++)this._renderOctant(b,e,C[f],a,c)};a.compute180=function(b,e,a,c,k){k(b,e,0,1);var f=(c-1+8)%8,m=(c+1+8)%8;this._renderOctant(b,e,C[(c-2+8)%8],a,k);this._renderOctant(b,e,C[f],a,k);this._renderOctant(b,e,C[c],a,k);this._renderOctant(b,e,C[m],a,k)};a.compute90=function(b,e,a,c,k){k(b,e,0,1);var f=
|
||||
(c-1+8)%8;this._renderOctant(b,e,C[c],a,k);this._renderOctant(b,e,C[f],a,k)};a._renderOctant=function(b,e,a,c,k){this._castVisibility(b,e,1,1,0,c+1,a[0],a[1],a[2],a[3],k)};a._castVisibility=function(b,e,a,c,k,d,n,l,g,q,p){if(!(c<k))for(;a<=d;a++){for(var f=-a-1,m=-a,h=!1,W=0;0>=f;){f+=1;var z=b+f*n+m*l,y=e+f*g+m*q,r=(f-.5)/(m+.5),t=(f+.5)/(m-.5);if(!(t>c)){if(r<k)break;f*f+m*m<d*d&&p(z,y,a,1);h?this._lightPasses(z,y)?(h=!1,c=W):W=t:!this._lightPasses(z,y)&&a<d&&(h=!0,this._castVisibility(b,e,a+1,
|
||||
c,r,d,n,l,g,q,p),W=t)}}if(h)break}};return c}(u);B={DiscreteShadowcasting:B,PreciseShadowcasting:x,RecursiveShadowcasting:u};u=function(){function d(c,a){void 0===c&&(c=80);void 0===a&&(a=25);this._width=c;this._height=a}d.prototype._fillMap=function(c){for(var a=[],b=0;b<this._width;b++){a.push([]);for(var e=0;e<this._height;e++)a[b].push(c)}return a};return d}();x=function(d){function c(){return d.apply(this,arguments)||this}_inheritsLoose(c,d);c.prototype.create=function(a){for(var b=this._width-
|
||||
1,e=this._height-1,f=0;f<=b;f++)for(var c=0;c<=e;c++)a(f,c,f&&c&&f<b&&c<e?0:1);return this};return c}(u);var H=function(d){function c(b,e){b=d.call(this,b,e)||this;b._rooms=[];b._corridors=[];return b}_inheritsLoose(c,d);var a=c.prototype;a.getRooms=function(){return this._rooms};a.getCorridors=function(){return this._corridors};return c}(u),D=function(){},P=function(d){function c(b,e,a,c,k,h){var f=d.call(this)||this;f._x1=b;f._y1=e;f._x2=a;f._y2=c;f._doors={};void 0!==k&&void 0!==h&&f.addDoor(k,
|
||||
h);return f}_inheritsLoose(c,d);c.createRandomAt=function(b,e,a,c,k){var f=k.roomWidth[0],d=k.roomWidth[1],m=q.getUniformInt(f,d);f=k.roomHeight[0];d=k.roomHeight[1];k=q.getUniformInt(f,d);if(1==a)return a=e-Math.floor(q.getUniform()*k),new this(b+1,a,b+m,a+k-1,b,e);if(-1==a)return a=e-Math.floor(q.getUniform()*k),new this(b-m,a,b-1,a+k-1,b,e);if(1==c)return a=b-Math.floor(q.getUniform()*m),new this(a,e+1,a+m-1,e+k,b,e);if(-1==c)return a=b-Math.floor(q.getUniform()*m),new this(a,e-k,a+m-1,e-1,b,e);
|
||||
throw Error("dx or dy must be 1 or -1");};c.createRandomCenter=function(b,e,a){var f=a.roomWidth[0],c=a.roomWidth[1],d=q.getUniformInt(f,c);f=a.roomHeight[0];c=a.roomHeight[1];a=q.getUniformInt(f,c);b-=Math.floor(q.getUniform()*d);e-=Math.floor(q.getUniform()*a);return new this(b,e,b+d-1,e+a-1)};c.createRandom=function(b,a,f){var e=f.roomWidth[0],c=f.roomWidth[1],d=q.getUniformInt(e,c);e=f.roomHeight[0];c=f.roomHeight[1];f=q.getUniformInt(e,c);b=b-d-1;a=a-f-1;b=1+Math.floor(q.getUniform()*b);a=1+
|
||||
Math.floor(q.getUniform()*a);return new this(b,a,b+d-1,a+f-1)};var a=c.prototype;a.addDoor=function(b,a){this._doors[b+","+a]=1;return this};a.getDoors=function(b){for(var a in this._doors){var f=a.split(",");b(parseInt(f[0]),parseInt(f[1]))}return this};a.clearDoors=function(){this._doors={};return this};a.addDoors=function(b){for(var a=this._x1-1,f=this._x2+1,c=this._y1-1,d=this._y2+1,h=a;h<=f;h++)for(var n=c;n<=d;n++)if(h==a||h==f||n==c||n==d)b(h,n)||this.addDoor(h,n);return this};a.debug=function(){console.log("room",
|
||||
this._x1,this._y1,this._x2,this._y2)};a.isValid=function(b,a){for(var e=this._x1-1,c=this._x2+1,d=this._y1-1,h=this._y2+1,n=e;n<=c;n++)for(var l=d;l<=h;l++)if(n==e||n==c||l==d||l==h){if(!b(n,l))return!1}else if(!a(n,l))return!1;return!0};a.create=function(b){for(var a=this._x1-1,f=this._x2+1,c=this._y1-1,d=this._y2+1,h,n=a;n<=f;n++)for(var l=c;l<=d;l++)h=n+","+l in this._doors?2:n==a||n==f||l==c||l==d?1:0,b(n,l,h)};a.getCenter=function(){return[Math.round((this._x1+this._x2)/2),Math.round((this._y1+
|
||||
this._y2)/2)]};a.getLeft=function(){return this._x1};a.getRight=function(){return this._x2};a.getTop=function(){return this._y1};a.getBottom=function(){return this._y2};return c}(D),X=function(d){function c(b,a,f,c){var e=d.call(this)||this;e._startX=b;e._startY=a;e._endX=f;e._endY=c;e._endsWithAWall=!0;return e}_inheritsLoose(c,d);c.createRandomAt=function(b,a,f,c,d){d=q.getUniformInt(d.corridorLength[0],d.corridorLength[1]);return new this(b,a,b+f*d,a+c*d)};var a=c.prototype;a.debug=function(){console.log("corridor",
|
||||
this._startX,this._startY,this._endX,this._endY)};a.isValid=function(b,a){var e=this._startX,c=this._startY,d=this._endX-e,h=this._endY-c,n=1+Math.max(Math.abs(d),Math.abs(h));d&&(d/=Math.abs(d));h&&(h/=Math.abs(h));for(var l=h,g=-d,q=!0,p=0;p<n;p++){var r=e+p*d,t=c+p*h;a(r,t)||(q=!1);b(r+l,t+g)||(q=!1);b(r-l,t-g)||(q=!1);if(!q){n=p;this._endX=r-d;this._endY=t-h;break}}if(0==n||1==n&&b(this._endX+d,this._endY+h))return!1;a=!b(this._endX+d+l,this._endY+h+g);l=!b(this._endX+d-l,this._endY+h-g);this._endsWithAWall=
|
||||
b(this._endX+d,this._endY+h);return(a||l)&&this._endsWithAWall?!1:!0};a.create=function(b){var a=this._startX,f=this._startY,c=this._endX-a,d=this._endY-f,h=1+Math.max(Math.abs(c),Math.abs(d));c&&(c/=Math.abs(c));d&&(d/=Math.abs(d));for(var n=0;n<h;n++)b(a+n*c,f+n*d,0);return!0};a.createPriorityWalls=function(b){if(this._endsWithAWall){var a=this._endX-this._startX,f=this._endY-this._startY;a&&(a/=Math.abs(a));f&&(f/=Math.abs(f));var c=f,d=-a;b(this._endX+a,this._endY+f);b(this._endX+c,this._endY+
|
||||
d);b(this._endX-c,this._endY-d)}};return c}(D);D=function(d){function c(b,a,f){b=d.call(this,b,a)||this;b._options={roomWidth:[3,9],roomHeight:[3,5],roomDugPercentage:.1,timeLimit:1E3};Object.assign(b._options,f);b._map=[];b._dug=0;b._roomAttempts=20;b._corridorAttempts=20;b._connected=[];b._unconnected=[];b._digCallback=b._digCallback.bind(_assertThisInitialized(_assertThisInitialized(b)));b._canBeDugCallback=b._canBeDugCallback.bind(_assertThisInitialized(_assertThisInitialized(b)));b._isWallCallback=
|
||||
b._isWallCallback.bind(_assertThisInitialized(_assertThisInitialized(b)));return b}_inheritsLoose(c,d);var a=c.prototype;a.create=function(b){for(var a=Date.now();;){if(Date.now()-a>this._options.timeLimit)return null;this._map=this._fillMap(1);this._dug=0;this._rooms=[];this._unconnected=[];this._generateRooms();if(!(2>this._rooms.length)&&this._generateCorridors())break}if(b)for(a=0;a<this._width;a++)for(var f=0;f<this._height;f++)b(a,f,this._map[a][f]);return this};a._generateRooms=function(){var b=
|
||||
this._width-2,a=this._height-2;do{var f=this._generateRoom();if(this._dug/(b*a)>this._options.roomDugPercentage)break}while(f)};a._generateRoom=function(){for(var b=0;b<this._roomAttempts;){b++;var a=P.createRandom(this._width,this._height,this._options);if(a.isValid(this._isWallCallback,this._canBeDugCallback))return a.create(this._digCallback),this._rooms.push(a),a}return null};a._generateCorridors=function(){for(var b=0;b<this._corridorAttempts;){b++;this._corridors=[];this._map=this._fillMap(1);
|
||||
for(var a=0;a<this._rooms.length;a++){var f=this._rooms[a];f.clearDoors();f.create(this._digCallback)}this._unconnected=q.shuffle(this._rooms.slice());this._connected=[];for(this._unconnected.length&&this._connected.push(this._unconnected.pop());;){a=q.getItem(this._connected);if(!a)break;a=this._closestRoom(this._unconnected,a);if(!a)break;f=this._closestRoom(this._connected,a);if(!f)break;if(!this._connectRooms(a,f))break;if(!this._unconnected.length)return!0}}return!1};a._closestRoom=function(b,
|
||||
a){var e=Infinity;a=a.getCenter();for(var c=null,d=0;d<b.length;d++){var h=b[d],n=h.getCenter(),l=n[0]-a[0];n=n[1]-a[1];l=l*l+n*n;l<e&&(e=l,c=h)}return c};a._connectRooms=function(b,a){var e=b.getCenter(),c=a.getCenter(),d=c[0]-e[0];e=c[1]-e[1];if(Math.abs(d)<Math.abs(e)){d=0<e?2:0;var h=(d+2)%4;var n=a.getLeft();var l=a.getRight();e=0}else d=0<d?1:3,h=(d+2)%4,n=a.getTop(),l=a.getBottom(),e=1;d=this._placeInWall(b,d);if(!d)return!1;if(d[e]>=n&&d[e]<=l){c=d.slice();n=0;switch(h){case 0:n=a.getTop()-
|
||||
1;break;case 1:n=a.getRight()+1;break;case 2:n=a.getBottom()+1;break;case 3:n=a.getLeft()-1}c[(e+1)%2]=n;this._digLine([d,c])}else if(d[e]<n-1||d[e]>l+1){c=d[e]-c[e];n=0;switch(h){case 0:case 1:n=0>c?3:1;break;case 2:case 3:n=0>c?1:3}c=this._placeInWall(a,(h+n)%4);if(!c)return!1;h=[0,0];h[e]=d[e];e=(e+1)%2;h[e]=c[e];this._digLine([d,h,c])}else{n=(e+1)%2;c=this._placeInWall(a,h);if(!c)return!1;h=Math.round((c[n]+d[n])/2);l=[0,0];var g=[0,0];l[e]=d[e];l[n]=h;g[e]=c[e];g[n]=h;this._digLine([d,l,g,c])}b.addDoor(d[0],
|
||||
d[1]);a.addDoor(c[0],c[1]);e=this._unconnected.indexOf(b);-1!=e&&(this._unconnected.splice(e,1),this._connected.push(b));e=this._unconnected.indexOf(a);-1!=e&&(this._unconnected.splice(e,1),this._connected.push(a));return!0};a._placeInWall=function(b,a){var e=[0,0],c=[0,0],d=0;switch(a){case 0:c=[1,0];e=[b.getLeft(),b.getTop()-1];d=b.getRight()-b.getLeft()+1;break;case 1:c=[0,1];e=[b.getRight()+1,b.getTop()];d=b.getBottom()-b.getTop()+1;break;case 2:c=[1,0];e=[b.getLeft(),b.getBottom()+1];d=b.getRight()-
|
||||
b.getLeft()+1;break;case 3:c=[0,1],e=[b.getLeft()-1,b.getTop()],d=b.getBottom()-b.getTop()+1}b=[];a=-2;for(var h=0;h<d;h++){var n=e[0]+h*c[0],l=e[1]+h*c[1];b.push(null);1==this._map[n][l]?a!=h-1&&(b[h]=[n,l]):(a=h)&&(b[h-1]=null)}for(e=b.length-1;0<=e;e--)b[e]||b.splice(e,1);return b.length?q.getItem(b):null};a._digLine=function(b){for(var a=1;a<b.length;a++){var f=b[a-1],c=b[a];f=new X(f[0],f[1],c[0],c[1]);f.create(this._digCallback);this._corridors.push(f)}};a._digCallback=function(b,a,f){this._map[b][a]=
|
||||
f;0==f&&this._dug++};a._isWallCallback=function(b,a){return 0>b||0>a||b>=this._width||a>=this._height?!1:1==this._map[b][a]};a._canBeDugCallback=function(b,a){return 1>b||1>a||b+1>=this._width||a+1>=this._height?!1:1==this._map[b][a]};return c}(H);var E=function(d){function c(b,a,f){void 0===f&&(f={});b=d.call(this,b,a)||this;b._options={born:[5,6,7,8],survive:[4,5,6,7,8],topology:8};b.setOptions(f);b._dirs=v[b._options.topology];b._map=b._fillMap(0);return b}_inheritsLoose(c,d);var a=c.prototype;
|
||||
a.randomize=function(b){for(var a=0;a<this._width;a++)for(var f=0;f<this._height;f++)this._map[a][f]=q.getUniform()<b?1:0;return this};a.setOptions=function(b){Object.assign(this._options,b)};a.set=function(b,a,f){this._map[b][a]=f};a.create=function(b){for(var a=this._fillMap(0),f=this._options.born,c=this._options.survive,d=0;d<this._height;d++){var h=1,n=0;6==this._options.topology&&(h=2,n=d%2);for(;n<this._width;n+=h){var l=this._map[n][d],g=this._getNeighbors(n,d);l&&-1!=c.indexOf(g)?a[n][d]=
|
||||
1:l||-1==f.indexOf(g)||(a[n][d]=1)}}this._map=a;b&&this._serviceCallback(b)};a._serviceCallback=function(b){for(var a=0;a<this._height;a++){var f=1,c=0;6==this._options.topology&&(f=2,c=a%2);for(;c<this._width;c+=f)b(c,a,this._map[c][a])}};a._getNeighbors=function(b,a){for(var e=0,c=0;c<this._dirs.length;c++){var d=this._dirs[c],h=b+d[0];d=a+d[1];0>h||h>=this._width||0>d||d>=this._height||(e+=1==this._map[h][d]?1:0)}return e};a.connect=function(b,a,f){a||(a=0);var e=[],c={},d=1,n=[0,0];6==this._options.topology&&
|
||||
(d=2,n=[0,1]);for(var l=0;l<this._height;l++)for(var g=n[l%2];g<this._width;g+=d)if(this._freeSpace(g,l,a)){var p=[g,l];c[this._pointKey(p)]=p;e.push([g,l])}d=e[q.getUniformInt(0,e.length-1)];n=this._pointKey(d);e={};e[n]=d;delete c[n];for(this._findConnected(e,c,[d],!1,a);0<Object.keys(c).length;){n=this._getFromTo(e,c);d=n[0];l=n[1];n={};n[this._pointKey(d)]=d;this._findConnected(n,c,[d],!0,a);(6==this._options.topology?this._tunnelToConnected6:this._tunnelToConnected).call(this,l,d,e,c,a,f);for(var r in n)d=
|
||||
n[r],this._map[d[0]][d[1]]=a,e[r]=d,delete c[r]}b&&this._serviceCallback(b)};a._getFromTo=function(b,a){for(var e=[0,0],c=[0,0],d,h=Object.keys(b),n=Object.keys(a),l=0;5>l&&!(h.length<n.length?(e=h,c=b[e[q.getUniformInt(0,e.length-1)]],e=this._getClosest(c,a)):(e=n,e=a[e[q.getUniformInt(0,e.length-1)]],c=this._getClosest(e,b)),d=(e[0]-c[0])*(e[0]-c[0])+(e[1]-c[1])*(e[1]-c[1]),64>d);l++);return[e,c]};a._getClosest=function(b,a){var e=null,c=null,d;for(d in a){var h=a[d],n=(h[0]-b[0])*(h[0]-b[0])+(h[1]-
|
||||
b[1])*(h[1]-b[1]);if(null==c||n<c)c=n,e=h}return e};a._findConnected=function(b,a,f,c,d){for(;0<f.length;){var e=f.splice(0,1)[0];e=6==this._options.topology?[[e[0]+2,e[1]],[e[0]+1,e[1]-1],[e[0]-1,e[1]-1],[e[0]-2,e[1]],[e[0]-1,e[1]+1],[e[0]+1,e[1]+1]]:[[e[0]+1,e[1]],[e[0]-1,e[1]],[e[0],e[1]+1],[e[0],e[1]-1]];for(var m=0;m<e.length;m++){var k=this._pointKey(e[m]);null==b[k]&&this._freeSpace(e[m][0],e[m][1],d)&&(b[k]=e[m],c||delete a[k],f.push(e[m]))}}};a._tunnelToConnected=function(b,a,f,c,d,h){if(a[0]<
|
||||
b[0]){var e=a;var m=b}else e=b,m=a;for(var k=e[0];k<=m[0];k++){this._map[k][e[1]]=d;var g=[k,e[1]],q=this._pointKey(g);f[q]=g;delete c[q]}h&&e[0]<m[0]&&h(e,[m[0],e[1]]);k=m[0];a[1]<b[1]?(e=a,m=b):(e=b,m=a);for(b=e[1];b<m[1];b++)this._map[k][b]=d,a=[k,b],g=this._pointKey(a),f[g]=a,delete c[g];h&&e[1]<m[1]&&h([m[0],e[1]],[m[0],m[1]])};a._tunnelToConnected6=function(b,a,c,d,k,h){if(a[0]<b[0]){var e=a;var f=b}else e=b,f=a;var m=e[0];for(e=e[1];m!=f[0]||e!=f[1];){var g=2;e<f[1]?(e++,g=1):e>f[1]&&(e--,
|
||||
g=1);m=m<f[0]?m+g:m>f[0]?m-g:f[1]%2?m-g:m+g;this._map[m][e]=k;g=[m,e];var q=this._pointKey(g);c[q]=g;delete d[q]}h&&h(a,b)};a._freeSpace=function(b,a,f){return 0<=b&&b<this._width&&0<=a&&a<this._height&&this._map[b][a]==f};a._pointKey=function(b){return b[0]+"."+b[1]};return c}(u),qa={room:P,corridor:X};H=function(d){function c(b,a,f){void 0===f&&(f={});b=d.call(this,b,a)||this;b._options=Object.assign({roomWidth:[3,9],roomHeight:[3,5],corridorLength:[3,10],dugPercentage:.2,timeLimit:1E3},f);b._features=
|
||||
{room:4,corridor:4};b._map=[];b._featureAttempts=20;b._walls={};b._dug=0;b._digCallback=b._digCallback.bind(_assertThisInitialized(_assertThisInitialized(b)));b._canBeDugCallback=b._canBeDugCallback.bind(_assertThisInitialized(_assertThisInitialized(b)));b._isWallCallback=b._isWallCallback.bind(_assertThisInitialized(_assertThisInitialized(b)));b._priorityWallCallback=b._priorityWallCallback.bind(_assertThisInitialized(_assertThisInitialized(b)));return b}_inheritsLoose(c,d);var a=c.prototype;a.create=
|
||||
function(b){this._rooms=[];this._corridors=[];this._map=this._fillMap(1);this._walls={};this._dug=0;var a=(this._width-2)*(this._height-2);this._firstRoom();var f=Date.now();do{var c=0;if(Date.now()-f>this._options.timeLimit)break;var d=this._findWall();if(!d)break;var h=d.split(",");d=parseInt(h[0]);h=parseInt(h[1]);var g=this._getDiggingDirection(d,h);if(g){var l=0;do if(l++,this._tryFeature(d,h,g[0],g[1])){this._removeSurroundingWalls(d,h);this._removeSurroundingWalls(d-g[0],h-g[1]);break}while(l<
|
||||
this._featureAttempts);for(var q in this._walls)1<this._walls[q]&&c++}}while(this._dug/a<this._options.dugPercentage||c);this._addDoors();if(b)for(a=0;a<this._width;a++)for(f=0;f<this._height;f++)b(a,f,this._map[a][f]);this._walls={};this._map=[];return this};a._digCallback=function(b,a,f){0==f||2==f?(this._map[b][a]=0,this._dug++):this._walls[b+","+a]=1};a._isWallCallback=function(b,a){return 0>b||0>a||b>=this._width||a>=this._height?!1:1==this._map[b][a]};a._canBeDugCallback=function(b,a){return 1>
|
||||
b||1>a||b+1>=this._width||a+1>=this._height?!1:1==this._map[b][a]};a._priorityWallCallback=function(b,a){this._walls[b+","+a]=2};a._firstRoom=function(){var b=P.createRandomCenter(Math.floor(this._width/2),Math.floor(this._height/2),this._options);this._rooms.push(b);b.create(this._digCallback)};a._findWall=function(){var b=[],a=[],f;for(f in this._walls)2==this._walls[f]?a.push(f):b.push(f);b=a.length?a:b;if(!b.length)return null;b=q.getItem(b.sort());delete this._walls[b];return b};a._tryFeature=
|
||||
function(b,a,f,c){var e=q.getWeightedValue(this._features);b=qa[e].createRandomAt(b,a,f,c,this._options);if(!b.isValid(this._isWallCallback,this._canBeDugCallback))return!1;b.create(this._digCallback);b instanceof P&&this._rooms.push(b);b instanceof X&&(b.createPriorityWalls(this._priorityWallCallback),this._corridors.push(b));return!0};a._removeSurroundingWalls=function(b,a){for(var e=v[4],c=0;c<e.length;c++){var d=e[c],h=b+d[0],g=a+d[1];delete this._walls[h+","+g];h=b+2*d[0];g=a+2*d[1];delete this._walls[h+
|
||||
","+g]}};a._getDiggingDirection=function(b,a){if(0>=b||0>=a||b>=this._width-1||a>=this._height-1)return null;for(var e=null,c=v[4],d=0;d<c.length;d++){var h=c[d];if(!this._map[b+h[0]][a+h[1]]){if(e)return null;e=h}}return e?[-e[0],-e[1]]:null};a._addDoors=function(){function b(b,e){return 1==a[b][e]}for(var a=this._map,c=0;c<this._rooms.length;c++){var d=this._rooms[c];d.clearDoors();d.addDoors(b)}};return c}(H);var ra=function(d){function c(){return d.apply(this,arguments)||this}_inheritsLoose(c,
|
||||
d);c.prototype.create=function(a){for(var b=this._fillMap(1),e=Math.ceil((this._width-2)/2),c=[],d=[],k=0;k<e;k++)c.push(k),d.push(k);c.push(e-1);for(k=1;k+3<this._height;k+=2)for(var h=0;h<e;h++){var g=2*h+1,l=k;b[g][l]=0;h!=c[h+1]&&.375<q.getUniform()&&(ca(h,c,d),b[g+1][l]=0);h!=c[h]&&.375<q.getUniform()?da(h,c,d):b[g][l+1]=0}for(h=0;h<e;h++)g=2*h+1,l=k,b[g][l]=0,h!=c[h+1]&&(h==c[h]||.375<q.getUniform())&&(ca(h,c,d),b[g+1][l]=0),da(h,c,d);for(e=0;e<this._width;e++)for(c=0;c<this._height;c++)a(e,
|
||||
c,b[e][c]);return this};return c}(u),sa=function(d){function c(){var b=d.apply(this,arguments)||this;b._stack=[];b._map=[];return b}_inheritsLoose(c,d);var a=c.prototype;a.create=function(b){var a=this._width,c=this._height;this._map=[];for(var d=0;d<a;d++){this._map.push([]);for(var k=0;k<c;k++)this._map[d].push(0==d||0==k||d+1==a||k+1==c?1:0)}this._stack=[[1,1,a-2,c-2]];this._process();for(d=0;d<a;d++)for(k=0;k<c;k++)b(d,k,this._map[d][k]);this._map=[];return this};a._process=function(){for(;this._stack.length;){var b=
|
||||
this._stack.shift();this._partitionRoom(b)}};a._partitionRoom=function(b){for(var a=[],c=[],d=b[0]+1;d<b[2];d++){var k=this._map[d][b[3]+1];!this._map[d][b[1]-1]||!k||d%2||a.push(d)}for(d=b[1]+1;d<b[3];d++)k=this._map[b[2]+1][d],!this._map[b[0]-1][d]||!k||d%2||c.push(d);if(a.length&&c.length){a=q.getItem(a);c=q.getItem(c);this._map[a][c]=1;d=[];k=[];d.push(k);for(var h=b[0];h<a;h++)this._map[h][c]=1,k.push([h,c]);k=[];d.push(k);for(h=a+1;h<=b[2];h++)this._map[h][c]=1,k.push([h,c]);k=[];d.push(k);
|
||||
for(h=b[1];h<c;h++)this._map[a][h]=1,k.push([a,h]);k=[];d.push(k);for(h=c+1;h<=b[3];h++)this._map[a][h]=1,k.push([a,h]);k=q.getItem(d);for(h=0;h<d.length;h++){var g=d[h];g!=k&&(g=q.getItem(g),this._map[g[0]][g[1]]=0)}this._stack.push([b[0],b[1],a-1,c-1]);this._stack.push([a+1,b[1],b[2],c-1]);this._stack.push([b[0],c+1,a-1,b[3]]);this._stack.push([a+1,c+1,b[2],b[3]])}};return c}(u),ta=function(d){function c(b,a,c){void 0===c&&(c=0);b=d.call(this,b,a)||this;b._regularity=c;b._map=[];return b}_inheritsLoose(c,
|
||||
d);var a=c.prototype;a.create=function(b){var a=this._width,c=this._height,d=this._fillMap(1);a-=a%2?1:2;c-=c%2?1:2;var k=0,h=[[0,0],[0,0],[0,0],[0,0]];do{var g=1+2*Math.floor(q.getUniform()*(a-1)/2);var l=1+2*Math.floor(q.getUniform()*(c-1)/2);k||(d[g][l]=0);if(!d[g][l]){this._randomize(h);do{0==Math.floor(q.getUniform()*(this._regularity+1))&&this._randomize(h);var p=!0;for(var r=0;4>r;r++){var t=g+2*h[r][0];var A=l+2*h[r][1];if(this._isFree(d,t,A,a,c)){d[t][A]=0;d[g+h[r][0]][l+h[r][1]]=0;g=t;l=
|
||||
A;p=!1;k++;break}}}while(!p)}}while(k+1<a*c/4);for(a=0;a<this._width;a++)for(c=0;c<this._height;c++)b(a,c,d[a][c]);this._map=[];return this};a._randomize=function(b){for(var a=0;4>a;a++)b[a][0]=0,b[a][1]=0;switch(Math.floor(4*q.getUniform())){case 0:b[0][0]=-1;b[1][0]=1;b[2][1]=-1;b[3][1]=1;break;case 1:b[3][0]=-1;b[2][0]=1;b[1][1]=-1;b[0][1]=1;break;case 2:b[2][0]=-1;b[3][0]=1;b[0][1]=-1;b[1][1]=1;break;case 3:b[1][0]=-1,b[0][0]=1,b[3][1]=-1,b[2][1]=1}};a._isFree=function(b,a,c,d,k){return 1>a||
|
||||
1>c||a>=d||c>=k?!1:b[a][c]};return c}(u);u=function(d){function c(b,a,c){b=d.call(this,b,a)||this;b.map=[];b.rooms=[];b.connectedCells=[];c=Object.assign({cellWidth:3,cellHeight:3},c);c.hasOwnProperty("roomWidth")||(c.roomWidth=b._calculateRoomSize(b._width,c.cellWidth));c.hasOwnProperty("roomHeight")||(c.roomHeight=b._calculateRoomSize(b._height,c.cellHeight));b._options=c;return b}_inheritsLoose(c,d);var a=c.prototype;a.create=function(b){this.map=this._fillMap(1);this.rooms=[];this.connectedCells=
|
||||
[];this._initRooms();this._connectRooms();this._connectUnconnectedRooms();this._createRandomRoomConnections();this._createRooms();this._createCorridors();if(b)for(var a=0;a<this._width;a++)for(var c=0;c<this._height;c++)b(a,c,this.map[a][c]);return this};a._calculateRoomSize=function(b,a){var c=Math.floor(b/a*.8);b=Math.floor(b/a*.25);2>b&&(b=2);2>c&&(c=2);return[b,c]};a._initRooms=function(){for(var b=0;b<this._options.cellWidth;b++){this.rooms.push([]);for(var a=0;a<this._options.cellHeight;a++)this.rooms[b].push({x:0,
|
||||
y:0,width:0,height:0,connections:[],cellx:b,celly:a})}};a._connectRooms=function(){var b=q.getUniformInt(0,this._options.cellWidth-1),a=q.getUniformInt(0,this._options.cellHeight-1);do{var c=[0,2,4,6];c=q.shuffle(c);do{var d=!1;var k=c.pop();var h=b+v[8][k][0];k=a+v[8][k][1];if(!(0>h||h>=this._options.cellWidth||0>k||k>=this._options.cellHeight)){var g=this.rooms[b][a];if(0<g.connections.length&&g.connections[0][0]==h&&g.connections[0][1]==k)break;g=this.rooms[h][k];0==g.connections.length&&(g.connections.push([b,
|
||||
a]),this.connectedCells.push([h,k]),b=h,a=k,d=!0)}}while(0<c.length&&0==d)}while(0<c.length)};a._connectUnconnectedRooms=function(){var b=this._options.cellWidth,a=this._options.cellHeight;this.connectedCells=q.shuffle(this.connectedCells);for(var c,d,k,h=0;h<this._options.cellWidth;h++)for(var g=0;g<this._options.cellHeight;g++)if(c=this.rooms[h][g],0==c.connections.length){var l=[0,2,4,6];l=q.shuffle(l);k=!1;do{var p=l.pop(),r=h+v[8][p][0];p=g+v[8][p][1];if(!(0>r||r>=b||0>p||p>=a)){d=this.rooms[r][p];
|
||||
k=!0;if(0==d.connections.length)break;for(r=0;r<d.connections.length;r++)if(d.connections[r][0]==h&&d.connections[r][1]==g){k=!1;break}if(k)break}}while(l.length);k?c.connections.push([d.cellx,d.celly]):console.log("-- Unable to connect room.")}};a._createRandomRoomConnections=function(){};a._createRooms=function(){for(var b=this._width,a=this._height,c=this._options.cellWidth,d=this._options.cellHeight,k=Math.floor(this._width/c),h=Math.floor(this._height/d),g,l,p=this._options.roomWidth,r=this._options.roomHeight,
|
||||
t,A,u,v=0;v<c;v++)for(var w=0;w<d;w++){t=k*v;A=h*w;0==t&&(t=1);0==A&&(A=1);g=q.getUniformInt(p[0],p[1]);l=q.getUniformInt(r[0],r[1]);if(0<w)for(u=this.rooms[v][w-1];3>A-(u.y+u.height);)A++;if(0<v)for(u=this.rooms[v-1][w];3>t-(u.x+u.width);)t++;u=Math.round(q.getUniformInt(0,k-g)/2);for(var x=Math.round(q.getUniformInt(0,h-l)/2);t+u+g>=b;)u?u--:g--;for(;A+x+l>=a;)x?x--:l--;t+=u;A+=x;this.rooms[v][w].x=t;this.rooms[v][w].y=A;this.rooms[v][w].width=g;this.rooms[v][w].height=l;for(u=t;u<t+g;u++)for(x=
|
||||
A;x<A+l;x++)this.map[u][x]=0}};a._getWallPosition=function(b,a){if(1==a||3==a){var c=q.getUniformInt(b.x+1,b.x+b.width-2);if(1==a){var e=b.y-2;b=e+1}else e=b.y+b.height+1,b=e-1;this.map[c][b]=0}else e=q.getUniformInt(b.y+1,b.y+b.height-2),2==a?(c=b.x+b.width+1,b=c-1):(c=b.x-2,b=c+1),this.map[b][e]=0;return[c,e]};a._drawCorridor=function(b,a){var c=a[0]-b[0],e=a[1]-b[1];a=b[0];b=b[1];var d=[];var h=Math.abs(c);var g=Math.abs(e);var l=q.getUniform();var p=1-l;c=0<c?2:6;e=0<e?4:0;h<g?(l=Math.ceil(g*
|
||||
l),d.push([e,l]),d.push([c,h]),l=Math.floor(g*p),d.push([e,l])):(l=Math.ceil(h*l),d.push([c,l]),d.push([e,g]),l=Math.floor(h*p),d.push([c,l]));for(this.map[a][b]=0;0<d.length;)for(h=d.pop();0<h[1];)a+=v[8][h[0]][0],b+=v[8][h[0]][1],this.map[a][b]=0,--h[1]};a._createCorridors=function(){for(var a=this._options.cellWidth,c=this._options.cellHeight,d,g,k,h,n=0;n<a;n++)for(var l=0;l<c;l++){d=this.rooms[n][l];for(var p=0;p<d.connections.length;p++)g=d.connections[p],g=this.rooms[g[0]][g[1]],g.cellx>d.cellx?
|
||||
(k=2,h=4):g.cellx<d.cellx?(k=4,h=2):g.celly>d.celly?(k=3,h=1):(k=1,h=3),this._drawCorridor(this._getWallPosition(d,k),this._getWallPosition(g,h))}};return c}(u);x={Arena:x,Uniform:D,Cellular:E,Digger:H,EllerMaze:ra,DividedMaze:sa,IceyMaze:ta,Rogue:u};var ua=.5*(Math.sqrt(3)-1),K=(3-Math.sqrt(3))/6;u={Simplex:function(d){function c(a){void 0===a&&(a=256);var b=d.call(this)||this;b._gradients=[[0,-1],[1,-1],[1,0],[1,1],[0,1],[-1,1],[-1,0],[-1,-1]];for(var c=[],f=0;f<a;f++)c.push(f);c=q.shuffle(c);b._perms=
|
||||
[];b._indexes=[];for(f=0;f<2*a;f++)b._perms.push(c[f%a]),b._indexes.push(b._perms[f]%b._gradients.length);return b}_inheritsLoose(c,d);c.prototype.get=function(a,b){var c=this._perms,d=this._indexes,g=c.length/2,k=0,h=0,n=0,l=(a+b)*ua,q=Math.floor(a+l);var r=Math.floor(b+l);var t=(q+r)*K;l=a-(q-t);var u=b-(r-t);if(l>u){var v=1;var w=0}else v=0,w=1;t=l-v+K;var x=u-w+K;b=l-1+2*K;a=u-1+2*K;q=p(q,g);g=p(r,g);var B=.5-l*l-u*u;0<=B&&(B*=B,r=d[q+c[g]],k=this._gradients[r],k=B*B*(k[0]*l+k[1]*u));l=.5-t*t-
|
||||
x*x;0<=l&&(l*=l,r=d[q+v+c[g+w]],h=this._gradients[r],h=l*l*(h[0]*t+h[1]*x));l=.5-b*b-a*a;0<=l&&(l*=l,r=d[q+1+c[g+1]],c=this._gradients[r],n=l*l*(c[0]*b+c[1]*a));return 70*(k+h+n)};return c}(function(){})};E=function(){function d(c,a,b,d){void 0===d&&(d={});this._toX=c;this._toY=a;this._passableCallback=b;this._options=Object.assign({topology:8},d);this._dirs=v[this._options.topology];8==this._options.topology&&(this._dirs=[this._dirs[0],this._dirs[2],this._dirs[4],this._dirs[6],this._dirs[1],this._dirs[3],
|
||||
this._dirs[5],this._dirs[7]])}d.prototype._getNeighbors=function(c,a){for(var b=[],d=0;d<this._dirs.length;d++){var f=this._dirs[d],g=c+f[0];f=a+f[1];this._passableCallback(g,f)&&b.push([g,f])}return b};return d}();D=function(d){function c(a,c,f,g){f=d.call(this,a,c,f,g)||this;f._computed={};f._todo=[];f._add(a,c,null);return f}_inheritsLoose(c,d);var a=c.prototype;a.compute=function(a,c,d){var b=a+","+c;b in this._computed||this._compute(a,c);if(b in this._computed)for(a=this._computed[b];a;)d(a.x,
|
||||
a.y),a=a.prev};a._compute=function(a,c){for(;this._todo.length;){var b=this._todo.shift();if(b.x==a&&b.y==c)break;for(var d=this._getNeighbors(b.x,b.y),e=0;e<d.length;e++){var g=d[e],n=g[0];g=g[1];n+","+g in this._computed||this._add(n,g,b)}}};a._add=function(a,c,d){d={x:a,y:c,prev:d};this._computed[a+","+c]=d;this._todo.push(d)};return c}(E);E=function(d){function c(a,c,f,g){void 0===g&&(g={});a=d.call(this,a,c,f,g)||this;a._todo=[];a._done={};return a}_inheritsLoose(c,d);var a=c.prototype;a.compute=
|
||||
function(a,c,d){this._todo=[];this._done={};this._fromX=a;this._fromY=c;for(this._add(this._toX,this._toY,null);this._todo.length;){var b=this._todo.shift(),e=b.x+","+b.y;if(!(e in this._done)){this._done[e]=b;if(b.x==a&&b.y==c)break;e=this._getNeighbors(b.x,b.y);for(var f=0;f<e.length;f++){var g=e[f],l=g[0];g=g[1];l+","+g in this._done||this._add(l,g,b)}}}if(a=this._done[a+","+c])for(;a;)d(a.x,a.y),a=a.prev};a._add=function(a,c,d){var b=this._distance(a,c);a={x:a,y:c,prev:d,g:d?d.g+1:0,h:b};c=a.g+
|
||||
a.h;for(d=0;d<this._todo.length;d++){var e=this._todo[d],f=e.g+e.h;if(c<f||c==f&&b<e.h){this._todo.splice(d,0,a);return}}this._todo.push(a)};a._distance=function(a,c){switch(this._options.topology){case 4:return Math.abs(a-this._fromX)+Math.abs(c-this._fromY);case 6:return c=Math.abs(c-this._fromY),c+Math.max(0,(Math.abs(a-this._fromX)-c)/2);case 8:return Math.max(Math.abs(a-this._fromX),Math.abs(c-this._fromY))}};return c}(E);D={Dijkstra:D,AStar:E};E=function(){function d(a){this._scheduler=a;this._lock=
|
||||
1}var c=d.prototype;c.start=function(){return this.unlock()};c.lock=function(){this._lock++;return this};c.unlock=function(){if(!this._lock)throw Error("Cannot unlock unlocked engine");for(this._lock--;!this._lock;){var a=this._scheduler.next();if(!a)return this.lock();(a=a.act())&&a.then&&(this.lock(),a.then(this.unlock.bind(this)))}return this};return d}();H=function(){function d(a,b){void 0===b&&(b={});this._reflectivityCallback=a;this._options={};b=Object.assign({passes:1,emissionThreshold:100,
|
||||
range:10},b);this._lights={};this._reflectivityCache={};this._fovCache={};this.setOptions(b)}var c=d.prototype;c.setOptions=function(a){Object.assign(this._options,a);a&&a.range&&this.reset();return this};c.setFOV=function(a){this._fov=a;this._fovCache={};return this};c.setLight=function(a,b,c){a=a+","+b;c?this._lights[a]="string"==typeof c?L(c):c:delete this._lights[a];return this};c.clearLights=function(){this._lights={}};c.reset=function(){this._reflectivityCache={};this._fovCache={};return this};
|
||||
c.compute=function(a){var b={},c={},d={},g;for(g in this._lights){var k=this._lights[g];c[g]=[0,0,0];Y(c[g],k)}for(g=0;g<this._options.passes;g++)this._emitLight(c,d,b),g+1!=this._options.passes&&(c=this._computeEmitters(d,b));for(var h in d)c=h.split(","),b=parseInt(c[0]),c=parseInt(c[1]),a(b,c,d[h]);return this};c._emitLight=function(a,b,c){for(var d in a){var e=d.split(","),g=parseInt(e[0]);e=parseInt(e[1]);this._emitLightFromCell(g,e,a[d],b);c[d]=1}return this};c._computeEmitters=function(a,b){var c=
|
||||
{},d;for(d in a)if(!(d in b)){var g=a[d];if(d in this._reflectivityCache)var k=this._reflectivityCache[d];else{var h=d.split(",");k=parseInt(h[0]);h=parseInt(h[1]);k=this._reflectivityCallback(k,h);this._reflectivityCache[d]=k}if(0!=k){h=[0,0,0];for(var n=0,l=0;3>l;l++){var p=Math.round(g[l]*k);h[l]=p;n+=p}n>this._options.emissionThreshold&&(c[d]=h)}}return c};c._emitLightFromCell=function(a,b,c,d){var e=a+","+b;a=e in this._fovCache?this._fovCache[e]:this._updateFOV(a,b);for(var f in a){b=a[f];f in
|
||||
d?e=d[f]:(e=[0,0,0],d[f]=e);for(var g=0;3>g;g++)e[g]+=Math.round(c[g]*b)}return this};c._updateFOV=function(a,b){var c={};this._fovCache[a+","+b]=c;var d=this._options.range;this._fov.compute(a,b,d,function(a,b,e,f){e=f*(1-e/d);0!=e&&(c[a+","+b]=e)}.bind(this));return c};return d}();g.Util=ka;g.Color=J;g.Text=I;g.RNG=q;g.Display=G;g.StringGenerator=O;g.EventQueue=ea;g.Scheduler=F;g.FOV=B;g.Map=x;g.Noise=u;g.Path=D;g.Engine=E;g.Lighting=H;g.DEFAULT_WIDTH=80;g.DEFAULT_HEIGHT=25;g.DIRS=v;g.KEYS={VK_CANCEL:3,
|
||||
VK_HELP:6,VK_BACK_SPACE:8,VK_TAB:9,VK_CLEAR:12,VK_RETURN:13,VK_ENTER:14,VK_SHIFT:16,VK_CONTROL:17,VK_ALT:18,VK_PAUSE:19,VK_CAPS_LOCK:20,VK_ESCAPE:27,VK_SPACE:32,VK_PAGE_UP:33,VK_PAGE_DOWN:34,VK_END:35,VK_HOME:36,VK_LEFT:37,VK_UP:38,VK_RIGHT:39,VK_DOWN:40,VK_PRINTSCREEN:44,VK_INSERT:45,VK_DELETE:46,VK_0:48,VK_1:49,VK_2:50,VK_3:51,VK_4:52,VK_5:53,VK_6:54,VK_7:55,VK_8:56,VK_9:57,VK_COLON:58,VK_SEMICOLON:59,VK_LESS_THAN:60,VK_EQUALS:61,VK_GREATER_THAN:62,VK_QUESTION_MARK:63,VK_AT:64,VK_A:65,VK_B:66,VK_C:67,
|
||||
VK_D:68,VK_E:69,VK_F:70,VK_G:71,VK_H:72,VK_I:73,VK_J:74,VK_K:75,VK_L:76,VK_M:77,VK_N:78,VK_O:79,VK_P:80,VK_Q:81,VK_R:82,VK_S:83,VK_T:84,VK_U:85,VK_V:86,VK_W:87,VK_X:88,VK_Y:89,VK_Z:90,VK_CONTEXT_MENU:93,VK_NUMPAD0:96,VK_NUMPAD1:97,VK_NUMPAD2:98,VK_NUMPAD3:99,VK_NUMPAD4:100,VK_NUMPAD5:101,VK_NUMPAD6:102,VK_NUMPAD7:103,VK_NUMPAD8:104,VK_NUMPAD9:105,VK_MULTIPLY:106,VK_ADD:107,VK_SEPARATOR:108,VK_SUBTRACT:109,VK_DECIMAL:110,VK_DIVIDE:111,VK_F1:112,VK_F2:113,VK_F3:114,VK_F4:115,VK_F5:116,VK_F6:117,VK_F7:118,
|
||||
VK_F8:119,VK_F9:120,VK_F10:121,VK_F11:122,VK_F12:123,VK_F13:124,VK_F14:125,VK_F15:126,VK_F16:127,VK_F17:128,VK_F18:129,VK_F19:130,VK_F20:131,VK_F21:132,VK_F22:133,VK_F23:134,VK_F24:135,VK_NUM_LOCK:144,VK_SCROLL_LOCK:145,VK_CIRCUMFLEX:160,VK_EXCLAMATION:161,VK_DOUBLE_QUOTE:162,VK_HASH:163,VK_DOLLAR:164,VK_PERCENT:165,VK_AMPERSAND:166,VK_UNDERSCORE:167,VK_OPEN_PAREN:168,VK_CLOSE_PAREN:169,VK_ASTERISK:170,VK_PLUS:171,VK_PIPE:172,VK_HYPHEN_MINUS:173,VK_OPEN_CURLY_BRACKET:174,VK_CLOSE_CURLY_BRACKET:175,
|
||||
VK_TILDE:176,VK_COMMA:188,VK_PERIOD:190,VK_SLASH:191,VK_BACK_QUOTE:192,VK_OPEN_BRACKET:219,VK_BACK_SLASH:220,VK_CLOSE_BRACKET:221,VK_QUOTE:222,VK_META:224,VK_ALTGR:225,VK_WIN:91,VK_KANA:21,VK_HANGUL:21,VK_EISU:22,VK_JUNJA:23,VK_FINAL:24,VK_HANJA:25,VK_KANJI:25,VK_CONVERT:28,VK_NONCONVERT:29,VK_ACCEPT:30,VK_MODECHANGE:31,VK_SELECT:41,VK_PRINT:42,VK_EXECUTE:43,VK_SLEEP:95};Object.defineProperty(g,"__esModule",{value:!0})});
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Ruleset — gameplay as a data object (Pillar 4).
|
||||
* Ruleset: gameplay as a data object (Pillar 4).
|
||||
*
|
||||
* A Ruleset captures everything that makes one game mode different from another: the world it
|
||||
* generates, the entities in it, how many players, how someone wins, the clock, and the economy.
|
||||
* The match engine READS a ruleset instead of hardcoding rules, so new modes (PvP Last-Alive,
|
||||
* Score Attack, Co-op) are data — not new subsystems — and operators/players can later author their
|
||||
* Score Attack, Co-op) are data, not new subsystems, and operators/players can later author their
|
||||
* own lobbies by supplying a ruleset object.
|
||||
*
|
||||
* defineRuleset normalizes a partial spec to a complete, frozen ruleset with safe defaults. The
|
||||
@@ -38,7 +38,7 @@ function defineRuleset(spec = {}) {
|
||||
const rs = {
|
||||
id: String(s.id || 'custom'),
|
||||
label: String(s.label || s.id || 'Custom'),
|
||||
mode: s.mode || 'race', // 'solo' | 'race' | 'pvp' | 'coop' — a coarse family label
|
||||
mode: s.mode || 'race', // 'solo' | 'race' | 'pvp' | 'coop': a coarse family label
|
||||
world: Object.freeze({
|
||||
difficultyPreset: world.difficultyPreset || 'normal',
|
||||
maps: clampInt(world.maps, 1, 10, 1) // reserved for multi-level; engine uses 1 today
|
||||
|
||||
@@ -3,7 +3,7 @@ const { defineRuleset, WIN, ECONOMY } = require('./Ruleset');
|
||||
/**
|
||||
* Built-in rulesets. `race` reproduces the current match behavior exactly (so nothing changes for
|
||||
* existing matches). `last-alive` and `score-attack` are the new PvP / score modes the match engine
|
||||
* now supports as data. `solo-classic` describes the live single-player escape for catalog/parity —
|
||||
* now supports as data. `solo-classic` describes the live single-player escape for catalog/parity:
|
||||
* the single-player engine isn't refactored to consume it yet, so it's a descriptor, not wired.
|
||||
*/
|
||||
const BUILTINS = {
|
||||
@@ -80,7 +80,7 @@ function resolveMatchRuleset(id) {
|
||||
|
||||
/**
|
||||
* Map the legacy MatchRoom option bag ({economy, variant, difficultyPreset, maxPlayers}) to a
|
||||
* ruleset that preserves the current race behavior — win condition stays first-to-exit regardless
|
||||
* ruleset that preserves the current race behavior: win condition stays first-to-exit regardless
|
||||
* of `variant` (which was a stored-but-unused label), so wiring the engine to rulesets changes
|
||||
* nothing until a caller explicitly asks for a different ruleset by id.
|
||||
*/
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* Win-condition strategies. Each is a small object the MatchRoom consults at three decision points,
|
||||
* so the room no longer hardcodes "first to the exit wins":
|
||||
*
|
||||
* onExit(room, id) — a player reached the exit. Decide whether that ends the match / sets a winner.
|
||||
* onDeath(room) — a player just died. Decide whether the match is now over.
|
||||
* rank(room) — final ordering used for placement and competitive winnerId.
|
||||
* onExit(room, id): a player reached the exit. Decide whether that ends the match / sets a winner.
|
||||
* onDeath(room): a player just died. Decide whether the match is now over.
|
||||
* rank(room): final ordering used for placement and competitive winnerId.
|
||||
*
|
||||
* FIRST_TO_EXIT reproduces the classic race behavior byte-for-byte (so existing matches are
|
||||
* unchanged); LAST_ALIVE and HIGH_SCORE are the new PvP / score modes.
|
||||
@@ -126,7 +126,7 @@ const HIGH_SCORE = {
|
||||
}
|
||||
};
|
||||
|
||||
// ALL_ESCAPE (co-op) — a lightweight variant of race: the match ends when everyone still alive has
|
||||
// ALL_ESCAPE (co-op) is a lightweight variant of race: the match ends when everyone still alive has
|
||||
// escaped (or all died). Ranking falls back to the race ordering.
|
||||
const ALL_ESCAPE = {
|
||||
onExit(room) {
|
||||
|
||||
+49
-80
@@ -1,5 +1,5 @@
|
||||
// Load .env before reading any process setting (notably TRUST_PROXY). systemd's
|
||||
// EnvironmentFile masked this ordering bug in production, while `npm run dev` did not.
|
||||
// .env must load before any process setting is read (notably TRUST_PROXY). Under systemd the
|
||||
// EnvironmentFile supplies those values already, so only non-systemd runs depend on this ordering.
|
||||
require('dotenv').config();
|
||||
|
||||
const express = require('express');
|
||||
@@ -22,7 +22,6 @@ var io = require('socket.io')(http, {
|
||||
allowRequest: createSocketOriginAllowRequest(process.env)
|
||||
});
|
||||
|
||||
// Import payment system components
|
||||
const DatabaseManager = require('./db/databaseManager');
|
||||
const WalletRPCService = require('./payments/walletRPCService');
|
||||
const PayoutRetryService = require('./payments/payoutRetryService');
|
||||
@@ -42,7 +41,6 @@ const { createCanaryDatabaseIdentityHandler } = require('./services/canaryDataba
|
||||
const { AppError, ValidationError, NotFoundError } = require('./utils/errors');
|
||||
const money = require('./money/atomic');
|
||||
|
||||
// Import modular components
|
||||
const BroadcastManager = require('./network/broadcastManager');
|
||||
const DebugManager = require('./debug/debugManager');
|
||||
const SocketHandlers = require('./network/socketHandlers');
|
||||
@@ -57,7 +55,6 @@ const createAuthRoutes = require('./routes/auth');
|
||||
const { createLeaderboardHandler } = require('./routes/leaderboard');
|
||||
const { isSmirkEnabled } = require('./auth/smirkPolicy');
|
||||
|
||||
// Initialize payment configuration
|
||||
const paymentConfigManager = new PaymentConfigManager({ logger: console });
|
||||
const environmentValidator = new EnvironmentValidator({ logger: console });
|
||||
let releaseIdentity;
|
||||
@@ -70,21 +67,20 @@ try {
|
||||
}
|
||||
process.env.GAME_MODE = paymentConfigManager.getLegacyGameMode();
|
||||
|
||||
// Initialize modular components first
|
||||
// Constructed before the payment components below, which take debugManager as a dependency.
|
||||
const broadcastManager = new BroadcastManager(io);
|
||||
const debugManager = new DebugManager(broadcastManager);
|
||||
broadcastManager.setDebugManager(debugManager);
|
||||
|
||||
// Initialize payment system components (debugManager is now available)
|
||||
const databaseManager = new DatabaseManager();
|
||||
const db = databaseManager; // Alias for convenience in API endpoints
|
||||
const db = databaseManager; // Alias used by the API endpoints
|
||||
const financialEventExporter = new FinancialEventExporter({ db: databaseManager });
|
||||
const walletRPCService = new WalletRPCService(debugManager, {
|
||||
minConfirmations: paymentConfigManager.getConfig().payouts.processing.confirmations
|
||||
});
|
||||
const gameModeManager = new GameModeManager(databaseManager, walletRPCService, debugManager, paymentConfigManager);
|
||||
gameModeManager.releaseIdentity = releaseIdentity;
|
||||
// Provide io reference so GameModeManager can emit events (e.g., credits_update)
|
||||
// GameModeManager emits socket events (such as credits_update) through this reference.
|
||||
gameModeManager.io = io;
|
||||
|
||||
// One effective payout gate used by every dispatcher. PAYOUTS_ENABLED is the master switch;
|
||||
@@ -123,12 +119,11 @@ gameModeManager.financialAdmissionAllowed = isFinancialRecoveryReady;
|
||||
// Dispatchers still check earlier so they do not claim durable rows while the gate is closed.
|
||||
walletRPCService.transferAllowed = canDispatchPayouts;
|
||||
|
||||
// Initialize remaining components
|
||||
const activeGames = new Map(); // Maps socketId to Game objects
|
||||
let alertService = null; // Initialized later when payment system starts
|
||||
let payoutRetryService = null; // Initialized later when payment system starts
|
||||
let batchPayoutInterval = null; // Initialized later when payment system starts
|
||||
let paymentExpiryInterval = null; // Initialized later when payment system starts
|
||||
let alertService = null; // Set when the payment system starts
|
||||
let payoutRetryService = null; // Set when the payment system starts
|
||||
let batchPayoutInterval = null; // Set when the payment system starts
|
||||
let paymentExpiryInterval = null; // Set when the payment system starts
|
||||
let latePaymentReconciler = null;
|
||||
let walletHealthInterval = null; // Keeps readiness tied to a live wallet, not startup state
|
||||
let databaseHealthInterval = null; // Detects a database outage after startup
|
||||
@@ -145,12 +140,12 @@ const rendererCdnEnabled = /^true$/i.test(String(process.env.RENDERER_CDN_ENABLE
|
||||
if (rendererCdnEnabled && process.env.NODE_ENV === 'production') {
|
||||
console.warn('⚠️ RENDERER_CDN_ENABLED=true: third-party jsDelivr code is trusted by the production CSP');
|
||||
}
|
||||
// Security headers (defense in depth alongside output escaping).
|
||||
// CSP locks down where scripts/styles/connections may come from. Inline scripts/styles
|
||||
// are still permitted ('unsafe-inline') because the current pages rely on them heavily;
|
||||
// removing that is tracked as Phase 4.4 (nonce-based CSP). connect-src 'self' covers
|
||||
// same-origin Socket.IO websockets under CSP Level 3. blob: is limited to render-kit
|
||||
// GLB texture decoding; GLTFLoader turns embedded textures into blob URLs.
|
||||
// Security headers, defense in depth alongside output escaping.
|
||||
// CSP constrains where scripts, styles, and connections may come from. Inline scripts and styles
|
||||
// are permitted ('unsafe-inline') because the pages rely on them; a nonce-based policy requires
|
||||
// reworking those pages first. connect-src 'self' covers same-origin Socket.IO websockets under
|
||||
// CSP Level 3. blob: serves render-kit GLB texture decoding, where GLTFLoader turns embedded
|
||||
// textures into blob URLs.
|
||||
app.use((req, res, next) => {
|
||||
const scriptSources = ["'self'", "'unsafe-inline'"];
|
||||
const connectSources = ["'self'", 'blob:', 'https://smirk.cash'];
|
||||
@@ -220,7 +215,6 @@ app.use(
|
||||
express.static(path.join(threePackagePath, 'build'), rendererVendorStaticOptions)
|
||||
);
|
||||
|
||||
// Configure static file serving
|
||||
const htmlPath = path.join(__dirname, '../html');
|
||||
app.use(express.static(htmlPath, {
|
||||
etag: true,
|
||||
@@ -231,9 +225,8 @@ app.use(express.static(htmlPath, {
|
||||
}
|
||||
}
|
||||
}));
|
||||
app.use(express.json()); // Parse JSON for API endpoints
|
||||
app.use(express.json());
|
||||
|
||||
// Serve main page
|
||||
app.get('/', function(req, res) {
|
||||
res.sendFile('index.html', { root: htmlPath });
|
||||
});
|
||||
@@ -278,12 +271,11 @@ app.get(['/tavern', '/tavern/'], function(req, res) {
|
||||
res.sendFile('tavern.html', { root: htmlPath });
|
||||
});
|
||||
|
||||
// Serve admin panel
|
||||
app.get('/admin', function(req, res) {
|
||||
res.sendFile('admin.html', { root: htmlPath });
|
||||
});
|
||||
|
||||
// Debug endpoint to receive client-side debug info
|
||||
// Receives client-side debug info.
|
||||
app.post('/debug', (req, res) => {
|
||||
if (debugManager.CONSOLE_LOGGING) {
|
||||
console.log('📝 CLIENT DEBUG INFO:', req.body);
|
||||
@@ -291,7 +283,6 @@ app.post('/debug', (req, res) => {
|
||||
res.json({ received: true, timestamp: Date.now() });
|
||||
});
|
||||
|
||||
// Payment system API endpoints
|
||||
app.post('/api/payment/create', asyncHandler(async (req, res) => {
|
||||
const { userId, gameMode } = req.body || {};
|
||||
if (!userId || !gameMode) {
|
||||
@@ -322,13 +313,12 @@ app.get('/api/payment/status/:paymentId', asyncHandler(async (req, res) => {
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// /api/user/* protection (S1 / S2 / S3, contract C2)
|
||||
// /api/user/* protection
|
||||
// =============================================================================
|
||||
|
||||
// S3: simple in-memory IP rate limiter for the /api/user/* REST surface. Each of these
|
||||
// endpoints hits the DB, so cap requests per IP over a short window. Best-effort only (the
|
||||
// socket layer has its own limiter and nginx can add another); the map is pruned lazily so
|
||||
// it can't grow unbounded.
|
||||
// In-memory IP rate limiter for the /api/user/* REST surface. Each of these endpoints hits the
|
||||
// DB, so requests per IP are capped over a short window. Best-effort only: the socket layer has
|
||||
// its own limiter and nginx can add another. The map is pruned lazily so it cannot grow unbounded.
|
||||
const _userApiHits = new Map(); // ip -> { count, windowStart }
|
||||
const USER_API_WINDOW_MS = 60 * 1000;
|
||||
const USER_API_MAX = Number(process.env.USER_API_RATE_MAX) || 120;
|
||||
@@ -353,13 +343,12 @@ function userApiRateLimit(req, res, next) {
|
||||
}
|
||||
app.use('/api/user', userApiRateLimit);
|
||||
|
||||
// C2: session-ownership guard for /api/user/:socketId/*. The caller must present the session
|
||||
// token (users.anon_token — emitted to the client as the 'session_token'/'session_resumed'
|
||||
// event and stored as localStorage['wownerogue_token']) via the 'X-Session-Token' header.
|
||||
// Tokens are never accepted in URLs because reverse proxies and browser history log them.
|
||||
// We only proceed for the row whose socket_id AND anon_token both match, so
|
||||
// one client can't read or mutate another player's payments/payouts/address. failStatus is
|
||||
// 403 for mutations, 401 for reads.
|
||||
// Session-ownership guard for /api/user/:socketId/*. The caller must present the session token
|
||||
// (users.anon_token, emitted to the client as the 'session_token'/'session_resumed' event and
|
||||
// stored as localStorage['wownerogue_token']) via the 'X-Session-Token' header. Tokens are never
|
||||
// accepted in URLs because reverse proxies and browser history log them. Only the row whose
|
||||
// socket_id AND anon_token both match proceeds, so one client cannot read or mutate another
|
||||
// player's payments, payouts, or address. failStatus is 403 for mutations, 401 for reads.
|
||||
function requireSessionOwnership(failStatus) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
@@ -393,7 +382,7 @@ app.get('/api/user/:socketId/credits', requireSessionOwnership(401), asyncHandle
|
||||
}
|
||||
|
||||
try {
|
||||
// C1: read-only route — never mint a user row for a stranger's socketId.
|
||||
// Read-only route: never mint a user row for a stranger's socketId.
|
||||
const user = await gameModeManager.getOrCreateUser(socketId, { create: false });
|
||||
if (!user) {
|
||||
return res.json({});
|
||||
@@ -421,7 +410,7 @@ app.get('/api/user/:socketId/mode', requireSessionOwnership(401), asyncHandler(a
|
||||
}
|
||||
|
||||
try {
|
||||
// C1: read-only route — do not create a user row here.
|
||||
// Read-only route: no user row is created here.
|
||||
const user = await gameModeManager.getOrCreateUser(socketId, { create: false });
|
||||
if (!user) {
|
||||
return res.json({});
|
||||
@@ -533,7 +522,6 @@ app.get('/health/ready', (req, res) => {
|
||||
res.status(health.ready ? 200 : 503).json(health);
|
||||
});
|
||||
|
||||
// Get payment options for a user (mixed mode support)
|
||||
app.get('/api/user/:socketId/payment-options', requireSessionOwnership(401), asyncHandler(async (req, res) => {
|
||||
const { socketId } = req.params;
|
||||
if (!socketId) {
|
||||
@@ -554,7 +542,6 @@ app.get('/api/user/:socketId/payment-options', requireSessionOwnership(401), asy
|
||||
}
|
||||
}));
|
||||
|
||||
// Get payment history for a user
|
||||
app.get('/api/user/:socketId/payments', requireSessionOwnership(401), asyncHandler(async (req, res) => {
|
||||
const { socketId } = req.params;
|
||||
const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
|
||||
@@ -567,7 +554,7 @@ app.get('/api/user/:socketId/payments', requireSessionOwnership(401), asyncHandl
|
||||
}
|
||||
|
||||
try {
|
||||
// C1: read-only route — do not create a user row here.
|
||||
// Read-only route: no user row is created here.
|
||||
const user = await gameModeManager.getOrCreateUser(socketId, { create: false });
|
||||
if (!user) {
|
||||
return res.json({ payments: [], total: 0, totalPaid: 0, limit, offset });
|
||||
@@ -625,7 +612,6 @@ app.get('/api/user/:socketId/payments', requireSessionOwnership(401), asyncHandl
|
||||
}
|
||||
}));
|
||||
|
||||
// Get payout history for a user
|
||||
app.get('/api/user/:socketId/payouts', requireSessionOwnership(401), asyncHandler(async (req, res) => {
|
||||
const { socketId } = req.params;
|
||||
const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
|
||||
@@ -638,7 +624,7 @@ app.get('/api/user/:socketId/payouts', requireSessionOwnership(401), asyncHandle
|
||||
}
|
||||
|
||||
try {
|
||||
// C1: read-only route — do not create a user row here.
|
||||
// Read-only route: no user row is created here.
|
||||
const user = await gameModeManager.getOrCreateUser(socketId, { create: false });
|
||||
if (!user) {
|
||||
return res.json({ payouts: [], total: 0, totalReceived: 0, limit, offset });
|
||||
@@ -666,7 +652,7 @@ app.get('/api/user/:socketId/payouts', requireSessionOwnership(401), asyncHandle
|
||||
[user.id]
|
||||
);
|
||||
|
||||
// Calculate total received (confirmed payouts)
|
||||
// Total received counts only payouts that completed on chain.
|
||||
const totalResult = await db.query(
|
||||
`SELECT COALESCE(SUM(amount), 0) as total_received FROM payouts WHERE user_id = $1 AND status = 'completed'`,
|
||||
[user.id]
|
||||
@@ -716,7 +702,7 @@ if (smirkEnabled) {
|
||||
|
||||
|
||||
// =============================================================================
|
||||
// Admin API Endpoints (see src/routes/admin.js) — DI ctx; alertService is set later
|
||||
// Admin API endpoints (see src/routes/admin.js). Injected ctx; alertService is set later.
|
||||
// =============================================================================
|
||||
const adminRouteCtx = {
|
||||
db,
|
||||
@@ -802,7 +788,6 @@ app.get('/api/game-modes', (req, res) => {
|
||||
const directMode = config.modes.direct;
|
||||
const creditsMode = config.modes.credits;
|
||||
|
||||
// Include hosted by info if configured
|
||||
let hostedBy = null;
|
||||
if (process.env.HOSTED_BY) {
|
||||
try {
|
||||
@@ -876,7 +861,7 @@ app.get('/api/game-modes', (req, res) => {
|
||||
app.get('/verify/:gameId', asyncHandler(async (req, res) => {
|
||||
const { gameId } = req.params;
|
||||
|
||||
// Try to look up the game in the database
|
||||
// The page still renders for an unknown gameId, so the lookup is optional.
|
||||
let gameRecord = null;
|
||||
try {
|
||||
const result = await databaseManager.query(
|
||||
@@ -990,11 +975,8 @@ app.get('/api/verify', (req, res) => {
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// Initialize debug manager with new block callbacks
|
||||
debugManager.onNewBlockCallback((blockHeight) => {
|
||||
// Start games for waiting players when new block detected
|
||||
socketHandlers.startGamesForWaiting(blockHeight);
|
||||
// Check active games for timeout
|
||||
Promise.resolve(socketHandlers.checkGamesTimeout(blockHeight)).catch(err => {
|
||||
console.error('❌ checkGamesTimeout error:', err.message);
|
||||
});
|
||||
@@ -1006,7 +988,6 @@ debugManager.onNewBlockCallback((blockHeight) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize payment system
|
||||
async function initializePaymentSystem() {
|
||||
try {
|
||||
const production = process.env.NODE_ENV === 'production';
|
||||
@@ -1066,7 +1047,7 @@ async function initializePaymentSystem() {
|
||||
console.log('ℹ️ Payments disabled; wallet RPC initialization skipped.');
|
||||
}
|
||||
|
||||
// Initialize database connection and run migrations
|
||||
// Opens the connection and runs pending migrations.
|
||||
await databaseManager.initialize();
|
||||
if (!databaseManager.isConnected()) {
|
||||
throw new Error('PostgreSQL is unavailable.');
|
||||
@@ -1105,10 +1086,8 @@ async function initializePaymentSystem() {
|
||||
}
|
||||
}
|
||||
|
||||
// Start the debug/production system
|
||||
debugManager.initialize();
|
||||
|
||||
// Debug function for registered users
|
||||
function debugRegisteredUsers() {
|
||||
if (debugManager.CONSOLE_LOGGING) {
|
||||
console.log("📊 REGISTERED USERS DEBUG:");
|
||||
@@ -1122,13 +1101,10 @@ function debugRegisteredUsers() {
|
||||
}
|
||||
}
|
||||
|
||||
// Regular debug logging
|
||||
setInterval(debugRegisteredUsers, 10000);
|
||||
|
||||
// Start server
|
||||
async function startServer() {
|
||||
try {
|
||||
// Initialize payment system first
|
||||
const financialRuntime = await initializePaymentSystem();
|
||||
const { paymentSystemReady } = financialRuntime;
|
||||
|
||||
@@ -1155,18 +1131,17 @@ async function startServer() {
|
||||
payoutDispatchReady = financialRuntime.walletReady && isPayoutProcessingEnabled();
|
||||
financialEventExporter.start();
|
||||
|
||||
// Socket.io connection handler - ONLY after payment system is ready
|
||||
// Connections are accepted only once the payment system is ready.
|
||||
io.on('connection', function(socket) {
|
||||
socketHandlers.handleConnection(socket);
|
||||
});
|
||||
|
||||
// Start HTTP server.
|
||||
// Bind all interfaces by default: the reverse proxy (Nginx Proxy Manager) is off-host /
|
||||
// containerized and reaches this app over a non-loopback address, so a loopback-only bind
|
||||
// makes the public site 502. Operators whose proxy runs on THIS host can set HOST=127.0.0.1.
|
||||
// The real "don't expose :3000" hardening is a firewall rule limiting the port to the
|
||||
// proxy's source, not the bind address; and X-Forwarded-For is already validated (rightmost
|
||||
// hop) so a direct hit can't spoof the client IP.
|
||||
// All interfaces are bound by default: the reverse proxy (Nginx Proxy Manager) is off-host
|
||||
// or containerized and reaches this app over a non-loopback address, so a loopback-only
|
||||
// bind makes the public site 502. Operators whose proxy runs on this host can set
|
||||
// HOST=127.0.0.1. Restricting exposure of :3000 is a firewall rule limiting the port to
|
||||
// the proxy's source, not a bind address; X-Forwarded-For is validated at the rightmost
|
||||
// hop, so a direct hit cannot spoof the client IP.
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const HOST = process.env.HOST || '0.0.0.0';
|
||||
http.listen(PORT, HOST, function() {
|
||||
@@ -1205,19 +1180,17 @@ async function startServer() {
|
||||
}
|
||||
}, payoutIntervalMs);
|
||||
|
||||
// Start payout retry service for failed/stuck payouts
|
||||
const maxRetries = Number(process.env.PAYOUT_MAX_RETRIES) || 3;
|
||||
const retryIntervalMs = Number(process.env.PAYOUT_RETRY_INTERVAL_MS) || 300000; // 5 minutes
|
||||
// Initialize alert service for email notifications
|
||||
alertService = new AlertService({
|
||||
walletService: walletRPCService,
|
||||
db: databaseManager,
|
||||
debugManager
|
||||
});
|
||||
|
||||
// Make alertService available to gameModeManager/paymentHandlers for balance checks
|
||||
// gameModeManager and paymentHandlers use this for balance-check alerts.
|
||||
gameModeManager.alertService = alertService;
|
||||
// And to the admin routes (late-bound via the shared ctx object)
|
||||
// Admin routes read it late-bound through the shared ctx object.
|
||||
adminRouteCtx.alertService = alertService;
|
||||
|
||||
payoutRetryService = new PayoutRetryService({
|
||||
@@ -1226,13 +1199,12 @@ async function startServer() {
|
||||
debugManager,
|
||||
maxRetries,
|
||||
retryIntervalMs,
|
||||
alertService, // Pass alert service for failure notifications
|
||||
alertService,
|
||||
isEnabled: canDispatchPayouts
|
||||
});
|
||||
payoutRetryService.start();
|
||||
|
||||
// Start periodic alert checks (every 5 minutes)
|
||||
alertService.startPeriodicChecks(300000);
|
||||
alertService.startPeriodicChecks(300000); // 5 minutes
|
||||
}
|
||||
|
||||
// Reconcile transfers that mined just after invoice expiry into durable manual review.
|
||||
@@ -1278,7 +1250,7 @@ async function startServer() {
|
||||
}
|
||||
}
|
||||
|
||||
// Attach 404 and error handlers last
|
||||
// The 404 and error handlers must be registered after every route.
|
||||
app.use((req, res, next) => {
|
||||
next(new NotFoundError(`Route not found: ${req.method} ${req.originalUrl}`, {
|
||||
safeMessage: 'The requested resource was not found.'
|
||||
@@ -1289,7 +1261,7 @@ app.use(createErrorMiddleware({ logger: console }));
|
||||
|
||||
startServer();
|
||||
|
||||
// Graceful shutdown — clean up timers, wait for in-flight payouts, close DB pool
|
||||
// Graceful shutdown: clean up timers, wait for in-flight payouts, close the DB pool.
|
||||
let shutdownStarted = false;
|
||||
async function gracefulShutdown(signal, exitCode = 0) {
|
||||
if (shutdownStarted) return;
|
||||
@@ -1309,10 +1281,8 @@ async function gracefulShutdown(signal, exitCode = 0) {
|
||||
}, 15000);
|
||||
forcedExit.unref?.();
|
||||
|
||||
// Stop accepting new connections
|
||||
http.close();
|
||||
|
||||
// Stop scheduled timers
|
||||
if (batchPayoutInterval) clearInterval(batchPayoutInterval);
|
||||
if (paymentExpiryInterval) clearInterval(paymentExpiryInterval);
|
||||
if (walletHealthInterval) clearInterval(walletHealthInterval);
|
||||
@@ -1358,7 +1328,7 @@ async function gracefulShutdown(signal, exitCode = 0) {
|
||||
if (payoutRetryService) payoutRetryService.stop();
|
||||
if (alertService) alertService.stopPeriodicChecks?.();
|
||||
|
||||
// Wait for in-flight batch processing to finish (up to 10 seconds)
|
||||
// Waits up to 10 seconds for in-flight batch processing to finish.
|
||||
let waitMs = 0;
|
||||
while (gameModeManager._isBatchProcessing && waitMs < 10000) {
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
@@ -1368,7 +1338,6 @@ async function gracefulShutdown(signal, exitCode = 0) {
|
||||
console.log(` Waited ${waitMs}ms for in-flight payout processing`);
|
||||
}
|
||||
|
||||
// Close database pool
|
||||
try {
|
||||
await databaseManager.close();
|
||||
console.log(' Database pool closed');
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user