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.
Adversarial multi-agent review of the launch candidate produced 22 confirmed
findings (12 more were refuted). This closes the critical, high, and the
mediums/lows that are cheap and real, then does the iso/3D/Tavern art pass.
CRITICAL — unauthenticated remote kill switch
Socket.IO does not wrap listener invocation, and this process treats
uncaughtException/unhandledRejection as fatal, so ONE unguarded throw in ANY
listener exits the server. `socket.emit('debug_ping')` with no argument did
exactly that, and systemd restarts straight back into it. Every registration
in handleConnection now goes through a safe-dispatch wrapper that contains
sync throws and rejected promises to the offending socket; handleDebugPing
also stops trusting its payload.
HIGH
- NIP-98 login now binds the signed event to this deployment's host. The path
check alone let an event signed for any other origin authenticate here, and
linking ADOPTS the account owning that pubkey — full account takeover.
- Fog of war is enforced server-side. getState() shipped absolute
entrance/exit/treasure coordinates and the monster's exact position in every
frame, with concealment implemented only in the browser. Discovery is sticky;
the monster is reported only while in view.
- NODE_ENV must now be explicit and recognized. Unset, every production gate
read false while DebugManager reported production and silenced logging.
- Tavern chat applies the lobby's ban list, rate limiter and user_id
attribution before entering global chat.
- userRegistry is deleted by socket.id (it was keyed by socket.id and deleted
by socket.client.id, so nothing was ever freed).
MEDIUM / LOW
- Payouts: a legitimately split transfer_split kept its transaction hashes
instead of quarantining already-broadcast funds with tx_hash NULL, and
provably pre-broadcast failures return to 'pending' rather than needs_review.
- RateLimiter key parsing fixed — cleanup() freed nothing and expired windows
never reset, because ids contain colons.
- Enter no longer submits a paid entry from behind a modal.
- Lobby chat no longer double-escapes server-escaped text.
- Rate limits for identity:get / address:prompt / early_entry.
- Spectators released when a game ends by disconnect, not just settlement.
- movementManager / addressManager per-socket maps cleaned on disconnect.
Renderers
Iso: opaque shading instead of translucent fog, additive torch/lantern/beacon
light pools, crevice AO, embers, cold memory wash, and it stops reallocating
the whole canvas every animation frame.
3D: instanced level geometry (3 draw calls), procedural stone/flagstone
materials, torch lights + shadows, real meshes for stairs/chest/monster
(they were humanoid capsules), dust motes, wall cutaway, and lighting
rebalanced now that fog is measured from the camera.
Tavern
Block height and hash fragments drift around the stage edge and ripple when a
block lands, fed by a cosmetic chain tip added to the blockheight broadcast.
Speech bubbles work again (they keyed off a socketId the server stopped
sending).
126 suites / 1005 tests pass.
Previously the timing-choice modal (Start Now / Wait for Next Block) only appeared for
paid-credits players; free players got a silent immediate drop-in. Now it shows whenever
the game would start instantly:
- Server getGameModeInfo exposes freePlayEnabled (was server-only) so the client knows
free play is available even on a paid instance.
- Client (inputHandler) gates the modal on free-play-available OR paid-credits-with-credits;
the modal is purely a TIMING pick (auto_start vs join_queue) — payment method is unchanged.
- Modal cost text reflects free play.
Both waiting animations are untouched: they render on the ROT display via WaitingScreenAnimator
using the base GameTiles, independent of the render-kit packs (which are disabled for SP anyway),
so they work regardless of visualization style. Suite 62/435 green.
Recon of the operator's LAN BTCPay box (ssh four-btcpay / 10.42.1.33) showed the
real topology: BTCPay Server 2.3.9 handles BTC/LTC only (BTCPAY_CHAINS=ltc,btc),
while XMR and WOW run through separate 'xmrcheckout' (:8080) and 'wowcheckout'
(:8180) apps — and crucially, both expose a BTCPay-Greenfield-COMPATIBLE API
(/api/v1/stores/{id}/invoices, token auth, Settled/New/Expired status). Grin
isn't deployed there yet. So one Greenfield client class serves all three.
- btcpayProvider: id is now constructor-configurable so the same class registers
as 'btcpay' (BTC/LTC), 'xmrcheckout' (XMR), and 'wowcheckout' (WOW).
- buildProviderRegistry: activates each gateway independently from its *_URL/
_STORE_ID/_API_KEY trio (BTCPAY_*, XMRCHECKOUT_*, WOWCHECKOUT_*), with
topology-correct default routing (BTC/LTC->btcpay, XMR/WOW->their checkout or
native), BTCPAY_CHAINS to widen, PAYMENT_ROUTING to override.
- gameModeManager: takes an optional paymentProviders registry (defaults to a
native-wrapping registry built from walletService). createPaymentRequest now
creates the invoice via getProvider(cryptoType).createInvoice(...). The native
provider delegates to walletService.createPaymentRequest, so the shipped
single-chain config is byte-for-byte unchanged — same subaddress, same
monitoring maps. paymentRequest now also carries invoiceId for the monitoring
wire to come.
- nativeMoneroProvider: surfaces addressIndex so the call site stays provider-agnostic.
Behavior-preserving: no gateway env => native-only, every chain routes native.
Monitoring + payout paths intentionally left on the native path this pass;
gateway monitoring (provider.startWatch) is the flagged next step. Tests: +4
(provider wiring routes to a gateway; default registry delegates to native).
Suite 53 suites / 365 tests green.
① assetPacks.js merges the server-authoritative catalog (from the entitlement snapshot) into RK.PACKS — operator-added packs now appear client-side and existing packs pick up server definitions, retiring the client half of the split-brain. ② A confirmed direct/single_game payment now records as 'buy 1 credit + spend it': balance nets to zero but total_credits_purchased advances (recordDirectEntryPurchase), so direct play unlocks the same tier/threshold cosmetics as buying credits, and the client gets a fresh entitlement snapshot. +2 tests. Suite 49/345 green.
P1 foundation (branch feat/credits-catalog-foundation). Remaining: the buy modal reflects the unified credits model; a later pass can fully collapse single_game into the credit spend path.
Follow-up 1: when a user sets a payout address, gameModeManager.setUserPayoutAddress now converts any claimable match winnings recorded with no address (reason 'match_winner_no_address', status 'needs_review', sentinel 'PENDING_NO_ADDRESS') into sendable 'pending' payouts and kicks the batcher. The reason filter is narrow so it never touches the ambiguous-broadcast needs_review rows (which would double-pay).
Follow-up 2: match rows are now marked status='active' with started_at when the countdown completes, and _recoverAbandonedMatches gains a lock-free age guard — single-instance (default) reclaims all in-flight rows immediately, multi-instance (MATCH_SINGLE_INSTANCE=false) only reclaims rows older than the hard ceiling + buffer, so a race running on a sibling instance is never cross-cancelled.
Adds test/followups_ultra.test.js (5 tests). Full suite: 46 suites / 317 tests pass.
Adds a complete block-cadence multiplayer race mode alongside solo and tavern.
Disabled by default; enable with MATCH_ENABLED=true.
Highlights:
- New shared engine: MatchRoom, MatchEngine, MatchState (transport-agnostic, unit-tested).
- Per-economy queues: free, credits_prestige, crypto_race with atomic escrow/refund.
- MatchScheduler drains queues on every new block and starts 2–32 player races.
- MatchManager handles Socket.IO lifecycle, server-authoritative movement, reconnect grace,
forfeit/AFK handling, and end-of-match persistence.
- Crypto races use race-entry tickets (grants.race_entries) to avoid on-chain refunds; winner
payout is queued through the existing batch payout/retry service.
- Leaderboard integration: free → Pleb, prestige → /api/leaderboard?board=prestige,
crypto → Hall of Champions.
- Tavern integration: Queue for Race panel, active race list, and live spectator rendering.
- Dedicated /match.html client with economy selector, race HUD, and post-match scoreboard.
- Migration 022 adds matches, match_entrants, match_events, match_queue_entries,
race_entry_transactions, users.race_entries, and payouts.match_id.
Docs:
- New docs/MATCH_MODE.md with full architecture, config, events, and operational notes.
- Updated README.md, docs/TAVERN_AND_MULTIPLAYER.md, docs/TODO.md, src/.env.example,
and src/.env.
Tests: 40 new unit tests across 7 suites, all passing.
Known follow-ups for real-world validation:
- Full integration test with Postgres + Socket.IO.
- Server boot check with MATCH_ENABLED=false (default) to ensure solo/tavern remain unaffected.
- Browser render-kit verification in /match.html and tavern spectator.
- Crypto-race payout end-to-end with real wallet-rpc.
Spent outputs (including change) lock for ~10 blocks on Monero. When a
payout batch has no unlocked balance to cover it, attempting transfer_split
fails noisily and, for batches, gets parked in needs_review for an operator.
Add a pre-flight unlocked-balance check that reverts the batch to 'pending'
(re-checked every new-block tick and batch interval) so it sends as soon as
outputs unlock — no manual intervention. Also classify insufficient-funds
RPC errors as pre-broadcast/safe-to-retry: single -> pending (not failed),
batch -> pending without an operator alert (not needs_review), since no tx
went on-chain and there's no double-pay risk.
- Currency display is now network-aware: XMR on a test network shows sXMR
(stagenet) / tXMR (testnet) so it's clear the coins aren't mainnet; mainnet
XMR shows XMR, Wownero stays WOW. A fork running mainnet automatically shows
XMR. cryptoType (used for decimals/QR asset) is unchanged.
- Product name (Monerogue/Wownerogue) is derived from the currency and no
longer hardcoded: the chat "Connected to <name> server" message, payment
descriptions, and the verify page title all follow it.
- Smirk is forced off on any test network (stagenet/testnet) regardless of
SMIRK_ENABLED, since Smirk only works on mainnet — so forks don't have to
remember the env flag.
New shared helpers (gameNameFor, currencyLabelFor, isTestNetworkFor); exposed
via game_mode_info (currencyLabel, gameName). Full suite: 22 suites / 159 tests.
completeGame only gated PAID_CREDITS payouts (on creditsPayoutEnabled); a
PAID_SINGLE win paid out unconditionally, so DIRECT_PAYOUTS_ENABLED=false did
NOT actually stop direct payouts — a no-payout instance (mainnet legitimacy)
would still send crypto on an entry-fee win. Caught during deploy verification.
- Add this.directPayoutEnabled (from DIRECT_PAYOUTS_ENABLED env / direct rule
enabled in the config snapshot), mirroring creditsPayoutEnabled.
- completeGame now gates the PAID_SINGLE payout on directPayoutEnabled.
- The derived directPayoutsEnabled flags (getGameModeInfo / effective mode)
and /api/game-modes PAID_SINGLE.payoutMultiplier now respect it (show 0 when
off) instead of inferring from multipliers > 0.
Adds a regression test: a PAID_SINGLE win with directPayoutEnabled=false
queues no payout. Full suite: 22 suites / 159 tests.
- Leaderboard panel: two boards via tabs — 🏅 Hall of Champions (paid) and
🪙 Pleb (free) — default Champions; passes board= to /api/leaderboard and
retitles per board.
- PaymentUI: the Free button now shows whenever free play is offered (instance
free-only OR free-as-a-choice via freePlayEnabled), labelled "Play Free / no
payout / Pleb leaderboard" on paid instances, and emits the play_free event
(records game_mode=FREE). Replaces an emit of the dead 'enter_game' event.
- Server game_mode_info payload now includes freePlayEnabled so the client can
show the choice.
Full suite: 22 suites / 158 tests.
Free-to-play alongside paid, with scores split per game between a Pleb board
(free) and the Hall of Champions (paid). Keys on the existing per-game
game_mode, so no new game columns.
- Leaderboard API: GET /api/leaderboard?board=pleb|champions|all. champions =
game_mode IN (PAID_SINGLE,PAID_CREDITS), pleb = FREE. board is whitelisted.
Migration 019 adds a supporting partial index.
- FREE_PLAY_ENABLED config: offer free play as an explicit choice even when the
instance also sells credits/entry (always on when payments are off).
- play_free socket event -> handleAutoStart(socket, {free:true}); skips all
payment/credits/payout-address gating and records the game as game_mode=FREE
via processGameStart(..., {forceFree:true}) -> _processGameStartFree (no
payment, no payout, counted).
- getPaymentOptionsForUser exposes a play_free option; /api/game-modes exposes
freePlayEnabled.
Adds freePlay.test.js. Full backend suite: 22 suites / 158 tests.
There were two identity systems: SessionManager keyed on the stable, UNIQUE
anon_token, and getOrCreateUser keyed on the mutable, non-unique socket_id.
The latter could create a duplicate "orphan" user row (or resolve the wrong
row) when the socket_id lookup raced the session's socket_id update —
desyncing credits and payouts between the two rows for one human.
- getOrCreateUser now resolves through SessionManager's stable identity first
(anon_token -> users.id), re-selecting a fresh row by stable id, and only
falls back to the legacy socket_id lookup when no session exists. The
last-resort create now warns, since it indicates a missing session.
- gameModeManager.sessionManager is wired in SocketHandlers so the
session-first path is active in production.
- anon_token is already UNIQUE (migration 003), so the stable-identity
constraint already exists; no new migration needed.
Re-keying the in-memory activeGames map by user_id is deferred (invasive;
already mitigated by stamping dbUserId on the game in the disconnect fix).
Adds identityResolution.test.js proving no duplicate row is created when a
session exists. Full suite: 17 suites / 136 tests.
A typo in a status string (e.g. 'complete' vs 'completed') silently produced
a money row no index/predicate matched, orphaning funds.
- Migration 017 adds idempotent NOT VALID CHECK constraints on payments.status,
payments.payment_type and payouts.status. NOT VALID enforces all new writes
without failing on any pre-existing rows; conname guards make it re-runnable.
- The in-code existing-payout guard in completeGame now also covers the
'processing' status (matching the unique index from migration 015), so a
duplicate win can't create a second payable row while a payout is mid-flight.
Full suite: 15 suites / 131 tests.
Payouts were computed at completion from LIVE config, so an admin changing
multipliers mid-game would change the payout of an already-in-progress game.
- Migration 016 adds payout_escape_amount/payout_treasure_amount (BIGINT) and
the corresponding multipliers to the games table.
- Both game-start paths stamp the resolved payout amounts (computed from the
config in effect at entry) onto the game row.
- completeGame pays from the snapshot, falling back to live calculation only
for legacy games that predate the snapshot columns.
Adds payoutSnapshot.test.js proving a mid-game config change cannot alter an
in-flight payout. Full suite: 15 suites / 131 tests.
Money is integer atomic units, but payout and sum math ran on float64
(`Math.round(base * multiplier)`, `sum + Number(x)`), which silently loses
precision above 2^53 — a real risk for large/credit/XMR amounts.
- Add src/money/atomic.js: the single source of truth for atomic-unit math
(toBig, toSafe, sum, add, mulByDecimal, format). mulByDecimal multiplies an
atomic amount by a decimal multiplier using exact integer arithmetic with
half-up rounding; toSafe narrows to a number only when exactly representable
and otherwise returns a decimal string.
- calculatePayout now uses money.mulByDecimal/toSafe instead of float math.
- walletRPCService batch total + fee sum use money.sum instead of float reduce.
Adds moneyAtomic.test.js with property tests on amounts above 2^53 proving
exactness where the old float path drifted. Full suite: 14 suites / 129 tests.
transfer_split sends one on-chain tx to many destinations, so payout rows
in a batch share a tx_hash. The old UNIQUE index on payouts.tx_hash made
the 2nd+ row fail to record, leaving funds sent but rows stranded in
'processing' and re-sendable by the retry service.
- Migration 015 drops the payouts tx_hash unique index (idempotency is
enforced by idx_payouts_one_per_game) and extends that guard to cover
'processing' so no second payable row can be inserted mid-flight.
- Batch completion now updates the whole batch in a single transaction via
id = ANY(...), so rows can't be left half-completed. Per-user stats use
BIGINT arithmetic in SQL (no JS float).
- Ambiguous batch failures are marked 'needs_review' (which the retry
service does NOT pick up) instead of 'failed', preventing a double-send
when transfer_split may have already broadcast. Operator is alerted.
Adds batchPayout.test.js covering the shared-tx_hash completion and the
needs_review failure path. Full suite: 12 suites / 115 tests.
Phase 0.1 — Underpayment confirmation bug (CRITICAL):
Payment confirmation gated on `status.confirmed`, which checkPaymentStatus
sets for ANY incoming tx regardless of amount, so sending a single atomic
unit granted a full game/credits. Now gate on `status.complete` (received
>= expected). Underpayers are warned once and the payment stays pending so
a top-up can complete it. Persist `received_amount` for single-game and
credits confirmations for reconciliation. Adds an underpayment regression
test.
Phase 0.2 — Provably-fair was decorative (CRITICAL):
The committed seed had zero effect on the dungeon (created seededRNG was
unused; generation used Math.random + a globally-seeded ROT.RNG). Thread a
per-game seeded RNG derived from the committed seed through dungeon
generation (reseed the Digger via seedToInt + replace all Math.random) and
monster movement (seededShuffle). Add DungeonGenerator.regenerateFromSeed
and layoutFingerprint; /api/verify now regenerates the dungeon from the
seed and returns a fingerprint a player can independently reproduce. Adds a
determinism test suite.
Full suite: 11 suites / 113 tests passing.
Plan and progress tracked in docs/REMEDIATION_PLAN.md.
Payment Fixes:
- Prevent credits going negative with WHERE credits >= $1 check
- completeGame now uses recorded payment_mode from game record instead of global gameMode
- Add duplicate payout prevention by checking for existing payouts before processing
Dead Code Cleanup:
- Remove unused MoneroPayService (WalletRPCService is the actual service)
- Remove legacy MoneroPay references from comments
Tests:
- Add 50 comprehensive payment/payout tests (payment_payout_comprehensive.test.js)
- Expand WalletRPCService tests (4 -> 31 tests)
- Total: 106 tests passing across 10 test suites
Documentation:
- Update TODO.md with recent fixes and correct test counts
- Update migration comments to reflect actual wallet service used
- Add payout database records: processPayout now inserts into payouts
table with tx_hash, fee, status, and updates user statistics
- Fix credits deduction: QueueManager game start methods now properly
call processGameStart() to deduct credits (was being skipped)
- Fix credits_update emission: moved from GameModeManager (no io access)
to socketHandlers/queueManager where io is available
- Add payment recovery: users who disconnect during payment confirmation
now get credits recovered automatically on reconnect
- Remove dead handlePaymentDetected method that referenced undefined this.db