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.
The rate limiter caps new connections (10/min/IP) and game starts (15/min/IP),
but one IP could still hold open unbounded sockets by trickling them in under
the connection rate and keeping them alive — hoarding spectator slots and
server resources ahead of a public launch.
Track live sockets per IP in ConnectionHandler and reject new ones past a cap
(MAX_SOCKETS_PER_IP, default 10). Slots are freed on disconnect and reconciled
against live sockets in the periodic cleanup so a missed disconnect can't
wrongly lock out an IP.
- 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.
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.
- Remove the duplicate _gameMode declaration in the client SocketHandlers
object literal — it was declared twice, so the later `null` silently
shadowed the earlier `'free'`. Now declared once with the effective value.
- Remove the dead SocketHandlers._appendMessage calls in smirkAuth.js; that
method never existed, so the guard always fell through to the working
.text() append. Keep the safe append.
- Gate broadcastManager's high-volume per-event logs (user count, chat message
incl. message/socketId PII, game update, game start) behind debug mode;
previously they logged on every event regardless of NODE_ENV.
Full suite: 21 suites / 155 tests.
4.2 (partial): Delete the dead handlePlayerMove socket handler and its
orphaned playerMoveTimestamps map + cleanup. It was never registered (the live
move path is MovementManager.handleMove) but, if ever wired, it double-stepped
the monster and bypassed the escape/collision event handling — a loaded
footgun. No behaviour change.
4.5: Delete 14 stale non-jest files from test/ (5 scratch .js scripts + 9
.html debug pages) — they contain no jest constructs and are referenced
nowhere. test/ is now exclusively the 21 real jest suites.
Full suite: 21 suites / 155 tests.
Random block timing is the core mechanic and is intentionally NOT softened —
early entry is a knowingly-risky bet. The only change is a small configurable
grace (GAME_START_GRACE_MS, default 2000ms, 0 to disable) so a block landing
in the same instant a game starts can't kill the player before the dungeon
even renders or before their first move is possible (100ms move cooldown).
(Replaces an earlier over-reaching "uniform time budget" idea that would have
defeated the random-timing mechanic.)
- register_client now validates the client-supplied clientId (type, length,
charset) before it enters clientSocketMap as both key and value, closing a
map-poisoning / mapping-impersonation vector.
- mempoolNotified is now a Map with real TTL eviction owned by PaymentHandlers
(the actual dedup set). The dead duplicate Set in SocketHandlers and its
random-10%-per-cycle eviction (which could drop live entries) are removed;
addresses are also evicted promptly when monitoring stops.
- The SessionManager `sessions` cache is evicted on disconnect (removeSocket,
called after the suspend logic reads it) so it no longer grows unbounded
with every socket ever seen.
- sessionManager.dispose(), suspendedGameManager.cleanup() and
paymentHandlers.dispose() are wired into SocketHandlers.shutdown() so their
timers/maps don't leak on shutdown.
- checkGamesTimeout snapshots activeGames before iterating and awaits each
game-over sequentially (was mutating the Map during forEach and firing
async game-overs concurrently, which could skip games / double-process).
Adds inputValidation.test.js. Full suite: 19 suites / 149 tests.
Socket-event rate limits keyed on socket.id (new on every reconnect) and
passed NO ip argument, so the "IP-limited" prefixes never actually applied —
reconnecting reset every limit, and chat had no IP limit at all.
- New rateLimitContext helper resolves a STABLE identity (anon_token -> id,
falling back to socket id) and the real client IP (X-Forwarded-For honoured
only when TRUST_PROXY=true, else handshake address — no spoofing on direct
exposure).
- game:start, address:set and chat:message now pass (stableId, action, ip);
connection:new uses the real IP. 'chat:' added to IP-limited prefixes.
- Express 'trust proxy' enabled when TRUST_PROXY=true; documented in
.env.example. Aligned the contradictory default limit values with the
production override.
Adds rateLimit.test.js (reconnect can't bypass, IP backstop for fresh
identities, XFF gating). Full suite: 18 suites / 143 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.
Two disconnect-related money/UX gaps:
1. A confirmed single_game payment whose game never started (user paid, then
disconnected) was money-in-nothing-given with no recovery path. recover-
PendingPayments now detects confirmed single_game payments with no linked
games row and grants the equivalent credits — idempotently (per-payment
reason key, row-locked re-check of both the game link and prior recovery).
2. In-progress games could be dropped on disconnect when a fresh getBySocket
lookup failed. The stable dbUserId is now stamped on the game at creation,
so suspend/restore keys on it directly without a failure-prone disconnect-
time lookup. Any game (paid or free) is preserved/reconnectable whenever a
session exists; the remaining drop-branch is only the true no-session edge
(e.g. DB unavailable at connect), which is logged for audit rather than
masked.
Adds singleGameRecovery.test.js. Full suite: 13 suites / 119 tests.
The anonymous session token is a bearer credential that was sent in the
Socket.IO handshake query string, where it leaks into proxy/access/referer
logs and is easily observed. Anyone holding it becomes that user (credits,
payout address, pending payments).
- Client now sends the token in the handshake `auth` payload instead of the
query string. Server reads `auth` first and falls back to the query string
for backward compatibility during rollout.
- The token is rotated on every successful resume, so a leaked/observed
token is invalidated the next time the legitimate owner reconnects. The
client already persists the token returned in `session_resumed`.
Adds a security test asserting resume returns a new token and the old token
no longer resumes. Full suite: 12 suites / 116 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.
When both payment modes are enabled (mixed mode) and user has no credits
or confirmed payment, the 'choose_payment' action was falling through to
the error case. Now 'choose_payment' triggers the payment options modal
just like 'make_payment' and 'purchase_credits' do.
- Created SuspendedGameManager to preserve game state when users disconnect
- Games are keyed by DB userId (persistent) instead of ephemeral socketId
- handleDisconnect now suspends games for 5 minutes instead of deleting
- handleConnection restores suspended games on reconnect with session token
- Payment monitoring is restored for pending payments on reconnect
- Added hasActiveMonitoring() method to PaymentHandlers
- Queue entries preserved for users with active sessions/payments
- Updated TODO.md with reconnection feature documentation
Users in dungeon or awaiting payment can now close/refresh browser and
seamlessly resume within 5 minutes when reconnecting.
- Fix game start failure in direct payment mode when payment confirms
before mempool detection (add player to queue if not already there)
- Fix debug logging in production: correct indentation in debugRegisteredUsers,
swap console_logging config (false in prod, true in debug)
- Add close button to QR code container
- Auto-hide QR on payment detected, confirmed, and game start
- 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