From ed24d43b56719eb4b5e01cba62db8428d601b45f Mon Sep 17 00:00:00 2001 From: jwinterm Date: Mon, 27 Jul 2026 06:40:38 -0400 Subject: [PATCH] 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. --- .gitignore | 20 +- README.md | 418 +++-- docs/ARCHITECTURE.md | 156 +- docs/BALANCE_SIM.md | 146 +- docs/CHAT_AND_NOSTR.md | 234 ++- docs/CLONE_MIGRATIONS.md | 98 +- docs/DEPLOY.md | 319 ++-- docs/DEPLOY_INSTANCES.md | 386 ++-- docs/FINANCIAL_CONSTRAINT_VALIDATION.md | 72 +- docs/FINANCIAL_EVENT_EXPORT.md | 164 +- docs/FINANCIAL_RECONCILIATION.md | 109 +- docs/INTEGRATION.md | 325 +++- docs/LOGS_AND_BACKUP.md | 324 ++-- docs/MATCH_MODE.md | 316 ++-- docs/MONETIZATION.md | 213 ++- docs/MULTI_LEVEL.md | 106 +- docs/PAYMENTS.md | 198 +- docs/PRODUCTION_DISCLOSURES.md | 175 +- docs/PRODUCTION_STATE_2026-07-25.md | 58 - docs/PVP_CAPTURE.md | 170 +- docs/RELEASE_ARTIFACT.md | 167 +- docs/REMEDIATION_PLAN.md | 80 - docs/RENDER_PACKS.md | 188 +- docs/RULESETS.md | 234 ++- docs/STAGENET_FINANCIAL_CANARY.md | 296 ++- docs/TAVERN_AND_MULTIPLAYER.md | 447 ++--- docs/TODO.md | 188 -- docs/WALLET_KEYFILE_RECOVERY_PROOF.md | 168 -- html/index.html | 4 +- html/js/core/escapeHtml.js | 2 +- html/js/core/game.js | 138 +- html/js/core/gameState.js | 78 +- html/js/core/singlePlayerAvatar.js | 4 +- html/js/display/fxOverlay.js | 12 +- html/js/display/renderEngine.js | 4 +- html/js/display/screenManager.js | 6 +- html/js/display/waitingScreenAnimator.js | 10 +- html/js/input/inputHandler.js | 102 +- html/js/network/smirkAuth.js | 6 +- html/js/network/socketHandlers.js | 314 ++-- html/js/render/asciiRenderer.js | 4 +- html/js/render/assetPacks.js | 123 +- html/js/render/atlas.js | 2 +- html/js/render/avatarVisuals.js | 2 +- html/js/render/catSprites.js | 2 +- html/js/render/chainAmbience.js | 27 +- html/js/render/charCustomize.js | 4 +- html/js/render/charSprites.js | 2 +- html/js/render/designedRoom.js | 2 +- html/js/render/fancyRenderer.js | 6 +- html/js/render/fxLayer.js | 4 +- html/js/render/isoRenderer.js | 122 +- html/js/render/packRegistry.js | 4 +- html/js/render/renderModes.js | 6 +- html/js/render/sceneModel.js | 54 +- html/js/render/skins.js | 5 +- html/js/render/spGameRenderer.js | 77 +- html/js/render/tavernLife.js | 20 +- html/js/render/threeRenderer.js | 71 +- html/js/render/tilePicker.js | 82 - html/js/render/tileRenderer.js | 69 +- html/js/smirkChatSign.js | 4 +- html/js/ui/helpModal.js | 10 +- html/js/ui/paymentUI.js | 8 +- html/js/ui/publicStats.js | 4 +- html/js/ui/spRenderPicker.js | 8 +- html/match.html | 2 +- html/styles/stylesheet.css | 24 +- html/tavern.html | 12 +- scripts/build_kenney_3d_characters.py | 18 +- ...ADME-monero-stagenet-parallel-candidate.md | 241 --- .../monero-stagenet-parallel-fixture-test.py | 306 --- .../monero-stagenet-parallel-preflight.py | 374 ---- scripts/deploy/monero-wallet-rpc.service | 51 +- ...onerogue-stagenet-wallet-candidate.service | 54 - .../wallet-keyfile-recovery-fixture-test.py | 372 ---- .../deploy/wallet-keyfile-recovery-proof.py | 1646 ----------------- scripts/splitOutputs.js | 8 +- src/.env.example | 16 +- src/.env.mainnet.example | 12 +- src/chain/chainProfile.js | 12 +- src/config/configPersistence.js | 289 --- src/config/environmentValidator.js | 9 +- src/config/operatedProductProfiles.js | 4 +- src/db/databaseManager.js | 2 +- src/db/user.js | 41 +- src/debug/debugManager.js | 2 +- src/game/difficultyConfig.js | 57 +- src/game/game.js | 104 +- src/game/gameManager.js | 39 +- src/game/gameModeManager.js | 209 +-- src/game/helpers/gameModeUtils.js | 2 +- src/game/lightingAndFov.js | 22 +- src/game/movementManager.js | 19 +- src/game/provablyFair.js | 2 +- src/game/rot.min.js | 138 -- src/game/rulesets/Ruleset.js | 6 +- src/game/rulesets/registry.js | 4 +- src/game/rulesets/winConditions.js | 8 +- src/index.js | 129 +- src/migrations/015_payout_batch_tx_hash.sql | 4 +- src/migrations/017_money_status_checks.sql | 2 +- src/migrations/023_payment_safety.sql | 2 +- src/migrations/024_cosmetic_catalog.sql | 2 +- .../025_graduate_cosmetic_tiers.sql | 2 +- src/migrations/028_cosmetic_ladder.sql | 2 +- src/money/atomic.js | 4 +- src/multiplayer/MatchEngine.js | 2 +- src/multiplayer/MatchRoom.js | 6 +- src/multiplayer/MatchState.js | 6 +- src/multiplayer/Occupant.js | 2 +- src/multiplayer/Room.js | 10 +- src/multiplayer/entitlements.js | 22 +- src/multiplayer/tavernMap.js | 10 +- src/network/addressManager.js | 15 +- src/network/broadcastManager.js | 63 +- src/network/chat/ChatProvider.js | 6 +- src/network/chat/NostrChatProvider.js | 10 +- src/network/chat/SocketChatProvider.js | 4 +- src/network/chat/nostr/bridgeSigner.js | 4 +- src/network/chatHandler.js | 32 +- src/network/connectionHandler.js | 88 +- src/network/matchLeaderboard.js | 4 +- src/network/matchManager.js | 6 +- src/network/matchPayoutService.js | 10 +- src/network/matchQueue.js | 14 +- src/network/matchScheduler.js | 10 +- src/network/paymentHandlers.js | 134 +- src/network/queueHandler.js | 56 +- src/network/queueManager.js | 46 +- src/network/rateLimitContext.js | 2 +- src/network/rateLimiter.js | 59 +- src/network/sessionManager.js | 139 +- src/network/socketHandlers.js | 179 +- src/network/spectatorManager.js | 45 +- src/network/suspendedGameManager.js | 38 +- src/network/tavernManager.js | 32 +- src/network/tavernMatchBridge.js | 2 +- src/payments/providers/btcpayProvider.js | 4 +- src/payments/providers/index.js | 8 +- .../providers/nativeMoneroProvider.js | 6 +- src/payments/providers/paymentProvider.js | 2 +- src/payments/walletRPCService.js | 48 +- src/routes/admin.js | 2 +- src/routes/auth.js | 67 +- src/rpc/rpcService.js | 37 +- src/rpc/rpccalls.js | 67 - src/scripts/pvp-capture.js | 4 +- src/scripts/smoke.js | 18 +- src/scripts/stagenet-financial-canary.js | 4 +- src/services/alertService.js | 64 +- src/services/catalogService.js | 4 +- src/services/financialEventExporter.js | 8 +- src/services/subscriptionService.js | 4 +- src/sim/calibrate.js | 10 +- src/sim/pathfind.js | 2 +- src/sim/simBots.js | 14 +- src/sim/simulate.js | 10 +- src/utils/escapeChat.js | 2 +- src/utils/nip98.js | 54 +- src/utils/nostrLoader.js | 2 +- src/utils/verifyChatEvent.js | 10 +- src/views/legalPages.js | 4 +- src/views/verifyPage.js | 6 +- test/adminRoutes.test.js | 2 +- test/batchPayout.test.js | 4 +- test/catalogEntitlements_ultra.test.js | 6 +- test/chainProfile_ultra.test.js | 2 +- test/chatProvider.test.js | 2 +- test/directEntryCredit_ultra.test.js | 4 +- test/entitlements.test.js | 4 +- test/environmentValidator.test.js | 4 +- test/followups_ultra.test.js | 8 +- test/gameModeManager.test.js | 8 +- test/identityResolution.test.js | 2 +- test/matchRework_ultra.test.js | 14 +- test/matchScheduler.test.js | 2 +- test/moneyAtomic.test.js | 2 +- test/multiLevel_ultra.test.js | 10 +- test/networkTuning_ultra.test.js | 2 +- test/nodeUnreachableAdmission.test.js | 2 +- test/nostrChatProvider_ultra.test.js | 2 +- test/packRegistry_ultra.test.js | 2 +- test/paymentProviderBootstrap_ultra.test.js | 12 +- test/paymentProviders_ultra.test.js | 2 +- test/paymentSafety_ultra.test.js | 10 +- test/payment_flow.integration.test.js | 2 +- test/payment_payout_comprehensive.test.js | 14 +- test/perPlayerSigning_ultra.test.js | 4 +- test/provablyFair.test.js | 2 +- test/queueFairness.test.js | 2 +- test/renderPackResolver.test.js | 2 +- test/rpcEndpointFailover.test.js | 4 +- test/rulesets_ultra.test.js | 6 +- test/security.test.js | 4 +- test/serverSideFogOfWar.test.js | 6 +- test/sim_ultra.test.js | 2 +- test/singleGameRecovery.test.js | 6 +- test/singlePlayerAvatar.test.js | 2 +- test/smirkNip98_ultra.test.js | 14 +- test/socketHandlerFaultContainment.test.js | 4 +- test/spSceneAdapter_ultra.test.js | 2 +- test/subscriptionTier_ultra.test.js | 2 +- test/tavernRoom.test.js | 2 +- test/verifyPageOg.test.js | 2 +- test/walletKeyfileRecoveryProofStatic.test.js | 150 -- test/walletParallelCandidateStatic.test.js | 71 - 207 files changed, 5023 insertions(+), 8332 deletions(-) delete mode 100644 docs/PRODUCTION_STATE_2026-07-25.md delete mode 100644 docs/REMEDIATION_PLAN.md delete mode 100644 docs/TODO.md delete mode 100644 docs/WALLET_KEYFILE_RECOVERY_PROOF.md delete mode 100644 html/js/render/tilePicker.js delete mode 100644 scripts/deploy/README-monero-stagenet-parallel-candidate.md delete mode 100755 scripts/deploy/monero-stagenet-parallel-fixture-test.py delete mode 100755 scripts/deploy/monero-stagenet-parallel-preflight.py delete mode 100644 scripts/deploy/monerogue-stagenet-wallet-candidate.service delete mode 100755 scripts/deploy/wallet-keyfile-recovery-fixture-test.py delete mode 100755 scripts/deploy/wallet-keyfile-recovery-proof.py delete mode 100644 src/config/configPersistence.js delete mode 100644 src/game/rot.min.js delete mode 100644 src/rpc/rpccalls.js delete mode 100644 test/walletKeyfileRecoveryProofStatic.test.js delete mode 100644 test/walletParallelCandidateStatic.test.js diff --git a/.gitignore b/.gitignore index 4270899..911a49f 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/README.md b/README.md index 3ec9db8..223f1bf 100644 --- a/README.md +++ b/README.md @@ -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 +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/` 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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e4e6e38..31f2aaa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/BALANCE_SIM.md b/docs/BALANCE_SIM.md index a7febed..10c6975 100644 --- a/docs/BALANCE_SIM.md +++ b/docs/BALANCE_SIM.md @@ -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. diff --git a/docs/CHAT_AND_NOSTR.md b/docs/CHAT_AND_NOSTR.md index a123dfa..3062075 100644 --- a/docs/CHAT_AND_NOSTR.md +++ b/docs/CHAT_AND_NOSTR.md @@ -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:` 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 ``` diff --git a/docs/CLONE_MIGRATIONS.md b/docs/CLONE_MIGRATIONS.md index 8dc14cd..e358a4e 100644 --- a/docs/CLONE_MIGRATIONS.md +++ b/docs/CLONE_MIGRATIONS.md @@ -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_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. diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 8992fd0..2457fd6 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -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//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//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=
`. 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=
`. `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/`, 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//releases/`, 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//releases/`, 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 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 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. diff --git a/docs/DEPLOY_INSTANCES.md b/docs/DEPLOY_INSTANCES.md index 8e33d9b..d11efee 100644 --- a/docs/DEPLOY_INSTANCES.md +++ b/docs/DEPLOY_INSTANCES.md @@ -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//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//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//src +cd /var/www/monerogue/releases//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=
` 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=
` 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//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//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. diff --git a/docs/FINANCIAL_CONSTRAINT_VALIDATION.md b/docs/FINANCIAL_CONSTRAINT_VALIDATION.md index b6f5d1e..de1ce2e 100644 --- a/docs/FINANCIAL_CONSTRAINT_VALIDATION.md +++ b/docs/FINANCIAL_CONSTRAINT_VALIDATION.md @@ -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. diff --git a/docs/FINANCIAL_EVENT_EXPORT.md b/docs/FINANCIAL_EVENT_EXPORT.md index 947d5c5..027f201 100644 --- a/docs/FINANCIAL_EVENT_EXPORT.md +++ b/docs/FINANCIAL_EVENT_EXPORT.md @@ -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-` or `payment-refund-`. +- `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:` 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. diff --git a/docs/FINANCIAL_RECONCILIATION.md b/docs/FINANCIAL_RECONCILIATION.md index 4220f97..27c9ded 100644 --- a/docs/FINANCIAL_RECONCILIATION.md +++ b/docs/FINANCIAL_RECONCILIATION.md @@ -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. diff --git a/docs/INTEGRATION.md b/docs/INTEGRATION.md index 3bde935..b8cda36 100644 --- a/docs/INTEGRATION.md +++ b/docs/INTEGRATION.md @@ -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: ` **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: ` + +**Request (NIP-98, preferred):** +```json +{ + "socketId": "", + "event": { "kind": 27235, "created_at": 0, "pubkey": "", "id": "", "sig": "", "content": "", "tags": [] } +} +``` + +**Request (legacy Ed25519):** ```json { "socketId": "", @@ -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": "", + "address": "", + "message": "Signed in to your wallet-linked account." +} +``` + +**Errors** are returned as `{ "error": "", "message": "" }`: + +| 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=` +**Headers:** `X-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` -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` + +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` + +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` Returns wallet addresses for all assets. Requires prior `connect()`. @@ -177,28 +289,31 @@ const addrs = await window.smirk.getAddresses(); ### `smirk.requestPayment(options)` -> `Promise` -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` -Check if the current origin is connected (approved). +Whether the current origin is approved. ### `smirk.disconnect()` -> `Promise` @@ -206,47 +321,83 @@ Revoke site access. ### `smirk.getPublicKeys()` -> `Promise` -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. diff --git a/docs/LOGS_AND_BACKUP.md b/docs/LOGS_AND_BACKUP.md index 6c8863a..1485dcd 100644 --- a/docs/LOGS_AND_BACKUP.md +++ b/docs/LOGS_AND_BACKUP.md @@ -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 `-.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 +`_.sql.gz`, refreshes a `_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/-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 diff --git a/docs/MATCH_MODE.md b/docs/MATCH_MODE.md index 9b7bc24..0a54485 100644 --- a/docs/MATCH_MODE.md +++ b/docs/MATCH_MODE.md @@ -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) diff --git a/docs/MONETIZATION.md b/docs/MONETIZATION.md index 12d691b..c850596 100644 --- a/docs/MONETIZATION.md +++ b/docs/MONETIZATION.md @@ -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. diff --git a/docs/MULTI_LEVEL.md b/docs/MULTI_LEVEL.md index 0808211..6944f91 100644 --- a/docs/MULTI_LEVEL.md +++ b/docs/MULTI_LEVEL.md @@ -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 (`:L`). 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 `:L` 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=`; -`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` | diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index c59ded7..cb11e5b 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -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 `. 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 ``` diff --git a/docs/PRODUCTION_DISCLOSURES.md b/docs/PRODUCTION_DISCLOSURES.md index 51fc37e..393b0f1 100644 --- a/docs/PRODUCTION_DISCLOSURES.md +++ b/docs/PRODUCTION_DISCLOSURES.md @@ -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/` 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. diff --git a/docs/PRODUCTION_STATE_2026-07-25.md b/docs/PRODUCTION_STATE_2026-07-25.md deleted file mode 100644 index af0b7d8..0000000 --- a/docs/PRODUCTION_STATE_2026-07-25.md +++ /dev/null @@ -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. diff --git a/docs/PVP_CAPTURE.md b/docs/PVP_CAPTURE.md index 9e27d67..0d90e78 100644 --- a/docs/PVP_CAPTURE.md +++ b/docs/PVP_CAPTURE.md @@ -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. diff --git a/docs/RELEASE_ARTIFACT.md b/docs/RELEASE_ARTIFACT.md index 93988fd..e596b11 100644 --- a/docs/RELEASE_ARTIFACT.md +++ b/docs/RELEASE_ARTIFACT.md @@ -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 | +| `.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 .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 .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 @@ -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-`. 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 diff --git a/docs/REMEDIATION_PLAN.md b/docs/REMEDIATION_PLAN.md deleted file mode 100644 index 862860f..0000000 --- a/docs/REMEDIATION_PLAN.md +++ /dev/null @@ -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._ diff --git a/docs/RENDER_PACKS.md b/docs/RENDER_PACKS.md index a8bbafd..e5ffce2 100644 --- a/docs/RENDER_PACKS.md +++ b/docs/RENDER_PACKS.md @@ -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_` 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: ''` 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: ''` 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`. diff --git a/docs/RULESETS.md b/docs/RULESETS.md index d59e9b1..8c54e8b 100644 --- a/docs/RULESETS.md +++ b/docs/RULESETS.md @@ -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 ``` diff --git a/docs/STAGENET_FINANCIAL_CANARY.md b/docs/STAGENET_FINANCIAL_CANARY.md index ca76ec2..a40799b 100644 --- a/docs/STAGENET_FINANCIAL_CANARY.md +++ b/docs/STAGENET_FINANCIAL_CANARY.md @@ -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. diff --git a/docs/TAVERN_AND_MULTIPLAYER.md b/docs/TAVERN_AND_MULTIPLAYER.md index efa9f13..30d0228 100644 --- a/docs/TAVERN_AND_MULTIPLAYER.md +++ b/docs/TAVERN_AND_MULTIPLAYER.md @@ -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:` 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. diff --git a/docs/TODO.md b/docs/TODO.md deleted file mode 100644 index 6d21543..0000000 --- a/docs/TODO.md +++ /dev/null @@ -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: ` @@ -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; diff --git a/scripts/build_kenney_3d_characters.py b/scripts/build_kenney_3d_characters.py index aca1db0..ffd24c5 100644 --- a/scripts/build_kenney_3d_characters.py +++ b/scripts/build_kenney_3d_characters.py @@ -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" diff --git a/scripts/deploy/README-monero-stagenet-parallel-candidate.md b/scripts/deploy/README-monero-stagenet-parallel-candidate.md deleted file mode 100644 index 6c1a4c2..0000000 --- a/scripts/deploy/README-monero-stagenet-parallel-candidate.md +++ /dev/null @@ -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//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//exe`, UID, cgroup, - and the sole PID-owned `127.0.0.1:38083` listener before and after capture; -- read NUL-delimited `/proc//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: -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. diff --git a/scripts/deploy/monero-stagenet-parallel-fixture-test.py b/scripts/deploy/monero-stagenet-parallel-fixture-test.py deleted file mode 100755 index 93b97a3..0000000 --- a/scripts/deploy/monero-stagenet-parallel-fixture-test.py +++ /dev/null @@ -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) diff --git a/scripts/deploy/monero-stagenet-parallel-preflight.py b/scripts/deploy/monero-stagenet-parallel-preflight.py deleted file mode 100755 index b68620e..0000000 --- a/scripts/deploy/monero-stagenet-parallel-preflight.py +++ /dev/null @@ -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) diff --git a/scripts/deploy/monero-wallet-rpc.service b/scripts/deploy/monero-wallet-rpc.service index b330243..b26b724 100644 --- a/scripts/deploy/monero-wallet-rpc.service +++ b/scripts/deploy/monero-wallet-rpc.service @@ -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 diff --git a/scripts/deploy/monerogue-stagenet-wallet-candidate.service b/scripts/deploy/monerogue-stagenet-wallet-candidate.service deleted file mode 100644 index dcfa8f4..0000000 --- a/scripts/deploy/monerogue-stagenet-wallet-candidate.service +++ /dev/null @@ -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 diff --git a/scripts/deploy/wallet-keyfile-recovery-fixture-test.py b/scripts/deploy/wallet-keyfile-recovery-fixture-test.py deleted file mode 100755 index 4c53e57..0000000 --- a/scripts/deploy/wallet-keyfile-recovery-fixture-test.py +++ /dev/null @@ -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) diff --git a/scripts/deploy/wallet-keyfile-recovery-proof.py b/scripts/deploy/wallet-keyfile-recovery-proof.py deleted file mode 100755 index 9d67727..0000000 --- a/scripts/deploy/wallet-keyfile-recovery-proof.py +++ /dev/null @@ -1,1646 +0,0 @@ -#!/usr/bin/env python3 -"""Fixed, root-only encrypted-wallet keyfile/password recovery proof for ``deb``. - -The two accepted profile names select compile-time paths. No caller-controlled path, port, unit, -binary, user, or RPC method is accepted. The proof reads no mnemonic and has no mnemonic RPC. -Its only output is ``result=passed`` or ``result=refused``. -""" - -from __future__ import annotations - -import ctypes -import grp -import hashlib -import hmac -import http.client -import json -import os -import pathlib -import pwd -import resource -import signal -import socket -import stat -import subprocess -import sys -import time -import urllib.request -from dataclasses import dataclass -from typing import Callable - - -PR_SET_DUMPABLE = 4 -PR_SET_KEEPCAPS = 8 -PR_SET_NO_NEW_PRIVS = 38 -MADV_DONTDUMP = 16 -O_DIRECTORY = getattr(os, "O_DIRECTORY", 0) -O_NOFOLLOW = getattr(os, "O_NOFOLLOW", 0) -O_CLOEXEC = getattr(os, "O_CLOEXEC", 0) -MAX_CMDLINE = 64 * 1024 -MAX_RPC_RESPONSE = 1024 * 1024 -RPC_PATH = "/json_rpc" -RPC_USER = "recovery-proof" -ZERO_BLOCK = b"\0" * (1024 * 1024) -LIBC = ctypes.CDLL(None, use_errno=True) -BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" - -ADDRESS_REQUEST = json.dumps( - { - "jsonrpc": "2.0", - "id": "keyfile-recovery-proof", - "method": "get_address", - "params": {"account_index": 0}, - }, - separators=(",", ":"), -).encode("ascii") -SIGN_MESSAGE = "wowngeon-wallet-keyfile-recovery-proof-v1" - - -@dataclass(frozen=True) -class WalletProfile: - name: str - unit: str - user: str - group: str - binary: pathlib.Path - live_wallet: pathlib.Path - live_rpc_port: int - live_bind_address: str - snapshot_wallet: pathlib.Path - snapshot_keys: pathlib.Path - proof_port: int - stagenet: bool - address_kind: str - proof_directory: pathlib.Path - - @property - def candidate_directory(self) -> pathlib.Path: - return self.proof_directory / "candidate" - - -SNAPSHOT_DIRECTORY = pathlib.Path("/var/backups/wowngeon/wallets") -PROFILES = { - "monero-stagenet": WalletProfile( - name="monero-stagenet", - unit="monero-wallet-rpc.service", - user="jw", - group="jw", - binary=pathlib.Path("/usr/bin/monero-wallet-rpc"), - live_wallet=pathlib.Path( - "/home/jw/Programs/monero-x86_64-linux-gnu-v0.18.4.4/test" - ), - live_rpc_port=38083, - live_bind_address="127.0.0.1", - snapshot_wallet=SNAPSHOT_DIRECTORY - / "monero-stagenet-preprod-20260721T1140Z.wallet", - snapshot_keys=SNAPSHOT_DIRECTORY - / "monero-stagenet-preprod-20260721T1140Z.keys", - proof_port=39084, - stagenet=True, - address_kind="xmr-stagenet", - proof_directory=pathlib.Path( - "/run/wowngeon-wallet-keyfile-proof-monero-stagenet" - ), - ), - "wownero-mainnet": WalletProfile( - name="wownero-mainnet", - unit="wownero-wallet-rpc.service", - user="jw", - group="jw", - binary=pathlib.Path("/usr/bin/wownero-wallet-rpc"), - live_wallet=pathlib.Path("/home/jw/Programs/wow-11.3.0/game"), - live_rpc_port=34570, - # This is the audited legacy listener. The proof does not broaden or change it. - live_bind_address="0.0.0.0", - snapshot_wallet=SNAPSHOT_DIRECTORY - / "wownero-mainnet-preprod-20260721T1140Z.wallet", - snapshot_keys=SNAPSHOT_DIRECTORY - / "wownero-mainnet-preprod-20260721T1140Z.keys", - proof_port=39570, - stagenet=False, - address_kind="wow-mainnet", - proof_directory=pathlib.Path( - "/run/wowngeon-wallet-keyfile-proof-wownero-mainnet" - ), - ), -} - - -class Refusal(RuntimeError): - """A fixed fail-closed condition. Its detail is deliberately never rendered.""" - - -def refuse(code: str) -> None: - raise Refusal(code) - - -def harden_process() -> None: - os.umask(0o077) - resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) - if LIBC.prctl(PR_SET_DUMPABLE, 0, 0, 0, 0) != 0: - refuse("dump-hardening") - if LIBC.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0: - refuse("privilege-hardening") - - -def protect_buffer(value: bytearray) -> None: - if not value: - refuse("empty-protected-buffer") - address = ctypes.addressof(ctypes.c_char.from_buffer(value)) - page_size = os.sysconf("SC_PAGESIZE") - page_start = address - (address % page_size) - page_end = (address + len(value) + page_size - 1) // page_size * page_size - if LIBC.mlock(ctypes.c_void_p(address), ctypes.c_size_t(len(value))) != 0: - ctypes.memset(ctypes.c_void_p(address), 0, len(value)) - refuse("secret-lock") - if LIBC.madvise( - ctypes.c_void_p(page_start), - ctypes.c_size_t(page_end - page_start), - ctypes.c_int(MADV_DONTDUMP), - ) != 0: - ctypes.memset(ctypes.c_void_p(address), 0, len(value)) - LIBC.munlock(ctypes.c_void_p(address), ctypes.c_size_t(len(value))) - refuse("secret-dontdump") - - -def wipe_buffer(value: bytearray | None, protected: bool = False) -> None: - if value is None or len(value) == 0: - return - address = ctypes.addressof(ctypes.c_char.from_buffer(value)) - ctypes.memset(ctypes.c_void_p(address), 0, len(value)) - if protected: - # mlock is page-granular and Python allocations can share a page. Unlocking one wiped - # allocation could unlock another live protected buffer, so retain the small lock set until - # this short-lived helper exits. - pass - - -def identity(user: str, group: str) -> tuple[int, int]: - try: - return pwd.getpwnam(user).pw_uid, grp.getgrnam(group).gr_gid - except KeyError: - refuse("identity") - - -def open_directory_nofollow(path: pathlib.Path) -> int: - if not path.is_absolute() or pathlib.Path(os.path.normpath(path)) != path: - refuse("path") - descriptor = os.open("/", os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC) - try: - for component in path.parts[1:]: - following = os.open( - component, - os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC, - dir_fd=descriptor, - ) - os.close(descriptor) - descriptor = following - if not stat.S_ISDIR(os.fstat(descriptor).st_mode): - refuse("directory") - return descriptor - except BaseException: - os.close(descriptor) - raise - - -def require_directory( - path: pathlib.Path, expected_uid: int, expected_gid: int, expected_mode: int -) -> os.stat_result: - descriptor = open_directory_nofollow(path) - try: - details = os.fstat(descriptor) - if ( - details.st_uid, - details.st_gid, - stat.S_IMODE(details.st_mode), - ) != (expected_uid, expected_gid, expected_mode): - refuse("directory-metadata") - return details - finally: - os.close(descriptor) - - -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_secure_file_at( - directory_fd: int, - name: str, - expected_uid: int, - expected_gid: int, - expected_mode: int, -) -> tuple[int, tuple[int, ...]]: - try: - descriptor = os.open( - name, os.O_RDONLY | O_NOFOLLOW | O_CLOEXEC, dir_fd=directory_fd - ) - except OSError: - refuse("source-file") - try: - details = os.fstat(descriptor) - if ( - not stat.S_ISREG(details.st_mode) - or details.st_nlink != 1 - or details.st_size <= 0 - or ( - details.st_uid, - details.st_gid, - stat.S_IMODE(details.st_mode), - ) - != (expected_uid, expected_gid, expected_mode) - ): - refuse("source-metadata") - return descriptor, file_identity(details) - except BaseException: - os.close(descriptor) - raise - - -def secure_wipe_fd(descriptor: int) -> None: - details = os.fstat(descriptor) - if not stat.S_ISREG(details.st_mode) or details.st_nlink != 1: - refuse("wipe-target") - os.lseek(descriptor, 0, os.SEEK_SET) - remaining = details.st_size - while remaining: - block = ZERO_BLOCK if remaining >= len(ZERO_BLOCK) else ZERO_BLOCK[:remaining] - view = memoryview(block) - while view: - written = os.write(descriptor, view) - if written <= 0: - refuse("wipe-write") - view = view[written:] - remaining -= len(block) - os.ftruncate(descriptor, 0) - os.fsync(descriptor) - - -def copy_one_exact( - source_fd: int, - expected_source: tuple[int, ...], - destination_fd: int, - destination_name: str, - expected_destination_uid: int, - expected_destination_gid: int, - after_copy: Callable[[], None] | None = None, -) -> None: - output = os.open( - destination_name, - os.O_RDWR | os.O_CREAT | os.O_EXCL | O_NOFOLLOW | O_CLOEXEC, - 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: - refuse("copy-write") - view = view[written:] - os.fsync(output) - if after_copy is not None: - after_copy() - if file_identity(os.fstat(source_fd)) != expected_source: - refuse("source-race") - 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) - copy_block = os.read(output, 1024 * 1024) - if source_block != copy_block: - refuse("copy-mismatch") - if not source_block: - break - output_details = os.fstat(output) - if ( - not stat.S_ISREG(output_details.st_mode) - or output_details.st_uid != expected_destination_uid - or output_details.st_gid != expected_destination_gid - or output_details.st_nlink != 1 - or stat.S_IMODE(output_details.st_mode) != 0o600 - ): - refuse("copy-mode") - published = True - os.fsync(destination_fd) - finally: - if not published: - try: - secure_wipe_fd(output) - except BaseException: - pass - os.close(output) - if not published: - try: - os.unlink(destination_name, dir_fd=destination_fd) - except OSError: - pass - - -def copy_snapshot_pair( - source_directory: pathlib.Path, - source_wallet_name: str, - source_keys_name: str, - destination_directory: pathlib.Path, - expected_uid: int, - expected_gid: int, - after_first_copy: Callable[[], None] | None = None, -) -> None: - source_fd = open_directory_nofollow(source_directory) - destination_fd = open_directory_nofollow(destination_directory) - opened: list[tuple[int, tuple[int, ...]]] = [] - try: - opened.append( - open_secure_file_at( - source_fd, source_wallet_name, expected_uid, expected_gid, 0o600 - ) - ) - opened.append( - open_secure_file_at( - source_fd, source_keys_name, expected_uid, expected_gid, 0o600 - ) - ) - copy_one_exact( - opened[0][0], - opened[0][1], - destination_fd, - "wallet", - expected_uid, - expected_gid, - after_first_copy, - ) - copy_one_exact( - opened[1][0], - opened[1][1], - destination_fd, - "wallet.keys", - expected_uid, - expected_gid, - ) - for descriptor, expected in opened: - if file_identity(os.fstat(descriptor)) != expected: - refuse("pair-source-race") - finally: - for descriptor, _ in opened: - os.close(descriptor) - os.close(source_fd) - os.close(destination_fd) - - -def span_equals(buffer: bytearray, span: tuple[int, int], expected: bytes) -> bool: - start, end = span - return end - start == len(expected) and all( - buffer[start + index] == value for index, value in enumerate(expected) - ) - - -def span_starts(buffer: bytearray, span: tuple[int, int], expected: bytes) -> bool: - start, end = span - return end - start >= len(expected) and all( - buffer[start + index] == value for index, value in enumerate(expected) - ) - - -def argv_spans(buffer: bytearray, length: int) -> list[tuple[int, int]]: - if length < 2 or buffer[length - 1] != 0: - refuse("cmdline-shape") - spans: list[tuple[int, int]] = [] - start = 0 - for index in range(length): - if buffer[index] == 0: - spans.append((start, index)) - start = index + 1 - if not spans or spans[0][0] == spans[0][1]: - refuse("cmdline-empty") - return spans - - -def option_value_span( - buffer: bytearray, spans: list[tuple[int, int]], option: bytes -) -> tuple[int, int]: - matches: list[tuple[int, int]] = [] - inline_prefix = option + b"=" - for index, span in enumerate(spans): - if span_equals(buffer, span, option): - if index + 1 >= len(spans): - refuse("option-value") - matches.append(spans[index + 1]) - elif span_starts(buffer, span, inline_prefix): - matches.append((span[0] + len(inline_prefix), span[1])) - if len(matches) != 1 or matches[0][0] == matches[0][1]: - refuse("option-count") - return matches[0] - - -def count_arg(buffer: bytearray, spans: list[tuple[int, int]], value: bytes) -> int: - return sum(1 for span in spans if span_equals(buffer, span, value)) - - -def count_inline_option( - buffer: bytearray, spans: list[tuple[int, int]], option: bytes -) -> int: - prefix = option + b"=" - return sum(1 for span in spans if span_starts(buffer, span, prefix)) - - -def extract_inline_wallet_password( - buffer: bytearray, - length: int, - profile: WalletProfile, - protect_secret: bool = False, - buffer_is_protected: bool = False, -) -> bytearray: - secret: bytearray | None = None - try: - spans = argv_spans(buffer, length) - if not span_equals(buffer, spans[0], os.fsencode(profile.binary)): - refuse("argv-binary") - if any( - span_equals(buffer, span, forbidden) - or span_starts(buffer, span, forbidden + b"=") - for span in spans - for forbidden in (b"--password-file", b"--wallet-dir") - ): - refuse("argv-indirect-wallet") - wallet_span = option_value_span(buffer, spans, b"--wallet-file") - if not span_equals(buffer, wallet_span, os.fsencode(profile.live_wallet)): - refuse("argv-wallet") - port_span = option_value_span(buffer, spans, b"--rpc-bind-port") - if not span_equals(buffer, port_span, str(profile.live_rpc_port).encode("ascii")): - refuse("argv-port") - if profile.stagenet: - if ( - count_arg(buffer, spans, b"--stagenet") != 1 - or count_inline_option(buffer, spans, b"--stagenet") != 0 - ): - refuse("argv-network") - elif ( - count_arg(buffer, spans, b"--stagenet") != 0 - or count_inline_option(buffer, spans, b"--stagenet") != 0 - ): - refuse("argv-network") - if ( - count_arg(buffer, spans, b"--testnet") != 0 - or count_inline_option(buffer, spans, b"--testnet") != 0 - ): - refuse("argv-network") - password_span = option_value_span(buffer, spans, b"--password") - password_length = password_span[1] - password_span[0] - if password_length < 1 or password_length > 4096: - refuse("password-shape") - if any(buffer[index] in (10, 13) for index in range(*password_span)): - refuse("password-shape") - secret = bytearray(memoryview(buffer)[password_span[0] : password_span[1]]) - if protect_secret: - protect_buffer(secret) - return secret - finally: - wipe_buffer(buffer, buffer_is_protected) - - -def read_live_cmdline_password(pid: int, profile: WalletProfile) -> bytearray: - buffer = bytearray(MAX_CMDLINE) - protect_buffer(buffer) - try: - descriptor = os.open( - f"/proc/{pid}/cmdline", os.O_RDONLY | O_NOFOLLOW | O_CLOEXEC - ) - try: - if not stat.S_ISREG(os.fstat(descriptor).st_mode): - refuse("cmdline-file") - length = os.readv(descriptor, [buffer]) - overflow = bytearray(1) - try: - if os.readv(descriptor, [overflow]) != 0: - refuse("cmdline-size") - finally: - wipe_buffer(overflow) - finally: - os.close(descriptor) - return extract_inline_wallet_password( - buffer, - length, - profile, - protect_secret=True, - buffer_is_protected=True, - ) - except BaseException: - # extract_inline_wallet_password already wipes on the normal parsing path. - wipe_buffer(buffer, True) - raise - - -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, - env={"PATH": "/usr/bin:/bin", "LANG": "C", "LC_ALL": "C"}, - ) - except (OSError, subprocess.TimeoutExpired): - refuse("systemd") - - -def main_pid(unit: str) -> int: - result = systemctl("show", unit, "--property=MainPID", "--value") - if result.returncode != 0: - refuse("mainpid") - try: - return int(result.stdout.strip()) - except ValueError: - refuse("mainpid") - - -def process_status_ids(pid: int, field: str) -> tuple[int, int, int, int]: - try: - with open(f"/proc/{pid}/status", "r", encoding="ascii") as source: - for line in source: - if line.startswith(f"{field}:"): - values = line.split()[1:] - if len(values) == 4: - return ( - int(values[0]), - int(values[1]), - int(values[2]), - int(values[3]), - ) - except (OSError, UnicodeDecodeError, ValueError): - pass - refuse("process-identity") - - -def process_uid(pid: int) -> int: - values = process_status_ids(pid, "Uid") - if len(set(values)) != 1: - refuse("process-uid") - return values[0] - - -def process_gid(pid: int) -> int: - values = process_status_ids(pid, "Gid") - if len(set(values)) != 1: - refuse("process-gid") - return values[0] - - -def process_groups(pid: int) -> list[int]: - try: - with open(f"/proc/{pid}/status", "r", encoding="ascii") as source: - for line in source: - if line.startswith("Groups:"): - return [int(value) for value in line.split()[1:]] - except (OSError, UnicodeDecodeError, ValueError): - pass - refuse("process-groups") - - -def process_status_integer(pid: int, field: str) -> int: - try: - with open(f"/proc/{pid}/status", "r", encoding="ascii") as source: - for line in source: - if line.startswith(f"{field}:"): - values = line.split()[1:] - if len(values) == 1: - return int(values[0]) - except (OSError, UnicodeDecodeError, ValueError): - pass - refuse("process-security-state") - - -def process_status_hex(pid: int, field: str) -> int: - try: - with open(f"/proc/{pid}/status", "r", encoding="ascii") as source: - for line in source: - if line.startswith(f"{field}:"): - values = line.split()[1:] - if len(values) == 1: - return int(values[0], 16) - except (OSError, UnicodeDecodeError, ValueError): - pass - refuse("process-capabilities") - - -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("process-start") - - -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("process-cgroup") - return any(line.rsplit(":", 1)[-1].endswith(f"/{unit}") for line in lines) - - -def process_executable(pid: int) -> pathlib.Path: - try: - return pathlib.Path(os.readlink(f"/proc/{pid}/exe")) - except OSError: - refuse("process-executable") - - -def process_executable_identity(pid: int) -> tuple[int, ...]: - try: - descriptor = os.open(f"/proc/{pid}/exe", os.O_RDONLY | O_CLOEXEC) - except OSError: - refuse("process-executable-file") - try: - details = os.fstat(descriptor) - if not stat.S_ISREG(details.st_mode): - refuse("process-executable-file") - return file_identity(details) - finally: - os.close(descriptor) - - -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("listener-table") - 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("process-fds") - return False - - -def require_exact_listener(pid: int, port: int, bind_address: str) -> None: - active = listeners(port) - if len(active) != 1 or active[0][0] != bind_address: - refuse("listener-identity") - if not process_owns_socket(pid, active[0][1]): - refuse("listener-owner") - - -def require_unused_port(port: int) -> None: - if listeners(port): - refuse("proof-port-collision") - - -@dataclass(frozen=True) -class LiveIdentity: - pid: int - start_time: str - binary_identity: tuple[int, ...] - - -def require_live_process( - profile: WalletProfile, expected_binary: tuple[int, ...] -) -> LiveIdentity: - if systemctl("is-active", "--quiet", profile.unit).returncode != 0: - refuse("unit-inactive") - pid = main_pid(profile.unit) - if pid <= 1: - refuse("unit-pid") - expected_uid, expected_gid = identity(profile.user, profile.group) - started = process_start_time(pid) - if process_uid(pid) != expected_uid: - refuse("live-uid") - if process_gid(pid) != expected_gid: - refuse("live-gid") - if process_executable(pid) != profile.binary: - refuse("live-executable") - if process_executable_identity(pid) != expected_binary: - refuse("live-executable-identity") - if not process_has_unit_cgroup(pid, profile.unit): - refuse("live-cgroup") - require_exact_listener( - pid, profile.live_rpc_port, profile.live_bind_address - ) - return LiveIdentity( - pid=pid, start_time=started, binary_identity=expected_binary - ) - - -def require_live_unchanged(profile: WalletProfile, live: LiveIdentity) -> None: - if main_pid(profile.unit) != live.pid: - refuse("live-race") - if process_start_time(live.pid) != live.start_time: - refuse("live-race") - expected_uid, expected_gid = identity(profile.user, profile.group) - if process_uid(live.pid) != expected_uid: - refuse("live-race") - if process_gid(live.pid) != expected_gid: - refuse("live-race") - if process_executable(live.pid) != profile.binary: - refuse("live-race") - if ( - process_executable_identity(live.pid) != live.binary_identity - or require_binary(profile) != live.binary_identity - ): - refuse("live-race") - if not process_has_unit_cgroup(live.pid, profile.unit): - refuse("live-race") - require_exact_listener( - live.pid, profile.live_rpc_port, profile.live_bind_address - ) - - -def network_shaped_address(value: object, profile: WalletProfile) -> bool: - if not isinstance(value, str) or not value.isascii(): - return False - if profile.address_kind == "xmr-stagenet": - return ( - len(value) == 95 - and value.startswith("5") - and all(character in BASE58_ALPHABET for character in value) - ) - if profile.address_kind == "wow-mainnet": - return ( - len(value) == 97 - and value.startswith("Wo") - and all(character in BASE58_ALPHABET for character in value) - ) - return False - - -def rpc_post( - port: int, - request_body: bytes | bytearray = ADDRESS_REQUEST, - authorization: str | None = None, -) -> tuple[int, str | None, bytes]: - connection = http.client.HTTPConnection("127.0.0.1", port, timeout=5) - headers = {"Content-Type": "application/json", "Accept": "application/json"} - if authorization is not None: - headers["Authorization"] = authorization - try: - connection.request("POST", RPC_PATH, body=request_body, headers=headers) - response = connection.getresponse() - challenge = response.getheader("WWW-Authenticate") - raw = response.read(MAX_RPC_RESPONSE + 1) - if len(raw) > MAX_RPC_RESPONSE: - refuse("rpc-size") - return response.status, challenge, raw - except (OSError, TimeoutError, http.client.HTTPException): - refuse("rpc-transport") - finally: - connection.close() - - -def address_from_response(raw: bytes, profile: WalletProfile) -> str: - try: - payload = json.loads(raw.decode("utf-8")) - address = payload["result"]["address"] - except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError): - refuse("rpc-payload") - if not network_shaped_address(address, profile): - refuse("address-network") - return address - - -def current_live_address(profile: WalletProfile) -> str: - status, _, raw = rpc_post(profile.live_rpc_port) - if status != 200: - refuse("live-rpc-status") - return address_from_response(raw, profile) - - -def parse_digest_challenge(header: str | None) -> dict[str, str]: - if header is None or len(header) > 4096 or "\r" in header or "\n" in header: - refuse("digest-challenge") - scheme, separator, fields = header.partition(" ") - if scheme.lower() != "digest" or not separator: - refuse("digest-scheme") - try: - parsed = urllib.request.parse_keqv_list(urllib.request.parse_http_list(fields)) - except (TypeError, ValueError): - refuse("digest-parse") - if not isinstance(parsed, dict): - refuse("digest-parse") - realm = parsed.get("realm") - nonce = parsed.get("nonce") - qop = parsed.get("qop") - algorithm = parsed.get("algorithm", "MD5") - qop_values = {value.strip().lower() for value in qop.split(",")} if qop else set() - if not realm or not nonce or "auth" not in qop_values: - refuse("digest-fields") - if algorithm.upper() != "MD5": - refuse("digest-algorithm") - for value in (realm, nonce, parsed.get("opaque", "")): - if not value.isascii() or any(character in value for character in ('"', "\\")): - refuse("digest-value") - return parsed - - -def md5_hex(*parts: bytes | bytearray | memoryview) -> str: - try: - digest = hashlib.md5(usedforsecurity=False) - except TypeError: # pragma: no cover - compatibility with older Python/OpenSSL builds - digest = hashlib.md5() - for part in parts: - digest.update(part) - return digest.hexdigest() - - -def random_hex(byte_count: int) -> bytearray: - entropy = bytearray(byte_count) - protect_buffer(entropy) - output = bytearray(byte_count * 2) - try: - protect_buffer(output) - descriptor = os.open("/dev/urandom", os.O_RDONLY | O_NOFOLLOW | O_CLOEXEC) - try: - if os.readv(descriptor, [entropy]) != byte_count: - refuse("random-read") - finally: - os.close(descriptor) - alphabet = b"0123456789abcdef" - for index, value in enumerate(entropy): - output[index * 2] = alphabet[value >> 4] - output[index * 2 + 1] = alphabet[value & 15] - return output - except BaseException: - wipe_buffer(output, True) - raise - finally: - wipe_buffer(entropy, True) - - -def digest_authorization( - challenge: str, rpc_secret: bytearray, nonce_count: int = 1 -) -> str: - if nonce_count < 1 or nonce_count > 0xFFFFFFFF: - refuse("digest-count") - parsed = parse_digest_challenge(challenge) - realm = parsed["realm"] - nonce = parsed["nonce"] - cnonce = random_hex(16) - try: - cnonce_text = bytes(cnonce).decode("ascii") - ha1 = md5_hex( - RPC_USER.encode("ascii"), - b":", - realm.encode("ascii"), - b":", - memoryview(rpc_secret), - ) - ha2 = md5_hex(b"POST", b":", RPC_PATH.encode("ascii")) - nonce_count_text = f"{nonce_count:08x}" - response = md5_hex( - ha1.encode("ascii"), - b":", - nonce.encode("ascii"), - b":", - nonce_count_text.encode("ascii"), - b":", - cnonce_text.encode("ascii"), - b":auth:", - ha2.encode("ascii"), - ) - fields = [ - f'username="{RPC_USER}"', - f'realm="{realm}"', - f'nonce="{nonce}"', - f'uri="{RPC_PATH}"', - "algorithm=MD5", - f'response="{response}"', - "qop=auth", - f"nc={nonce_count_text}", - f'cnonce="{cnonce_text}"', - ] - if parsed.get("opaque"): - fields.append(f'opaque="{parsed["opaque"]}"') - return "Digest " + ", ".join(fields) - finally: - wipe_buffer(cnonce, True) - - -def authenticated_candidate_rpc( - profile: WalletProfile, - challenge: str, - rpc_secret: bytearray, - request_body: bytes | bytearray, - nonce_count: int, -) -> bytes: - authorization = digest_authorization(challenge, rpc_secret, nonce_count) - status, _, raw = rpc_post(profile.proof_port, request_body, authorization) - if status != 200: - refuse("authenticated-rpc-status") - return raw - - -def candidate_address(profile: WalletProfile, challenge: str, rpc_secret: bytearray) -> str: - raw = authenticated_candidate_rpc( - profile, challenge, rpc_secret, ADDRESS_REQUEST, 1 - ) - return address_from_response(raw, profile) - - -def rpc_request(method: str, params: dict[str, object]) -> bytearray: - if method not in {"sign", "verify"}: - refuse("rpc-method") - request = bytearray( - json.dumps( - { - "jsonrpc": "2.0", - "id": "keyfile-recovery-proof", - "method": method, - "params": params, - }, - separators=(",", ":"), - ).encode("ascii") - ) - protect_buffer(request) - return request - - -def prove_spend_capability( - profile: WalletProfile, - challenge: str, - rpc_secret: bytearray, - restored_address: str, -) -> None: - sign_request = rpc_request("sign", {"data": SIGN_MESSAGE}) - try: - sign_raw = authenticated_candidate_rpc( - profile, challenge, rpc_secret, sign_request, 2 - ) - finally: - wipe_buffer(sign_request, True) - try: - sign_payload = json.loads(sign_raw.decode("utf-8")) - signature = sign_payload["result"]["signature"] - except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError): - refuse("sign-payload") - if ( - not isinstance(signature, str) - or not signature.isascii() - or len(signature) < 16 - or len(signature) > 4096 - or not signature.startswith("Sig") - or any(character in ('"', "\\", "\r", "\n") for character in signature) - ): - refuse("signature-shape") - verify_request = rpc_request( - "verify", - { - "data": SIGN_MESSAGE, - "address": restored_address, - "signature": signature, - }, - ) - try: - verify_raw = authenticated_candidate_rpc( - profile, challenge, rpc_secret, verify_request, 3 - ) - finally: - wipe_buffer(verify_request, True) - try: - verify_result = json.loads(verify_raw.decode("utf-8"))["result"] - good = verify_result["good"] - signature_type = verify_result.get("signature_type") - except (UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError): - refuse("verify-payload") - if good is not True or signature_type not in (None, "spend"): - refuse("spend-capability") - - -def wait_for_digest_challenge( - profile: WalletProfile, process: subprocess.Popen[bytes] -) -> str: - deadline = time.monotonic() + 25 - while time.monotonic() < deadline: - if process.poll() is not None: - refuse("candidate-exited") - try: - status, challenge, _ = rpc_post(profile.proof_port, ADDRESS_REQUEST) - if status != 401: - refuse("unauthenticated-not-refused") - parse_digest_challenge(challenge) - require_exact_listener( - process.pid, profile.proof_port, "127.0.0.1" - ) - return challenge or "" - except Refusal as problem: - if str(problem) not in {"rpc-transport", "listener-identity"}: - raise - time.sleep(0.1) - refuse("candidate-timeout") - - -def write_new_file(directory_fd: int, name: str, value: bytearray) -> None: - descriptor = os.open( - name, - os.O_WRONLY | os.O_CREAT | os.O_EXCL | O_NOFOLLOW | O_CLOEXEC, - 0o600, - dir_fd=directory_fd, - ) - try: - view = memoryview(value) - while view: - written = os.write(descriptor, view) - if written <= 0: - refuse("secret-write") - view = view[written:] - os.fsync(descriptor) - details = os.fstat(descriptor) - if ( - details.st_uid != 0 - or details.st_gid != 0 - or details.st_nlink != 1 - or stat.S_IMODE(details.st_mode) != 0o600 - ): - refuse("secret-file-metadata") - finally: - os.close(descriptor) - - -def prepare_candidate_files( - profile: WalletProfile, wallet_password: bytearray -) -> bytearray: - rpc_secret = random_hex(32) - directory_fd = open_directory_nofollow(profile.candidate_directory) - password_file = bytearray(wallet_password) - password_file.extend(b"\n") - config = bytearray() - try: - protect_buffer(password_file) - write_new_file(directory_fd, "wallet-password", password_file) - config.extend( - f"wallet-file={profile.candidate_directory}/wallet\n" - f"password-file={profile.candidate_directory}/wallet-password\n" - f"rpc-bind-port={profile.proof_port}\n" - "rpc-bind-ip=127.0.0.1\n" - "non-interactive=1\n" - f"rpc-login={RPC_USER}:".encode("ascii") - ) - config.extend(rpc_secret) - config.extend(b"\n") - if profile.stagenet: - config.extend(b"stagenet=1\n") - protect_buffer(config) - try: - write_new_file(directory_fd, "wallet-rpc.conf", config) - finally: - wipe_buffer(config, True) - os.fsync(directory_fd) - return rpc_secret - except BaseException: - wipe_buffer(rpc_secret, True) - raise - finally: - wipe_buffer(password_file, True) - os.close(directory_fd) - - -def require_binary(profile: WalletProfile) -> tuple[int, ...]: - parent_fd = open_directory_nofollow(profile.binary.parent) - try: - descriptor, expected = open_secure_file_at( - parent_fd, profile.binary.name, 0, 0, 0o755 - ) - os.close(descriptor) - return expected - finally: - os.close(parent_fd) - - -def require_candidate_process( - profile: WalletProfile, - process: subprocess.Popen[bytes], - expected_binary: tuple[int, ...], - expected_uid: int, - expected_gid: int, -) -> None: - if process.poll() is not None: - refuse("candidate-exited") - if process_uid(process.pid) != expected_uid: - refuse("candidate-uid") - if process_gid(process.pid) != expected_gid: - refuse("candidate-gid") - if process_groups(process.pid): - refuse("candidate-groups") - if process_status_integer(process.pid, "NoNewPrivs") != 1: - refuse("candidate-no-new-privileges") - if any( - process_status_hex(process.pid, field) != 0 - for field in ("CapInh", "CapPrm", "CapEff", "CapAmb") - ): - refuse("candidate-capabilities") - if process_executable(process.pid) != profile.binary: - refuse("candidate-executable") - if process_executable_identity(process.pid) != expected_binary: - refuse("candidate-executable-identity") - if require_binary(profile) != expected_binary: - refuse("candidate-binary-race") - - -def child_hardening(expected_uid: int, expected_gid: int) -> None: - try: - if LIBC.prctl(PR_SET_KEEPCAPS, 0, 0, 0, 0) != 0: - os._exit(126) - os.setgroups([]) - os.setgid(expected_gid) - os.setuid(expected_uid) - if ( - os.getresuid() != (expected_uid, expected_uid, expected_uid) - or os.getresgid() != (expected_gid, expected_gid, expected_gid) - or os.getgroups() - ): - os._exit(126) - os.umask(0o077) - resource.setrlimit(resource.RLIMIT_CORE, (0, 0)) - # Linux resets dumpability across a credential transition, so set this after setuid. - if LIBC.prctl(PR_SET_DUMPABLE, 0, 0, 0, 0) != 0: - os._exit(126) - if LIBC.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0: - os._exit(126) - except BaseException: - os._exit(126) - - -def launch_candidate( - profile: WalletProfile, expected_uid: int, expected_gid: int -) -> subprocess.Popen[bytes]: - command = [ - os.fspath(profile.binary), - "--config-file", - os.fspath(profile.candidate_directory / "wallet-rpc.conf"), - "--offline", - "--log-file", - "/dev/null", - ] - try: - return subprocess.Popen( - command, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - cwd=profile.candidate_directory, - env={ - "PATH": "/usr/bin:/bin", - "HOME": os.fspath(profile.candidate_directory), - "LANG": "C", - "LC_ALL": "C", - }, - close_fds=True, - start_new_session=True, - preexec_fn=lambda: child_hardening(expected_uid, expected_gid), - ) - except OSError: - refuse("candidate-launch") - - -def stop_candidate( - profile: WalletProfile, process: subprocess.Popen[bytes] | None -) -> bool: - if process is None: - return not listeners(profile.proof_port) - clean = True - if process.poll() is None: - try: - process.terminate() - process.wait(timeout=20) - except subprocess.TimeoutExpired: - clean = False - try: - process.kill() - process.wait(timeout=5) - except (OSError, subprocess.TimeoutExpired): - clean = False - except OSError: - clean = False - if process.returncode not in (0, -signal.SIGTERM): - clean = False - deadline = time.monotonic() + 3 - while time.monotonic() < deadline and listeners(profile.proof_port): - time.sleep(0.05) - return clean and not listeners(profile.proof_port) - - -def secure_delete_file_at(directory_fd: int, name: str) -> bool: - try: - before = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) - if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1: - os.unlink(name, dir_fd=directory_fd) - return False - descriptor = os.open( - name, os.O_RDWR | O_NOFOLLOW | O_CLOEXEC, dir_fd=directory_fd - ) - try: - after = os.fstat(descriptor) - if (before.st_dev, before.st_ino) != (after.st_dev, after.st_ino): - return False - secure_wipe_fd(descriptor) - finally: - os.close(descriptor) - os.unlink(name, dir_fd=directory_fd) - os.fsync(directory_fd) - return True - except FileNotFoundError: - return True - except OSError: - return False - - -def secure_clean_directory_fd(directory_fd: int, depth: int = 0) -> bool: - if depth > 8: - return False - clean = True - try: - names = os.listdir(directory_fd) - except OSError: - return False - for name in names: - try: - details = os.stat(name, dir_fd=directory_fd, follow_symlinks=False) - if stat.S_ISREG(details.st_mode): - clean = secure_delete_file_at(directory_fd, name) and clean - elif stat.S_ISDIR(details.st_mode): - child_fd = os.open( - name, - os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC, - dir_fd=directory_fd, - ) - try: - clean = secure_clean_directory_fd(child_fd, depth + 1) and clean - finally: - os.close(child_fd) - os.rmdir(name, dir_fd=directory_fd) - else: - os.unlink(name, dir_fd=directory_fd) - clean = False - except OSError: - clean = False - try: - os.fsync(directory_fd) - except OSError: - clean = False - return clean - - -def secure_remove_proof_directory( - path: pathlib.Path, expected_identity: tuple[int, int] -) -> bool: - try: - directory_fd = open_directory_nofollow(path) - except BaseException: - return False - clean = True - try: - details = os.fstat(directory_fd) - if (details.st_dev, details.st_ino) != expected_identity: - return False - clean = secure_clean_directory_fd(directory_fd) and clean - finally: - os.close(directory_fd) - try: - parent_fd = open_directory_nofollow(path.parent) - try: - current = os.stat(path.name, dir_fd=parent_fd, follow_symlinks=False) - if (current.st_dev, current.st_ino) != expected_identity: - return False - os.rmdir(path.name, dir_fd=parent_fd) - os.fsync(parent_fd) - finally: - os.close(parent_fd) - except BaseException: - return False - return clean - - -def create_proof_directory( - profile: WalletProfile, expected_gid: int -) -> tuple[int, int]: - require_directory(pathlib.Path("/run"), 0, 0, 0o755) - parent_fd = open_directory_nofollow(profile.proof_directory.parent) - created_identity: tuple[int, int] | None = None - proof_fd: int | None = None - try: - try: - os.mkdir(profile.proof_directory.name, 0o700, dir_fd=parent_fd) - except FileExistsError: - refuse("stale-proof-directory") - proof_fd = os.open( - profile.proof_directory.name, - os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC, - dir_fd=parent_fd, - ) - details = os.fstat(proof_fd) - created_identity = details.st_dev, details.st_ino - os.fchown(proof_fd, 0, expected_gid) - os.fchmod(proof_fd, 0o710) - details = os.fstat(proof_fd) - if ( - not stat.S_ISDIR(details.st_mode) - or details.st_uid != 0 - or details.st_gid != expected_gid - or stat.S_IMODE(details.st_mode) != 0o710 - ): - refuse("proof-directory-metadata") - os.mkdir("candidate", 0o700, dir_fd=proof_fd) - candidate_fd = os.open( - "candidate", - os.O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC, - dir_fd=proof_fd, - ) - try: - os.fchown(candidate_fd, 0, 0) - os.fchmod(candidate_fd, 0o700) - candidate_details = os.fstat(candidate_fd) - if ( - not stat.S_ISDIR(candidate_details.st_mode) - or candidate_details.st_uid != 0 - or candidate_details.st_gid != 0 - or stat.S_IMODE(candidate_details.st_mode) != 0o700 - ): - refuse("candidate-directory-metadata") - os.fsync(candidate_fd) - finally: - os.close(candidate_fd) - os.fsync(proof_fd) - os.fsync(parent_fd) - return created_identity - except BaseException: - if created_identity is not None: - try: - current = os.stat( - profile.proof_directory.name, - dir_fd=parent_fd, - follow_symlinks=False, - ) - if (current.st_dev, current.st_ino) == created_identity: - if proof_fd is not None: - try: - os.rmdir("candidate", dir_fd=proof_fd) - except OSError: - pass - os.rmdir(profile.proof_directory.name, dir_fd=parent_fd) - os.fsync(parent_fd) - except OSError: - pass - raise - finally: - if proof_fd is not None: - os.close(proof_fd) - os.close(parent_fd) - - -CANDIDATE_FILES = ("wallet", "wallet.keys", "wallet-password", "wallet-rpc.conf") - - -def publish_candidate_tree( - profile: WalletProfile, expected_uid: int, expected_gid: int -) -> None: - require_directory(profile.proof_directory, 0, expected_gid, 0o710) - directory_fd = open_directory_nofollow(profile.candidate_directory) - try: - directory_details = os.fstat(directory_fd) - if ( - directory_details.st_uid != 0 - or directory_details.st_gid != 0 - or stat.S_IMODE(directory_details.st_mode) != 0o700 - ): - refuse("candidate-directory-prepublish") - if sorted(os.listdir(directory_fd)) != sorted(CANDIDATE_FILES): - refuse("candidate-files") - for name in CANDIDATE_FILES: - descriptor, _ = open_secure_file_at(directory_fd, name, 0, 0, 0o600) - try: - os.fchown(descriptor, expected_uid, expected_gid) - details = os.fstat(descriptor) - if ( - details.st_uid != expected_uid - or details.st_gid != expected_gid - or details.st_nlink != 1 - or stat.S_IMODE(details.st_mode) != 0o600 - ): - refuse("candidate-file-publish") - finally: - os.close(descriptor) - os.fchown(directory_fd, expected_uid, expected_gid) - os.fchmod(directory_fd, 0o700) - os.fsync(directory_fd) - finally: - os.close(directory_fd) - - -def require_published_candidate_tree( - profile: WalletProfile, expected_uid: int, expected_gid: int -) -> None: - require_directory(profile.proof_directory, 0, expected_gid, 0o710) - require_directory( - profile.candidate_directory, expected_uid, expected_gid, 0o700 - ) - directory_fd = open_directory_nofollow(profile.candidate_directory) - try: - if sorted(os.listdir(directory_fd)) != sorted(CANDIDATE_FILES): - refuse("candidate-files-race") - for name in CANDIDATE_FILES: - descriptor, _ = open_secure_file_at( - directory_fd, name, expected_uid, expected_gid, 0o600 - ) - os.close(descriptor) - finally: - os.close(directory_fd) - - -def remove_loaded_secret_files(profile: WalletProfile) -> bool: - directory_fd = open_directory_nofollow(profile.candidate_directory) - try: - password_removed = secure_delete_file_at(directory_fd, "wallet-password") - config_removed = secure_delete_file_at(directory_fd, "wallet-rpc.conf") - return password_removed and config_removed - finally: - os.close(directory_fd) - - -def run_proof(profile: WalletProfile) -> None: - if os.getuid() != 0 or os.geteuid() != 0: - refuse("root-only") - expected_uid, expected_gid = identity(profile.user, profile.group) - if expected_uid <= 0 or expected_gid <= 0: - refuse("non-root-candidate-identity") - require_unused_port(profile.proof_port) - expected_binary = require_binary(profile) - live = require_live_process(profile, expected_binary) - - candidate: subprocess.Popen[bytes] | None = None - wallet_password: bytearray | None = None - rpc_secret: bytearray | None = None - proof_identity: tuple[int, int] | None = None - failed = False - cleanup_ok = True - try: - wallet_password = read_live_cmdline_password(live.pid, profile) - require_live_unchanged(profile, live) - live_address = current_live_address(profile) - require_live_unchanged(profile, live) - blocked_signals = {signal.SIGINT, signal.SIGTERM, signal.SIGHUP, signal.SIGQUIT} - previous_mask = signal.pthread_sigmask(signal.SIG_BLOCK, blocked_signals) - try: - proof_identity = create_proof_directory(profile, expected_gid) - finally: - signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask) - require_directory( - pathlib.Path("/var/backups/wowngeon"), - 0, - grp.getgrnam("postgres").gr_gid, - 0o710, - ) - require_directory(SNAPSHOT_DIRECTORY, 0, 0, 0o700) - copy_snapshot_pair( - SNAPSHOT_DIRECTORY, - profile.snapshot_wallet.name, - profile.snapshot_keys.name, - profile.candidate_directory, - 0, - 0, - ) - rpc_secret = prepare_candidate_files(profile, wallet_password) - wipe_buffer(wallet_password, True) - wallet_password = None - publish_candidate_tree(profile, expected_uid, expected_gid) - require_published_candidate_tree(profile, expected_uid, expected_gid) - candidate = launch_candidate(profile, expected_uid, expected_gid) - require_candidate_process( - profile, candidate, expected_binary, expected_uid, expected_gid - ) - challenge = wait_for_digest_challenge(profile, candidate) - require_candidate_process( - profile, candidate, expected_binary, expected_uid, expected_gid - ) - if not remove_loaded_secret_files(profile): - refuse("loaded-secret-cleanup") - restored_address = candidate_address(profile, challenge, rpc_secret) - if not hmac.compare_digest(live_address, restored_address): - refuse("address-mismatch") - prove_spend_capability( - profile, challenge, rpc_secret, restored_address - ) - wipe_buffer(rpc_secret, True) - rpc_secret = None - require_live_unchanged(profile, live) - require_candidate_process( - profile, candidate, expected_binary, expected_uid, expected_gid - ) - require_exact_listener(candidate.pid, profile.proof_port, "127.0.0.1") - except BaseException: - failed = True - finally: - cleanup_signals = {signal.SIGINT, signal.SIGTERM, signal.SIGHUP, signal.SIGQUIT} - cleanup_mask: set[signal.Signals] | None = None - try: - try: - cleanup_mask = signal.pthread_sigmask(signal.SIG_BLOCK, cleanup_signals) - except BaseException: - cleanup_ok = False - try: - wipe_buffer(wallet_password, wallet_password is not None) - wipe_buffer(rpc_secret, rpc_secret is not None) - except BaseException: - cleanup_ok = False - try: - cleanup_ok = stop_candidate(profile, candidate) and cleanup_ok - except BaseException: - cleanup_ok = False - if proof_identity is not None: - try: - cleanup_ok = ( - secure_remove_proof_directory( - profile.proof_directory, proof_identity - ) - and cleanup_ok - ) - except BaseException: - cleanup_ok = False - finally: - if cleanup_mask is not None: - signal.pthread_sigmask(signal.SIG_SETMASK, cleanup_mask) - if failed or not cleanup_ok: - refuse("proof-failed") - - -def interrupted(_signum: int, _frame: object) -> None: - refuse("interrupted") - - -def main(argv: list[str] | None = None) -> int: - arguments = sys.argv[1:] if argv is None else argv - harden_process() - for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP, signal.SIGQUIT): - signal.signal(signum, interrupted) - if len(arguments) != 1 or arguments[0] not in PROFILES: - refuse("arguments") - run_proof(PROFILES[arguments[0]]) - return 0 - - -def emit_status(passed: bool) -> None: - try: - os.write(1, b"result=passed\n" if passed else b"result=refused\n") - except OSError: - pass - - -if __name__ == "__main__": - exit_code = 1 - try: - exit_code = main() - except BaseException: - # Never render exception text, a traceback, argv, an address, or secret material. - exit_code = 1 - emit_status(exit_code == 0) - raise SystemExit(exit_code) diff --git a/scripts/splitOutputs.js b/scripts/splitOutputs.js index 7b99fa5..130d67c 100755 --- a/scripts/splitOutputs.js +++ b/scripts/splitOutputs.js @@ -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] diff --git a/src/.env.example b/src/.env.example index 7e3d9d8..d39e216 100644 --- a/src/.env.example +++ b/src/.env.example @@ -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 diff --git a/src/.env.mainnet.example b/src/.env.mainnet.example index 6794b46..5f3f12a 100644 --- a/src/.env.mainnet.example +++ b/src/.env.mainnet.example @@ -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 diff --git a/src/chain/chainProfile.js b/src/chain/chainProfile.js index 428ce77..4c72e60 100644 --- a/src/chain/chainProfile.js +++ b/src/chain/chainProfile.js @@ -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)); } diff --git a/src/config/configPersistence.js b/src/config/configPersistence.js deleted file mode 100644 index 28748b2..0000000 --- a/src/config/configPersistence.js +++ /dev/null @@ -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; diff --git a/src/config/environmentValidator.js b/src/config/environmentValidator.js index 0cb9b00..0371684 100644 --- a/src/config/environmentValidator.js +++ b/src/config/environmentValidator.js @@ -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) { diff --git a/src/config/operatedProductProfiles.js b/src/config/operatedProductProfiles.js index eab2c65..aeb5033 100644 --- a/src/config/operatedProductProfiles.js +++ b/src/config/operatedProductProfiles.js @@ -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.' }) }); diff --git a/src/db/databaseManager.js b/src/db/databaseManager.js index 4d2c915..ef14a10 100644 --- a/src/db/databaseManager.js +++ b/src/db/databaseManager.js @@ -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; diff --git a/src/db/user.js b/src/db/user.js index 7277fd8..1e32b7e 100644 --- a/src/db/user.js +++ b/src/db/user.js @@ -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()) }; diff --git a/src/debug/debugManager.js b/src/debug/debugManager.js index 56c3a09..7763b76 100644 --- a/src/debug/debugManager.js +++ b/src/debug/debugManager.js @@ -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); } diff --git a/src/game/difficultyConfig.js b/src/game/difficultyConfig.js index ba89975..6ca3b2f 100644 --- a/src/game/difficultyConfig.js +++ b/src/game/difficultyConfig.js @@ -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); diff --git a/src/game/game.js b/src/game/game.js index 90510cb..6edbdf3 100644 --- a/src/game/game.js +++ b/src/game/game.js @@ -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(), diff --git a/src/game/gameManager.js b/src/game/gameManager.js index b19d650..ace79c2 100644 --- a/src/game/gameManager.js +++ b/src/game/gameManager.js @@ -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) diff --git a/src/game/gameModeManager.js b/src/game/gameModeManager.js index 07f1413..10c87a1 100644 --- a/src/game/gameModeManager.js +++ b/src/game/gameModeManager.js @@ -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: `

