The instrument the design discussion said was missing. src/sim/ drives the actual
game engine (game.js — same dungeon gen, monster AI, movement as prod) with
headless bots to terminal outcomes, then measures what was only ever DECLARED:
- Per preset: escape/treasure/caught/stuck rates + completion-move/-time pctiles.
- MEASURED house-win-rate per network, integrating out the random block deadline
analytically: for D~Exp(mean=blockTime), P(house wins an escape at T)=1-exp(-T/BT),
so no Monte-Carlo over deadlines.
- Two bot skill models (omniscient beeline / explorer fog-of-war) as an upper band;
neither evades the monster, so true house-win is likely below both.
First findings (explorer, 200 runs, 320ms/move): presets UNDERSHOOT their declared
targets (casino 57% vs 70%, normal 47% vs 55% on WOW) and the edge swings ~15pts
across chains from IDENTICAL dungeons (casino 48% BTC .. 65% GRIN) — because
cryptoType never actually sizes the dungeon. Exactly the two gaps we predicted.
Files: src/sim/{pathfind,simBots,simulate}.js + test/sim_ultra.test.js (structural
guard, not a balance assertion). Run: node src/sim/simulate.js --help-ish flags.
Suite 59/422 green.
The shared "juice" layer, pure-canvas (CSP-safe, no WebGL/shaders), driven off a
clock so both renderers light a scene the same way:
- html/js/render/fxLayer.js (NEW): RK.fx — layered-sine flame flicker, additive
warm glow, a dancing two-layer canvas flame, and pulsing hazard overlays
(lava/poison/spikes) with a footprint-clip callback so it fits square cells
and iso diamonds alike.
- Tavern now has FIRE: 4 corner braziers + a hearth on the left wall throw real
flickering light. Placed along walls, clear of spawn/aisles/tables.
- Both renderers: torch/hearth/hazard tiles draw only their FLOOR base in the
static pass (no more solid amber block), then RK.fx paints the animated flame
/ hazard pulse in the live loop. Tiled caches emitter positions and draws them
over the vignette so light punches through the dark edges; iso adds them as
depth-sorted fx items and keeps its RAF alive while any fire/hazard is onscreen.
- Dungeon hazard zones wired end-to-end in the scene model (lava L / poison P /
spikes ^ chars -> pulsing tiles) — they render the moment the generator emits
them; no client-authoritative fakes.
- fxLayer.js included in tavern.html + match.html.
Suite 58/419 green; render files node --check clean.
Fixes the "thin disconnected slabs" on the top-left/bottom-right walls and juices
the iso scene:
- Wall facing: iso walls have an orientation. A run along grid-x uses the _S
rotation, a run along grid-y uses the perpendicular _W (I viewed the tiles:
_S faces SW, _W faces SE — the two camera-facing rotations), and true corners
use the corner tile. The renderer picks per-cell from neighbours. Enclosing
walls now read as continuous faces on all four edges. Same logic gives
windows/doors a Y rotation.
- Floor variety: deterministic per-cell hash sprinkles planksBroken into wood
floors and stoneTile/stoneUneven into stone — no more flat identical sea.
- Juice: soft contact shadows ground every prop + character, and a warm radial
vignette lights the room instead of it looking pasted onto black.
- Tavern map: barrels/crates line the side walls (clear of windows/spawn, via
the wall-safe put()) so the lobby reads lived-in in every render mode.
Suite 58/419 green.
Modes are TECHNIQUES; 'Fancy' (tiles+FX) was really a topdown PACK, so:
- Remove 'fancy' from RENDER_MODES (saved 'fancy' falls back to tiles; its dead
Pixi branch is short-circuited by canUseMode). Modes = Tiled/ASCII/Iso/3D.
- Modes now carry a ; a premium mode unlocks once the user has ANY
unlocked pack for that projection (not a single hardcoded pack) — needed now
that iso is multi-pack.
- New 'roguelike-dungeon' topdown pack: same sheet, a DUNGEON palette (stone
floors + barrels/crates vs the wood interior) — a distinct 2D tileset with zero
new assets. Registered + catalog (migration 027, topdown tier 1 / 1 credit).
The tiled renderer's lighting/shadows/vignette are the built-in FX.
- Also: iso facing fix (Male 0=NW..6=NE) reflected in tests.
So the Tiled pack picker now offers Roguelike Interior <-> Dungeon, and Iso offers
Dungeon <-> Medieval — packs as products, proven in the best-looking mode too.
Suite 58/419 green.
Proves the packs-as-products loop end to end:
- Copied Kenney Isometric Medieval Town wood tiles (floor/floor2/wall/window/
door/banner) into assets/kenney/iso-medieval/.
- Registered 'iso-medieval' as a 2nd iso RENDER pack (its own tile geometry —
Medieval tiles are shorter than the dungeon set; reuses the dungeon characters).
Strong on architecture; no medieval furniture at this scale, so furniture kinds
fall back to floor for now (shines most in the dungeon).
- Catalog product: DEFAULT_CATALOG + migration 026, iso tier 2 / 15 credits.
- PACK PICKER in the tavern: when a projection has >1 unlocked pack, a selector
appears to switch live (setActivePack -> re-render). Uses ?unlock=1 to preview.
So: switch Iso ★, and the pack picker lets you flip iso-dungeon <-> Medieval Town
live. Adding the next pack = assets + registerPack + a catalog row. Suite 58/419 green.
- catSprites: scale the cat to ~2.7x its cell (the sprite is ~40% cat, 60%
transparent padding) so it reads tile-sized instead of tiny.
- tavernMap: remove the centre rug — tile 923 renders green, which looks wrong on
the wood floor. (Re-add with a verified warm rug tile later.)
Used the sheet's hand-labelled roguelikeSheet_meta.json to pick the RIGHT tiles
instead of guessing:
- New tile kinds + coords (window, counter, keg/barrel, shelf, table, chair,
crate, rug, door) in sceneModel TAVERN_LEGEND/TILE_OF + assetPacks tileMap.
- tavernMap redesigned: windowed walls, a bar counter with a shelf of bottles
behind it and a keg (beer!) at each end, six tables each with a chair, a
central rug, corner barrels/crates, and a door. Chairs are walkable so standing
on one reads as sitting. Warm wood floor color (was a dark green in the fallback).
- Bartender now stands BEHIND the bar (spawn + roam confined to the row between
the top wall and the counter, sealed off by it).
Still TODO from feedback: a cat (no cat sprite on the sheet — needs an asset) and
richer bar bottles. Suite 57 suites / 413 tests green.
- The imported .tmx tavern room renders as a block-scatter in the browser (the
client falls back to the walkable grid, drawing furniture/walls as generic
blocks). Gate it behind TAVERN_DESIGNED_ROOM=true and default to the hand-built
procedural tavern (tavernMap.js) — bar + tables + walls — which renders reliably
through the theme atlas.
- Remove the tavern 'cat': it used the char-goblin sprite, so it read as a green
monster stuck in the wall. Gone until there's a real critter sprite.
Caveat 2 (premium sub had no data source) -> RESOLVED:
- src/services/subscriptionService.js: resolves a premium tier by npub. Two
sources, both optional (absent => null => unchanged): PREMIUM_NPUBS operator
allowlist (npub1/hex[:tier]) works TODAY with zero backend dependency, and an
optional SMIRK_PREMIUM_STATUS_URL HTTP endpoint for full automation. Cached.
- identityService.entitlementsForUser populates user.subscription_tier from it
(by smirk_public_key) before the snapshot, so an active sub unlocks cosmetics.
(The Smirk backend's /premium/status is self-only, so a by-npub service lookup
is the one remaining backend addition for full automation — documented.)
Caveat 1 (client signing was bare scaffold) -> HARDENED:
- html/js/smirkChatSign.js now does the Nostr-scope-grant retry (sign first; on
NOT_AUTHORIZED, getNostrPublicKey() once + retry), same as SmirkAuth — so a
returning user signs with one approval instead of silently falling back. Still
feature-detected + graceful. A real Smirk wallet click is the last confirm.
Docs updated (MONETIZATION.md, CHAT_AND_NOSTR.md). Tests: +6 (allowlist hex/npub1,
HTTP active/inactive+cache, identityService unlock, non-premium unchanged).
Suite 57 suites / 413 tests green.
Phase 2 — a signed-in player can post global chat under their OWN Smirk npub
instead of the shared bridge account:
- src/utils/verifyChatEvent.js: verify a client-signed chat event with the same
defenses as verifyNip98Event (rebuild clean event, recompute id, verify schnorr,
freshness) PLUS the impersonation guard — the event's pubkey MUST equal the
session's authenticated npub (users.smirk_public_key from NIP-98 login).
- ChatProvider.relaySignedEvent (base: local delivery only) + NostrChatProvider
override (local delivery + publish the PRE-SIGNED event to the relays, no
re-signing, echo-deduped) so it carries the player's npub.
- chatHandler.handleSignedChatMessage: rate-limit + resolve the session npub +
verify + escape + relay. socketHandlers wires the event. Serves
lobby + tavern (both render the global broadcast).
- html/js/smirkChatSign.js + tavern send wire: feature-detected client helper —
signs via window.smirk.signNostrEvent, falls back to the unsigned send when
Smirk isn't connected, so it's inert (and safe) until a page has a wallet.
Premium -> cosmetics (the P1 catalog already had the hook):
- entitlements.levelForUser now takes the HIGHER of premium_level and an
active-subscription tier, so a wowne.ro premium sub sets the entitlement tier
and unlocks tile/customization packs at/below it — one sub, chat perks +
cosmetics. Buying credits still never joins the premium ladder.
Behavior-preserving: no subscription_tier => unchanged; chat_signed only fires
when a client opts in. The client signing path needs browser verification against
a connected Smirk wallet (like the NIP-98 login). Tests: +13 (verify incl.
impersonation/tamper/channel/freshness, relay+echo-dedupe, sub-tier unlock).
Suite 56 suites / 407 tests green.
The tavern used ephemeral, room-scoped chat with no backlog, so entering it
showed a blank log. Wire it to the shared global chat provider instead:
- TavernManager takes a globalChatProvider (the lobby's chat provider) and
publishes tavern messages to the 'global' scope, so tavern + lobby share ONE
conversation that persists to Postgres and (when NOSTR_CHAT_ENABLED) fans out
over relay.smirk.cash. Falls back to the old tavern-scoped ephemeral provider
when none is injected.
- On join, the tavern sends the recent global backlog (chat_history) to the
joiner so history shows immediately.
- tavern.html: render chat_history on join and accept global-scope broadcasts
(not just this room's), so lobby<->tavern messages flow both ways.
Tests: +3 (join sends backlog, chat routes to global scope, legacy fallback
stays tavern-scoped). Suite 55 suites / 394 tests green.
Gameplay becomes a data object. A Ruleset captures world/entities/players/
win-condition/timing/economy, and MatchRoom now READS it instead of hardcoding
'first to the exit wins' — so new modes are data, not new subsystems.
- src/game/rulesets/Ruleset.js: defineRuleset normalizer (frozen, clamped, safe
defaults) + WIN/ECONOMY enums.
- src/game/rulesets/winConditions.js: strategies with onExit/onDeath/rank hooks.
FIRST_TO_EXIT is a verbatim port of the old race logic (so existing matches are
byte-identical); LAST_ALIVE (PvP battle-royale), HIGH_SCORE (score attack) and
ALL_ESCAPE (co-op) are new.
- src/game/rulesets/registry.js: built-ins (solo-classic descriptor, race,
last-alive, score-attack, coop-escape) + getRuleset/listRulesets +
rulesetFromMatchOpts (legacy opts -> race, preserved).
- MatchRoom: resolves a ruleset (explicit object / rulesetId / legacy opts),
delegates win/lose/rank to the strategy, honors entities.monster (optional
monster) and entities.pvpCombat (stepping onto a living rival strikes them
down; attacker holds position), and takes timing from the ruleset. deathOrder
tracked for the last-alive tiebreak.
The live single-player engine is intentionally NOT refactored (represented as the
'solo-classic' descriptor for catalog parity only — too risky to touch the
deployed path). PvP modes are opt-in via rulesetId; the scheduler still builds
'race' so nothing changes until wired.
Behavior-preserving: all 5 existing match suites (42 tests) green unchanged.
Tests: +9 (ruleset normalize/registry + real-MatchRoom last-alive kill->winner,
high-score no-instant-win, race-unchanged). Suite 55 suites / 391 tests green.
Layers global chat on the existing ChatProvider seam without changing callers.
NostrChatProvider DECORATES the local SocketChatProvider: every message is still
delivered in-game + persisted exactly as before, and a global message is also
signed and fanned out to nostr relays (relay.smirk.cash). Messages from the relay
are delivered locally-only (never re-published) so there are no echo loops.
North-star fit: relay.smirk.cash's default inbox-outbox policy only lets
REGISTERED Smirk npubs publish — so putting a message in global chat is a concrete
reason to sign in with Smirk. Signer is injectable: a server bridge key today
(createBridgeSigner, must be a registered npub to publish; omit -> receive-only),
each player's own Smirk npub later (chat identity == wallet identity) by swapping
the signer, no provider change.
- src/utils/nostrLoader.js: shared nostr-tools loader (prod require / Jest bundle
shim), factored from the nip98 pattern.
- src/utils/escapeChat.js: entity-escape + length cap, applied to untrusted remote
messages (they have no trusted caller, unlike local ones).
- src/network/chat/nostr/{NostrTransport,bridgeSigner}.js: SimplePool transport
(degrades to noop if nostr-tools/ws unavailable — enabling can't crash the
server) + server key signer.
- src/network/chat/NostrChatProvider.js: decorator; echo dedupe (own pubkey +
seen-id FIFO), remote-delivery rate limit, XSS escape on remote content/username.
- src/network/chat/index.js buildChatProvider: opt-in via NOSTR_CHAT_ENABLED /
NOSTR_CHAT_SCOPE / NOSTR_RELAYS / NOSTR_CHAT_TAG / NOSTR_BRIDGE_SK. chatHandler
wraps its local provider through it and calls initialize().
Behavior-preserving: NOSTR_CHAT_ENABLED unset (today) => the plain local provider
is returned, chat is byte-for-byte unchanged. Server stays moderation authority
(bans/rate-limit/history in Postgres via the local provider). Tests: +10 (decorate/
fan-out/room-skip, remote deliver+escape+dedupe+rate-limit, factory, real schnorr
round-trip). Suite 54 suites / 382 tests green. Not yet live against relay.smirk.cash.
Completes gateway support (code side). network/paymentHandlers._monitorAddress
now selects the provider for the chain and calls provider.startWatch; the whole
confirmation callback is extracted to _handlePaymentStatus so native and gateway
payments share the exact same logic. stopMonitoringForSocket stops via the
provider too.
- nativeMoneroProvider.startWatch passes the RAW wallet status through untouched,
so native monitoring is byte-for-byte the legacy path (getInvoiceStatus still
returns the normalized contract shape for other callers).
- btcpayProvider.getWalletStatus maps a Greenfield invoice to that same raw shape:
Settled -> confirmed+complete, Processing -> in_mempool, with paid/required
amounts read from the invoice payment-methods (totalPaid/amount) and converted
to atomic units via ChainProfile decimals. startWatch polls it and stops on
complete/expired/invalid.
- paymentHandlers falls back to walletService.startPaymentMonitoring when no
registry is present, so existing behavior/tests are preserved.
Behavior-preserving: no gateway env => native provider selected => identical
path. Tests: +7 (getWalletStatus status/decimal mapping x5, native raw
passthrough, gateway monitoring routing). Suite 53 suites / 372 tests green.
Not yet live-verified against the real box (needs a real store API key).
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.
buildProviderRegistry({env, walletService}) assembles the registry:
- native Monero/Wownero provider registered whenever a walletService exists;
- BTCPay auto-registers when BTCPAY_URL/STORE_ID/API_KEY are set;
- default routing sends BTC/LTC/GRIN to BTCPay and keeps XMR/WOW on native;
- PAYMENT_ROUTING (JSON or CHAIN:provider,... ) overrides any mapping.
Behavior-preserving: with no BTCPAY_* env (prod today) only the native
provider registers and all chains route to it — identical to the current
flow. Inert until index.js calls it; this just makes P3 instantiable behind
one factory. parseRouting + bootstrap covered by 5 tests.
Pillar 3, reframed around the operator's BTCPay infra. The game no longer
implements per-chain wallet/daemon code directly; it talks to pluggable
payment providers behind one contract (createInvoice/getInvoiceStatus/
startWatch/sendPayout/validateAddress/supportsChain).
- paymentProvider.js: PaymentProvider base + PaymentProviderRegistry with
operator-configurable chain->provider routing and support-based fallback.
- btcpayProvider.js: first plugin. BTCPay Greenfield API client covering all
five Smirk chains (BTC/LTC native, XMR via xmrcheckout, WOW via wowcheckout,
GRIN via the in-house plugin). Invoice priced in the chain's own crypto;
address read from the invoice payment-methods. Injectable fetch for tests.
- nativeMoneroProvider.js: second plugin. Wraps the existing walletRPCService
(subaddress invoicing + transfer_split payouts) so our own XMR/WOW stack is
just another routable option. Behavior-preserving delegation.
- paymentProviders_ultra.test.js: registry routing/fallback, BTCPay invoice+
status mapping with mock fetch, native provider delegation. 6 tests.
Not yet wired into the live payment flow (env config + registry bootstrap is
the next step). Suite 51 suites / 356 tests green.
New src/chain/chainProfile.js — one data-driven source for chain params (decimals, mean block time, adapter family, URI scheme) across WOW/XMR/BTC/LTC/GRIN, all PoW/high-variance. inferCurrencyDecimals now delegates to it: WOW=11/XMR=12 unchanged, and BTC=8/LTC=8/GRIN=9 are fixed (were silently defaulting to 12). This is the seam the daemon/wallet/address adapters and the block-time difficulty-scaling plug into. +6 tests, suite 50/350 green.
P2 foundation on branch feat/credits-catalog-foundation.
① 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.
Migration 024 adds a server-authoritative cosmetic_catalog table (seeds today's 4 packs). New CatalogService loads it (cached, falls back to a built-in default when the table is absent). entitlements.snapshotForUser is rewritten to the three-way unlock rule — a pack unlocks via: it's free, an explicit grant, lifetime spend >= its threshold, OR premium tier >= its tier. This kills the old bug where ANY purchase (total_credits_purchased>0 -> level 'credits') unlocked EVERY premium pack, and makes '1 credit -> skin A, 10-pack -> pack B' expressible. Credit-buying no longer auto-promotes to a premium tier. Snapshot now ships a catalog summary to the client (the seed for killing the client-side split-brain next). identityService loads + passes the catalog. +13 tests (catalogEntitlements_ultra), suite 48/343 green.
First stone of the P1 foundation (branch feat/credits-catalog-foundation). Next: client consumes the served catalog (retires RK.PACKS), direct payment -> mint 1 credit, then the buy modal reflects the unified model.
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 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.
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.
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.
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.
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).
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.
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.