The Smirk dapp API requires the origin to hold the Nostr scope before signNostrEvent(); without it the wallet throws 'Origin lacks the Nostr scope — call getNostrPublicKey() first'. getNostrPublicKey() establishes that grant (prompts once), so call it first in the NIP-98 path.
Only an explicit user-cancel skips the fallback now — a LOCKED signNostrEvent again tries connect(), which opens the unlock popup on capable builds. My earlier refinement had removed this path.
A Smirk wallet that already owns another account no longer fails with 'already linked to another account'. Ownership is cryptographically proven, so it's a login: the server re-points that account to the current socket and returns its session token; the client persists it and reloads to re-establish as that account. Fixes a returning user being unable to sign in from a fresh browser session.
NOTE: touches the identity/session path — verify live (sign in with a wallet already linked to another account -> lands on that account with its address/credits).
The textarea had width:100% + padding:10px + 1px border with no box-sizing (no global reset), so it rendered ~22px wider than the modal content box and ran into the right edge. border-box keeps padding/border inside the 100%.
Play area: add a 6px right gutter on .container (border-box) so the outward yellow focus outline isn't clipped by overflow:hidden at the viewport edge — the inset-outline attempt got clipped in Chromium. Revert to an outward outline (ui.js).
Smirk: on the NIP-98 path, only fall back to the legacy connect() when the wallet genuinely lacks signNostrEvent — a LOCKED or user-cancelled signNostrEvent now surfaces instead of triggering a second unlock prompt. (The unlock-popup-on-locked behavior itself is a wallet-build capability, added to smirk-monorepo 2026-07-09; builds before that throw LOCKED and get the friendly retry hint.)
Tavern: revert the per-cell 'varied floor/wall' tiles to coherent single tiles — mixing wood/stone/plank per cell read as visual noise (assetPacks.js).
Play area: draw the keyboard-focus outline inset (outline-offset -2px) so the yellow border shows on the right edge, which the container's overflow:hidden was clipping (ui.js).
Smirk: a locked wallet now shows a friendly 'unlock in the extension, then Retry' hint instead of a red error, and the LOCKED error code is preserved through connect()/signMessage(). Current Smirk builds auto-open the unlock popup on connect(); older builds throw LOCKED, which the Retry button re-invokes (smirkAuth.js).
The reverse proxy (Nginx Proxy Manager) is containerized/off-host and reaches the app over a non-loopback address, so binding to 127.0.0.1 made play.wowne.ro return 502. Revert the default to bind all interfaces (original behavior). Direct-exposure hardening is a firewall rule + the already-applied X-Forwarded-For rightmost-hop validation, not the bind address. Operators with a same-host proxy can still set HOST=127.0.0.1.
Adds schnorr (BIP-340) NIP-98 verification (kind 27235) as the preferred Smirk login, with the legacy Ed25519 wallet-message path kept as a feature-detected fallback for wallets without signNostrEvent. New src/utils/nip98.js verifies securely: rebuilds a clean event, independently recomputes getEventHash and compares to event.id (defeats nostr-tools' verify-memo footgun), then schnorr-verifies; enforces kind, created_at freshness, and exactly-one u/method/challenge tags bound to the server nonce.
auth.js: /verify branches on body.event (NIP-98) vs {publicKey,signature,challenge} (legacy). Challenge consume is now atomic single-use (UPDATE ... WHERE used=FALSE AND expires_at>NOW() RETURNING) before verification. Removed the deprecated SHA256(challenge) pre-hash fallback (past its removal date). /status is now session-gated (X-Session-Token vs users.anon_token; 401/403 on mismatch) — closes the unauthenticated per-socketId disclosure. Client smirkAuth.js: feature-detects signNostrEvent, signs the NIP-98 event, falls back to legacy; checkStatus() now sends the session token.
Adds nostr-tools ^2.23.9 (works under CJS in prod; test loader falls back to its self-contained bundle under Jest). New adversarial suite test/smirkNip98_ultra.test.js (13 tests: tamper/expiry/wrong-tag/bad-sig/memo-bypass/replay). Suite 47/330 green.
The skin/hair/cloth-base/gear palette-ramp recoloring on the top-down Tiles character sprites looked bad, so it's disabled: tintedTileCanvas() now returns null, making every caller (drawCharTileCanvas, charTileTexture, in-game + customizer preview + Pixi) fall through to the native Kenney sprite art.
The customizer's top-down editor drops the Cloth/Skin/Hair and per-gear color rows and keeps only equipment PIECE selection (body/head/shield/weapon). The ASCII '@' color picker is unchanged (shown in the ascii/color branch, driven by colors.base via avatarVisuals.tintColorFor). Iso skin tint and premium generated-skins are untouched. Suite 46/317 green.
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.
- tavernLife.js: client-side NPCs (bartender, patrons, cat) with autonomous
movement, random speech bubbles, and real-player chat bubbles
- T2b: spectator camera — watch live dungeon runs from inside the tavern
(game list, spectate_start/update, ESC to leave)
- R1: sceneFromGameState adapter — converts dungeon game state to render-kit
Scene with lighting grid and entity mapping
- R2: pre-populated Kenney roguelikeSheet tile defaults for the tiled renderer
- Dungeon renderer support: TileRenderer + AsciiRenderer handle lightGrid,
dungeon features/items/monster entities
- 3 new tests (242 total, 36 suites, all passing)
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.
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.
On phones the primary buttons now read START / WATCH / SHOP (short labels via
spans toggled by media query) with tighter padding so they fit a single row.
The on-screen D-pad is smaller (44px) and more transparent so it covers less
of the chat/game underneath.
On phones the dungeon canvas was letterboxed: the game grid row was 1fr and
got squeezed by the status bar + chat, so the 800x608 canvas scaled to that
short height and rendered narrow with black side bars.
Drive the game area by width instead — full-width box with the canvas's own
aspect ratio (auto height) — and hand the leftover vertical space to chat
(now 1fr, max-height cap removed) so it's roomier too.
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.
The layout was already partly responsive (max-width:100vw, min(480px,90vw)
modals, canvas max-width:100%, and the renderer centres a fixed viewport on the
player) but had no viewport meta, so none of it engaged on phones.
- Add the viewport (+charset) meta — unlocks the existing responsive rules.
- Mobile media query (<=820px): stack the 3-column grid into one column
(header/status/game/chat/input/footer), shrink fonts, and cap the fixed-width
play/history/leaderboard modals to 94vw so they fit instead of overflowing.
- On-screen D-pad + swipe-to-move (new touchControls.js), both emitting the
same player_move {direction} event the keyboard uses — no game-logic change.
Shown on small screens or any touch device (body.touch).
- Remove the leftover "GAME AREA" debug overlay.
- 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.
paymentConfig set currency.symbol from CRYPTO_TYPE but left currency.decimals
at the WOW default (11), so on the XMR (Monero) instance every amount in
/api/game-modes and the payment UI displayed 10x too large (0.01 XMR shown as
0.10). The atomic amounts and the payment QR were always correct — only the
web display divisor was wrong. Now decimals is inferred from the symbol
(CURRENCY_DECIMALS env still overrides). WOW stays 11 (unchanged).
- Footer title and favicon now track the currency: "Monero Roguelike" + the
Monero logo favicon on the XMR instance, "Wownero Roguelike" + the existing
favicon on WOW (driven by UI.updateGameTitle).
- "Choose How to Play" modal: a header explains that paying (credits/entry)
puts high scores in the Hall of Champions while free play uses the Pleb
leaderboard, and each option now says which board it feeds — on both the
no-payout (mainnet) and payout (stagenet) variants.
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.
Prepared (not executed) artifacts for the two-instance deployment:
- scripts/deploy/monero-wallet-rpc.service: Monero STAGENET wallet-rpc unit
(mirrors wownero-wallet-rpc; binds 127.0.0.1:38083, daemon :38081, password
placeholder).
- scripts/deploy/monerogue.service: second game instance unit (separate
user/dir/DB, :3001).
- docs/DEPLOY_INSTANCES.md: full runbook for both instances — mainnet
reconfigured to free-to-play + NO payouts, and the new Monero stagenet
instance with payouts ON; includes the monerogue .env and verification.
No server changes and no secrets committed (placeholders only).
Adds FREE_PLAY_ENABLED to .env.example with the two deployment patterns:
real-money/mainnet = free-to-play + sell credits/entry but NO payouts (paid =
prestige Hall of Champions, not gambling); test/stagenet = full pay-to-play
with payouts. No live config changed.
- 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.
- 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.
The three /api/auth/smirk/* endpoints move to src/routes/auth.js as a DI
factory createAuthRoutes(ctx), mounted conditionally on SMIRK_ENABLED.
Handler bodies unchanged.
index.js: 1047 -> 882 lines (was 2029 before the split began). Adds an auth
smoke test. Full suite: 21 suites / 155 tests.
Begin splitting the 2029-line index.js. No behaviour change.
- src/views/verifyPage.js: the inline provably-fair verification HTML page,
now with the interpolated values HTML-escaped (defense in depth).
- src/routes/admin.js: all 15 /api/admin/* endpoints + adminAuth, as a
dependency-injected factory createAdminRoutes(ctx). Handler bodies are
byte-for-byte identical; alertService is late-bound via the shared ctx
object (set when the payment system starts).
index.js: 2029 -> 1047 lines. Adds adminRoutes.test.js asserting all 15
routes register. Full suite: 21 suites / 154 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.
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.)