Single payout ${p.id} failed and was marked needs_review to avoid a possible double-payout.

` + `

Amount: ${p.amount} → ${p.payout_address}

Error: ${err.message}

` }).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 ? `

A batch of ${pending.rows.length} payout(s) was broadcast as ${err.hashes.length} transactions.` + ` The funds have been sent; only the per-row attribution needs confirming.

` diff --git a/src/game/helpers/gameModeUtils.js b/src/game/helpers/gameModeUtils.js index fb59e0c..8e47208 100644 --- a/src/game/helpers/gameModeUtils.js +++ b/src/game/helpers/gameModeUtils.js @@ -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); diff --git a/src/game/lightingAndFov.js b/src/game/lightingAndFov.js index 70eef073..fd51899 100644 --- a/src/game/lightingAndFov.js +++ b/src/game/lightingAndFov.js @@ -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; } } diff --git a/src/game/movementManager.js b/src/game/movementManager.js index 1d70466..6b02f98 100644 --- a/src/game/movementManager.js +++ b/src/game/movementManager.js @@ -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); diff --git a/src/game/provablyFair.js b/src/game/provablyFair.js index b9a4be2..38a2f2e 100644 --- a/src/game/provablyFair.js +++ b/src/game/provablyFair.js @@ -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 diff --git a/src/game/rot.min.js b/src/game/rot.min.js deleted file mode 100644 index c8d1f38..0000000 --- a/src/game/rot.min.js +++ /dev/null @@ -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;tr&&(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);rg||1342177279>>=1)p+=p;return t}},"es6","es3");$jscomp.stringPadding=function(g,p){g=void 0!==g?String(g):" ";return 0a?a:d}function t(d){return d.charAt(0).toUpperCase()+d.substring(1)}function w(d){for(var c=arguments.length,a=Array(1c;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(1c;c++)for(b=0;be;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=.5a&&(a+=1);1a?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(-1a;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;ac){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;ba?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(1b||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;fa;a++)for(e=0;ea;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(1c;c++){for(b=0;ba;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 -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;bl||65500l||65518this._options.order?a=a.slice(-this._options.order):a.lengthb){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(0b)return e=this._visibleCoords(0,e,a,m),b=this._visibleCoords(360+b,360,a,m),e||b;for(var f=0;fe[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;fd&&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=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(rthis._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;athis._options.roomDugPercentage)break}while(f)};a._generateRoom=function(){for(var b=0;b=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]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;hb||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;ah||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;ll&&!(h.lengthd);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||nf[1]&&(e--, -g=1);m=mf[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&&bthis._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)1b||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=b||0>=a||b>=this._width-1||a>=this._height-1)return null;for(var e=null,c=v[4],d=0;dr;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+1a;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;ab&&(b=2);2>c&&(c=2);return[b,c]};a._initRooms=function(){for(var b=0;bh||h>=this._options.cellWidth||0>k||k>=this._options.cellHeight)){var g=this.rooms[b][a];if(0r||r>=b||0>p||p>=a)){d=this.rooms[r][p]; -k=!0;if(0==d.connections.length)break;for(r=0;rA-(u.y+u.height);)A++;if(0t-(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;ud.cellx? -(k=2,h=4):g.cellxd.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;fu){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;dl;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})}); diff --git a/src/game/rulesets/Ruleset.js b/src/game/rulesets/Ruleset.js index 4c8bf02..49e8c86 100644 --- a/src/game/rulesets/Ruleset.js +++ b/src/game/rulesets/Ruleset.js @@ -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 diff --git a/src/game/rulesets/registry.js b/src/game/rulesets/registry.js index 4f17866..ad4e24f 100644 --- a/src/game/rulesets/registry.js +++ b/src/game/rulesets/registry.js @@ -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. */ diff --git a/src/game/rulesets/winConditions.js b/src/game/rulesets/winConditions.js index 1655350..eef202d 100644 --- a/src/game/rulesets/winConditions.js +++ b/src/game/rulesets/winConditions.js @@ -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) { diff --git a/src/index.js b/src/index.js index bf38663..ab42c11 100644 --- a/src/index.js +++ b/src/index.js @@ -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'); diff --git a/src/migrations/015_payout_batch_tx_hash.sql b/src/migrations/015_payout_batch_tx_hash.sql index e2f0a29..170ffba 100644 --- a/src/migrations/015_payout_batch_tx_hash.sql +++ b/src/migrations/015_payout_batch_tx_hash.sql @@ -3,12 +3,12 @@ -- `transfer_split` sends ONE on-chain transaction to many destinations, so multiple -- payout rows in a batch legitimately share the same tx_hash. The old UNIQUE index on -- payouts.tx_hash (migration 008) made the 2nd+ row in a batch fail to record its --- tx_hash, leaving funds already sent but rows stranded in 'processing' — and at risk +-- tx_hash, leaving funds already sent but rows stranded in 'processing', and at risk -- of being re-sent by the retry service (a double-payout vector). -- -- Payout idempotency does NOT depend on tx_hash uniqueness; it is enforced by -- idx_payouts_one_per_game (one active payout per game). So we drop the payouts --- tx_hash unique index. (The payments.tx_hash unique index stays — incoming payments +-- tx_hash unique index. (The payments.tx_hash unique index stays: incoming payments -- are 1:1 with a transaction.) DROP INDEX IF EXISTS idx_payouts_tx_hash_unique; diff --git a/src/migrations/017_money_status_checks.sql b/src/migrations/017_money_status_checks.sql index c0951b1..442830f 100644 --- a/src/migrations/017_money_status_checks.sql +++ b/src/migrations/017_money_status_checks.sql @@ -2,7 +2,7 @@ -- -- payments.status, payouts.status and payments.payment_type were free-form VARCHARs. -- A typo in any one query (e.g. 'complete' vs 'completed') silently produces a row that --- no index/predicate matches — orphaning money (a payout stuck in a misspelled status is +-- no index/predicate matches, orphaning money (a payout stuck in a misspelled status is -- invisible to both the batcher and the retry candidate query). These CHECKs catch typos. -- -- Added NOT VALID so they enforce all NEW writes without failing on any pre-existing rows, diff --git a/src/migrations/023_payment_safety.sql b/src/migrations/023_payment_safety.sql index a7f7f38..2baf964 100644 --- a/src/migrations/023_payment_safety.sql +++ b/src/migrations/023_payment_safety.sql @@ -1,4 +1,4 @@ --- Migration 023: Payment safety — one game per confirmed single_game payment +-- Migration 023: Payment safety: one game per confirmed single_game payment -- -- M3: claiming a confirmed single_game payment must be atomic. The application locks the -- payment row FOR UPDATE while linking the game, but this partial unique index is the diff --git a/src/migrations/024_cosmetic_catalog.sql b/src/migrations/024_cosmetic_catalog.sql index 9be47b6..243fbb1 100644 --- a/src/migrations/024_cosmetic_catalog.sql +++ b/src/migrations/024_cosmetic_catalog.sql @@ -39,7 +39,7 @@ CREATE INDEX IF NOT EXISTS idx_cosmetic_catalog_active ON cosmetic_catalog(activ -- Seed the four packs that exist today so nothing regresses on first migration. These mirror the -- current hardcoded definitions (roguelike-interior free; the rest premium, credit-unlockable at --- 1 lifetime credit — matching today's {kind:'credits_purchase', minTotalCreditsPurchased:1}). +-- 1 lifetime credit, matching today's {kind:'credits_purchase', minTotalCreditsPurchased:1}). INSERT INTO cosmetic_catalog (pack_id, label, kind, projection, tier, unlock_min_credits, grant_only, sort_order) VALUES ('roguelike-interior', 'Kenney Roguelike Interior', 'render-pack', 'topdown', 0, NULL, FALSE, 0), ('generated-skins', 'Premium Generated Skins', 'render-pack', 'topdown', 1, 1, FALSE, 1), diff --git a/src/migrations/025_graduate_cosmetic_tiers.sql b/src/migrations/025_graduate_cosmetic_tiers.sql index 2a94038..8daa2b8 100644 --- a/src/migrations/025_graduate_cosmetic_tiers.sql +++ b/src/migrations/025_graduate_cosmetic_tiers.sql @@ -1,7 +1,7 @@ -- 025: graduate the cosmetic ladder. -- -- Migration 024 seeded every premium pack at tier 1 / unlock_min_credits 1, so buying ANY credit --- unlocked skins + iso + 3D at once (flat, not tiered). Turn it into a real ladder — unlock by +-- unlocked skins + iso + 3D at once (flat, not tiered). Turn it into a real ladder: unlock by -- lifetime credit spend OR by subscription tier, whichever comes first: -- Fancy (generated-skins) 1 credit / supporter (tier 1) -- Iso (iso-dungeon) 10 credits / premium (tier 2) diff --git a/src/migrations/028_cosmetic_ladder.sql b/src/migrations/028_cosmetic_ladder.sql index 058c611..5fb93ff 100644 --- a/src/migrations/028_cosmetic_ladder.sql +++ b/src/migrations/028_cosmetic_ladder.sql @@ -1,4 +1,4 @@ --- 028: lock the cosmetic ladder (loyalty model — unlock by lifetime credits purchased, cumulative). +-- 028: lock the cosmetic ladder (loyalty model: unlock by lifetime credits purchased, cumulative). -- Adds the FREE `original` bare-tiles baseline and reprices the ladder: Interior 1, skins 5, iso 10, -- +tilepack 20, +iso 40, 3D 50. Mirrors DEFAULT_CATALOG in src/multiplayer/entitlements.js. -- Idempotent. See docs/MONETIZATION.md. diff --git a/src/money/atomic.js b/src/money/atomic.js index 316a535..ced849e 100644 --- a/src/money/atomic.js +++ b/src/money/atomic.js @@ -1,5 +1,5 @@ /** - * Money — atomic-unit arithmetic on BigInt. + * Money: atomic-unit arithmetic on BigInt. * * The single source of truth for money math. All monetary values in this app are integer * atomic units (1 WOW = 10^11, 1 XMR = 10^12). JavaScript `number` is float64 and loses @@ -59,7 +59,7 @@ function add(a, b) { /** * Multiply an atomic amount by a non-negative decimal multiplier (e.g. 2, 3, 1.5, 2.5) - * using exact integer math with half-up rounding — no float multiplication. + * using exact integer math with half-up rounding: no float multiplication. * @param {string|number|bigint} atomic * @param {number|string} multiplier * @returns {bigint} diff --git a/src/multiplayer/MatchEngine.js b/src/multiplayer/MatchEngine.js index 3b1c2a3..0f80113 100644 --- a/src/multiplayer/MatchEngine.js +++ b/src/multiplayer/MatchEngine.js @@ -1,5 +1,5 @@ /** - * MatchEngine — server tick driver for a MatchRoom. + * MatchEngine: server tick driver for a MatchRoom. * * The engine is intentionally thin: all state/rules live in MatchRoom, and this class owns * the timer that calls `room.resolveTick()` at a fixed interval. It also handles graceful diff --git a/src/multiplayer/MatchRoom.js b/src/multiplayer/MatchRoom.js index c64d7c3..0827014 100644 --- a/src/multiplayer/MatchRoom.js +++ b/src/multiplayer/MatchRoom.js @@ -1,5 +1,5 @@ /** - * MatchRoom — a server-authoritative multiplayer race room. + * MatchRoom: a server-authoritative multiplayer race room. * * Extends Room with a shared dungeon, shared monsters, treasure, and per-player * life/death/finish state. The engine is transport-agnostic; MatchManager owns the @@ -48,7 +48,7 @@ class MatchRoom extends Room { super({ id, type: 'match', layout: ['.'], solidOccupants: true, maxOccupants: opts.maxPlayers || 4 }); // Resolve the ruleset (Pillar 4). Precedence: an explicit ruleset object, then a rulesetId - // from the registry, else one synthesized from the legacy opts — which preserves the classic + // from the registry, else one synthesized from the legacy opts, which preserves the classic // race behavior exactly, so nothing changes until a caller asks for a different ruleset. this.ruleset = opts.ruleset ? defineRuleset(opts.ruleset) : (opts.rulesetId ? getRuleset(opts.rulesetId, opts.rulesetOverrides) : rulesetFromMatchOpts(opts)); @@ -176,7 +176,7 @@ class MatchRoom extends Room { /** * Replace the inherited tavern occupants with match entrants placed at the entrance. - * @param {object} entrants — id -> { userId, name, avatar, appearance } + * @param {object} entrants - id -> { userId, name, avatar, appearance } */ _resetEntrants(entrants) { this.occupants.clear(); diff --git a/src/multiplayer/MatchState.js b/src/multiplayer/MatchState.js index e258b1a..37999b9 100644 --- a/src/multiplayer/MatchState.js +++ b/src/multiplayer/MatchState.js @@ -1,5 +1,5 @@ /** - * MatchState — serialization helpers for MatchRoom. + * MatchState: serialization helpers for MatchRoom. * * Produces renderer-agnostic and broadcast-safe state objects. The render kit already * understands the `gameState` shape from `sceneModel.js`, so the primary job here is to @@ -11,7 +11,7 @@ class MatchState { * Build a spectator/client-friendly game-state object from a MatchRoom. * Compatible with `RK.sceneFromGameState()` in `html/js/render/sceneModel.js`. * @param {MatchRoom} room - * @param {string} [viewerId] — if provided, that player is marked `you` + * @param {string} [viewerId] if provided, that player is marked `you` * @returns {object} */ static toGameState(room, viewerId = null) { @@ -43,7 +43,7 @@ class MatchState { /** * Flatten a MatchRoom into the persistence shape for `match_events`. * @param {MatchRoom} room - * @param {Array} events — events from the last tick (or full match) + * @param {Array} events events from the last tick (or full match) * @returns {Array} */ static toEventRows(room, events = []) { diff --git a/src/multiplayer/Occupant.js b/src/multiplayer/Occupant.js index a1e5580..a2e494b 100644 --- a/src/multiplayer/Occupant.js +++ b/src/multiplayer/Occupant.js @@ -1,5 +1,5 @@ /** - * Occupant — a player's avatar inside a real-time Room (Tavern or Multiplayer match). + * Occupant: a player's avatar inside a real-time Room (Tavern or Multiplayer match). * * The multiplayer analogue of game/player.js, but for a shared world: many Occupants * live in one Room and everyone sees everyone move. Kept small and dependency-free so the diff --git a/src/multiplayer/Room.js b/src/multiplayer/Room.js index 4febcb7..3218a77 100644 --- a/src/multiplayer/Room.js +++ b/src/multiplayer/Room.js @@ -5,14 +5,14 @@ const { TAVERN_LAYOUT, FLOOR_CHARS } = require('./tavernMap'); const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development'; /** - * Room — a shared, real-time, multi-occupant world. Shared engine for both new modes: + * Room: a shared, real-time, multi-occupant world. Shared engine for both new modes: * - * • Tavern (type 'tavern'): a peaceful room — walk around, chat, spectate. + * • Tavern (type 'tavern'): a peaceful room: walk around, chat, spectate. * • Multiplayer (type 'match'): the same engine with a dungeon, monsters, hazards and * combat added (later milestones). * * Design invariants (kept transport-agnostic so the engine is testable and reusable): - * • No Socket.IO / DB / Wownerogue coupling here — game-state logic only. A manager layer + * • No Socket.IO / DB / Wownerogue coupling here: game-state logic only. A manager layer * owns transport (Socket.IO rooms, broadcasts) and the server-tick timer. * • Server-authoritative movement: the client requests a step; the Room decides. * • Serialisable: snapshot()/fullState() are broadcast-safe (no engine internals). @@ -184,7 +184,7 @@ class Room { return { tick: this.tickCount, events: [] }; } - /** Occupants-only snapshot — broadcast every tick/update. */ + /** Occupants-only snapshot: broadcast every tick/update. */ snapshot() { return { roomId: this.id, @@ -194,7 +194,7 @@ class Room { }; } - /** Full state including the static map — sent once when an occupant joins. */ + /** Full state including the static map: sent once when an occupant joins. */ fullState() { return { ...this.snapshot(), diff --git a/src/multiplayer/entitlements.js b/src/multiplayer/entitlements.js index f7a59c0..8c03712 100644 --- a/src/multiplayer/entitlements.js +++ b/src/multiplayer/entitlements.js @@ -6,22 +6,22 @@ const Appearance = require('./appearance'); // object just mirrors the four seed packs so nothing regresses without the table. // // Each entry: { id, label, kind, projection, tier, unlockMinCredits, grantOnly, premium }. -// tier — 0 = free tier; ordered ladder (1,2,3) for premium subscriptions. -// unlockMinCredits — lifetime-spend threshold to unlock, or null (not credit-unlockable). -// grantOnly — only obtainable via an explicit grant/purchase. -// premium — derived convenience flag: true unless the pack is free (no gate at all). -// Cosmetic ladder. Unlock by LIFETIME CREDITS PURCHASED (cumulative — spend more, unlock more; NOT +// tier : 0 = free tier; ordered ladder (1,2,3) for premium subscriptions. +// unlockMinCredits : lifetime-spend threshold to unlock, or null (not credit-unlockable). +// grantOnly : only obtainable via an explicit grant/purchase. +// premium : derived convenience flag, true unless the pack is free (no gate at all). +// Cosmetic ladder. Unlock by LIFETIME CREDITS PURCHASED (cumulative: spend more, unlock more; NOT // deducted, so buying credits to play also walks you up the ladder) OR by subscription tier. Each -// row is just data — a tilepack OR a char-skin, at any credit rung — so the operator adds more of +// 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). See docs/MONETIZATION.md. // -// free original tiles (bare — no furniture, the "ugly tavern") + plain ASCII (in renderModes) +// free original tiles (bare: no furniture, the "ugly tavern") + plain ASCII (in renderModes) // 1 Roguelike Interior (first premium tilepack) // 5 Character skins // 10 Iso technique (first iso pack) 20 another tilepack // 40 second iso pack 50 3D technique const DEFAULT_CATALOG = Object.freeze({ - // FREE pleb baseline — the game's original bare tiles. Furniture kinds fall back to floor, so + // FREE pleb baseline: the game's original bare tiles. Furniture kinds fall back to floor, so // the free tavern is pointedly plain (just floor + walls + players/monsters). By design: it's // the reason to unlock the richer packs. 'original': Object.freeze({ @@ -55,7 +55,7 @@ const DEFAULT_CATALOG = Object.freeze({ }); // Ordered premium-tier ladder. Buying credits does NOT put you on this ladder (that was the old -// bug — any purchase → level 'credits' → every premium pack unlocked). 'credits' maps to tier 0. +// bug: any purchase → level 'credits' → every premium pack unlocked). 'credits' maps to tier 0. const TIER_OF = Object.freeze({ free: 0, credits: 0, supporter: 1, premium: 2, operator: 3 }); // Backwards-compatible alias: productGrants.js and older callers validate pack ids against PACKS. @@ -97,7 +97,7 @@ function normalizePackGrants(grants = [], catalog = DEFAULT_CATALOG) { // Only a REAL premium tier (supporter/premium/operator) counts as a level; buying credits does not. // The tier is the HIGHEST of the stored premium_level and any active-subscription tier: a live // wowne.ro premium sub sets subscription_tier (e.g. 'premium') and thereby unlocks the cosmetic -// packs at/below that tier — one subscription drives both chat perks and tile/customization unlocks. +// packs at/below that tier: one subscription drives both chat perks and tile/customization unlocks. // (The subscription_tier field is populated by the user loader from a subscription check; absent = // unchanged legacy behavior.) function levelForUser(user = {}) { @@ -144,7 +144,7 @@ function snapshotForUser(user = {}, packGrants = [], catalog = DEFAULT_CATALOG) }; } -// Display-facing catalog the client renders (labels/projection/gating) — replaces the hardcoded +// Display-facing catalog the client renders (labels/projection/gating): replaces the hardcoded // client RK.PACKS so the operator owns the catalog from one place (the DB). function catalogSummary(catalog = DEFAULT_CATALOG) { const out = []; diff --git a/src/multiplayer/tavernMap.js b/src/multiplayer/tavernMap.js index 50e43c6..dda99e0 100644 --- a/src/multiplayer/tavernMap.js +++ b/src/multiplayer/tavernMap.js @@ -1,5 +1,5 @@ /** - * Default Tavern layout — a hand-built room rendered through the theme atlas. Every row is the same + * Default Tavern layout: a hand-built room rendered through the theme atlas. Every row is the same * width (ragged ASCII maps are a classic off-by-one collision bug), and the legend below drives both * the client tiles (sceneModel TAVERN_LEGEND/TILE_OF) and Room walkability. * @@ -36,7 +36,7 @@ function buildDefaultTavern(cols = 28, rows = 15) { // The bar: a counter across the top interior (row 2), a shelf of bottles behind it (row 1), // and a keg (barrel) bracketing each end. Row 1 is the bartender's space, sealed off by the - // counter — players can't get behind it. + // counter; players can't get behind it. const barY = 2, barX0 = 3, barX1 = cols - 5; for (let x = barX0; x <= barX1; x++) set(x, barY, '='); for (let x = barX0; x <= barX1; x += 2) set(x, barY - 1, 'h'); // bottles behind the bar @@ -46,13 +46,13 @@ function buildDefaultTavern(cols = 28, rows = 15) { const tables = [[5, 6], [12, 6], [19, 6], [5, 10], [12, 10], [19, 10]]; for (const [tx, ty] of tables) { put(tx, ty, 'T'); put(tx, ty + 1, 'c'); } - // (No rug for now — the rug tile renders green, which reads wrong on the wood floor.) + // (No rug for now: the rug tile renders green, which reads wrong on the wood floor.) - // Corner decor — barrels and crates. + // Corner decor: barrels and crates. put(1, 1, 'B'); put(cols - 2, 1, 'C'); put(1, rows - 2, 'C'); put(cols - 2, rows - 2, 'B'); // Fixtures lining the room so it reads lived-in instead of a big empty floor: barrels/crates - // (material variety) plus fire fixtures — braziers ('i') throwing flickering light in the + // (material variety) plus fire fixtures: braziers ('i') throwing flickering light in the // corners and a hearth ('F') anchoring the left wall. All solid, all clear of windows/spawn/ // tables/aisles. The renderers paint the animated flame + glow on the fire tiles. const wallDecor = [ diff --git a/src/network/addressManager.js b/src/network/addressManager.js index f462ab8..75248f5 100644 --- a/src/network/addressManager.js +++ b/src/network/addressManager.js @@ -23,10 +23,9 @@ class AddressManager { } /** - * Drop an unconfirmed address prompt for a departed socket. - * - * `pending` entries were only removed inside confirm(), so any player who triggered address - * detection and then left without answering left a permanent entry keyed on a volatile socket id. + * Drop an unconfirmed address prompt for a departed socket. Required because confirm() is the + * only other path that clears `pending`, and its keys are volatile socket ids: a player who + * triggers detection then leaves without answering would leak an entry. */ forgetSocket(socketId) { this.pending.delete(socketId); @@ -43,7 +42,7 @@ class AddressManager { isValidAddress(address) { const value = typeof address === 'string' ? address.trim() : ''; if (!value) return false; - // Basic length guard for sanity (allowing integrated addresses) + // Upper bound accommodates integrated addresses. if (value.length < 80 || value.length > 120) return false; const currency = String(this.gameModeManager?.cryptoType || '').toUpperCase(); @@ -60,7 +59,7 @@ class AddressManager { async _validateWithWallet(address) { if (!this.gameModeManager || typeof this.gameModeManager.validatePayoutAddress !== 'function') { - return true; // legacy/injected managers retain syntax-only behavior + return true; // injected managers without wallet access are syntax-checked only } let result; try { @@ -142,7 +141,6 @@ class AddressManager { await this._validateWithWallet(pending); const saved = await this.gameModeManager.setUserPayoutAddress(socketId, pending); if (saved !== true) throw new Error('Payout address was not persisted'); - // this.broadcastManager?.sendStatusUpdate(socketId, 'success', 'Payout address saved.'); // Removed duplicate this.io.to(socketId).emit('address_confirmed', { address: pending, message: 'Payout address saved.' }); } catch (e) { const normalized = normalizeError?.(e, 'Failed to save address') || e; @@ -156,7 +154,6 @@ class AddressManager { } } else { this.pending.delete(socketId); - // this.broadcastManager?.sendStatusUpdate(socketId, 'success', 'Payout address accepted (session only).'); // Removed duplicate this.io.to(socketId).emit('address_confirmed', { address: pending, message: 'Payout address accepted (session only).' }); } if (this.onConfirmed) { @@ -166,7 +163,7 @@ class AddressManager { } requiresPayoutAddress() { - if (!this.gameModeManager) return false; // free mode => no requirement + if (!this.gameModeManager) return false; // free mode has no payout address requirement const mode = this.gameModeManager.gameMode; if (typeof this.gameModeManager.requiresPayoutAddressForMode === 'function') { return this.gameModeManager.requiresPayoutAddressForMode(mode); diff --git a/src/network/broadcastManager.js b/src/network/broadcastManager.js index 349af5e..f06230c 100644 --- a/src/network/broadcastManager.js +++ b/src/network/broadcastManager.js @@ -1,7 +1,7 @@ /** * Broadcast Manager Module - * Handles all socket.io event broadcasting for the Wownerogue game server - * Supports current player-specific events and future spectator broadcasting + * Handles all socket.io event broadcasting for the Wownerogue game server: + * global emits to every client and targeted emits to a single player socket. */ class BroadcastManager { @@ -22,19 +22,21 @@ class BroadcastManager { * Broadcast connected user count to all clients */ broadcastUserCount() { - // Try multiple ways to get the count + // io.sockets.sockets is the authoritative set; io.engine.clientsCount is the fallback for + // adapters that do not expose it. let count = 0; if (this.io.sockets && this.io.sockets.sockets) { count = this.io.sockets.sockets.size || 0; } else if (this.io.engine) { count = this.io.engine.clientsCount || 0; } - + if (this.debugManager?.CONSOLE_LOGGING) { console.log(`👥 Broadcasting user count: ${count} (last was ${this._lastUserCount})`); } - // Always broadcast to ensure new clients get the count + // Emitted unconditionally, including on an unchanged count, so clients that just connected + // receive a value. this._lastUserCount = count; this.io.emit('user_count', { count }); } @@ -71,8 +73,8 @@ class BroadcastManager { * @param {string} username - Username of the sender * @param {string} message - Chat message content * @param {number} timestamp - Message timestamp - * @param {string} publicId - Short, non-sensitive public id of the sender (NOT the raw - * full socket.id — S1 defense in depth; the full socket.id must never leave the server) + * @param {string} publicId - Short, non-sensitive public id of the sender. Never the full + * socket.id, which must not leave the server. */ broadcastChatMessage(username, message, timestamp, publicId) { if (this.debugManager?.CONSOLE_LOGGING) { @@ -112,7 +114,7 @@ class BroadcastManager { } /** - * Send game state update to player and any spectators + * Send game state update to the player's socket * @param {string} playerSocketId - The socket ID of the player * @param {object} gameState - The game state to broadcast */ @@ -123,18 +125,7 @@ class BroadcastManager { console.log(` - Torch data: ${!!gameState.torches} (${gameState.torches ? gameState.torches.length : 0} torches)`); } - // Send to the player this.io.to(playerSocketId).emit('game_update', gameState); - - // TODO: Future spectator support - // Get list of spectators for this game and broadcast to them too - // const spectators = getSpectatorsForPlayer(playerSocketId); - // spectators.forEach(spectatorId => { - // this.io.to(spectatorId).emit('spectator_update', { - // playerSocketId: playerSocketId, - // gameState: gameState - // }); - // }); } /** @@ -202,40 +193,6 @@ class BroadcastManager { this.io.to(socketId).emit('debug_pong', pongData); } - // ====== FUTURE SPECTATOR SUPPORT ====== - - /** - * Get spectators for a specific player's game - * @param {string} playerSocketId - The player's socket ID - * @returns {string[]} Array of spectator socket IDs - * TODO: Implement spectator tracking system - */ - getSpectatorsForPlayer(playerSocketId) { - // Future implementation: return array of spectator socket IDs - return []; - } - - /** - * Add spectator to a player's game - * @param {string} playerSocketId - The player's socket ID - * @param {string} spectatorSocketId - The spectator's socket ID - * TODO: Implement spectator tracking system - */ - addSpectator(playerSocketId, spectatorSocketId) { - // Future implementation: track spectators per game - console.log(`TODO: Add spectator ${spectatorSocketId} to watch player ${playerSocketId}`); - } - - /** - * Remove spectator from a player's game - * @param {string} playerSocketId - The player's socket ID - * @param {string} spectatorSocketId - The spectator's socket ID - * TODO: Implement spectator tracking system - */ - removeSpectator(playerSocketId, spectatorSocketId) { - // Future implementation: remove spectator tracking - console.log(`TODO: Remove spectator ${spectatorSocketId} from watching player ${playerSocketId}`); - } } module.exports = BroadcastManager; diff --git a/src/network/chat/ChatProvider.js b/src/network/chat/ChatProvider.js index 7a521f6..41cc486 100644 --- a/src/network/chat/ChatProvider.js +++ b/src/network/chat/ChatProvider.js @@ -1,8 +1,8 @@ /** - * ChatProvider — abstraction over chat message delivery and history. + * ChatProvider: abstraction over chat message delivery and history. * * The seam that lets the chat backend be swapped (e.g. for a Nostr channel) without changing - * callers. Application concerns — commands, moderation, rate limiting, XSS escaping — stay in + * callers. Application concerns (commands, moderation, rate limiting, XSS escaping) stay in * the caller (ChatHandler / TavernManager); a provider only delivers and (optionally) stores. * * Scopes: @@ -40,7 +40,7 @@ class ChatProvider { /** * Deliver a message whose nostr event was signed by the CLIENT (per-player identity, Phase 2). - * The base/local provider has no relay, so it just delivers the message in-game — the signed + * The base/local provider has no relay, so it just delivers the message in-game: the signed * `event` is ignored. NostrChatProvider overrides this to ALSO publish the pre-signed event. * @param {object} req { event, scope, username, text, ts, socketId, userId } */ diff --git a/src/network/chat/NostrChatProvider.js b/src/network/chat/NostrChatProvider.js index 6ac7297..f5ed41e 100644 --- a/src/network/chat/NostrChatProvider.js +++ b/src/network/chat/NostrChatProvider.js @@ -2,7 +2,7 @@ const ChatProvider = require('./ChatProvider'); const { escapeChatText } = require('../../utils/escapeChat'); /** - * NostrChatProvider — global cross-server chat over nostr, layered on the existing ChatProvider seam. + * NostrChatProvider: global cross-server chat over nostr, layered on the existing ChatProvider seam. * * It DECORATES a local provider (SocketChatProvider): every message is still delivered in-game + * persisted exactly as before, and on top of that a global message is fanned out to nostr relays @@ -15,7 +15,7 @@ const { escapeChatText } = require('../../utils/escapeChat'); * Topology: client → server → relay. The server stays the moderation authority (bans, rate-limit, * history in Postgres via the local provider); the relay is transport + fan-out. Signing with a * registered Smirk npub (the bridge key, or later each player's own) is what the relay's - * inbox-outbox policy requires to publish — so global chat is a concrete reason to sign in with Smirk. + * inbox-outbox policy requires to publish, so global chat is a concrete reason to sign in with Smirk. * * Behavior-preserving: with no transport/signer (nostr disabled) this is exactly the local provider. */ @@ -58,7 +58,7 @@ class NostrChatProvider extends ChatProvider { await this.local.initialize(); if (!this._globalEnabled()) return; const since = Math.floor(this.now() / 1000); - // Only future messages — no history flood on connect (local history covers the past). + // Only future messages: no history flood on connect (local history covers the past). this.transport.subscribe([{ kinds: [this.kind], '#t': [this.channelTag], since }], (ev) => this._onRemote(ev)); if (this.debugManager?.CONSOLE_LOGGING) { console.log(`[nostr] chat subscribed: tag=${this.channelTag} kind=${this.kind} publish=${this.signer ? 'on' : 'read-only'}`); @@ -70,7 +70,7 @@ class NostrChatProvider extends ChatProvider { await this.local.publish(msg); // 2) Fan out global messages to nostr (skip room-scoped and skip messages we received - // from the relay, which arrive here only via local delivery — never through publish). + // from the relay, which arrive here only via local delivery, never through publish). if (msg.remote || msg.scope !== 'global' || !this._globalEnabled() || !this.signer) return; try { const template = { @@ -131,7 +131,7 @@ class NostrChatProvider extends ChatProvider { // Relay a CLIENT-signed event (per-player identity, Phase 2). Delivers the message in-game // (escaped text, exactly like a normal global message) and publishes the pre-signed event to - // the relays WITHOUT re-signing — so it carries the player's OWN npub, not the bridge. The + // the relays WITHOUT re-signing, so it carries the player's OWN npub, not the bridge. The // signed id is marked seen so the relay round-trip doesn't re-deliver it. async relaySignedEvent({ event, username, text, ts, socketId, userId } = {}) { await this.local.publish({ scope: 'global', username, text, ts, socketId, userId }); diff --git a/src/network/chat/SocketChatProvider.js b/src/network/chat/SocketChatProvider.js index 942c99a..4ec280f 100644 --- a/src/network/chat/SocketChatProvider.js +++ b/src/network/chat/SocketChatProvider.js @@ -1,7 +1,7 @@ const ChatProvider = require('./ChatProvider'); /** - * SocketChatProvider — the default ChatProvider, backed by Socket.IO for delivery and (for + * SocketChatProvider: the default ChatProvider, backed by Socket.IO for delivery and (for * global chat) the Postgres-backed ChatHistoryManager for persistence. * * Behaviour matches the pre-seam chat exactly: @@ -29,7 +29,7 @@ class SocketChatProvider extends ChatProvider { async publish(msg = {}) { const { scope = 'global', username, text, ts = Date.now(), socketId = null, userId = null } = msg; - // Defense in depth (S1): never broadcast the raw full socket.id to other clients — it is + // Defense in depth (S1): never broadcast the raw full socket.id to other clients; it is // a hijackable handle. Derive a short, non-sensitive public id for display/attribution. const publicId = userId != null ? String(userId) : (socketId ? String(socketId).substring(0, 6) : null); diff --git a/src/network/chat/nostr/bridgeSigner.js b/src/network/chat/nostr/bridgeSigner.js index d65f314..9b9714d 100644 --- a/src/network/chat/nostr/bridgeSigner.js +++ b/src/network/chat/nostr/bridgeSigner.js @@ -9,7 +9,7 @@ const { loadNostrTools } = require('../../../utils/nostrLoader'); * ACCEPTED by relay.smirk.cash (default `inbox-outbox` policy → only registered npubs may publish), * this bridge pubkey must be registered as a Smirk npub (an ops step, like the BTCPay API key). * A future upgrade signs per-message with each player's OWN Smirk npub (window.smirk.signNostrEvent) - * so chat identity == wallet identity cryptographically — the provider stays signer-agnostic, so + * so chat identity == wallet identity cryptographically; the provider stays signer-agnostic, so * that path only swaps the signer. * * Returns null when no key is configured → outbound nostr disabled (the provider still receives). @@ -22,7 +22,7 @@ function createBridgeSigner(secret) { sk = normalizeSecret(secret, tools); pubkey = tools.getPublicKey(sk); } catch (err) { - console.error('[nostr] bridge signer disabled — bad NOSTR_BRIDGE_SK:', err.message); + console.error('[nostr] bridge signer disabled, bad NOSTR_BRIDGE_SK:', err.message); return null; } return { diff --git a/src/network/chatHandler.js b/src/network/chatHandler.js index 541f9a4..d581f1c 100644 --- a/src/network/chatHandler.js +++ b/src/network/chatHandler.js @@ -29,10 +29,10 @@ class ChatHandler { }); // Chat delivery + history go through a ChatProvider seam so the backend is swappable. - // The local provider shares the ChatHistoryManager instance above, so ban checks / stats + // The local provider shares the ChatHistoryManager instance above, so ban checks and stats // that read this.chatHistory keep working and lifecycle stays owned here. buildChatProvider - // optionally wraps it with a NostrChatProvider for global cross-server chat (Pillar 5) when - // NOSTR_CHAT_ENABLED is set; otherwise it returns this local provider unchanged. + // wraps it with a NostrChatProvider for global cross-server chat when NOSTR_CHAT_ENABLED is + // set; otherwise it returns this local provider unchanged. const localChatProvider = new SocketChatProvider({ io: this.io, broadcastManager: this.broadcastManager, @@ -84,7 +84,7 @@ class ChatHandler { */ async handleChatMessage(socket, msg, additionalHandlers = {}) { try { - // Rate limiting for chat messages — stable id + IP so reconnecting can't reset it. + // Rate limiting keys on stable id + IP so reconnecting can't reset the counter. const rlId = stableId(socket, this.gameModeManager?.sessionManager); const rlIp = clientIp(socket); const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'chat:message', rlIp); @@ -95,7 +95,6 @@ class ChatHandler { return; } - // Record the chat attempt await this.rateLimiter.recordAttempt(rlId, 'chat:message', rlIp); if (this.debugManager.CONSOLE_LOGGING) { @@ -104,14 +103,13 @@ class ChatHandler { const command = msg.toLowerCase(); - // Check for XMR/WOW payout address in the message using AddressManager + // A pasted XMR/WOW payout address takes priority over command and chat handling. const detected = this.addressManager.detectInText(msg); if (detected) { await this._handleAddressDetection(socket, detected); return; } - // Handle game commands const handled = await this._handleGameCommand(socket, command, additionalHandlers); if (handled) { return; @@ -127,12 +125,11 @@ class ChatHandler { } /** - * Handle a CLIENT-signed global chat event (Phase 2 per-player nostr identity). The client signs - * with the player's OWN Smirk npub (window.smirk.signNostrEvent) and sends the finished event; - * the server verifies it's authentic AND signed by THIS session's authenticated npub - * (users.smirk_public_key from NIP-98 login), moderates, then relays it — the provider publishes - * the pre-signed event to nostr under the player's npub (not the bridge). Serves lobby + tavern: - * it delivers to global, which both render. + * Handle a client-signed global chat event. The client signs with the player's own Smirk npub + * (window.smirk.signNostrEvent) and sends the finished event; the server verifies it is + * authentic and signed by this session's authenticated npub (users.smirk_public_key from NIP-98 + * login), moderates, then relays it: the provider publishes the pre-signed event to nostr under + * the player's npub, not the bridge. Serves lobby and tavern, both of which render global scope. */ async handleSignedChatMessage(socket, payload) { try { @@ -232,7 +229,7 @@ class ChatHandler { } async _handleAddressDetection(socket, detected) { - // Rate limiting for address setting — stable id + IP. + // Rate limiting keys on stable id + IP. const rlId = stableId(socket, this.gameModeManager?.sessionManager); const rlIp = clientIp(socket); const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'address:set', rlIp); @@ -286,7 +283,6 @@ class ChatHandler { case 'payment': case 'pay': - // Legacy payment command path removed; auto-create request if needed if (this.gameModeManager) { await this.paymentHandlers.createAndShowPaymentRequest(socket); } else { @@ -301,7 +297,7 @@ class ChatHandler { return true; default: - // Check for /nick command (case-sensitive prefix) + // command is already lowercased by the caller, so the prefix match is case-insensitive. if (command.startsWith('/nick ') || command === '/nick') { await this._handleNickCommand(socket, command); return true; @@ -363,7 +359,6 @@ class ChatHandler { const userRow = await this.gameModeManager.getOrCreateUser(socket.id); userId = userRow?.id || null; - // Check if user is chat banned if (userId && await this.chatHistory.isUserChatBanned(userId)) { this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'You have been banned from chat.'); @@ -401,8 +396,7 @@ class ChatHandler { .slice(0, MAX_MESSAGE_LENGTH); const username = socket.id.substring(0, 6); - // Deliver + persist through the chat provider (global scope). The provider saves to - // history (fire-and-forget) and broadcasts, matching the previous inline behaviour. + // The provider broadcasts and saves to history (fire-and-forget). await this.chatProvider.publish({ scope: 'global', username: username, diff --git a/src/network/connectionHandler.js b/src/network/connectionHandler.js index 86865b0..669841b 100644 --- a/src/network/connectionHandler.js +++ b/src/network/connectionHandler.js @@ -17,22 +17,20 @@ class ConnectionHandler { this.clientSocketMap = new Map(); - // Per-IP concurrent-socket cap. The rate limiter already caps NEW connections - // (10/min/IP) and game starts (15/min/IP), but nothing stopped one IP from - // *holding open* hundreds of sockets (trickle in under the rate limit, keep alive) - // to hoard server resources / spectator slots. Track live sockets per IP and reject - // beyond the cap. Default 10 — generous for shared NATs / multi-tab, lethal to farms. + // Per-IP concurrent-socket cap. The rate limiter caps the RATE of new connections and + // game starts, but not how many sockets one IP HOLDS OPEN; a client can trickle in + // under the rate limit and keep hundreds alive to hoard resources and spectator slots. + // The default of 10 is generous for shared NATs and multi-tab use. this.maxSocketsPerIp = parseInt(process.env.MAX_SOCKETS_PER_IP, 10) || 10; this.socketsByIp = new Map(); // ip -> Set - this.socketIpMap = new Map(); // socketId -> ip (for O(1) disconnect cleanup) + this.socketIpMap = new Map(); // socketId -> ip, for O(1) disconnect cleanup - // Memory leak prevention - cleanup old mappings this.mapCleanupInterval = setInterval(() => this.cleanupMappings(), 300000); // 5 minutes } /** Register a live socket for its IP. @returns {boolean} false if the IP is at the cap. */ _trackIpSocket(ip, socketId) { - if (!ip) return true; // can't attribute -> don't block + if (!ip) return true; // unattributable connections are not blocked let set = this.socketsByIp.get(ip); if (set && set.size >= this.maxSocketsPerIp && !set.has(socketId)) { return false; @@ -60,8 +58,8 @@ class ConnectionHandler { */ async handleConnection(socket) { try { - // Rate limiting for connections — use the real client IP (honours a trusted - // reverse proxy via TRUST_PROXY) so per-IP connection limits work behind a proxy. + // The real client IP (honouring a trusted reverse proxy via TRUST_PROXY) so per-IP + // connection limits stay correct behind a proxy. const ip = clientIp(socket) || socket.handshake.address; const rateLimitResult = await this.rateLimiter.checkLimit(ip, 'connection:new', ip); @@ -78,10 +76,8 @@ class ConnectionHandler { return; } - // Record the connection attempt await this.rateLimiter.recordAttempt(ip, 'connection:new', ip); - // Per-IP concurrent-socket cap (resource/hoarding protection). if (!this._trackIpSocket(ip, socket.id)) { if (this.debugManager.CONSOLE_LOGGING) { console.log(`🚫 Concurrent socket cap (${this.maxSocketsPerIp}) reached for IP ${ip}`); @@ -100,21 +96,19 @@ class ConnectionHandler { console.log(socket.handshake.address); } - // Session resume/create (DB authoritative) + ephemeral in-memory user object for legacy game logic + // The DB is authoritative for the session; the in-memory user is ephemeral state + // for game logic that keys off socket.id. const resumeToken = this._getResumeToken(socket); const sessionInfo = await this._initializeSession(socket, resumeToken); - // Always create an in-memory user (legacy systems rely on it) but map to socket.id new user.User(socket.id, socket.handshake.address); const memUser = this._setupMemoryUser(socket); - // Emit session/welcome events this._emitSessionEvents(socket, sessionInfo); - // Send current state this._sendConnectionStatus(socket); - // Broadcast updated user count to all clients (small delay to ensure socket is registered) + // Delayed so the socket is registered with the IO server before it is counted. setTimeout(() => this.broadcastManager.broadcastUserCount(), 100); return { sessionInfo, memUser }; @@ -130,9 +124,9 @@ class ConnectionHandler { * Handle client registration with cleanup */ handleRegisterClient(socket, data) { - // Validate the client-supplied id before it goes into clientSocketMap as both key - // and value. Without this a client could send a huge or colliding id to poison the - // map (and getUserBySocket follows that mapping). + // The client-supplied id becomes both a key and a value in clientSocketMap, and + // getUserBySocket follows that mapping, so an oversized or colliding id would poison + // the map. Validate before storing. const clientId = data && data.clientId; if (typeof clientId !== 'string' || clientId.length === 0 || clientId.length > 64 || !/^[A-Za-z0-9_-]+$/.test(clientId)) { socket.emit('socket_registered', { serverId: socket.id, success: false, error: 'Invalid clientId' }); @@ -143,7 +137,6 @@ class ConnectionHandler { console.log(`Client registered: ${socket.id} (server) <-> ${clientId} (client)`); } - // Clean up any existing mappings for these IDs to prevent memory leaks this._cleanupExistingMappings(socket.id, clientId); this.clientSocketMap.set(clientId, socket.id); @@ -164,31 +157,24 @@ class ConnectionHandler { console.log('User disconnected', socket.client.id); } - // Release the per-IP concurrent-socket slot. this._untrackIpSocket(socket.id); - // Clean up client socket mappings const clientId = this.clientSocketMap.get(socket.id); if (clientId) { this.clientSocketMap.delete(clientId); } this.clientSocketMap.delete(socket.id); - // Clean up user records. - // - // The registry is keyed by socket.id (see `new user.User(socket.id, ...)` in handleConnection - // and User's constructor), but this used to delete by socket.client.id — a DIFFERENT value - // under the Engine.IO 4 protocol. Nothing was ever removed, so the Map grew by one entry per - // connection for the process lifetime, and each retained User can pin a whole Game instance - // through `currentGame`. Remove both keys: socket.id is the real one, client.id is harmless - // and covers any legacy entry. + // The user registry is keyed by socket.id (see `new user.User(socket.id, ...)` in + // handleConnection). socket.client.id is a DIFFERENT value under the Engine.IO 4 + // protocol, so deleting by it alone removes nothing and leaks one User per connection, + // each able to pin a whole Game instance through `currentGame`. Remove both keys: + // socket.id is the real one, client.id covers any entry stored under the older key. user.removeUser(socket.id); if (socket.client?.id && socket.client.id !== socket.id) user.removeUser(socket.client.id); - // Broadcast updated user count to all clients this.broadcastManager.broadcastUserCount(); - - // Call additional cleanup if provided + if (additionalCleanup && typeof additionalCleanup === 'function') { additionalCleanup(socket); } @@ -223,13 +209,13 @@ class ConnectionHandler { */ getStats() { return { - clientSocketMappings: this.clientSocketMap.size / 2, // Divided by 2 since we store bidirectional mappings + clientSocketMappings: this.clientSocketMap.size / 2, // Halved: mappings are stored in both directions rateLimiterStats: this.rateLimiter.getStats() }; } /** - * Shutdown handler - cleanup resources + * Release resources held by this handler. */ shutdown() { if (this.mapCleanupInterval) { @@ -268,7 +254,7 @@ class ConnectionHandler { // already consumed. Do not downgrade it into an anonymous live socket: the outer // connection handler will emit a generic error and disconnect it. if (e.code === 'SESSION_TOKEN_REPLAY') throw e; - // For database connection issues, allow connection but without session features + // A database outage still permits a connection, just without session features. if (e.message.includes('Database not connected')) { console.log('⚠️ Database not ready, allowing connection without session features'); return null; @@ -297,8 +283,8 @@ class ConnectionHandler { } else { this.io.to(socket.id).emit('session_token', { token: sessionInfo.token }); - // Only emit address_confirmed for NEW sessions (not resumed ones) - // Resumed sessions already get payoutAddress in session_resumed + // Only new sessions need address_confirmed; resumed ones carry payoutAddress + // in session_resumed. if (sessionInfo.user.payout_address) { this.io.to(socket.id).emit('address_confirmed', { address: sessionInfo.user.payout_address, @@ -307,14 +293,13 @@ class ConnectionHandler { } } - // Credits convenience push (include creditsPerGame for games remaining calculation) + // creditsPerGame lets the client compute games remaining without a round trip. this.io.to(socket.id).emit('credits_update', { balance: sessionInfo.user.credits || 0, totalCreditsPurchased: sessionInfo.user.total_credits_purchased || 0, creditsPerGame: this.gameModeManager?.creditsPerGameCost || 1 }); - // Notify user if credits were recovered from unprocessed payments if (sessionInfo.recovered && sessionInfo.recovered.creditsRecovered > 0) { this.broadcastManager.sendStatusUpdate(socket.id, 'success', `💰 Payment recovered! ${sessionInfo.recovered.creditsRecovered} credits have been added to your balance.`); @@ -325,13 +310,12 @@ class ConnectionHandler { }); } } else { - // fallback legacy welcome + // Without a session, clients still expect a welcome keyed by client.id. this.io.to(socket.client.id).emit('welcome', socket.client.id); } } _sendConnectionStatus(socket) { - // Send current block height const currentBlock = this.debugManager.getCurrentBlockHeight(); if (this.debugManager.CONSOLE_LOGGING) { console.log(`📈 Sending current block height ${currentBlock} to new connection ${socket.id}`); @@ -345,13 +329,11 @@ class ConnectionHandler { ? this.broadcastManager.blockHeightPayload(currentBlock, tip) : { blockHeight: currentBlock } ); - - // Send connection status + this.broadcastManager.sendStatusUpdate(socket.id, 'connection', `Connected to ${gameNameFor(process.env.CRYPTO_TYPE)} server`); } _cleanupExistingMappings(socketId, clientId) { - // Remove any existing mappings to prevent duplicates and memory leaks const existingSocketForClient = this.clientSocketMap.get(clientId); const existingClientForSocket = this.clientSocketMap.get(socketId); @@ -368,8 +350,7 @@ class ConnectionHandler { */ cleanupMappings() { const activeSocketIds = new Set(); - - // Get all active socket IDs from the IO server + if (this.io && this.io.sockets && this.io.sockets.sockets) { for (const socketId of this.io.sockets.sockets.keys()) { activeSocketIds.add(socketId); @@ -379,24 +360,23 @@ class ConnectionHandler { let cleanedCount = 0; const toDelete = []; - // Find mappings that reference non-existent sockets + // Length distinguishes a socket ID from a client-supplied id closely enough to pick + // the socket-keyed direction; the paired reverse mapping goes with it. for (const [key, value] of this.clientSocketMap.entries()) { - // If key looks like a socket ID and it's not active if (key.length > 10 && !activeSocketIds.has(key)) { toDelete.push(key); - toDelete.push(value); // Also remove the reverse mapping + toDelete.push(value); } } - // Remove stale mappings for (const key of toDelete) { if (this.clientSocketMap.delete(key)) { cleanedCount++; } } - // Reconcile the per-IP concurrent-socket tracking against live sockets, in case a - // disconnect was ever missed (otherwise a leaked entry could wrongly cap an IP). + // Reconcile per-IP tracking against live sockets: a missed disconnect would leave a + // leaked entry that wrongly counts toward the cap for that IP. for (const [socketId, ip] of this.socketIpMap.entries()) { if (!activeSocketIds.has(socketId)) { const set = this.socketsByIp.get(ip); diff --git a/src/network/matchLeaderboard.js b/src/network/matchLeaderboard.js index 0443958..b0ce419 100644 --- a/src/network/matchLeaderboard.js +++ b/src/network/matchLeaderboard.js @@ -1,5 +1,5 @@ /** - * MatchLeaderboard — post-race leaderboard integration. + * MatchLeaderboard: post-race leaderboard integration. * * At the end of every match, this module decides which leaderboard each entrant's score * belongs to: @@ -65,7 +65,7 @@ class MatchLeaderboard { async _postFreeToPleb(room) { // Insert synthetic solo-style records so free races show up on the Pleb board. // Columns are the REAL games columns (treasure_found, moves_made); dungeon_seed is the - // match UUID (36 chars, fits VARCHAR(50)) — never the 64-char seedHash which overflows. + // match UUID (36 chars, fits VARCHAR(50)), never the 64-char seedHash which overflows. await this.db.withTransaction(async (client) => { for (const [socketId, state] of room.playerStates.entries()) { if (!state.userId || state.score <= 0) continue; diff --git a/src/network/matchManager.js b/src/network/matchManager.js index 0147750..f1c6541 100644 --- a/src/network/matchManager.js +++ b/src/network/matchManager.js @@ -1,5 +1,5 @@ /** - * MatchManager — Socket.IO transport and lifecycle for match mode. + * MatchManager: Socket.IO transport and lifecycle for match mode. * * Owns active MatchRooms, their MatchEngines, socket/user-to-match mappings, broadcasts, the * real pre-race countdown, and end-of-match finalization (payout, persistence, leaderboard, @@ -218,7 +218,7 @@ class MatchManager { // MP-H6: REAL countdown. The room stays in 'starting' (moves rejected) until this // fires; only then does the engine begin ticking, so honest and modified clients start - // together — no head-start cheat is possible. + // together: no head-start cheat is possible. const startTimer = setTimeout(() => { const r = this.rooms.get(room.id); if (!r || r.status !== 'starting') return; // expired/finalized during the countdown @@ -701,7 +701,7 @@ class MatchManager { if (userId == null) return false; const newId = socket.id; - // Find the match this user belongs to (user map first — socket id has changed). + // Find the match this user belongs to (user map first: socket id has changed). let matchId = this.userToMatch.get(userId); if (!matchId) matchId = this.socketToMatch.get(newId) || null; if (!matchId) return false; diff --git a/src/network/matchPayoutService.js b/src/network/matchPayoutService.js index febc1c6..49111a0 100644 --- a/src/network/matchPayoutService.js +++ b/src/network/matchPayoutService.js @@ -1,5 +1,5 @@ /** - * MatchPayoutService — crypto-race pot accounting and winner payout. + * MatchPayoutService: crypto-race pot accounting and winner payout. * * On match start: commit each entrant's held ticket to the match and lock the pot (the ACTUAL * atomic amount collected), computing the house fee with exact integer math. @@ -7,7 +7,7 @@ * minus the house fee, then hand it to the existing GameModeManager batch/retry machinery. * * This service does not send on-chain transactions itself; it reuses the existing payout retry - * / batching path in GameModeManager. All pot/fee arithmetic is BigInt — no float precision + * / batching path in GameModeManager. All pot/fee arithmetic is BigInt: no float precision * loss on large atomic amounts. */ @@ -17,7 +17,7 @@ const { reservePayoutCapacity } = require('../services/payoutAdmissionService'); // Sentinel address for a winner who has not set a payout address yet. The payout row is stored // as 'needs_review' so the pot is a durable, queryable liability the winner can claim once they -// provide an address — the house never silently keeps it, and the batcher never sends to this. +// provide an address; the house never silently keeps it, and the batcher never sends to this. const NO_ADDRESS_SENTINEL = 'PENDING_NO_ADDRESS'; class MatchPayoutService { @@ -70,7 +70,7 @@ class MatchPayoutService { * Called by MatchScheduler BEFORE a crypto_race match is announced/started. Commits each * entrant's held queue-join ticket to this match, records the ACTUAL collected pot, and * computes the house fee via exact integer (BigInt) math. Throws on DB failure so the - * caller can abort + refund (the transaction rolls back — no tickets are consumed). + * caller can abort + refund (the transaction rolls back: no tickets are consumed). */ async collectEntryTickets(room, entrants) { if (!this.db || !room || room.economy !== 'crypto_race') return; @@ -169,7 +169,7 @@ class MatchPayoutService { }; for (const entrant of (entrants || [])) { - // Commit the held ticket to this match (delta 0 — it was already deducted at + // Commit the held ticket to this match (delta 0: it was already deducted at // queue-join; this row records the commitment for the audit trail). await client.query(` INSERT INTO race_entry_transactions (user_id, delta, balance_after, reason, match_id) diff --git a/src/network/matchQueue.js b/src/network/matchQueue.js index 9bc914f..50324c1 100644 --- a/src/network/matchQueue.js +++ b/src/network/matchQueue.js @@ -1,11 +1,11 @@ /** - * MatchQueue — persisted, per-economy queue for match mode. + * MatchQueue: persisted, per-economy queue for match mode. * * Responsibilities: * • Enqueue/leave a queue for each economy (free, credits_prestige, crypto_race). * • Atomically deduct credits or tickets into escrow on join; REFUND on EVERY cancellation * path (leave, stale cleanup, drain abort, shutdown, boot recovery), always writing a - * ledger row — never a status flip alone. + * ledger row, never a status flip alone. * • Drain a full economy bucket into a match at block time (memory only mutated after the DB * commit succeeds, so a failed drain leaves the queue intact). * @@ -228,7 +228,7 @@ class MatchQueue { */ async _recoverAbandonedMatches() { // Single-instance (default, this deployment's model): a boot means all in-memory match - // state is gone, so every in-flight row is abandoned — reclaim them all immediately. + // state is gone, so every in-flight row is abandoned; reclaim them all immediately. // Multi-instance (MATCH_SINGLE_INSTANCE=false): a sibling instance may be actively running // a recent race, so only reclaim rows older than the maximum possible match duration // (hard ceiling + buffer). Anything younger is left for its owner (its own ceiling watchdog @@ -259,7 +259,7 @@ class MatchQueue { for (const m of rows) { try { await this.db.withTransaction(async (client) => { - // If a payout already exists, the race effectively completed — don't refund + // If a payout already exists, the race effectively completed: don't refund // (that would double-pay the winner); just finalize the match status so the // batcher/retry can settle the payout. const pay = await client.query(`SELECT id FROM payouts WHERE match_id = $1 LIMIT 1`, [m.id]); @@ -1173,7 +1173,7 @@ class MatchQueue { /** * Leave a queue before a match starts, refunding credits/tickets. Accepts either a resolved - * session object ({ userId, economy }) — the socketHandlers form — or the legacy + * session object ({ userId, economy }), the socketHandlers form, or the legacy * (userId, economy) pair. When no economy is supplied, leaves every economy the user is in. */ async leave(sessionOrUserId, maybeEconomy) { @@ -1408,7 +1408,7 @@ class MatchQueue { expectedById.get(String(row.id)) === String(row.user_id) )); if (!rowsMatch) { - // Memory/DB out of sync — abort without mutating memory; retry next block. + // Memory/DB out of sync: abort without mutating memory; retry next block. throw Object.assign( new Error(`Queue drain mismatch: expected ${queueEntryIds.length}, got ${result.rowCount}`), { code: 'DRAIN_MISMATCH' } @@ -1420,7 +1420,7 @@ class MatchQueue { return null; } - // DB commit succeeded — now remove them from the in-memory queue. + // DB commit succeeded; now remove them from the in-memory queue. const idSet = new Set(queueEntryIds.map(String)); this._queues[economy] = this._queues[economy] .filter(e => !idSet.has(String(e.queueEntryId))); diff --git a/src/network/matchScheduler.js b/src/network/matchScheduler.js index 89f7712..28e4c8e 100644 --- a/src/network/matchScheduler.js +++ b/src/network/matchScheduler.js @@ -1,5 +1,5 @@ /** - * MatchScheduler — block-cadence match maker. + * MatchScheduler: block-cadence match maker. * * Subscribes to the same block event as the solo queue handler. Free matches may drain and start * immediately. Paid matches first persist an exact FIFO entrant freeze, then wait for their @@ -8,7 +8,7 @@ * * MatchScheduler creates MatchRooms but does not own Socket.IO broadcasting; it delegates the * started room to MatchManager for transport, the real countdown, persistence, and finish - * handling. It does NOT start the engine directly — the manager's countdown does — so honest + * handling. It does NOT start the engine directly, the manager's countdown does, so honest * and modified clients always start together. */ @@ -257,7 +257,7 @@ class MatchScheduler { } // getblockcount is the number of blocks, while get_block(height) is zero-based. Every // persisted/disclosed fairness height below is the actual header height whose hash is - // requested—not the count emitted by DebugManager. + // requested, not the count emitted by DebugManager. const observedHeaderHeight = count - 1; // A hot economy/payout disable must release both queued and already-frozen escrow. @@ -665,7 +665,7 @@ class MatchScheduler { } // MP-C4: collect the crypto pot / commit entry tickets BEFORE notifying players or - // starting the engine. If collection fails, ABORT and REFUND every entrant — never + // starting the engine. If collection fails, ABORT and REFUND every entrant: never // consume tickets for a race that did not start. if (economy === 'crypto_race') { const payoutService = this.matchManager?.matchPayoutService; @@ -698,7 +698,7 @@ class MatchScheduler { } } - // Create the engine and hand it to the manager, but DO NOT start it here — the manager's + // Create the engine and hand it to the manager, but DO NOT start it here: the manager's // countdown starts it once the pre-race timer elapses (server-authoritative start). const engine = new MatchEngine({ room, diff --git a/src/network/paymentHandlers.js b/src/network/paymentHandlers.js index 622761b..f648312 100644 --- a/src/network/paymentHandlers.js +++ b/src/network/paymentHandlers.js @@ -23,14 +23,15 @@ class PaymentHandlers { this.queueManager = queueManager; this.broadcastManager = broadcastManager; this.sessionManager = sessionManager; - // Address -> timestamp of the first mempool notification, so we notify/queue once - // per payment address. A Map with TTL eviction (below) instead of an unbounded Set. + // Address -> timestamp of the first mempool notification, so notification and queueing + // happen once per payment address. A Map, not a Set, so the sweep below can evict by TTL + // and the collection stays bounded. this.mempoolNotified = new Map(); this._mempoolNotifiedTtlMs = 60 * 60 * 1000; // 1 hour - // Track underpaid payments we've already warned about (notify once, keep monitoring for top-up) + // Payment ids already warned about as underpaid: warn once, keep monitoring for a top-up. this.underpaidNotified = new Set(); this.paymentMonitors = new Map(); - // Track pending payment metadata per socket (may reuse existing) + // Pending payment metadata per socket; a reused request replaces the entry in place. this.socketPaymentMap = new Map(); // socketId -> { address, paymentId, amount, cryptoType, createdAt } // A single-game fairness offer may arrive before payout-address confirmation. Preserve // the already-consumed proof across that prompt; never fall back to generating a layout @@ -40,17 +41,17 @@ class PaymentHandlers { // bounded, expire quickly, and are cleared on disconnect; raw client objects never enter // this map. this.pendingCommerceAcknowledgement = new Map(); - // Track paymentIds already confirmed (avoid duplicate emits / queue actions) + // Payment ids already confirmed, so duplicate emits and queue actions are suppressed. this.confirmedPayments = new Set(); - // Periodic cleanup (confirmed payment IDs older than retention window) + // Confirmation times, so the sweep can drop ids past the retention window. this._confirmedTimestamps = new Map(); this._confirmedRetentionMs = 6 * 60 * 60 * 1000; // 6 hours - // Track expiry timeouts so they can be cleared / unref'd to avoid keeping process open + // Expiry timeouts, retained so they can be cleared on shutdown. this._expiryTimeouts = new Map(); // socketId -> timeout this._isShuttingDown = false; this._statusUpdatesInFlight = new Set(); this._invoiceCreationsInFlight = new Set(); - // Keep reference so tests can dispose / and unref so it doesn't keep event loop alive + // Retained so dispose() can clear it. this._cleanupInterval = setInterval(() => { const now = Date.now(); for (const [pid, ts] of this._confirmedTimestamps.entries()) { @@ -59,7 +60,7 @@ class PaymentHandlers { this.confirmedPayments.delete(pid); } } - // TTL-evict stale mempool-notified addresses (replaces the old random eviction). + // TTL-evict stale mempool-notified addresses. for (const [addr, ts] of this.mempoolNotified.entries()) { if (now - ts > this._mempoolNotifiedTtlMs) { this.mempoolNotified.delete(addr); @@ -68,7 +69,7 @@ class PaymentHandlers { this._sweepPendingCommerceAcknowledgements(now); }, 30 * 60 * 1000); // sweep every 30 min if (this._cleanupInterval && this._cleanupInterval.unref) { - // Allow process (including Jest) to exit naturally without waiting for this interval + // Unref'd so the interval alone never keeps the event loop alive. this._cleanupInterval.unref(); } } @@ -181,8 +182,8 @@ class PaymentHandlers { /** * Fail closed before creating an invoice when the resulting entry mode can pay out and the - * operator requires an address. The primary browser flow calls handlePaymentRequest directly, - * so this cannot live only in the legacy createAndShowPaymentRequest path. + * operator requires an address. The browser flow calls handlePaymentRequest directly, so this + * check cannot live only in the createAndShowPaymentRequest path. */ async _ensureRequiredPayoutAddress(socket, paymentType) { const mode = this._entryModeForPaymentType(paymentType); @@ -284,8 +285,7 @@ class PaymentHandlers { }; const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType, options); const cryptoType = this.gameModeManager.cryptoType; - - // Generate QR code + let qrDataUrl = null; try { const { generatePaymentQR } = require('../payments/qrService'); @@ -326,15 +326,13 @@ class PaymentHandlers { }); this.clearPendingCommerceAcknowledgement(socket.id); - // CRITICAL: Start payment monitoring! - // Stop any existing monitoring for this socket first + // Drop any monitor left over from an earlier request on this socket so only one + // watcher runs per socket. this.stopMonitoringForSocket(socket.id); - - // Get current user for queue management - const currentUser = this.queueManager?.getUserBySocket ? + + const currentUser = this.queueManager?.getUserBySocket ? this.queueManager.getUserBySocket(socket.id) : { serverId: socket.id }; - - // Start monitoring for payment + this._monitorAddress(socket, paymentRequest, paymentRequest.amount, cryptoType, currentUser, paymentType, paymentRequest.fairnessProof || options.fairnessProof || null); if (paymentType === 'single_game') this.pendingEntryFairness.delete(socket.id); @@ -374,12 +372,11 @@ class PaymentHandlers { return; } - // If both direct and credits modes are enabled, let the user choose + // With both direct and credits modes enabled the user picks the method. const bothModesEnabled = this.gameModeManager.directModeEnabled && this.gameModeManager.creditsModeEnabled; const forceShowOptions = options.showOptions === true; if (bothModesEnabled && !options.paymentType) { - // Send event to show payment options modal on client this.io.to(socket.id).emit('show_payment_options', { reason: 'choose_payment_method', message: 'Choose how you want to play' @@ -390,14 +387,13 @@ class PaymentHandlers { try { const currentUser = socket.id && this.queueManager.getUserBySocket ? this.queueManager.getUserBySocket(socket.id) : null; - // caller (SocketHandlers) will handle user existence; keep method generic + // The caller (SocketHandlers) owns user existence; this method stays generic. const gameMode = this.gameModeManager.gameMode; const cryptoType = this.gameModeManager.cryptoType; let paymentType = options.paymentType; let amount; let description; - // Determine payment type if not specified if (!paymentType) { if (gameMode === 'PAID_SINGLE' || this.gameModeManager.directModeEnabled) { paymentType = 'single_game'; @@ -450,7 +446,7 @@ class PaymentHandlers { return; } - // Resolve authoritative userId from session manager (stable across socket reconnects) + // Session userId is stable across socket reconnects; socket.id is not. let sessionUserId = null; if (this.sessionManager?.sessions?.has(socket.id)) { sessionUserId = this.sessionManager.sessions.get(socket.id).id; @@ -465,7 +461,7 @@ class PaymentHandlers { }); const reused = !!paymentRequest.reused; - // Stop monitoring any expired addresses from previous payment requests + // Release watchers and address maps for requests this one superseded. if (paymentRequest.expiredAddresses?.length > 0) { for (const addr of paymentRequest.expiredAddresses) { this.walletService.stopPaymentMonitoring(addr); @@ -528,7 +524,7 @@ class PaymentHandlers { `${statusHeader}${statusBody}` ); - // If we reused an existing request, ensure we refresh monitoring to avoid duplicate watchers + // A reused request already has a watcher; stop it so monitoring is not duplicated. this.stopMonitoringForSocket(socket.id); this._monitorAddress(socket, paymentRequest, paymentRequest.amount, cryptoType, currentUser, paymentType, paymentRequest.fairnessProof || fairnessProof); @@ -551,7 +547,7 @@ class PaymentHandlers { const isGateway = !!(provider && provider.id !== 'native-monero'); const watchRef = isGateway ? (paymentRequest.invoiceId || paymentRequest.address) : paymentRequest.address; - // Record mapping so we can stop later (replace existing entry) + // Mapping is required to stop the watcher later; it replaces any existing entry. this.socketPaymentMap.set(socket.id, { address: paymentRequest.address, paymentId: paymentRequest.id, @@ -568,19 +564,19 @@ class PaymentHandlers { // Expire request after 30 minutes if not confirmed const expiryTimeout = setTimeout(() => { const mapping = this.socketPaymentMap.get(socket.id); - if (mapping && mapping.address === paymentRequest.address) { // still active and not replaced + if (mapping && mapping.address === paymentRequest.address) { // still active, not replaced this.stopMonitoringForSocket(socket.id); this.broadcastManager.sendStatusUpdate(socket.id, 'warning', 'Payment request expired. Type \'enter\' again to create a new payment request.'); } }, 30 * 60 * 1000); - // Store & unref so tests / process can exit (the 30-min timer must never keep the - // event loop alive on its own — in production the server keeps the process running). + // Unref'd so the 30-minute timer never keeps the event loop alive on its own; the server + // itself keeps the process running. if (expiryTimeout && expiryTimeout.unref) expiryTimeout.unref(); this._expiryTimeouts.set(socket.id, expiryTimeout); - // Start watching. onUpdate delivers a raw wallet-style status; for native that IS the - // walletService status (unchanged legacy path), for a gateway it's mapped from the - // Greenfield invoice. Fall back to direct wallet monitoring when no provider is registered. + // The status callback receives a raw wallet-style status: for native that is the + // walletService status directly, for a gateway it is mapped from the Greenfield invoice. + // With no provider registered, watch the wallet directly. const onStatus = (status) => this._handlePaymentStatus(socket, paymentRequest, currentUser, status); if (provider && typeof provider.startWatch === 'function') { provider.startWatch(watchRef, onStatus, 2000); @@ -604,10 +600,10 @@ class PaymentHandlers { return { pending: 0 }; } - // Handle one payment status update (raw wallet-style shape: in_mempool/confirmed/complete/ - // amount/required/confirmations). A callback that began before the synchronous shutdown gate - // is tracked to completion; later callbacks do no admission work and startup recovery observes - // the underlying durable payment state. + // Handles one payment status update (raw wallet-style shape: in_mempool/confirmed/complete/ + // amount/required/confirmations). A callback that begins before the synchronous shutdown gate + // is tracked to completion; later callbacks do no admission work, and startup recovery + // observes the underlying durable payment state. async _handlePaymentStatus(socket, paymentRequest, currentUser, status) { if (this._isShuttingDown) return; const update = this._handlePaymentStatusActive(socket, paymentRequest, currentUser, status); @@ -627,9 +623,9 @@ class PaymentHandlers { const mapping = this.socketPaymentMap.get(socket.id); const isGameEntry = !mapping || mapping.paymentType === 'single_game'; - // A production paid entry is only usable with the proof durably bound to its - // invoice. Never put a legacy/corrupt unbound payment into the queue merely - // because its transaction appeared in the mempool. + // A paid entry is only usable with its fairness proof durably bound to the + // invoice. An unbound payment is never queued merely because its transaction + // appeared in the mempool. if (isGameEntry && this._requiresBoundPaidFairness() && !mapping?.fairnessProof) { console.error(`[PaymentHandlers] Refusing to queue unbound paid entry ${paymentRequest.id}`); socket.emit('payment_review_required', { @@ -654,7 +650,6 @@ class PaymentHandlers { if (isGameEntry) { const existingIdx = this.queueManager.getPlayerIndex(socket.id); if (existingIdx === -1) { - // Try to get DB userId from session let userId = null; if (this.sessionManager?.sessions?.has(socket.id)) { userId = this.sessionManager.sessions.get(socket.id).id; @@ -673,19 +668,19 @@ class PaymentHandlers { socket.emit('queue_joined', { position: (existingIdx === -1 ? this.queueManager.getQueueLength() : existingIdx + 1), message: 'Payment received! Waiting for next block to start game...', currentBlock: this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null, nextBlock: this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() + 1 : null }); } } else if (status.confirmed) { - // SECURITY (Phase 0.1): A confirmed transaction is NOT sufficient to grant a - // game/credits — we must verify the RECEIVED amount covers what was expected. - // checkPaymentStatus() sets `confirmed` for ANY incoming tx on the subaddress, - // while `complete` means totalReceived >= required. Without this gate a player - // could send a single atomic unit and play (or buy credits) for free. + // A confirmed transaction alone does not grant a game or credits; the received + // amount must cover what was expected. checkPaymentStatus() sets `confirmed` for + // any incoming tx on the subaddress, while `complete` means + // totalReceived >= required. Without this gate a single atomic unit would buy a + // game or credits. let exactCoverage = false; try { exactCoverage = money.toBig(status.amount || 0) >= money.toBig(paymentRequest.amount); } catch (_) { exactCoverage = false; } if (!status.complete || !exactCoverage) { - // Underpaid. Keep monitoring so a later top-up to the same address can - // complete it; warn the user once to avoid spamming on every 2s poll. + // Underpaid: monitoring continues so a later top-up to the same address can + // complete it. The warning fires once, not on every 2s poll. if (!this.underpaidNotified.has(paymentRequest.id)) { this.underpaidNotified.add(paymentRequest.id); const required = status.required || 0; @@ -710,10 +705,9 @@ class PaymentHandlers { return; } - // SECURITY: Use in-memory Set as fast-path, but DB is source of truth - // This prevents double-processing on server restart + // In-memory fast path against double-processing; the DB remains the source of + // truth, so a restart that empties this Set is still safe. if (this.confirmedPayments.has(paymentRequest.id)) { - // Already processed in this session, skip this.stopMonitoringForSocket(socket.id); return; } @@ -735,7 +729,6 @@ class PaymentHandlers { return; } - // Mark in memory to prevent duplicate processing within this session this.confirmedPayments.add(paymentRequest.id); this._confirmedTimestamps.set(paymentRequest.id, Date.now()); @@ -773,7 +766,6 @@ class PaymentHandlers { this.broadcastManager.sendStatusUpdate(socket.id, 'success', `✅ PURCHASE CONFIRMED!\n\n+${creditsResult.creditsAdded} credits added.\nNew balance: ${creditsResult.newBalance} credits.${packText}\n\nType 'enter' to start a game!`); } else if (creditsResult.alreadyProcessed) { - // Payment was already confirmed (e.g., after server restart) - just notify user console.log(`[PaymentHandlers] Payment ${paymentRequest.id} already processed, skipping duplicate`); socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment already processed.', confirmations: status.confirmations }); } else { @@ -796,12 +788,12 @@ class PaymentHandlers { this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'Payment received, but no entitlement was applied. Contact support for review.'); } - // Defensively remove from queue — credits purchases should never be queued + // Credit and product purchases are never queue entries. this.queueManager.removePlayer(socket.id); } else { - // single_game payment - standard flow - // CRITICAL: Atomically update payment status and check if we actually updated it - // This prevents double-processing on server restart + // single_game entry. The status update is conditional on status='pending', + // so the returned row count is what authorizes the game logic below and a + // second confirmation (including after a restart) is a no-op. let wasUpdated = false; let confirmationError = null; try { @@ -831,7 +823,6 @@ class PaymentHandlers { console.error('[PaymentHandlers] Failed to update payment status in DB:', dbErr.message); } - // Only proceed with game logic if we actually confirmed this payment if (!wasUpdated) { if (confirmationError) { socket.emit('payment_review_required', { @@ -850,9 +841,9 @@ class PaymentHandlers { socket.emit('payment_confirmed', { paymentId: paymentRequest.id, message: 'Payment confirmed in block!', confirmations: status.confirmations }); - // Unify to credits: a direct/single_game entry counts as buying + spending 1 - // credit, so it advances total_credits_purchased and unlocks the same tier/ - // threshold cosmetics as a credit purchase. (Runs once — guarded by wasUpdated.) + // A direct single_game entry counts as buying and spending one credit, so it + // advances total_credits_purchased and unlocks the same threshold cosmetics + // as a credit purchase. The wasUpdated guard above keeps this to one run. if (this.gameModeManager && typeof this.gameModeManager.recordDirectEntryPurchase === 'function') { try { const rec = await this.gameModeManager.recordDirectEntryPurchase(socket.id); @@ -869,12 +860,11 @@ class PaymentHandlers { } } - // IMPORTANT: If payment confirmed before mempool detection (fast blocks), - // the player may not be in the queue yet. Add them now with confirmed=true. + // On fast blocks confirmation can precede mempool detection, leaving the + // player out of the queue; they are added here as already confirmed. const existingIdx = this.queueManager.getPlayerIndex(socket.id); if (existingIdx === -1) { console.log(`[PaymentHandlers] Payment confirmed but player not in queue - adding now (socket: ${socket.id})`); - // Try to get DB userId from session let userId = null; if (this.sessionManager?.sessions?.has(socket.id)) { userId = this.sessionManager.sessions.get(socket.id).id; @@ -889,11 +879,11 @@ class PaymentHandlers { fairnessProof: mapping?.fairnessProof || null }); } else { - // Player was in queue from mempool detection, just mark confirmed + // Already queued from mempool detection. this.queueManager.markConfirmed(socket.id); } - // Attempt immediate game start so user doesn't wait another full block + // Start immediately so the player does not wait another full block. const currentBlock = this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null; if (currentBlock !== null) { const started = await this.queueManager.startGameImmediately(socket.id, currentBlock); @@ -909,7 +899,7 @@ class PaymentHandlers { console.log(`[PaymentHandlers] No block height available - player will start on next block`); } } - // Clean up monitoring for this socket (even if duplicate) + // Monitoring ends here on every confirmed path, duplicates included. this.stopMonitoringForSocket(socket.id); } } @@ -924,13 +914,13 @@ class PaymentHandlers { } /** - * Stop monitoring (if any) for a given socket (disconnect/expiry) + * Stop monitoring for a given socket on disconnect or expiry. Safe when none is active. */ stopMonitoringForSocket(socketId) { const mapping = this.socketPaymentMap.get(socketId); if (mapping) { if (mapping.provider && typeof mapping.provider.stopWatch === 'function') { - mapping.provider.stopWatch(mapping.watchRef || mapping.address); // native delegates to stopPaymentMonitoring + mapping.provider.stopWatch(mapping.watchRef || mapping.address); // native provider delegates to stopPaymentMonitoring } else { this.walletService.stopPaymentMonitoring(mapping.address); } @@ -951,7 +941,7 @@ class PaymentHandlers { } /** - * Dispose resources (intervals, monitors) - useful for tests / graceful shutdown. + * Release intervals and monitors for graceful shutdown and test teardown. */ dispose() { this.beginShutdown(); diff --git a/src/network/queueHandler.js b/src/network/queueHandler.js index 7448a41..3617f14 100644 --- a/src/network/queueHandler.js +++ b/src/network/queueHandler.js @@ -21,14 +21,12 @@ class QueueHandler { */ async handleGameQueue(socket, getUserBySocket, opts = {}) { try { - // The player explicitly chose FREE play (Pleb board). Only honoured when the instance - // allows free play; otherwise fall through to the paid eligibility check below. Mirrors - // the auto_start free path so the "Next block · Free Play" choice doesn't wrongly pop the - // payment modal for a free queued game. + // Explicit FREE play (Pleb board) intent, honoured only when the instance allows free + // play; otherwise the paid eligibility check below applies. Matches the auto_start free + // path so a free queued game never raises the payment modal. const wantsFree = opts.free === true && this.gameModeManager?.freePlayEnabled; let authorizedFairnessProof = opts.fairnessProof || null; let authorizedPaymentId = null; - // Rate limiting for queue attempts const rateLimitResult = await this.rateLimiter.checkLimit(socket.id, 'game:queue'); if (!rateLimitResult.allowed) { this.broadcastManager.sendStatusUpdate(socket.id, 'warning', @@ -46,24 +44,21 @@ class QueueHandler { return; } - // Check if already waiting const existingIndex = this.queueManager.getPlayerIndex(socket.id); if (existingIndex !== -1) { this.broadcastManager.sendStatusUpdate(socket.id, 'info', 'You are already in the queue!'); return; } - // Check if already in a game if (this.activeGames.has(socket.id)) { this.broadcastManager.sendStatusUpdate(socket.id, 'error', 'You are already in a game!'); return; } - // Check payment eligibility and payout address requirements — SKIPPED for free play. + // Payment eligibility and payout address requirements do not apply to free play. if (!wantsFree) { const paymentCheckResult = await this._checkPaymentEligibility(socket.id); if (!paymentCheckResult.allowed) { - // Handle the specific reason for denial switch (paymentCheckResult.action) { case 'set_address': this.broadcastManager.sendStatusUpdate(socket.id, 'payment', '⚠️ Paste your payout address first, then type confirm.'); @@ -91,10 +86,8 @@ class QueueHandler { } } - // Record the queue attempt await this.rateLimiter.recordAttempt(socket.id, 'game:queue'); - // Get session info to pass userId let userId = null; if (this.paymentHandlers?.gameModeManager?.getOrCreateUser) { try { @@ -103,7 +96,7 @@ class QueueHandler { } catch (e) {} } - // Add to waiting queue (in free mode or already authorized paid mode). `free` carries the + // Reached only in free mode or with an already authorized paid entry. `free` carries the // Pleb-board intent to processGameStart when the block lands, so no credit/payment is taken. this.queueManager.addPlayer({ serverId: socket.id, @@ -151,7 +144,6 @@ class QueueHandler { getStats() { return { length: this.queueManager.getQueueLength(), - // Add more specific queue stats if needed }; } @@ -166,15 +158,13 @@ class QueueHandler { const config = this.gameModeManager.configSnapshot; const earlyEntry = config?.earlyEntry; - - // Check master toggle + if (!earlyEntry?.enabled) { return { allowed: false, reason: 'Early entry is disabled' }; } const mode = this.gameModeManager.gameMode; - - // Check mode-specific toggles + if (mode === 'FREE' && !earlyEntry.allowInFreeMode) { return { allowed: false, reason: 'Early entry not allowed in free mode' }; } @@ -182,8 +172,8 @@ class QueueHandler { if (mode === 'PAID_CREDITS' && !earlyEntry.allowInCreditsMode) { return { allowed: false, reason: 'Early entry not allowed in credits mode' }; } - - // PAID_SINGLE (direct payment) should NOT allow early entry - players paid for a full block + + // Direct payment buys a full block, so PAID_SINGLE never allows early entry. if (mode === 'PAID_SINGLE') { return { allowed: false, reason: 'Early entry not allowed for direct payment games' }; } @@ -192,12 +182,11 @@ class QueueHandler { } /** - * Handle early entry request - start game immediately without waiting for next block - * Risk: Player will die if next block is found before they escape + * Handle early entry request: start the game immediately instead of waiting for the next block. + * The player dies if the next block is found before they escape. */ async handleEarlyEntry(socket, getUserBySocket, opts = {}) { try { - // Rate limiting const rateLimitResult = await this.rateLimiter.checkLimit(socket.id, 'game:queue'); if (!rateLimitResult.allowed) { const msg = `Please wait ${Math.ceil(rateLimitResult.retryAfter / 1000)} seconds before trying again.`; @@ -210,7 +199,6 @@ class QueueHandler { console.log(`⚡ Player ${socket.id} requested early entry`); } - // Check if early entry is allowed const earlyEntryCheck = this.isEarlyEntryAllowed(); if (!earlyEntryCheck.allowed) { socket.emit('early_entry_error', { message: earlyEntryCheck.reason }); @@ -226,7 +214,6 @@ class QueueHandler { return { success: false, reason: 'user_not_found' }; } - // Check if already in queue if (this.queueManager.isPlayerQueued(socket.id)) { const msg = 'You are already in the queue! Use early entry only when not queued.'; socket.emit('early_entry_error', { message: msg }); @@ -234,7 +221,6 @@ class QueueHandler { return { success: false, reason: 'already_queued' }; } - // Check if already in a game if (this.activeGames.has(socket.id)) { const msg = 'You are already in a game!'; socket.emit('early_entry_error', { message: msg }); @@ -242,7 +228,6 @@ class QueueHandler { return { success: false, reason: 'already_in_game' }; } - // Check payment eligibility const paymentCheckResult = await this._checkPaymentEligibility(socket.id); if (!paymentCheckResult.allowed) { switch (paymentCheckResult.action) { @@ -261,20 +246,16 @@ class QueueHandler { return { success: false, reason: paymentCheckResult.reason }; } - // Record the attempt await this.rateLimiter.recordAttempt(socket.id, 'game:queue'); - // Start the game immediately + // The player's blockRec is the current block, so they die when block currentBlock + 1 + // is found. const currentBlock = this.debugManager.getCurrentBlockHeight(); - - // For early entry, the player's blockRec is set to current block - // This means they will die when the NEXT block is found (currentBlock + 1) const result = await this.queueManager.startEarlyGame(socket.id, currentUser, currentBlock, { fairnessProof: opts.fairnessProof || null }); if (result.success) { - // Emit early entry success event socket.emit('early_entry_success', { blockHeight: currentBlock }); this.broadcastManager.sendStatusUpdate(socket.id, 'info', @@ -284,7 +265,6 @@ class QueueHandler { } return { success: true, blockHeight: currentBlock }; } else { - // Emit early entry error event socket.emit('early_entry_error', { message: result.reason || 'Failed to start early game' }); this.broadcastManager.sendStatusUpdate(socket.id, 'error', result.reason || 'Failed to start early game'); @@ -313,9 +293,9 @@ class QueueHandler { } try { - // Resolve eligibility first: on a mixed instance the actual entry may consume a - // credit or a confirmed direct payment, and those modes can have different payout - // policies. Address-gate the mode that processGameStart will really use. + // Eligibility resolves first: on a mixed instance the entry may consume a credit or a + // confirmed direct payment, and those modes have different payout policies. The address + // gate applies to the mode processGameStart will actually use. const eligibility = await this.gameModeManager.canUserStartGame(socketId); if (!eligibility.allowed) { @@ -362,7 +342,7 @@ class QueueHandler { } catch (error) { console.error('Error checking payment eligibility:', error); - // In paid mode, don't add to queue if payment check fails + // A failed payment check blocks queue entry in any paid mode. if (this.gameModeManager.gameMode !== 'FREE') { return { allowed: false, @@ -370,7 +350,7 @@ class QueueHandler { }; } - // Only in FREE mode, continue to queue on payment system errors + // FREE mode degrades gracefully: queue entry continues when the payment system errors. if (this.debugManager.CONSOLE_LOGGING) { console.log('Payment system unavailable, allowing free mode queue entry'); } diff --git a/src/network/queueManager.js b/src/network/queueManager.js index 423adef..e11c3c5 100644 --- a/src/network/queueManager.js +++ b/src/network/queueManager.js @@ -14,7 +14,7 @@ class QueueManager { this.debugManager = debugManager; this.broadcastManager = broadcastManager; this.io = io; - this.createGameForUser = createGameForUser; // (user, gameType, options) => Game + this.createGameForUser = createGameForUser; // (user, options) => Game this.getUserBySocket = getUserBySocket; // (socketId) => User this.activeGames = activeGames; // Map socketId -> Game this.gameModeManager = gameModeManager; @@ -109,28 +109,25 @@ class QueueManager { console.log(`[QueueManager] Player ${serverId} enters on block ${blockHeight} (dies after ${blockHeight + 1})`); } try { - const game = await this.createGameForUser(currentUser, 'standard', { + const game = await this.createGameForUser(currentUser, { fairnessProof: entry.fairnessProof || null }); const gameState = game.getState(); gameState.blockHeight = blockHeight; - // Include provably fair commitment if (game.getProofCommitment) { gameState.proof = game.getProofCommitment(); } - // Process game start (credits deduction / payment link). forceFree honours a queued - // Pleb-board entry so no credit/payment is taken when the block lands. + // forceFree honours a queued Pleb-board entry, so no credit or payment is taken + // when the block lands. if (this.gameModeManager) { const startRes = await this.gameModeManager.processGameStart(serverId, game.id, { forceFree: !!entry.free }); if (!startRes.success) { - // Abort game + clean up orphaned DB record if (this.activeGames) this.activeGames.delete(serverId); await this._cleanupOrphanedGame(game); this.io.to(serverId).emit('message', 'Error starting game: ' + (startRes.reason || 'Payment processing failed')); continue; } - // Emit credits_update if credits were spent if (startRes.creditsRemaining !== undefined) { this.io.to(serverId).emit('credits_update', { balance: startRes.creditsRemaining }); } @@ -164,8 +161,8 @@ class QueueManager { } /** - * Immediately start a game for a single confirmed player (e.g. payment confirmed AFTER - * the block tick already processed). This prevents an additional full-block wait. + * Immediately start a game for a single confirmed player whose payment confirms after the + * block tick has already processed, avoiding an additional full-block wait. * Returns true if a game was started. */ async startGameImmediately(serverId, blockHeight) { @@ -173,37 +170,32 @@ class QueueManager { if (idx === -1) return false; // not queued const entry = this._waitingPlayers[idx]; if (entry.requiresConfirmation && !entry.confirmed) { - // Still not confirmed; cannot start return false; } - // Remove from queue this._waitingPlayers.splice(idx, 1); const currentUser = this.getUserBySocket(serverId); if (!currentUser) return false; currentUser.blockRec = blockHeight; // lifetime until next block try { - const game = await this.createGameForUser(currentUser, 'standard', { + const game = await this.createGameForUser(currentUser, { fairnessProof: entry.fairnessProof || null }); const gameState = game.getState(); gameState.blockHeight = blockHeight; - // Include provably fair commitment if (game.getProofCommitment) { gameState.proof = game.getProofCommitment(); } - // Process game start (credits deduction / payment link). forceFree honours a queued - // Pleb-board entry confirmed after the tick. + // forceFree honours a queued Pleb-board entry confirmed after the tick, so no + // credit or payment is taken. if (this.gameModeManager) { const startRes = await this.gameModeManager.processGameStart(serverId, game.id, { forceFree: !!entry.free }); if (!startRes.success) { - // Abort game + clean up orphaned DB record if (this.activeGames) this.activeGames.delete(serverId); await this._cleanupOrphanedGame(game); this.io.to(serverId).emit('message', 'Error starting game: ' + (startRes.reason || 'Payment processing failed')); return false; } - // Emit credits_update if credits were spent if (startRes.creditsRemaining !== undefined) { this.io.to(serverId).emit('credits_update', { balance: startRes.creditsRemaining }); } @@ -221,8 +213,8 @@ class QueueManager { } /** - * Start a game immediately for early entry (not from queue) - * Used when player opts for early entry without waiting for block + * Start a game immediately for early entry, bypassing the queue, when a player opts in + * without waiting for the next block. * @param {string} serverId - Socket ID * @param {Object} currentUser - User object * @param {number} blockHeight - Current block height @@ -233,17 +225,16 @@ class QueueManager { return { success: false, reason: 'User not found' }; } - // Check if already in a game if (this.activeGames && this.activeGames.has(serverId)) { return { success: false, reason: 'Already in a game' }; } - // Set blockRec to current block - player dies when next block (currentBlock + 1) is found + // blockRec is the entry block; the player dies when block blockHeight + 1 is found. currentUser.blockRec = blockHeight; - currentUser.isEarlyEntry = true; // Mark as early entry for potential special handling + currentUser.isEarlyEntry = true; try { - const game = await this.createGameForUser(currentUser, 'standard', { + const game = await this.createGameForUser(currentUser, { earlyEntry: true, fairnessProof: options.fairnessProof || null }); @@ -252,21 +243,18 @@ class QueueManager { gameState.isEarlyEntry = true; gameState.deathBlock = blockHeight + 1; // Explicit death block for client display - // Include provably fair commitment if (game.getProofCommitment) { gameState.proof = game.getProofCommitment(); } - // Process game start (credits deduction / payment link) + // Deducts credits or links the payment for this entry. if (this.gameModeManager) { const startRes = await this.gameModeManager.processGameStart(serverId, game.id); if (!startRes.success) { - // Abort game + clean up orphaned DB record if (this.activeGames) this.activeGames.delete(serverId); await this._cleanupOrphanedGame(game); return { success: false, reason: startRes.reason || 'Payment processing failed' }; } - // Emit credits_update if credits were spent if (startRes.creditsRemaining !== undefined) { this.io.to(serverId).emit('credits_update', { balance: startRes.creditsRemaining }); } @@ -303,8 +291,8 @@ class QueueManager { } /** - * Get list of pending games (players waiting in queue) - * Used by spectator system to show upcoming games + * Anonymized view of players waiting in queue, used by the spectator system to show + * upcoming games. * @returns {Array} List of pending game entries */ getPendingGamesList() { diff --git a/src/network/rateLimitContext.js b/src/network/rateLimitContext.js index 00e0ec2..a4ae7a7 100644 --- a/src/network/rateLimitContext.js +++ b/src/network/rateLimitContext.js @@ -14,7 +14,7 @@ // S5: with a single trusted nginx proxy in front, nginx APPENDS the real client IP as the // last hop of X-Forwarded-For. The leftmost entries are attacker-controlled (a client can -// pre-populate the header), so we must read the RIGHTMOST hop — the one the proxy added — +// pre-populate the header), so we must read the RIGHTMOST hop, the one the proxy added, // not the leftmost. Still gated on TRUST_PROXY so a direct-exposure deploy can't be spoofed. function clientIp(socket) { try { diff --git a/src/network/rateLimiter.js b/src/network/rateLimiter.js index c88e5fe..8a7ae6e 100644 --- a/src/network/rateLimiter.js +++ b/src/network/rateLimiter.js @@ -5,30 +5,29 @@ class RateLimiter { constructor(options = {}) { - // Default rate limits. SocketHandlers passes a production `limits` override that - // takes precedence (merged below); these defaults are kept ALIGNED with it so a - // RateLimiter constructed without options (e.g. in tests) behaves the same. + // Default rate limits. SocketHandlers passes a `limits` override that takes + // precedence (merged below); these defaults stay aligned with it so a RateLimiter + // constructed without options behaves the same. this.limits = { 'payment:create': { window: 60000, max: 3 }, // 3 payments per minute 'game:start': { window: 60000, max: 15 }, // 15 game starts per minute 'game:queue': { window: 30000, max: 5 }, // 5 queue attempts per 30s 'chat:message': { window: 10000, max: 12 }, // 12 messages per 10s 'address:set': { window: 300000, max: 3 }, // 3 address changes per 5min - 'move:player': { window: 1000, max: 50 }, // 50 moves per second (already handled separately) + 'move:player': { window: 1000, max: 50 }, // 50 moves per second 'connection:new': { window: 60000, max: 10 }, // 10 connections per minute per IP - // Cheap-looking socket events that each perform a users SELECT + UPDATE (last_active) - // and an entitlements SELECT. Unlimited, they can be pipelined at packet rate and turn - // one socket into a database load generator. + // Identity reads each perform a users SELECT + UPDATE (last_active) and an + // entitlements SELECT. Without a limit they can be pipelined at packet rate, + // turning one socket into a database load generator. 'identity:read': { window: 10000, max: 20 }, // 20 identity/address reads per 10s ...options.limits }; - // Storage for rate limit data - in production, use Redis + // In-process storage; a multi-instance deployment needs a shared store such as Redis. this.storage = new Map(); // key: userId:action, value: { count, firstAttempt } this.ipStorage = new Map(); // key: ip:action, value: { count, firstAttempt } - - // Cleanup interval to prevent memory leaks - this.cleanupInterval = setInterval(() => this.cleanup(), 60000); // cleanup every minute + + this.cleanupInterval = setInterval(() => this.cleanup(), 60000); this.debugMode = options.debugMode || false; } @@ -43,25 +42,21 @@ class RateLimiter { async checkLimit(userId, action, ip = null) { const limit = this.limits[action]; if (!limit) { - // No limit configured for this action return { allowed: true, remaining: Infinity, retryAfter: 0 }; } const now = Date.now(); const key = `${userId}:${action}`; - // Check user-based limits const userResult = this._checkSingleLimit(key, limit, now); - // Check IP-based limits if IP provided - // Apply to: connection actions, payment actions, and game actions let ipResult = { allowed: true, remaining: Infinity, retryAfter: 0 }; if (ip && this._shouldApplyIpLimit(action)) { const ipKey = `${ip}:${action}`; ipResult = this._checkSingleLimit(ipKey, limit, now, this.ipStorage); } - // Return most restrictive result + // Most restrictive of the two applies. const result = { allowed: userResult.allowed && ipResult.allowed, remaining: Math.min(userResult.remaining, ipResult.remaining), @@ -81,15 +76,14 @@ class RateLimiter { * @returns {boolean} */ _shouldApplyIpLimit(action) { - // Actions that should be rate limited by IP (in addition to socket ID) - // This prevents abuse when users reconnect to bypass socket-based limits + // Limited by IP in addition to socket ID, so reconnecting cannot reset the counter. const ipLimitedPrefixes = [ 'connection:', // Connection attempts 'payment:', // Payment creation/operations 'game:', // Game start/queue operations 'address:', // Address changes - 'identity:', // Identity/entitlement reads (each one hits the database) - 'chat:' // Chat messages (reconnect-proof spam protection) + 'identity:', // Identity/entitlement reads, each of which hits the database + 'chat:' // Chat messages ]; return ipLimitedPrefixes.some(prefix => action.startsWith(prefix)); } @@ -104,10 +98,8 @@ class RateLimiter { const now = Date.now(); const key = `${userId}:${action}`; - // Record user attempt this._recordSingleAttempt(key, now); - // Record IP attempt for actions that should have IP-based limiting if (ip && this._shouldApplyIpLimit(action)) { const ipKey = `${ip}:${action}`; this._recordSingleAttempt(ipKey, now, this.ipStorage); @@ -155,7 +147,6 @@ class RateLimiter { const key = `${userId}:${action}`; this.storage.delete(key); } else { - // Clear all limits for this user for (const key of this.storage.keys()) { if (key.startsWith(`${userId}:`)) { this.storage.delete(key); @@ -171,18 +162,14 @@ class RateLimiter { const data = storage.get(key); if (!data) { - // First attempt return { allowed: true, remaining: limit.max - 1, retryAfter: 0 }; } - // Check if window has expired if (now - data.firstAttempt > limit.window) { - // Window expired, reset storage.delete(key); return { allowed: true, remaining: limit.max - 1, retryAfter: 0 }; } - // Within window, check if limit exceeded if (data.count >= limit.max) { const retryAfter = (data.firstAttempt + limit.window) - now; return { allowed: false, remaining: 0, retryAfter: Math.max(0, retryAfter) }; @@ -200,13 +187,10 @@ class RateLimiter { if (!data) { storage.set(key, { count: 1, firstAttempt: now }); } else { - // Check if window has expired const limit = this._getLimitFromKey(key); if (limit && now - data.firstAttempt > limit.window) { - // Reset window storage.set(key, { count: 1, firstAttempt: now }); } else { - // Increment within window data.count++; } } @@ -215,13 +199,12 @@ class RateLimiter { /** * Get limit config from storage key. * - * Storage keys are `${id}:${action}`, but the id itself contains colons — `u:123` / `s:` - * from rateLimitContext.stableId, and raw IPv6 addresses in ipStorage. Stripping only the FIRST - * segment therefore produced `123:chat:message`, which matches no configured limit, so every - * lookup returned undefined. Two things silently broke: `cleanup()` skipped every entry (the only - * registered memory reclamation freed nothing, and both Maps grew for the process lifetime), and - * `_recordAttempt` never reset an expired window, so counts accumulated forever. Match the known - * action names as a suffix instead — correct regardless of how many colons the id contains. + * Storage keys are `${id}:${action}`, and the id itself contains colons: `u:123` / `s:` + * from rateLimitContext.stableId, and raw IPv6 addresses in ipStorage. The action is therefore + * matched as a key suffix rather than by splitting on the first colon, which stays correct no + * matter how many colons the id contains. An unmatched key would make `cleanup()` skip the entry + * (leaking it for the process lifetime) and stop `_recordSingleAttempt` from resetting an + * expired window. */ _getLimitFromKey(key) { for (const action of Object.keys(this.limits)) { @@ -237,7 +220,6 @@ class RateLimiter { const now = Date.now(); let cleanedCount = 0; - // Clean user storage for (const [key, data] of this.storage.entries()) { const limit = this._getLimitFromKey(key); if (limit && now - data.firstAttempt > limit.window) { @@ -246,7 +228,6 @@ class RateLimiter { } } - // Clean IP storage for (const [key, data] of this.ipStorage.entries()) { const limit = this._getLimitFromKey(key); if (limit && now - data.firstAttempt > limit.window) { diff --git a/src/network/sessionManager.js b/src/network/sessionManager.js index 4908063..d69b7cb 100644 --- a/src/network/sessionManager.js +++ b/src/network/sessionManager.js @@ -12,30 +12,27 @@ class SessionManager { this.debugManager = debugManager; this.gameModeManager = gameModeManager; this.io = io; - this.sessions = new Map(); // Memory cache + this.sessions = new Map(); // Keyed by socket id this.cleanupInterval = null; } async initialize() { - // Start cleanup timer for expired sessions this.cleanupInterval = setInterval(() => { this.cleanupExpiredSessions(); - }, 3600000); // Every hour + }, 3600000); // Hourly } async resumeOrCreate({ socketId, ipAddress, resumeToken }) { try { - // SECURE: Using parameterized query if (resumeToken) { const result = await this.db.query( 'SELECT * FROM users WHERE anon_token = $1', - [resumeToken] // Parameterized to prevent injection + [resumeToken] ); if (result.rows.length > 0) { const selectedUser = result.rows[0]; - // Check for unprocessed payments (payment recovery) const recovered = await this.recoverPendingPayments(selectedUser.id, socketId); // Compare-and-swap the bearer credential and socket ownership in one UPDATE. Two @@ -73,13 +70,11 @@ class SessionManager { } } - // Create new user with secure token const newToken = this.generateSecureToken(); - - // SECURE: Using parameterized query for INSERT + const result = await this.db.query( 'INSERT INTO users (socket_id, ip_address, anon_token, created_at, last_seen) VALUES ($1, $2, $3, NOW(), NOW()) RETURNING *', - [socketId, ipAddress, newToken] // All parameterized + [socketId, ipAddress, newToken] ); const newUser = result.rows[0]; @@ -100,15 +95,13 @@ class SessionManager { } async getBySocket(socketId) { - // Check cache first if (this.sessions.has(socketId)) { return this.sessions.get(socketId); } - // SECURE: Parameterized query const result = await this.db.query( 'SELECT * FROM users WHERE socket_id = $1', - [socketId] // Parameterized + [socketId] ); if (result.rows.length > 0) { @@ -121,7 +114,7 @@ class SessionManager { } async updateUser(userId, updates) { - // Build safe UPDATE query with parameterized values + // Column names come from this allowlist only; values are always parameterized. const allowedFields = ['credits', 'payout_address', 'last_seen']; const updateFields = []; const values = []; @@ -139,12 +132,11 @@ class SessionManager { } values.push(userId); - - // SECURE: Fully parameterized UPDATE + const query = `UPDATE users SET ${updateFields.join(', ')} WHERE id = $${paramIndex}`; await this.db.query(query, values); - // Clear cache to force refresh + // Evict the cached row so the next getBySocket re-reads the updated columns. for (const [socketId, user] of this.sessions.entries()) { if (user.id === userId) { this.sessions.delete(socketId); @@ -154,15 +146,15 @@ class SessionManager { } /** - * Recover unprocessed confirmed payments for a user who disconnected - * This handles credits_package payments that were confirmed but credits weren't added + * Recover confirmed payments whose entitlement was never delivered: credits_package + * invoices confirmed without credits added, and unconsumed single_game invoices. */ async recoverPendingPayments(userId, socketId) { const result = { creditsRecovered: 0, paymentsProcessed: 0 }; try { - // Find confirmed credits_package payments that have no credits_purchased or 0 - // These are payments where the user disconnected before confirmation was processed + // Confirmed credits_package payments with credits_purchased null or 0: confirmation + // was never processed through to a credit grant. const unprocessedPayments = await this.db.query(` SELECT p.id, p.payment_type, p.description, p.expected_amount, p.confirmed_at FROM payments p @@ -174,9 +166,8 @@ class SessionManager { ORDER BY p.confirmed_at ASC `, [userId]); - // Also find payments where credits_purchased was set but credits were never - // actually added to the user (e.g., crash/disconnect between payment update - // and user credit update before transaction wrapping was added) + // Payments where credits_purchased is set but no matching ledger row exists, so the + // balance was never actually moved. const orphanedPayments = await this.db.query(` SELECT p.id, p.credits_purchased, p.confirmed_at FROM payments p @@ -195,7 +186,7 @@ class SessionManager { ORDER BY p.confirmed_at ASC `, [userId]); - // Merge orphaned payments into unprocessed list (they need recovery too) + // Both sets take the same recovery path. for (const orphan of orphanedPayments.rows) { unprocessedPayments.rows.push({ id: orphan.id, @@ -203,18 +194,18 @@ class SessionManager { description: null, expected_amount: null, confirmed_at: orphan.confirmed_at, - _creditsFromRecord: orphan.credits_purchased // use stored value + _creditsFromRecord: orphan.credits_purchased }); } - // NOTE: do not early-return when there are no credits_package payments — the - // single_game recovery below must still run. + // An empty credits_package set must not short-circuit the method: the single_game + // recovery below still has to run. for (const payment of (unprocessedPayments.rows.length === 0 ? [] : unprocessedPayments.rows)) { try { - // Determine credits: use stored value from orphaned record, or parse from description + // Prefer the stored credit count; otherwise parse it out of the description. let creditsToAdd = payment._creditsFromRecord || 0; if (!creditsToAdd) { - creditsToAdd = 10; // Default fallback + creditsToAdd = 10; // Fallback when the description carries no count const desc = payment.description || ''; const match = desc.match(/(\d+)\s*credits?/i); if (match) { @@ -223,10 +214,9 @@ class SessionManager { } const isOrphaned = !!payment._creditsFromRecord; - // CRITICAL: Use transaction with row lock to prevent double-credit race condition - // This ensures only one instance can process a payment at a time + // The row lock serializes recovery of a payment across instances, so a payment + // cannot be credited twice. const recovered = await this.db.withTransaction(async (client) => { - // Lock the payment row and re-check status const lockResult = await client.query(` SELECT id, credits_purchased FROM payments @@ -236,14 +226,13 @@ class SessionManager { if (!lockResult.rows[0]) return null; - // For normal recovery: skip if credits_purchased already set - // For orphaned recovery: skip if a credit_transaction already exists + // Idempotency marker differs per case: credits_purchased for a plain + // unprocessed payment, an existing ledger row for an orphaned one. if (!isOrphaned) { if (lockResult.rows[0].credits_purchased && lockResult.rows[0].credits_purchased > 0) { return null; // Already processed } } else { - // Double-check: does a credit_transaction already exist for this? const txCheck = await client.query(` SELECT 1 FROM credit_transactions WHERE user_id = $1 @@ -257,7 +246,6 @@ class SessionManager { } } - // Add credits to user const updateResult = await client.query(` UPDATE users SET credits = credits + $1, @@ -268,14 +256,14 @@ class SessionManager { const newBalance = updateResult.rows[0]?.credits ?? 0; - // Mark payment as processed (atomically with credit add) + // Marking the payment and writing the ledger row share the transaction with + // the balance update, so all three commit together or not at all. await client.query(` UPDATE payments SET credits_purchased = $1 WHERE id = $2 `, [creditsToAdd, payment.id]); - // Record credit transaction await client.query(` INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type) VALUES ($1, $2, 'package_purchase_recovered', $3, 'purchase') @@ -296,11 +284,9 @@ class SessionManager { } } - // PHASE 0.5: Recover confirmed single_game payments that were never consumed. - // If a user paid for a single game and disconnected before a game started (no - // games row references the payment), the money was taken with nothing given and - // there was previously no recovery path. Grant the equivalent credits (one paid - // game) so they get what they paid for. Idempotent via a per-payment reason key. + // Confirmed single_game payments no games row references were paid for but never + // played. Grant the equivalent of one paid game in credits instead. Idempotent via + // the per-payment entitlement grant and reason key. try { const creditsPerGame = (this.gameModeManager && this.gameModeManager.creditsPerGameCost) || 1; const unconsumed = await this.db.query(` @@ -324,8 +310,8 @@ class SessionManager { for (const payment of unconsumed.rows) { try { const recovered = await this.db.withTransaction(async (client) => { - // Lock the payment and re-check under the lock that it is still unconsumed - // and not already recovered (prevents double-credit across instances/races). + // Re-check ownership, status and consumption under the lock so concurrent + // instances cannot both credit the same invoice. const lock = await client.query(` SELECT id, user_id, status, payment_type FROM payments @@ -338,7 +324,7 @@ class SessionManager { || lock.rows[0].payment_type !== 'single_game') return null; const gameCheck = await client.query(`SELECT 1 FROM games WHERE payment_id = $1 LIMIT 1`, [payment.id]); - if (gameCheck.rows.length > 0) return null; // a game was started for it after all + if (gameCheck.rows.length > 0) return null; // A game consumed it in the meantime const existingGrant = await client.query( `SELECT payment_id FROM payment_entitlement_grants WHERE payment_id = $1 FOR UPDATE`, @@ -352,9 +338,9 @@ class SessionManager { [userId, reason] ); if (dup.rows.length > 0) { - // Compatibility with rows written before migration 035. Make the legacy - // ledger reason authoritative by materializing the canonical marker, but do - // not grant anything again. + // Ledger rows written before migration 035 carry only the reason key. Treat + // that key as authoritative: materialize the canonical grant marker for it + // without granting credits again. await client.query(` INSERT INTO payment_entitlement_grants ( payment_id, user_id, source, credits_granted, metadata @@ -414,11 +400,10 @@ class SessionManager { async cleanupExpiredSessions() { try { - // Only delete truly disposable anonymous users: long-idle, zero credits, no payout - // address, and — critically — NO history in any table that references users via a - // RESTRICT/NO ACTION foreign key. Guarding with NOT EXISTS means the DELETE can never - // abort on an FK violation, and we never remove a user who has payments, payouts, or - // games on record (chat_messages / credit_transactions are optional extra guards). + // Only disposable anonymous users are deleted: long-idle, zero credits, no payout + // address, and no history in any table referencing users through a RESTRICT/NO ACTION + // foreign key. The NOT EXISTS guards keep the DELETE from aborting on an FK violation + // and keep users with payments, payouts, or games on record. const result = await this.db.query( `DELETE FROM users u WHERE u.last_seen < NOW() - INTERVAL '90 days' AND u.credits = 0 AND u.payout_address IS NULL @@ -426,7 +411,7 @@ class SessionManager { AND NOT EXISTS (SELECT 1 FROM payouts po WHERE po.user_id = u.id) AND NOT EXISTS (SELECT 1 FROM games g WHERE g.user_id = u.id) RETURNING u.id`, - [] // No parameters needed + [] ); if (result.rowCount > 0) { @@ -439,14 +424,13 @@ class SessionManager { } /** - * C6/G6: Recover games left stuck at status='active' when the server was killed - * mid-run. On a fresh boot no game can legitimately still be running, so every such row - * is an orphan: idempotently restore an exact, durably recorded PAID_CREDITS debit and - * then finalize the game's status. A mode label, payment link, or current entry price is - * never treated as debit evidence; direct-payment refunds require the explicit refund - * workflow. Safe to run repeatedly — each game is locked FOR UPDATE and skipped unless - * still 'active', and each credit restoration has a per-game ledger key. index.js calls - * this at startup right after recoverPendingPayments. + * Finalize games left at status='active' by a server kill. On a fresh boot no game can + * legitimately still be running, so every such row is an orphan: restore an exact, + * durably recorded PAID_CREDITS debit and then settle the game's status. A mode label, + * payment link, or current entry price is not debit evidence; direct-payment refunds go + * through the explicit refund workflow. Repeat runs are safe: each game is locked FOR + * UPDATE and skipped unless still 'active', and each restoration has a per-game ledger + * key. index.js calls this at startup right after recoverPendingPayments. */ async recoverOrphanedGames() { const summary = { @@ -483,8 +467,8 @@ class SessionManager { for (const orphan of rows) { try { const outcome = await this.db.withTransaction(async (client) => { - // Lock the game row and re-check under the lock: another instance (or a prior - // run) may already have finalized it. + // Re-check under the lock: another instance or a prior run may already have + // finalized this game. const lock = await client.query( `SELECT g.id, g.user_id, g.game_mode, g.payment_id, g.status, g.entry_consumed_at, g.entry_credits_spent @@ -497,24 +481,24 @@ class SessionManager { [orphan.id] ); const game = lock.rows[0]; - if (!game || game.status !== 'active') return null; // already handled + if (!game || game.status !== 'active') return null; const reason = `orphan_game_refunded:${game.id}`; let refunded = false; let creditsRefunded = 0; if (game.user_id) { - // Dedup: never refund the same orphaned game twice. + // The per-game reason key makes the refund idempotent. const dup = await client.query( `SELECT 1 FROM credit_transactions WHERE user_id = $1 AND reason = $2 LIMIT 1`, [game.user_id, reason] ); if (dup.rows.length === 0) { - // Refund only durable debit evidence. Merely inserting an active row with a - // PAID_CREDITS label does not prove a credit was taken (start can fail after - // game persistence but before its transaction commits). Never use the current - // configured cost: it may have changed since this entry was accepted. + // Refund only against durable debit evidence. An active row labelled + // PAID_CREDITS is not proof a credit was taken: a start can persist the game + // and then fail before its transaction commits. The current configured cost + // is not used either, since it may have changed since entry was accepted. const spent = Number(game.entry_credits_spent || 0); const doRefund = game.game_mode === 'PAID_CREDITS' && game.entry_consumed_at != null @@ -538,7 +522,6 @@ class SessionManager { } } - // Finalize the game: refunded games are marked 'refunded', the rest 'expired'. const newStatus = refunded ? 'refunded' : 'expired'; await client.query( `UPDATE games SET status = $1, completed_at = COALESCE(completed_at, NOW()) WHERE id = $2`, @@ -581,8 +564,8 @@ class SessionManager { /** * Evict and disconnect cached sockets belonging to one or more stable users. Extra socket ids - * cover pre-cache/legacy rows. Used by token rotation and wallet-account adoption so an old - * live socket cannot keep exercising paid actions from a stale in-memory session. + * cover sockets not present in the cache. Token rotation and wallet-account adoption call this + * so an old live socket cannot keep exercising paid actions from a stale in-memory session. */ disconnectUserSessions(userIds, extraSocketIds = [], { exceptSocketId = null } = {}) { const targetUsers = new Set((Array.isArray(userIds) ? userIds : [userIds]) @@ -632,9 +615,9 @@ class SessionManager { } /** - * Evict a socket's cached session row. Called on disconnect so the in-memory - * `sessions` map doesn't grow unbounded with every socket ever seen (and so stale - * cached rows aren't served on reconnect). Safe: a later getBySocket re-reads from DB. + * Evict a socket's cached session row. Called on disconnect to bound the `sessions` map + * and to keep stale rows from being served on reconnect. A later getBySocket re-reads + * from the database. */ removeSocket(socketId) { this.sessions.delete(socketId); diff --git a/src/network/socketHandlers.js b/src/network/socketHandlers.js index 28c2162..f5a6ebb 100644 --- a/src/network/socketHandlers.js +++ b/src/network/socketHandlers.js @@ -98,7 +98,7 @@ class SocketHandlers { gameModeManager: this.gameModeManager }); - // Legacy WAITING_PLAYERS removed; queueManager owns queue state + // queueManager owns all queue state // Initialize session manager first if (this.gameModeManager && this.gameModeManager.db) { @@ -108,8 +108,8 @@ class SocketHandlers { gameModeManager: this.gameModeManager, io: this.io }); - // IDENTITY (Phase 2.1): give GameModeManager the session manager so getOrCreateUser - // resolves through the stable anon_token identity instead of the mutable socket_id. + // GameModeManager needs the session manager so getOrCreateUser resolves through the + // stable anon_token identity instead of the mutable socket_id. this.gameModeManager.sessionManager = this.sessionManager; // Initialize cleanup timers this.sessionManager.initialize().catch(err => { @@ -130,12 +130,12 @@ class SocketHandlers { debugManager: this.debugManager, broadcastManager: this.broadcastManager, io: this.io, - createGameForUser: async (userObj, gameType, options) => { - const game = await this.gameManager.createGameForUser(userObj, gameType, options); + createGameForUser: async (userObj, options) => { + const game = await this.gameManager.createGameForUser(userObj, options); // Stamp the stable DB user id onto the game at creation. Suspend/restore keys - // on this id; capturing it now (when we reliably have the session) means the - // disconnect path never needs a fresh, failure-prone DB lookup to preserve a - // game — so ANY game, paid or free, is reconnectable. + // on this id; capturing it here, where the session is reliably available, keeps + // the disconnect path free of a failure-prone DB lookup, so any game, paid or + // free, is reconnectable. try { const sid = userObj?.id; const dbUid = sid != null ? this.sessionManager?.sessions?.get(sid)?.id : null; @@ -233,8 +233,8 @@ class SocketHandlers { let tavernRoomData = null; const tavernRoomUrl = 'assets/kenney/tavern_room.json'; // The imported .tmx room renders as a block-scatter in the browser (the client falls back to - // the walkable grid), so use the hand-built procedural tavern (tavernMap.js) — which renders - // reliably through the theme atlas — by default. Opt back into the .tmx with TAVERN_DESIGNED_ROOM=true. + // the walkable grid), so the default is the hand-built procedural tavern (tavernMap.js), + // which renders reliably through the theme atlas. TAVERN_DESIGNED_ROOM=true opts into the .tmx. if (process.env.TAVERN_DESIGNED_ROOM === 'true') { try { const roomPath = require('path').join(__dirname, '../../html', tavernRoomUrl); @@ -252,8 +252,8 @@ class SocketHandlers { // Share the lobby's global chat provider so the tavern joins one global chat with // persistent history (and nostr fan-out when enabled), instead of ephemeral room chat. globalChatProvider: this.chatHandler.chatProvider, - // Publishing into GLOBAL chat must carry the same guards the lobby path applies — - // otherwise `tavern_chat` is an unauthenticated bypass of the chat ban list, the + // Publishing into GLOBAL chat must carry the same guards the lobby path applies. + // Without them `tavern_chat` is an unauthenticated bypass of the chat ban list, the // reconnect-proof rate limiter, and user_id attribution, reaching every connected // client and the persisted history under a name the client picked. chatModeration: { @@ -283,9 +283,9 @@ class SocketHandlers { matchManager: this.matchManager, debugManager: this.debugManager }); - // Database connection + migrations happen later in startServer(). Initializing the - // durable queue here raced that lifecycle and silently left production match mode dead. - // initializeMatchMode() is awaited after DatabaseManager.initialize() instead. + // Database connection + migrations happen later in startServer(), so the durable queue + // cannot be initialized here. initializeMatchMode() is awaited after + // DatabaseManager.initialize() instead. this._matchInitializePromise = null; @@ -365,11 +365,10 @@ class SocketHandlers { * Refuse admission while no blockchain node is reachable. * * A run's lifetime is measured in blocks, and its entry block comes from the last SUCCESSFUL - * poll. With every node unreachable that value silently goes stale, so a game admitted during an - * outage was anchored to an already-past block and `checkGamesTimeout` killed it as a timeout on - * the first recovered poll — the player lost a run (and in paid mode, an entry) to our - * infrastructure. Failing the entry up front is the honest outcome: nothing is charged and the - * player is told why. + * poll. With every node unreachable that value silently goes stale, so a game admitted during + * an outage is anchored to an already-past block and `checkGamesTimeout` kills it as a timeout + * on the first recovered poll, costing the player a run (and in paid mode, an entry). Failing + * the entry up front is the honest outcome: nothing is charged and the player is told why. * * Deliberately conservative: it only fires when the health check explicitly reports false. * Simulated blocks report healthy, and a runtime without the probe is left alone. @@ -378,11 +377,11 @@ class SocketHandlers { if (this.debugManager?.isChainHealthy?.() !== false) return false; const chain = String(process.env.CRYPTO_TYPE || 'WOW').toUpperCase().slice(0, 8); const message = `Can't reach a ${chain} node right now, so entries are paused. ` - + 'Nothing was charged — please try again shortly.'; + + 'Nothing was charged; please try again shortly.'; try { socket?.emit?.('chain_unavailable', { code: 'NODE_UNREACHABLE', kind, message }); } catch (_) {} try { this.broadcastManager?.sendStatusUpdate?.(socket?.id, 'error', message); } catch (_) {} if (this.debugManager?.CONSOLE_LOGGING) { - console.warn(`[SocketHandlers] '${kind}' refused — no daemon reachable.`); + console.warn(`[SocketHandlers] '${kind}' refused: no daemon reachable.`); } return true; } @@ -393,7 +392,7 @@ class SocketHandlers { * Deliberately NOT a failover. 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 an * operator decision about custody, not something this code may take on its own. Daemons are - * read-only and interchangeable — wallets are neither. The safe behaviour when the wallet is + * read-only and interchangeable; wallets are neither. The safe behaviour when the wallet is * down is therefore to decline the invoice cleanly rather than to route around it. * * Only gates kinds that actually need the wallet: credits/free entry runs off the database and @@ -403,12 +402,12 @@ class SocketHandlers { if (kind !== 'request_payment') return false; // Only meaningful once a wallet has been wired up and has reported at least once. if (!this.walletService || this.walletService.isHealthy !== false) return false; - const message = 'Payments are temporarily unavailable — the wallet is unreachable. ' + const message = 'Payments are temporarily unavailable: the wallet is unreachable. ' + 'Nothing was charged. Credits and free play still work.'; try { socket?.emit?.('payment_unavailable', { code: 'WALLET_UNREACHABLE', kind, message }); } catch (_) {} try { this.broadcastManager?.sendStatusUpdate?.(socket?.id, 'error', message); } catch (_) {} if (this.debugManager?.CONSOLE_LOGGING) { - console.warn(`[SocketHandlers] '${kind}' refused — wallet unreachable.`); + console.warn(`[SocketHandlers] '${kind}' refused: wallet unreachable.`); } return true; } @@ -588,10 +587,8 @@ class SocketHandlers { * Register memory cleanup functions to prevent memory leaks */ _registerMemoryCleanups() { - // (mempool-notification dedup + TTL eviction lives in PaymentHandlers, which owns - // the actual dedup set; the old SocketHandlers copy here was unused and is removed.) - - // Let other components register their cleanups + // Mempool-notification dedup + TTL eviction lives in PaymentHandlers, which owns the + // dedup set. if (this.rateLimiter) { this.memoryManager.registerCleanup( 'rateLimiterCleanup', @@ -620,9 +617,9 @@ class SocketHandlers { return; } - // Player took the stairs down to a deeper level — the fresh level was already generated in - // movePlayer and rides out in the normal game_update. Don't end the game, and don't advance - // the new level's monster on the arrival turn. + // Player took the stairs down to a deeper level. The fresh level is generated in + // movePlayer and rides out in the normal game_update, so don't end the game and don't + // advance the new level's monster on the arrival turn. if (moveResult && moveResult.event === 'descend') { return; } @@ -734,12 +731,12 @@ class SocketHandlers { restoredGame = restored.game; const suspendedState = restored.suspendedState; - // G1/C3: resume the block-timeout clock from the ORIGINAL entry block, - // not the reconnect height. suspendedGameManager persists the entry block - // and hands it back here; restore it onto the in-memory user so - // checkGamesTimeout continues counting from where it left off. If it's - // missing, leave blockRec unset — checkGamesTimeout records the first - // observed height rather than treating the game as instant-death. + // Resume the block-timeout clock from the ORIGINAL entry block, not the + // reconnect height. suspendedGameManager persists the entry block and hands + // it back here; restoring it onto the in-memory user lets checkGamesTimeout + // continue counting from where it left off. When it is missing, blockRec + // stays unset and checkGamesTimeout records the first observed height + // rather than treating the game as instant-death. const entryBlock = (restored.blockRec ?? suspendedState?.blockRec ?? null); if (memUser && entryBlock != null) { memUser.blockRec = entryBlock; @@ -834,13 +831,13 @@ class SocketHandlers { // consult the dynamic shutdown flag, so existing connections cannot race the snapshot. const runAdmission = (kind, task) => this._runAdmission(socket, kind, task); - // SAFE DISPATCH — every listener below is registered through `on`, never `socket.on`. + // SAFE DISPATCH: every listener below is registered through `on`, never `socket.on`. // // Socket.IO invokes listeners without a try/catch, and this process treats // `uncaughtException` and `unhandledRejection` as fatal (see the handlers in src/index.js, // which call gracefulShutdown and exit). That combination makes ONE unguarded throw in ANY - // listener an unauthenticated remote kill switch: a client sends a malformed — or entirely - // absent — payload and the whole server exits, taking live games and payment monitoring + // listener an unauthenticated remote kill switch: a client sends a malformed, or entirely + // absent, payload and the whole server exits, taking live games and payment monitoring // with it. Containing the fault to the offending socket is the invariant; individual // handlers hardening their own inputs is defence in depth, not a substitute. const on = (event, handler) => { @@ -866,7 +863,7 @@ class SocketHandlers { handleCancelEntry: (socket) => this.queueHandler.handleCancelEntry(socket), handleStatsRequest: (socket) => this.handleStatsRequest(socket) })); - // Phase 2: a client-signed global chat event (posts under the player's own Smirk npub). + // Client-signed global chat event (posts under the player's own Smirk npub). on('chat_signed', (payload) => this.chatHandler.handleSignedChatMessage(socket, payload)); on('player_move', (moveData) => this.movementManager.handleMove(socket.id, moveData)); on('disconnect', () => this.handleDisconnect(socket)); @@ -874,11 +871,12 @@ class SocketHandlers { on('register_client', async (data) => { this.connectionHandler.handleRegisterClient(socket, data); this._emitFairnessOffer(socket); - // Re-send game mode info after client registers handlers (fixes race condition) + // Re-send after the client registers its handlers: the connection-time emit can + // arrive before those listeners exist. if (this.gameModeManager) { socket.emit('game_mode_info', this.gameModeManager.getGameModeInfo()); } - // Re-send credits_update (same race condition — event may arrive before handlers register) + // Re-sent for the same reason as game_mode_info above. if (this.sessionManager) { try { const sessionUser = await this.sessionManager.getBySocket(socket.id); @@ -889,20 +887,22 @@ class SocketHandlers { creditsPerGame: this.gameModeManager?.creditsPerGameCost || 1 }); } - } catch (e) { /* ignore — credits will update on next action */ } + } catch (e) { /* credits refresh on the next action */ } } this._emitIdentity(socket).catch(err => { if (this.debugManager?.CONSOLE_LOGGING) console.warn('Failed to re-send identity snapshot:', err.message); }); }); on('auto_start', (data) => runAdmission('auto_start', - () => this.handleAutoStart(socket, (data && typeof data === 'object') ? data : {}))); // New handler for start button + () => this.handleAutoStart(socket, (data && typeof data === 'object') ? data : {}))); on('play_free', (data) => runAdmission('play_free', - () => this.handleAutoStart(socket, { ...((data && typeof data === 'object') ? data : {}), free: true }))); // Explicit free-play choice + () => this.handleAutoStart(socket, { ...((data && typeof data === 'object') ? data : {}), free: true }))); + // { free: true } selects Pleb-board free play. on('join_queue', (data) => runAdmission('join_queue', - () => this.handleJoinQueue(socket, (data && typeof data === 'object') ? data : {}))); // Queue instead of auto-start ({ free: true } for Pleb-board free play) + () => this.handleJoinQueue(socket, (data && typeof data === 'object') ? data : {}))); + // Entry without waiting for the next block. on('early_entry', (data) => runAdmission('early_entry', - () => this.handleEarlyEntry(socket, (data && typeof data === 'object') ? data : {}))); // Early entry without waiting for block + () => this.handleEarlyEntry(socket, (data && typeof data === 'object') ? data : {}))); on('fairness_offer_request', () => this._emitFairnessOffer(socket)); on('address:prompt', () => this._withIdentityReadLimit(socket, () => this.handleAddressPrompt(socket))); @@ -930,10 +930,10 @@ class SocketHandlers { on('tavern_move', (data) => this.tavernManager.move(socket, data)); on('tavern_chat', (data) => this.tavernManager.chat(socket, data)); on('tavern_leave', () => this.tavernManager.leave(socket)); - // Match mode handlers. Every match listener is wrapped so a match-layer fault can - // never crash the connection or leak a stack to the client — it logs and emits a - // benign 'match_error' instead (C5). Identity is always resolved from the CONNECTION, - // never from the client payload. + // Match mode handlers. Every match listener is wrapped so a match-layer fault can never + // crash the connection or leak a stack to the client; it logs and emits a benign + // 'match_error' instead. Identity is always resolved from the CONNECTION, never from the + // client payload. on('match_queue', (data) => runAdmission('match_queue', () => this._handleMatchQueue(socket, data))); on('match_move', (data) => { @@ -1030,7 +1030,7 @@ class SocketHandlers { // honoured when the instance allows free play; otherwise fall through to paid. const wantsFree = wantsExplicitFree; - // Rate limiting for game starts — keyed on stable identity + IP so reconnecting + // Rate limiting for game starts, keyed on stable identity + IP so reconnecting // (new socket.id) can't reset the limit. const rlId = stableId(socket, this.sessionManager); const rlIp = clientIp(socket); @@ -1118,7 +1118,7 @@ class SocketHandlers { // Create game immediately const blockHeight = this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null; memUser.blockRec = blockHeight; // keep legacy timeout logic consistent - const game = await this.gameManager.createGameForUser(memUser, 'standard', { + const game = await this.gameManager.createGameForUser(memUser, { fairnessProof: gameFairnessProof }); const state = game.getState(); @@ -1163,7 +1163,7 @@ class SocketHandlers { } /** - * Handle join_queue request — adds player to block queue instead of starting immediately + * Handle join_queue request: adds the player to the block queue instead of starting immediately */ async handleJoinQueue(socket, opts = {}) { if (this._isShuttingDown === true) { @@ -1222,16 +1222,16 @@ class SocketHandlers { return; } try { - // Early entry CREATES A GAME and may consume a credit, so it must carry the same - // reconnect-proof admission budget as auto_start / join_queue / request_payment. It - // previously had none of its own: the only limiter downstream keyed on the raw, volatile - // socket.id with no IP bucket, so simply reconnecting reset the allowance. + // Early entry CREATES A GAME and may consume a credit, so it carries the same + // reconnect-proof admission budget as auto_start / join_queue / request_payment. + // The downstream limiter keys on the raw, volatile socket.id with no IP bucket, so + // it alone would let a reconnect reset the allowance. const rlId = stableId(socket, this.sessionManager); const rlIp = clientIp(socket); const rateLimitResult = await this.rateLimiter.checkLimit(rlId, 'game:queue', rlIp); if (!rateLimitResult.allowed) { this.broadcastManager.sendStatusUpdate(socket.id, 'warning', - `Slow down — try entering again in ${Math.ceil(rateLimitResult.retryAfter / 1000)}s.`); + `Slow down; try entering again in ${Math.ceil(rateLimitResult.retryAfter / 1000)}s.`); return; } await this.rateLimiter.recordAttempt(rlId, 'game:queue', rlIp); @@ -1319,9 +1319,9 @@ class SocketHandlers { /** * Resolve the stable match session/identity for a connection. Identity is derived ONLY - * from the CONNECTION (the session cache, then a DB lookup by socket) — never from any - * client-supplied payload — so a client can't act on another user's behalf (C5). Mirrors - * how other handlers resolve identity via sessionManager. + * from the CONNECTION (the session cache, then a DB lookup by socket), never from any + * client-supplied payload, so a client can't act on another user's behalf. Mirrors how + * other handlers resolve identity via sessionManager. * @returns {Promise<{userId:number, socketId:string, sessionToken:(string|null), user:Object}|null>} */ async _resolveMatchSession(socket) { @@ -1343,8 +1343,8 @@ class SocketHandlers { } /** - * Handle a match-mode queue join/leave (C5/S4). The economy comes from the payload but - * the IDENTITY is resolved from the connection, never from data. Never throws: any fault + * Handle a match-mode queue join/leave. The economy comes from the payload but the + * IDENTITY is resolved from the connection, never from data. Never throws: any fault * is logged and reported to the client via 'match_error'. If match mode is disabled / * the queue is absent, respond benignly and return. * @param {Object} socket @@ -1416,7 +1416,7 @@ class SocketHandlers { } /** - * Handle a match-mode reconnect (C5). Resolves identity from the connection and hands the + * Handle a match-mode reconnect. Resolves identity from the connection and hands the * live socket + session to the match manager. Same never-throw discipline as the other * match listeners; benign no-op when match mode is disabled. */ @@ -1442,7 +1442,7 @@ class SocketHandlers { } /** - * Handle game entry request (legacy method - now delegates to queue) + * Handle game entry request (delegates to the queue) */ handleGameEntry(socket) { return this.handleGameQueue(socket); @@ -1511,10 +1511,10 @@ class SocketHandlers { // Resolve the stable DB user id that suspend/restore keys on. Preference order: // 1. the id stamped on the game at creation (always present for any game - // started with a session) — no lookup, can't fail, + // started with a session; no lookup, can't fail), // 2. the in-memory session cache, // 3. a DB lookup as a last resort. - // Every game — paid or free — is preserved and reconnectable as long as a stable + // Every game, paid or free, is preserved and reconnectable as long as a stable // id resolves, which it does whenever a session was ever established. let dbUserId = activeGame?.dbUserId || null; if (!dbUserId && this.sessionManager) { @@ -1544,10 +1544,10 @@ class SocketHandlers { } } else { if (activeGame) { - // Reachable only when no stable identity resolved at all — i.e. a session + // Reachable only when no stable identity resolved at all, i.e. a session // was never established (e.g. DB unavailable at connect), so there is // genuinely no id to restore the game under. Record it for auditing. - console.error(`[SocketHandlers] Active game dropped without suspension — no stable user id ` + console.error(`[SocketHandlers] Active game dropped without suspension, no stable user id ` + `(socket=${socketId}, mode=${activeGame.paymentMode || activeGame.gameMode || 'unknown'}, ` + `gameId=${activeGame.id || 'unknown'}). Cannot restore a game without a session identity.`); } @@ -1613,8 +1613,8 @@ class SocketHandlers { this.tavernManager.handleDisconnect(socketId); } - // Clean up match presence (starts the AFK grace timer). New signature takes the - // live socket (C5). Guarded so a match-layer fault can't abort disconnect cleanup. + // Clean up match presence (starts the AFK grace timer). Takes the live socket. + // Guarded so a match-layer fault can't abort disconnect cleanup. if (this.matchManager) { try { this.matchManager.handleDisconnect(socket); @@ -1681,7 +1681,7 @@ class SocketHandlers { console.error(`❌ Socket handler '${event}' faulted:`, (err && err.message) || err); if (err && err.stack && this.debugManager?.CONSOLE_LOGGING) console.error(err.stack); try { - // Deliberately generic — a client must never learn where in the server it broke. + // Deliberately generic: a client must never learn where in the server it broke. socket.emit('error_message', { message: 'That request could not be processed.' }); } catch (_) { /* socket already gone */ } } @@ -1690,10 +1690,9 @@ class SocketHandlers { * Gate a cheap-looking read that actually hits the database. * * `identity:get` and `address:prompt` each run getOrCreateUser (a users SELECT plus an - * UPDATE ... SET last_active) and an entitlements SELECT, but unlike their sibling - * `identity:update` neither had any limit — so a single socket could pipeline them at packet - * rate. Uses the reconnect-proof stable id + IP buckets, so dropping and re-opening the socket - * does not reset the budget. + * UPDATE ... SET last_active) and an entitlements SELECT, so without a limit a single socket + * can pipeline them at packet rate. Uses the reconnect-proof stable id + IP buckets, so + * dropping and re-opening the socket does not reset the budget. */ async _withIdentityReadLimit(socket, task) { const rlId = stableId(socket, this.sessionManager); @@ -1841,11 +1840,9 @@ class SocketHandlers { }); } - // Restart payment monitoring - // First stop any existing monitoring (unlikely but safe) + // Clear any monitoring already attached to this socket before restarting it. this.paymentHandlers.stopMonitoringForSocket(socket.id); - // Restart monitoring via paymentHandlers internal method if (typeof this.paymentHandlers._monitorAddress === 'function') { this.paymentHandlers._monitorAddress( socket, @@ -1887,8 +1884,8 @@ class SocketHandlers { /** * Create a new game for a user (delegates to GameManager) */ - createGameForUser(user, gameType = 'standard', options = {}) { - return this.gameManager.createGameForUser(user, gameType, options); + createGameForUser(user, options = {}) { + return this.gameManager.createGameForUser(user, options); } /** @@ -1937,7 +1934,7 @@ class SocketHandlers { // entry), so iterating the live Map could skip games. Process sequentially and // await each game-over rather than firing them all off concurrently on one tick. // Anti-instant-death guard ONLY (default 2s, set 0 to disable). Random block timing - // is the game's core mechanic and is deliberately preserved — this is not a fairness + // is the game's core mechanic and is deliberately preserved; this is not a fairness // floor. It only avoids the degenerate case where a block lands in the same instant // the game starts and the player dies before the dungeon even renders / before their // first move is possible (100ms move cooldown). @@ -1952,7 +1949,7 @@ class SocketHandlers { // while settling an earlier game. Never let it install a later terminal intent // after the shutdown producer drain has begun. if (this._isShuttingDown) break; - // G3: the snapshot can go stale while we await a previous game-over. If this game + // The snapshot can go stale while awaiting a previous game-over. If this game // was concurrently won or otherwise ended (removed from activeGames on another // async path), never re-end it here as a loss/timeout. if (!this.activeGames.has(socketId)) continue; @@ -1963,10 +1960,10 @@ class SocketHandlers { const user = this.connectionHandler.getUserBySocket(socketId); if (!user) continue; - // G1/C3: only end when the entry block is DEFINED and a later block has arrived. - // If we've never recorded an entry block for this player, record the current - // height as the first observation instead of treating a missing value as either - // immortal (never times out) or instant-death (times out on the first tick). + // Only end when the entry block is DEFINED and a later block has arrived. With no + // entry block recorded for this player, the current height becomes the first + // observation, so a missing value means neither immortal (never times out) nor + // instant-death (times out on the first tick). if (user.blockRec == null) { user.blockRec = currentHeight; continue; @@ -1992,7 +1989,7 @@ class SocketHandlers { }); } - // Payment system handlers (placeholder for compatibility) + // Payment system handlers (delegated to PaymentHandlers when present) async handleCheckPaymentStatus(socket, data) { if (this.paymentHandlers && typeof this.paymentHandlers.handleCheckPaymentStatus === 'function') { return this.paymentHandlers.handleCheckPaymentStatus(socket, data); diff --git a/src/network/spectatorManager.js b/src/network/spectatorManager.js index fc54a28..8034f1d 100644 --- a/src/network/spectatorManager.js +++ b/src/network/spectatorManager.js @@ -48,7 +48,6 @@ class SpectatorManager { * Initialize the manager - start periodic broadcasts */ initialize() { - // Start periodic game list broadcasting this._broadcastInterval = setInterval(() => { this._broadcastGameListToLobby(); }, this._broadcastIntervalMs); @@ -66,13 +65,11 @@ class SpectatorManager { getActiveGamesList(options = {}) { const { page = 1, pageSize = 20, sortBy = 'newest' } = options; - // Check cache const now = Date.now(); if (now - this._gameListLastUpdate < this._gameListCacheTimeout && this._cachedGameList.length > 0) { return this._applyPagination(this._cachedGameList, page, pageSize); } - // Build fresh list of active games const games = []; for (const [socketId, game] of this.activeGames.entries()) { if (game && game.gameState === 'active') { @@ -80,10 +77,8 @@ class SpectatorManager { } } - // Sort games this._sortGames(games, sortBy); - - // Cache the list + this._cachedGameList = games; this._gameListLastUpdate = now; @@ -115,7 +110,7 @@ class SpectatorManager { moveCount: game.moveCount || 0, hasTreasure: game.isComplete ? (game.player?.hasTreasure || false) : undefined, spectatorCount: spectatorCount, - // Don't expose exact positions - let spectators get that when they join + // Exact positions are withheld here; spectators receive them on joining a game. dungeonSize: { width: game.width || 25, height: game.height || 19 @@ -178,7 +173,6 @@ class SpectatorManager { * @returns {Object} Result with success status and initial game state */ addSpectator(spectatorSocketId, gameId) { - // Find the game by gameId let targetGame = null; let playerSocketId = null; @@ -198,7 +192,6 @@ class SpectatorManager { return { success: false, reason: 'Game is no longer active' }; } - // Check if already spectating this game const currentlyWatching = this._spectatorWatching.get(spectatorSocketId); if (currentlyWatching === gameId) { return { success: false, reason: 'Already spectating this game' }; @@ -209,16 +202,13 @@ class SpectatorManager { this.removeSpectator(spectatorSocketId); } - // Add to spectators set for this game if (!this._spectatorsByGame.has(gameId)) { this._spectatorsByGame.set(gameId, new Set()); } this._spectatorsByGame.get(gameId).add(spectatorSocketId); - // Track what this spectator is watching this._spectatorWatching.set(spectatorSocketId, gameId); - - // Join Socket.IO room for this game + const spectatorSocket = this._getSocket(spectatorSocketId); if (spectatorSocket) { spectatorSocket.join(`spectate:${gameId}`); @@ -228,7 +218,6 @@ class SpectatorManager { console.log(`👁️ Spectator ${spectatorSocketId.substring(0,6)} joined game ${gameId.substring(0,8)}`); } - // Get initial game state for spectator const initialState = this._getSpectatorGameState(targetGame, playerSocketId); return { @@ -249,7 +238,6 @@ class SpectatorManager { const gameId = this._spectatorWatching.get(spectatorSocketId); if (!gameId) return false; - // Remove from game's spectator set const spectators = this._spectatorsByGame.get(gameId); if (spectators) { spectators.delete(spectatorSocketId); @@ -258,10 +246,8 @@ class SpectatorManager { } } - // Remove from watching map this._spectatorWatching.delete(spectatorSocketId); - - // Leave Socket.IO room + const spectatorSocket = this._getSocket(spectatorSocketId); if (spectatorSocket) { spectatorSocket.leave(`spectate:${gameId}`); @@ -321,10 +307,10 @@ class SpectatorManager { /** * Notify spectators that a game has ended and release them. * - * MUST be called for every way a game can leave `activeGames`, not just committed settlement. - * A game that vanishes via disconnect/suspension used to leave its spectators sitting in the - * `spectate:` room — still tracked, already removed from the lobby room, receiving no - * further updates and no `spectate_ended`, i.e. permanently stuck on a dead view. + * Must be called for every way a game can leave `activeGames`, not just committed settlement. + * Spectators of a game that vanishes any other way stay in the `spectate:` room, + * still tracked and already out of the lobby room, so they receive neither further updates + * nor `spectate_ended` and are stuck on a dead view. * * @param {string} gameId - Game ID * @param {Object} gameOverData - Game over information @@ -333,7 +319,6 @@ class SpectatorManager { notifyGameEnded(gameId, gameOverData, reason = 'game_over') { const spectators = this._spectatorsByGame.get(gameId); - // Emit game over to spectators if any if (spectators && spectators.size > 0) { this.io.to(`spectate:${gameId}`).emit('spectate_ended', { gameId: gameId, @@ -341,7 +326,6 @@ class SpectatorManager { gameOverData: gameOverData }); - // Remove all spectators from this game's tracking for (const spectatorId of spectators) { this._spectatorWatching.delete(spectatorId); const sock = this._getSocket(spectatorId); @@ -350,7 +334,7 @@ class SpectatorManager { this._spectatorsByGame.delete(gameId); } - // Invalidate cache and immediately broadcast updated game list + // Zeroing the timestamp invalidates the cache so the lobby broadcast rebuilds the list. this._gameListLastUpdate = 0; this._broadcastGameListToLobby(); } @@ -360,7 +344,6 @@ class SpectatorManager { * @private */ _getSpectatorGameState(game, playerSocketId) { - // Return a sanitized version of the game state if (typeof game.getState === 'function') { const state = game.getState(); return { @@ -394,16 +377,11 @@ class SpectatorManager { * @private */ _broadcastGameListToLobby() { - // Get fresh game list const gameListData = this.getActiveGamesList({ page: 1, pageSize: 50 }); - - // Get pending games const pendingGames = this.getPendingGamesList(); - - // Add pending games to the response gameListData.pendingGames = pendingGames; - // Always broadcast, even when empty — clients need to clear their lists + // Always broadcast, even when empty: clients need to clear their lists this.io.to('lobby').emit('active_games', gameListData); } @@ -460,9 +438,8 @@ class SpectatorManager { * @param {string} socketId - Socket ID that disconnected */ handleDisconnect(socketId) { - // Remove from spectating if applicable this.removeSpectator(socketId); - // Leave lobby is automatic when socket disconnects + // The lobby room is left automatically on disconnect, so only spectator state needs clearing. } /** diff --git a/src/network/suspendedGameManager.js b/src/network/suspendedGameManager.js index 4a57b35..f54d70c 100644 --- a/src/network/suspendedGameManager.js +++ b/src/network/suspendedGameManager.js @@ -45,7 +45,6 @@ class SuspendedGameManager { return false; } - // Store the suspended game state const suspendedState = { game: game, originalSocketId: oldSocketId, @@ -55,10 +54,10 @@ class SuspendedGameManager { hasTreasure: game.player?.hasTreasure || false, moveCount: game.moveCount || 0, blockHeight: game.blockHeight || null, - // Entry block height (blockRec) for reconnect timeout continuity (C3). - // Prefer an explicit value from the caller, then the value captured on the - // game object at creation. Never invent one — a missing value stays null so - // the timeout logic treats it as "not yet observed" rather than instant-death. + // Entry block height, used to keep the reconnect block-timeout continuous. + // Prefers an explicit value from the caller, then the value captured on the + // game object at creation. A missing value stays null so the timeout logic + // treats it as "not yet observed" rather than as an immediate expiry. blockRec: (additionalState.blockRec !== undefined && additionalState.blockRec !== null) ? additionalState.blockRec : (game.blockRec ?? game.blockHeight ?? null), @@ -68,11 +67,10 @@ class SuspendedGameManager { this.suspendedGames.set(dbUserId, suspendedState); this.stats.gamesSuspended++; - // Ordinary network disconnects retain the existing bounded in-memory grace period. - // A durable restart snapshot is different: PostgreSQL is the authority and the process - // must keep the reconstructed object available until its owner reconnects (or an operator - // explicitly resolves the active row). Expiring only the Map entry would strand the - // durable active game and silently make it unresumable. + // Ordinary network disconnects get a bounded in-memory grace period. A durable restart + // snapshot does not: PostgreSQL is the authority, and the reconstructed object must stay + // available until its owner reconnects or an operator resolves the active row. Expiring + // the Map entry alone would strand the durable active game as unresumable. if (additionalState.durableRestartSnapshot !== true) { this._scheduleCleanup(dbUserId); } @@ -172,20 +170,18 @@ class SuspendedGameManager { } } - // Only mutate the cache/object after the durable claim succeeds. A transient database - // failure therefore leaves the suspended entry intact for a later reconnect attempt. + // Cache and game object are mutated only after the durable claim succeeds, so a transient + // database failure leaves the suspended entry intact for a later reconnect attempt. this._cancelCleanup(dbUserId); - // Update game with new socket ID game.socketId = newSocketId; // Keep a stable DB user id on the game object so completeGame can key on it - // without any socket_id lookup. It is normally stamped at creation; backfill - // from the alias just in case an older game object only carries one of them. + // without any socket_id lookup. It is normally stamped at creation; the backfill + // covers game objects that carry only one of the two aliases. if (game.userId == null && game.dbUserId != null) game.userId = game.dbUserId; if (game.dbUserId == null && game.userId != null) game.dbUserId = game.userId; - // Update the user reference if provided if (newUser) { game.user = newUser; // A terminal-pending object is restored only as an ownership lock while its DB @@ -194,10 +190,7 @@ class SuspendedGameManager { if (!game.settlementPending && !game.settlementCommitted) newUser.joinGame(game); } - // Re-add to active games with new socket ID this.activeGames.set(newSocketId, game); - - // Remove from suspended this.suspendedGames.delete(dbUserId); this.stats.gamesRestored++; @@ -210,8 +203,8 @@ class SuspendedGameManager { return { game, suspendedState: suspended, - // Expose the entry block height so the reconnect path can restore - // memUser.blockRec and keep the block-timeout logic continuous (C3). + // Exposed so the reconnect path can restore memUser.blockRec and keep the + // block-timeout logic continuous. blockRec: suspended.blockRec ?? null }; } @@ -246,7 +239,6 @@ class SuspendedGameManager { * @private */ _scheduleCleanup(dbUserId) { - // Cancel any existing timer this._cancelCleanup(dbUserId); const timer = setTimeout(() => { @@ -261,7 +253,7 @@ class SuspendedGameManager { } }, this.cleanupTimeoutMs); - // Don't prevent process exit + // Unref so a pending grace period never holds the process open. if (timer.unref) timer.unref(); this.cleanupTimers.set(dbUserId, timer); diff --git a/src/network/tavernManager.js b/src/network/tavernManager.js index a29eb08..ea7aab3 100644 --- a/src/network/tavernManager.js +++ b/src/network/tavernManager.js @@ -14,7 +14,7 @@ function escapeHtml(s) { } /** - * TavernManager — transport and lifecycle for the Tavern mode. + * TavernManager: transport and lifecycle for the Tavern mode. * * Owns a shared Room, a server-tick timer, and Socket.IO room broadcasts. Structured like * SpectatorManager. Disabled unless TAVERN_ENABLED=true: when disabled, no timer runs and @@ -43,12 +43,12 @@ class TavernManager { this.room = new Room({ id: this.roomId, type: 'tavern', roomData: roomData, roomUrl: roomUrl }); - // Chat: prefer the shared GLOBAL chat provider (persistent history + cross-page/relay - // fan-out) so the tavern participates in one global chat with backlog. When it isn't - // injected, fall back to an ephemeral tavern-scoped provider (previous behavior). + // Chat prefers the shared global provider (persistent history plus cross-page/relay + // fan-out) so the tavern participates in one global chat with backlog. Without one + // injected, the ephemeral tavern-scoped provider is used instead. this.globalChatProvider = globalChatProvider; this.chatProvider = new SocketChatProvider({ io: this.io, debugManager: this.debugManager }); - // Guards applied before anything reaches GLOBAL chat (ban list, shared rate limiter, + // Guards applied before anything reaches global chat (ban list, shared rate limiter, // user_id attribution). Optional so a tavern-only/ephemeral instance still works. this.chatModeration = chatModeration; } @@ -132,7 +132,7 @@ class TavernManager { // Full state (map + occupants) to the joiner; the next tick shows the arrival to others. socket.emit('tavern_joined', { you: socket.id, state: this.room.fullState() }); - // Send recent GLOBAL chat backlog so the tavern shows history on arrival (unified chat). + // Recent global chat backlog so the tavern shows history on arrival. if (this.globalChatProvider && typeof this.globalChatProvider.getHistory === 'function') { this.globalChatProvider.getHistory({ scope: 'global', limit: 50 }) .then(messages => { if (messages && messages.length) socket.emit('chat_history', { messages }); }) @@ -154,15 +154,15 @@ class TavernManager { const { dx, dy } = this._normalizeDir(data); if (dx === 0 && dy === 0) return; - this.room.moveOccupant(socket.id, dx, dy); // next tick broadcasts the result + this.room.moveOccupant(socket.id, dx, dy); // the next tick broadcasts the result } /** * Publish a tavern message. * - * When a global chat provider is injected this reaches EVERY connected client and the persisted + * When a global chat provider is injected this reaches every connected client and the persisted * history, so it must clear the same bar as the lobby path: chat ban, the reconnect-proof rate - * limiter, and user_id attribution. Async — callers are wrapped by the socket safe-dispatch, and + * limiter, and user_id attribution. Async: callers are wrapped by the socket safe-dispatch, and * a moderation lookup failure must never publish. */ async chat(socket, data = {}) { @@ -176,9 +176,9 @@ class TavernManager { const now = Date.now(); if (now - (this._lastChatAt.get(socket.id) || 0) < this.chatCooldownMs) { - // Tell the sender instead of silently dropping — a silent drop with a cleared + // Tell the sender instead of silently dropping: a silent drop with a cleared // input reads as "my message disappeared". - this._notice(socket, 'Easy — wait a moment before your next message.'); + this._notice(socket, 'Easy, wait a moment before your next message.'); return; } @@ -191,9 +191,9 @@ class TavernManager { this._lastChatAt.set(socket.id, now); const username = occ.name || String(socket.id).slice(0, 6); - // Escape here (delivery is trusted-escaped, rendered as HTML on the client). Route to the - // global chat (persisted + broadcast to everyone + relayed over nostr when enabled) so the - // tavern shares one global conversation; fall back to tavern-scoped if no global provider. + // Escape here: delivery is trusted-escaped and rendered as HTML on the client. Routing to + // global chat (persisted, broadcast to everyone, relayed over nostr when enabled) keeps the + // tavern in one global conversation; tavern-scoped is used when no global provider exists. const provider = this.globalChatProvider || this.chatProvider; const scope = this.globalChatProvider ? 'global' : this.channel; provider.publish({ @@ -215,7 +215,7 @@ class TavernManager { /** * Shared-chat guards. Returns { allowed, message, userId }. * - * Only enforced for messages that actually enter GLOBAL chat — a tavern-scoped fallback room is + * Only enforced for messages that actually enter global chat; a tavern-scoped fallback room is * ephemeral and stays governed by the per-occupant cooldown alone. */ async _moderateChat(socket) { @@ -284,7 +284,7 @@ class TavernManager { } } -// Cosmetic avatar ids the server will accept. Availability/unlocks are enforced via +// Cosmetic avatar ids the server accepts. Availability and unlocks are enforced by // the Entitlements policy during join. TavernManager.AVATARS = Appearance.avatarIds(); TavernManager.PREMIUM_AVATARS = Appearance.premiumAvatarIds(); diff --git a/src/network/tavernMatchBridge.js b/src/network/tavernMatchBridge.js index 9acbb87..57a5e85 100644 --- a/src/network/tavernMatchBridge.js +++ b/src/network/tavernMatchBridge.js @@ -1,5 +1,5 @@ /** - * TavernMatchBridge — connects active match rooms to tavern spectators. + * TavernMatchBridge: connects active match rooms to tavern spectators. * * Subscribes to MatchManager match events and forwards a lightweight, public-only * snapshot of each active race to the tavern Socket.IO room. Tavern clients can then diff --git a/src/payments/providers/btcpayProvider.js b/src/payments/providers/btcpayProvider.js index b5c95f2..7cdda48 100644 --- a/src/payments/providers/btcpayProvider.js +++ b/src/payments/providers/btcpayProvider.js @@ -15,13 +15,13 @@ function decimalToAtomic(dec, decimals) { } /** - * BTCPay Greenfield provider — the first payment plugin. One client class serves every endpoint + * BTCPay Greenfield provider: the first payment plugin. One client class serves every endpoint * that speaks the BTCPay Greenfield API. On the operator's LAN that is THREE endpoints (see the * btcpay-infra-topology memo): real BTCPay Server for BTC/LTC, plus the `xmrcheckout` and * `wowcheckout` shims which expose Greenfield-compatible `/api/v1/stores/{id}/invoices` routes for * XMR and WOW. So the same class is registered up to three times with different `id`/baseUrl/store/ * key/chains (id defaults to 'btcpay'). Grin is not deployed yet. The invoice is denominated by - * `currency` — pass the chain ticker (e.g. 'XMR') for a native-crypto amount. Auth is + * `currency`: pass the chain ticker (e.g. 'XMR') for a native-crypto amount. Auth is * `Authorization: token ` on all three. * * fetchImpl is injectable for tests; defaults to the global fetch (Node >= 18). diff --git a/src/payments/providers/index.js b/src/payments/providers/index.js index 7cd6cd4..d696f1a 100644 --- a/src/payments/providers/index.js +++ b/src/payments/providers/index.js @@ -35,11 +35,11 @@ function parseChains(raw, fallback) { /** * Assemble the payment-provider registry from environment/config. Single wiring seam for Pillar 3, - * shaped to the operator's real infra (see btcpay-infra-topology memo): three Greenfield endpoints - * — real BTCPay (BTC/LTC), xmrcheckout (XMR), wowcheckout (WOW) — plus the native wallet-RPC. + * shaped to the operator's real infra (see btcpay-infra-topology memo): three Greenfield endpoints: + * real BTCPay (BTC/LTC), xmrcheckout (XMR), and wowcheckout (WOW), plus the native wallet-RPC. * * Behavior-preserving: with NO gateway env set (prod today) only the native Monero/Wownero provider - * is registered and every chain routes to it — identical to the current flow. Each endpoint + * is registered and every chain routes to it, identical to the current flow. Each endpoint * activates independently when its *_URL/_STORE_ID/_API_KEY trio is present: * BTCPAY_URL/BTCPAY_STORE_ID/BTCPAY_API_KEY -> id 'btcpay' (BTCPAY_CHAINS, default BTC,LTC) * XMRCHECKOUT_URL/XMRCHECKOUT_STORE_ID/..._API_KEY -> id 'xmrcheckout' (XMR) @@ -66,7 +66,7 @@ function buildProviderRegistry({ env = process.env, walletService = null } = {}) for (const c of chains) routing[c] = 'btcpay'; } - // xmrcheckout / wowcheckout — Greenfield-compatible shims, same provider class, different id. + // xmrcheckout / wowcheckout: Greenfield-compatible shims, same provider class, different id. const shims = [ { prefix: 'XMRCHECKOUT', id: 'xmrcheckout', chain: 'XMR' }, { prefix: 'WOWCHECKOUT', id: 'wowcheckout', chain: 'WOW' } diff --git a/src/payments/providers/nativeMoneroProvider.js b/src/payments/providers/nativeMoneroProvider.js index c897b12..4b64f3b 100644 --- a/src/payments/providers/nativeMoneroProvider.js +++ b/src/payments/providers/nativeMoneroProvider.js @@ -1,7 +1,7 @@ const { PaymentProvider } = require('./paymentProvider'); /** - * Native Monero/Wownero provider — wraps the existing walletRPCService (subaddress invoicing + + * Native Monero/Wownero provider: wraps the existing walletRPCService (subaddress invoicing + * transfer_split payouts) so "our own XMR/WOW stuff" is just another plugin behind the same * contract. Behavior-preserving: it delegates to the exact calls the game already makes today. * Supports XMR/WOW only; other chains route to BTCPay. @@ -46,9 +46,9 @@ class NativeMoneroProvider extends PaymentProvider { return this._normalize(await this.wallet.checkPaymentStatus(this._addr(invoiceRef))); } - // The monitoring contract passes the RAW wallet status through untouched — the shape + // The monitoring contract passes the RAW wallet status through untouched, the shape // checkPaymentStatus produces ({ in_mempool, confirmed, complete, amount, required, - // confirmations }) — so the confirmation callback in paymentHandlers is byte-for-byte + // confirmations }), so the confirmation callback in paymentHandlers is byte-for-byte // the legacy path for native. (getInvoiceStatus above still returns the normalized shape.) startWatch(invoiceRef, onUpdate, intervalMs = 2000) { const address = this._addr(invoiceRef); diff --git a/src/payments/providers/paymentProvider.js b/src/payments/providers/paymentProvider.js index a9d87a3..44d845b 100644 --- a/src/payments/providers/paymentProvider.js +++ b/src/payments/providers/paymentProvider.js @@ -3,7 +3,7 @@ * * Instead of the game implementing per-chain wallet/daemon code, it talks to pluggable PAYMENT * PROVIDERS. BTCPay Server is the first plugin (it already handles BTC/LTC natively + XMR/WOW via - * checkout plugins + Grin via our own plugin — all five Smirk chains). The existing native + * checkout plugins + Grin via our own plugin: all five Smirk chains). The existing native * Monero/Wownero wallet-RPC integration is a second plugin, so an operator can route each chain to * whichever provider they run (e.g. BTCPay for BTC/LTC/GRIN, native-RPC for XMR/WOW). * diff --git a/src/payments/walletRPCService.js b/src/payments/walletRPCService.js index 85a9f6b..692bcf7 100644 --- a/src/payments/walletRPCService.js +++ b/src/payments/walletRPCService.js @@ -80,9 +80,9 @@ class WalletRPCService { reason: 'not_checked', checkedAt: 0 }; - // The production composition root replaces this with the single payout/readiness - // predicate. Isolated diagnostics and the explicitly guarded stagenet canary retain the - // legacy default unless they deliberately inject a stricter boundary. + // The production composition root injects the single payout/readiness predicate here. + // Isolated diagnostics and the explicitly guarded stagenet canary keep the permissive + // default unless they deliberately inject a stricter boundary. this.transferAllowed = typeof options.transferAllowed === 'function' ? options.transferAllowed : () => true; @@ -94,7 +94,6 @@ class WalletRPCService { async initialize() { try { await this.ensureAuthentication({ force: true }); - // Test wallet connection const response = await this.rpcCall('get_version'); if (!response?.result) throw new Error('Wallet version response is invalid.'); @@ -103,8 +102,7 @@ class WalletRPCService { version: response.result?.version || 'unknown' }); } - - // Get wallet height to ensure it's synced + const heightResponse = await this.rpcCall('get_height'); const height = Number(heightResponse.result?.height); if (!Number.isSafeInteger(height) || height <= 0) { @@ -345,7 +343,6 @@ class WalletRPCService { async createPaymentRequest(amount, description, userId, socketId) { try { await this.ensureNetworkIdentity(); - // Create a new subaddress for this payment const response = await this.rpcCall('create_address', { account_index: this.accountIndex, label: `game_${userId}_${Date.now()}` @@ -354,7 +351,7 @@ class WalletRPCService { const address = response.result.address; const addressIndex = response.result.address_index; - // Store mapping for payment monitoring + // Payment monitoring resolves an address back to its payer and invoice. const paymentInfo = { userId: userId, socketId: socketId, @@ -382,12 +379,12 @@ class WalletRPCService { return { success: true, - id: uuidv4(), // Generate payment ID + id: uuidv4(), address: address, amount: amount, description: description, addressIndex: addressIndex, - expiresAt: new Date(Date.now() + 30 * 60 * 1000) // 30 minutes + expiresAt: new Date(Date.now() + 30 * 60 * 1000) }; } catch (error) { const wrapped = error instanceof AppError ? error : new ExternalServiceError('Failed to create payment address', { @@ -478,13 +475,11 @@ class WalletRPCService { }); }; - // Check pending (mempool) transactions if (response.result?.pool && response.result.pool.length > 0) { for (const tx of response.result.pool) { poolReceived += money.toBig(tx.amount); inMempool = true; - - // Mark as detected if this is first time seeing it + if (!userInfo.detected) { userInfo.detected = true; if (this.debugManager?.CONSOLE_LOGGING) { @@ -619,7 +614,7 @@ class WalletRPCService { startPaymentMonitoring(address, callback, interval = 2000) { if (this.paymentWatchers.has(address)) { - return; // Already monitoring + return; } const watcher = setInterval(async () => { @@ -627,8 +622,7 @@ class WalletRPCService { if (status.in_mempool || status.confirmed) { callback(status); - - // Stop monitoring if payment is confirmed + if (status.confirmed && status.complete) { this.stopPaymentMonitoring(address); } @@ -658,15 +652,15 @@ class WalletRPCService { /** * Send a batch payout to multiple destinations in a single transfer_split call. - * More efficient than individual transfers — uses fewer outputs and pays less in fees. + * Uses fewer outputs and pays less in fees than individual transfers. * @param {Array<{amount: number|bigint, address: string}>} destinations * @returns {Object} - { tx_hash_list, tx_key_list, fee_list, totalFee } */ async processBatchPayout(destinations) { - // Everything up to the transfer_split call below happens BEFORE anything can be broadcast. - // Tag those failures so the caller can safely return the batch to 'pending' and retry it, - // instead of quarantining provably-unsent payouts in 'needs_review' — where the retry - // service deliberately never looks at them again. + // Everything up to the transfer_split call below runs before anything can be broadcast. + // Those failures are tagged preBroadcast so the caller can safely return the batch to + // 'pending' and retry it. Untagged failures go to 'needs_review', which the retry service + // never revisits. try { this._assertTransferAllowed(); if (!destinations || destinations.length === 0) { @@ -676,7 +670,6 @@ class WalletRPCService { } await this.ensureNetworkIdentity(); - // Validate all addresses before attempting transfer for (const dest of destinations) { if (!dest.address || typeof dest.address !== 'string') { throw new AppError('Invalid address in batch payout', { @@ -875,7 +868,6 @@ class WalletRPCService { } await this.ensureNetworkIdentity(); - // Validate address with wallet RPC before attempting transfer const validation = await this.validateAddress(address); if (!validation.valid) { throw new AppError(`Invalid payout address: ${validation.error || 'validation failed'}`, { @@ -911,8 +903,8 @@ class WalletRPCService { } this._assertTransferAllowed(); - // Use transfer_split for better output handling — automatically splits - // across multiple outputs if needed, more resilient than plain transfer + // transfer_split spreads a payout across multiple outputs when needed, so it succeeds + // in wallet states where a plain transfer would fail on output availability. const response = await this.rpcCall('transfer_split', transferParams); // transfer_split returns arrays: tx_hash_list, tx_key_list, fee_list @@ -977,7 +969,6 @@ class WalletRPCService { return { exists: false, confirmed: false, in_mempool: false }; } - // Use get_transfer_by_txid to check if transaction exists const response = await this.rpcCall('get_transfer_by_txid', { txid: txHash, account_index: this.accountIndex @@ -997,11 +988,11 @@ class WalletRPCService { return { exists: false, confirmed: false, in_mempool: false }; } catch (error) { - // If the RPC fails with "Transaction not found" style error, tx doesn't exist + // A "not found" RPC error is proof of absence; any other error leaves existence + // unknown, so it propagates rather than reporting a missing transaction. if (error.message?.includes('not found') || error.details?.message?.includes('not found')) { return { exists: false, confirmed: false, in_mempool: false }; } - // For other errors, we can't be sure, so throw throw error; } } @@ -1020,7 +1011,6 @@ class WalletRPCService { } } - // Get service health status getHealthStatus() { return { healthy: this.isHealthy diff --git a/src/routes/admin.js b/src/routes/admin.js index 0365455..1e65e30 100644 --- a/src/routes/admin.js +++ b/src/routes/admin.js @@ -65,7 +65,7 @@ const adminAuth = (req, res, next) => { }); } - // Origin check — reject cross-origin requests to admin endpoints + // Origin check: reject cross-origin requests to admin endpoints // Same-origin requests (Origin matches Host) are always allowed const origin = req.headers.origin || req.headers.referer; if (origin) { diff --git a/src/routes/auth.js b/src/routes/auth.js index ff48e95..88a5f19 100644 --- a/src/routes/auth.js +++ b/src/routes/auth.js @@ -1,6 +1,5 @@ /** - * Smirk wallet auth routes (extracted from index.js). - * DI via ctx so handler bodies stay identical. ctx: { db } + * Smirk wallet auth routes. Dependencies arrive via ctx: { db, sessionManager }. */ const express = require('express'); const crypto = require('crypto'); @@ -15,7 +14,7 @@ const AUTH_RATE_MAX_KEYS = 10000; * The host a NIP-98 auth event must be signed for. * * `HOSTED_BY` is the operator's canonical public origin and is contract-enforced in operated - * production profiles, so it is the authority whenever it is set — that keeps verification correct + * production profiles, so it is the authority whenever it is set: that keeps verification correct * behind a proxy and immune to a spoofed Host header. Unconfigured (dev / self-host) deployments * fall back to the request's own host, which still binds the event to the origin it was sent to. */ @@ -25,7 +24,7 @@ function expectedAuthHost(req) { try { const parsed = new URL(configured); if (parsed.host) return parsed.host; - } catch (_) { /* malformed HOSTED_BY — fall through to the request host */ } + } catch (_) { /* malformed HOSTED_BY: fall through to the request host */ } } return (req && (req.get('host') || req.headers?.host)) || null; } @@ -33,11 +32,11 @@ function expectedAuthHost(req) { /** * Per-IP auth rate limiter with a bounded key map. * - * The bound must be enforced by EVICTION, never by refusing new keys. Refusing turned the memory - * cap into a denial-of-service primitive: an attacker sourcing from many addresses could hold the - * map at capacity and every legitimate first-time caller of /challenge or /verify got a 429, locking - * the whole population out of Smirk login. Eviction keeps the same memory ceiling while leaving the - * limiter functional — the worst case is that a very old window's count is forgotten, which grants + * The bound is enforced by EVICTION, never by refusing new keys. Refusing would make the memory cap + * a denial-of-service primitive: an attacker sourcing from many addresses could hold the map at + * capacity so every legitimate first-time caller of /challenge or /verify gets a 429, locking the + * whole population out of Smirk login. Eviction keeps the same memory ceiling while leaving the + * limiter functional; the worst case is that a very old window's count is forgotten, which grants * one extra window to an attacker who was already being counted. */ function createIpRateLimiter({ max }) { @@ -257,7 +256,6 @@ router.post('/api/auth/smirk/challenge', challengeRateLimit, asyncHandler(async const session = await resolveSessionOwner(req, socketId); - // Generate a cryptographically secure challenge const challenge = crypto.randomBytes(32).toString('hex'); // Store a stable user binding as well as the current socket. The user binding is what @@ -271,7 +269,7 @@ router.post('/api/auth/smirk/challenge', challengeRateLimit, asyncHandler(async `, [challenge, socketId, session.userId, session.token]); if (inserted.rows.length !== 1) throw unauthorized(); - // Clean up old/expired challenges periodically + // Opportunistic cleanup of expired and long-used challenges. await db.query(` DELETE FROM smirk_challenges WHERE expires_at < NOW() OR (used = TRUE AND created_at < NOW() - INTERVAL '1 hour') @@ -301,9 +299,7 @@ router.post('/api/auth/smirk/verify', verifyRateLimit, asyncHandler(async (req, } const session = await resolveSessionOwner(req, socketId); - // --------------------------------------------------------------------------- - // NIP-98 path — a nostr event object is present. - // --------------------------------------------------------------------------- + // NIP-98 path: a nostr event object is present. if (body.event && typeof body.event === 'object' && !Array.isArray(body.event)) { // Extract the challenge carried by the event's 'challenge' tag WITHOUT trusting // the rest of the event yet. We consume the server nonce single-use FIRST, then @@ -318,7 +314,7 @@ router.post('/api/auth/smirk/verify', verifyRateLimit, asyncHandler(async (req, }); } - // Atomic single-use consume (bound to this socket) — prevents replay/double-use. + // Atomic single-use consume, bound to this socket, so the event cannot be replayed. const consumed = await consumeChallenge(challenge, session); if (!consumed) { throw new ValidationError('Invalid or expired challenge', { @@ -329,10 +325,9 @@ router.post('/api/auth/smirk/verify', verifyRateLimit, asyncHandler(async (req, const now = Math.floor(Date.now() / 1000); // Bind the signed event to THIS deployment's host. The 'u' tag path check alone is not an // authentication boundary: a kind:27235 event the same wallet signed for any other site whose - // path happens to end in /api/auth/smirk/verify would otherwise verify here — and because the + // path happens to end in /api/auth/smirk/verify would otherwise verify here, and because the // link step ADOPTS whatever account already owns that pubkey and returns its session token, a - // borrowed event is a full account takeover. Prefer the operator's configured HOSTED_BY origin; - // fall back to the request host only when it is not configured (dev/self-host). + // borrowed event would be a full account takeover. const result = verifyNip98Event(body.event, { challenge, expectedPathSuffix: '/api/auth/smirk/verify', @@ -347,9 +342,8 @@ router.post('/api/auth/smirk/verify', verifyRateLimit, asyncHandler(async (req, }); } - // NOTE: result.pubkey is a nostr x-only secp256k1 key (64-hex) — a DIFFERENT - // key namespace than the legacy Ed25519 spend key. A NIP-98 login therefore - // re-registers the account under the nostr key (acceptable per the cutover spec). + // result.pubkey is a nostr x-only secp256k1 key (64-hex), a different key namespace than the + // legacy Ed25519 spend key, so a NIP-98 login registers the account under the nostr key. const outcome = await linkSmirkKey(result.pubkey, session); if (outcome.adopted) { res.json({ @@ -370,9 +364,7 @@ router.post('/api/auth/smirk/verify', verifyRateLimit, asyncHandler(async (req, return; } - // --------------------------------------------------------------------------- - // Legacy Ed25519 path — { challenge, publicKey, signature }. - // --------------------------------------------------------------------------- + // Legacy Ed25519 path: { challenge, publicKey, signature }. const { challenge, publicKey, signature } = body; if (!challenge || !publicKey || !signature) { @@ -389,9 +381,8 @@ router.post('/api/auth/smirk/verify', verifyRateLimit, asyncHandler(async (req, }); } - // Verify Ed25519 signature over the RAW UTF-8 challenge bytes (RFC 8032). - // The deprecated SHA256(challenge) pre-hash fallback was removed (past its - // 2026-07-01 removal date) — only the raw-bytes verify remains. + // Ed25519 verification is over the RAW UTF-8 challenge bytes (RFC 8032). There is no + // SHA256(challenge) pre-hash fallback. let isValidSignature = false; try { const nacl = require('tweetnacl'); @@ -436,12 +427,9 @@ router.post('/api/auth/smirk/verify', verifyRateLimit, asyncHandler(async (req, * Check if a session has a linked Smirk wallet. * Query: socketId=string ; Auth: X-Session-Token header. * - * BOLA fix: previously this disclosed { linked, hasPayoutAddress } for ANY socketId - * with no auth, letting anyone probe another player's wallet-link state. We now - * require the caller to prove ownership of the socketId via the session token - * (users.anon_token) — the same guard used by /api/user/:socketId/* in index.js - * (requireSessionOwnership). We only resolve the row whose socket_id AND anon_token - * both match; a mismatch/absent token gets 401/403 and learns nothing. + * { linked, hasPayoutAddress } is owner-only state, so the caller must prove ownership with the + * session token (users.anon_token), the same guard requireSessionOwnership applies to + * /api/user/:socketId/* in index.js. An absent or unmatched token gets 401/403 and learns nothing. */ router.get('/api/auth/smirk/status', asyncHandler(async (req, res) => { const { socketId } = req.query; @@ -457,12 +445,11 @@ router.get('/api/auth/smirk/status', asyncHandler(async (req, res) => { return res.status(401).json({ error: 'Session token required' }); } - // Identify the caller by anon_token ALONE — it is the ownership secret. The previous query also - // required socket_id = current socket.id, but socket_id is VOLATILE (changes on every refresh / - // reconnect), so after a refresh the row's stored socket_id no longer matched and this 403'd — - // which left the client's _isLinked=false and silently disabled Smirk one-click payment. Matching - // on the unguessable token is the same guarantee (you can only read your OWN state) without the - // socket_id fragility. (socketId is still required in the query string for API shape.) + // The caller is identified by anon_token ALONE: it is the unguessable ownership secret, so + // matching on it already guarantees you can only read your OWN state. socket_id is deliberately + // NOT part of the match because it is volatile (it changes on every refresh / reconnect), and + // requiring it would 403 every post-refresh caller, leaving the client unlinked and disabling + // Smirk one-click payment. socketId stays required in the query string for API shape. const result = await db.query(` SELECT smirk_public_key, payout_address FROM users @@ -470,7 +457,7 @@ router.get('/api/auth/smirk/status', asyncHandler(async (req, res) => { `, [token]); if (result.rows.length === 0) { - // Unknown/again-rotated token — ownership check failed; disclose nothing. + // Unknown or rotated token: ownership is unproven, so disclose nothing. return res.status(403).json({ error: 'Session ownership verification failed' }); } diff --git a/src/rpc/rpcService.js b/src/rpc/rpcService.js index 69427ab..d941cab 100644 --- a/src/rpc/rpcService.js +++ b/src/rpc/rpcService.js @@ -51,7 +51,7 @@ function daemonIdentityFromInfo(info = {}, genesisHeader = {}) { const knownGenesis = HASH_PATTERN.test(genesisHash) ? Object.entries(GENESIS_HASHES).find(([, hash]) => hash === genesisHash) : null; - // nettype/target are useful consistency signals, but only the pinned genesis hash + // nettype/target are consistency signals only; the pinned genesis hash is what // distinguishes XMR mainnet from a Monero-derived mainnet without trusting daemon labels. const cryptoType = knownGenesis ? knownGenesis[0].split(':')[0] : null; @@ -127,9 +127,9 @@ class RPCService { const env = options.env || process.env; this.http = options.http || axios; // Ordered preference list. `RPC_ENDPOINTS` (comma/whitespace separated) declares any number - // of daemons and, when present, its ORDER is the preference order. PRIMARY/FALLBACK remain - // supported and are appended when explicitly configured. Deduped — the stock configuration - // pointed both at the same host, which looked like redundancy and was none. + // of daemons and, when present, its order is the preference order. PRIMARY/FALLBACK are + // appended when explicitly configured. The list is deduped so a configuration pointing + // primary and fallback at the same host does not read as redundancy it does not have. const declaredEndpoints = options.endpoints || parseEndpointList(env.RPC_ENDPOINTS); const configuredPrimary = options.primaryEndpoint || env.PRIMARY_RPC_ENDPOINT || null; const configuredFallback = options.fallbackEndpoint || env.FALLBACK_RPC_ENDPOINT || null; @@ -245,10 +245,9 @@ class RPCService { /** * The order to try endpoints in for one logical call. * - * Normally: whichever node we are already on, then every other node in declared preference - * order. Periodically the PREFERRED node goes first instead, so recovering from a blip does not - * require a restart — previously `failoverActive` was a one-way latch and the process stayed on - * the backup forever. + * The current node first, then every other node in declared preference order. Every + * preferredRetryMs the preferred node takes the first slot instead, so recovery from a blip + * happens without a restart rather than pinning the process to a backup indefinitely. */ _attemptOrder() { const preferred = this.endpoints[0]; @@ -268,8 +267,8 @@ class RPCService { const wasFailedOver = this.failoverActive; this.currentEndpoint = endpoint; this.failoverActive = endpoint !== this.endpoints[0]; - // Start the re-test clock AT the moment of failover. Leaving it at 0 meant the very next - // call re-probed the dead preferred node, paying its timeout on every request. + // The re-test clock starts at the moment of failover. Left at 0, the next call would + // re-probe the dead preferred node and pay its timeout on every request. if (this.failoverActive && !wasFailedOver) this._preferredProbedAt = Date.now(); if (!this.failoverActive) this._preferredProbedAt = 0; } @@ -306,7 +305,7 @@ class RPCService { } // Every configured node refused or was unreachable. Callers must degrade gracefully from - // here — nothing downstream may treat a stale cached height as live chain state. + // here: nothing downstream may treat a stale cached height as live chain state. this.consecutiveFailures++; this.healthy = false; this.lastFailureAt = Date.now(); @@ -315,10 +314,8 @@ class RPCService { } /** - * Retained for callers that explicitly ask for a failover attempt. - * - * makeRPCCall now walks every configured endpoint within a single call, so this is simply that - * walk. It no longer latches `failoverActive`, and it no longer gives up after two endpoints. + * Alias for callers that explicitly ask for a failover attempt. makeRPCCall already walks every + * configured endpoint within a single call, so this is that same walk. */ async tryFailover(method, params) { return this.makeRPCCall(method, params); @@ -345,7 +342,7 @@ class RPCService { } /** - * Get current block count, retaining the historical cached fallback for non-financial UI and + * Get current block count, falling back to the last known height for non-financial UI and * polling callers. Paid match fairness uses getBlockCountStrict() directly. */ async getBlockHeight() { @@ -355,7 +352,7 @@ class RPCService { if (CONSOLE_LOGGING) { console.error('❌ Failed to get block height:', error.message); } - return this.lastBlockHeight; // Return last known height on failure + return this.lastBlockHeight; } } @@ -363,8 +360,8 @@ class RPCService { * Cosmetic chain tip (top block hash + difficulty) for the Tavern's ambient chain display. * * Deliberately non-authoritative: it never throws and never feeds fairness, payouts, or match - * seeding — those use getBlockCountStrict()/matchFairness, which must not accept a cached or - * best-effort value. On any RPC failure this returns null and the UI simply shows nothing new. + * seeding. Those use getBlockCountStrict()/matchFairness, which must not accept a cached or + * best-effort value. On any RPC failure this returns null and the UI shows nothing new. */ async getChainTipInfo() { try { @@ -475,7 +472,7 @@ class RPCService { if (!selectedEndpoint && result.status === 'healthy') selectedEndpoint = endpoint; } - // `primary` / `fallback` remain in the payload for existing readiness consumers. + // `primary` / `fallback` stay in the payload for readiness consumers that read those keys. const byEndpoint = new Map(this.endpoints.map((e, i) => [e, checks.endpoints[i]])); checks.primary = byEndpoint.get(this.primaryEndpoint) || checks.primary; checks.fallback = byEndpoint.get(this.fallbackEndpoint) || { ...checks.primary }; diff --git a/src/rpc/rpccalls.js b/src/rpc/rpccalls.js deleted file mode 100644 index 79dee07..0000000 --- a/src/rpc/rpccalls.js +++ /dev/null @@ -1,67 +0,0 @@ -var axios = require('axios'); - -// Environment-based console logging control -const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development'; - -/*List of RPC calls -Daemon - -Get blockheight: "get_block_count" -*/ - -function constructCall(method, params) { - var body = { - "jsonrpc": "2.0", - "id": "0", - "method": method, - "params": params - } - return body; -} - -var lastBlock = { - height: 0, - hash: 0, - setHeight: function(newHeight) {this.height = newHeight} -} - -// Use environment variable for daemon URL, fallback to localhost for backward compatibility -var daemonURL = process.env.PRIMARY_RPC_ENDPOINT ? - process.env.PRIMARY_RPC_ENDPOINT + '/json_rpc' : - 'http://localhost:34568/json_rpc'; - -if (CONSOLE_LOGGING) { - console.log(`🔗 Legacy RPC service using URL: ${daemonURL}`); -} - -function daemonCall(method, params, callback, io) { - // Real blockchain call using axios - var body = constructCall(method, params); - axios.post(daemonURL, body, { - headers: { "content-type": "application/json" }, - timeout: 10000 - }) - .then(function(response) { - callback(response.data); - }) - .catch(function(error) { - if (CONSOLE_LOGGING) { - console.log("Daemon connection error:", error.message); - } - callback(null); - }); -} - -function getBlockHeight(body) { - if (body == null || body.result == null || body.result.count == null) { - return null; - } - return body.result.count; -} - - -// Export the functions -module.exports = { - daemonCall: daemonCall, - getBlockHeight: getBlockHeight, - lastBlock: lastBlock, -}; diff --git a/src/scripts/pvp-capture.js b/src/scripts/pvp-capture.js index 96a5b56..714c808 100644 --- a/src/scripts/pvp-capture.js +++ b/src/scripts/pvp-capture.js @@ -518,7 +518,7 @@ function capturePageHtml() { -Wownerogue — Deterministic PvP Capture +Wownerogue: Deterministic PvP Capture