Four pre-launch additions to make the game feel alive and shareable:
- Live win feed: server broadcasts 'win_feed' on every escape; client shows a
transient toast ('X escaped with the bag') so the room feels active.
- Public stats: GET /api/stats (10s-cached) returns games + escapes in the last
24h and total paid out; a status-area strip polls it for social proof.
- Share-a-win: game-over screen gets 'Share to X' + 'Copy link' buttons pointing
at the /verify page, which now renders OpenGraph/Twitter-card meta (brand OG
image per instance) so shared links unfurl with a preview.
- Land-on-action: a dismissible 'N games live now — watch' CTA appears on connect
when games are in progress, opening the existing spectator panel. Non-intrusive:
hidden while playing/spectating and never hijacks the canvas.
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.
3.2 Reachability: a dungeon whose exit is unreachable from the entrance is
paid-but-unwinnable. generate() now BFS-checks entrance->exit and regenerates
if unreachable. Retries are deterministic (per-attempt ROT seed = seedInt +
attempt; the seeded rng stream keeps advancing), so verify-regeneration
replays the same attempts and reproduces the exact dungeon — the provably-fair
determinism from Phase 0.2 is preserved. The single-pass body is extracted to
_generateOnce and isReachable does the flood-fill.
3.3 Monster collision: with movesPerPlayerMove > 1 (the casino preset's 1.5x
speed) the monster takes multiple steps per turn, but collision was only
checked after the loop — so it could step onto the player's tile and off again
between checks, phasing through and missing the catch (player-favorable, erodes
the house edge). Collision is now checked after each sub-step.
Adds reachability tests (provablyFair.test.js) and monsterCollision.test.js.
Random block timing remains the untouched core mechanic. Full suite: 20 suites
/ 153 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