- FOG: sceneFromGameState only emits exit/entrance/treasure/items once their cell is EXPLORED,
and the monster only while IN VIEW — no more exit stairs leaking through the fog.
- DESCEND FEEDBACK: reaching a non-final exit descends (multi-level) and used to silently dump the
player at a new entrance (read as a bug). Server now sends depth/maxDepth on game_event; the
client shows "Stairs down! Level N of M — keep going". Also lowered level counts (WOW 4->2,
BTC 8->3, XMR 2->1) — clearing 4-8 levels in one block was unwinnable; kept modest until proper
multi-level calibration + UX.
- CAMERA: the host filled the very-tall #game-display so centering put the player low; it's now a
bounded viewport (min(100%,78vh)) centered in #game-display, so the player stays on-screen-centered.
Suite 62/435 green.
The default->char-villager coercion (grey-circle guard) applied to legacy-rot too, so an
un-customized single-player showed a villager sprite with no way back to the classic hero.
Exclude legacy-rot from the coercion: default/color appearances now fall through to
RenderEngine's original @ tile fallback (chars/skins still draw their sprites; the tavern
canvas path keeps the villager to avoid grey dots). Updated the splash-legend test to the
new tile-based legend. Suite 62/435 green.
The migration that makes the pack ladder pay off IN the dungeon, not just the tavern.
The SP game now draws via RK (Tiled/ASCII/Iso/3D + unlocked packs) instead of the
legacy ROT display.
- Adapter: sceneFromGameState now consumes the CLIENT state shape (sparse {y:{x:v}}
tile maps, not just server arrays) via maxDims(). Verified + unit-tested.
- Bug fix: RK.sceneFromGameState was never actually exposed (only RK.scene.*), so
matchClient + tavern spectator were silently no-op-ing. Aliased onto RK.
- Bridge (spGameRenderer.js, RK.SPGame): mounts an RK renderer in a dedicated host
inside #game-display, toggles the legacy ROT canvases, zoom-enabled. Game._draw
GameScreen prefers it and FALLS BACK to RenderEngine on any failure, so the live
game can never blank. ROT still draws the splash/win/lose text screens.
- Picker (spRenderPicker.js): homepage mode + pack selector, gated by entitlements
(free = Tiled/Original + ASCII; ladder unlocks Iso/3D/packs), rebuilds on identity.
- index.html loads the render-kit renderers + the bridge/picker.
Bundled with the catalog ladder (migration 028): free Original bare pack, ladder
1/5/10/20/40/50. Deploying makes the free tavern bare (by design) AND the dungeon
render-kit-ed. v1 renders the whole explored level + zoom (player-centered camera is
the next polish). Suite 62/435 green; adapter smoke-verified.
Nails down the premium unlock system per the design pass:
- FREE `original` topdown pack (NEW): the game's bare tiles — floor/wall/door/
torch/features only, no furniture, so the free tavern reads plain (the intended
"reason to unlock"). Registered in assetPacks (RK.THEMES.original) as the free
default topdown pack; plain ASCII stays free (accessibility).
- Loyalty ladder (lifetime credits PURCHASED, cumulative, NOT deducted → buying to
play also unlocks cosmetics): Interior 1, skins 5, iso 10, +tilepack 20, +iso 40,
3D 50. Each row is data (tilepack OR skin) → operator appends more at any rung.
DEFAULT_CATALOG (entitlements.js) + migration 028 + MONETIZATION.md ladder table.
- Tests updated to the new thresholds (catalogEntitlements_ultra, directEntryCredit_ultra).
Note: this reprices roguelike-interior from free → 1cr, so once deployed the free
tavern becomes bare (by design). Deploy is bundled with the single-player render-kit
migration so the free-tier change lands with its payoff. Suite 61/432 green.
The fair, on-theme edge lever for slow chains (from the calibration work): a run
now descends `maxDepth` levels instead of one map, so cumulative run length makes
the block-timer bite WITHOUT a giant single dungeon or a cheating-fast monster.
Engine (game.js):
- depth/maxDepth; _generateLevel(depth) builds each level (fresh dungeon + monster
+ FOV), _descend() steps down. Reaching a non-final exit descends (event
"descend", stays active); only the final exit escapes (event "escaped").
- Treasure lives only in the vault (final level) — intermediate levels race to the
stairs. Single-level (maxDepth=1) is byte-identical to before.
- Provably fair per level: levelSeed(masterSeed, depth) (provablyFair.js) — level 1
= master seed, deeper levels salted; the whole descent regenerates from the one
committed seed. Verified: game levels == regenerateFromSeed(levelSeed(seed,d)).
Pacing (difficultyConfig.js): NETWORK_TUNING now sets LEVELS ∝ block time (GRIN 1,
XMR 2, LTC 2, WOW 4, BTC 8) — replacing the size-scale hack; size + monster stay at
the fair preset. DUNGEON_LEVELS operator override; NETWORK_TUNING_DISABLED → 1 level.
Wiring: socketHandlers afterPlayerMove gets a "descend" branch (no game-over, no
monster move on arrival). Client gameState.js clears the accumulated map/explored/
visible tiles on a depth change so the old level does not ghost through the new one.
Sim: explorer bot resets fog-of-war on descent so multi-level sims stay honest
(full re-calibration of the level counts is the pending follow-up).
Tests: multiLevel_ultra (descent, vault-only treasure, per-level provably-fair,
single-level default, DUNGEON_LEVELS) + networkTuning rewritten for the levels model.
Suite 61 suites / 432 green; end-to-end 4-level WOW run descends 3x then escapes.
WOW meanBlockTimeMs 120000 -> 300000. Measured 5.01 min/block over 1000 blocks on
the live daemon; the code comment claiming it was "corrected to 2 min" was backwards.
WOW is a SLOW chain, closer to BTC than XMR for calibration.
Per user feedback ("fast monster feels like cheating") + what the sim then proved:
a supernaturally fast monster doesnt even WORK on slow chains — WOW (5-min) caps
~64% house-win even at a 2.2x monster on a 111x55 map, because a single dungeon
clears in ~1 min << the block so the timer (the fair, on-theme edge) barely bites.
So NETWORK_TUNING drops the monster-speed lever entirely and keeps size∝√blockTime
only (monster stays at its fair preset speed). WOW sizeScale 1.0->1.6 for 5-min blocks.
Consequence: slow chains (WOW/BTC) run honest-but-under-target on the timer until
MULTI-LEVEL DEPTH lands — the real edge lever (cumulative run length across levels
without any single map being an unplayable slog). Deb (WOW/normal) now gets a bigger,
more block-appropriate dungeon (45x22 -> 72x35) with a fair monster.
meanBlockTimeMs has no runtime consumer yet (metadata) so the WOW fix is behavior-safe
today; it corrects the sim + future block-bounded features. Suite 60/428 green.
Solves the two gaps the sim measured (edge undershot targets AND swung ~15pts
across chains from identical dungeons because cryptoType was a dead parameter).
Data-driven design (src/sim/calibrate.js, rewritten): the size-probe showed
dungeon SIZE self-cancels as a house-edge lever — bigger map lengthens the run
(timer edge up) but gives more room to dodge (monster edge down), flat ~60% until
monstrous. So split the concerns:
• SIZE ∝ √blockTime → PACING only (consistent run-length fraction of the block;
GRIN sprint .. BTC epic), applied to every preset. Bot-robust.
• MONSTER speed → the house EDGE lever (monotonic), solved per network for
the casino/70% target only.
difficultyConfig.js: NETWORK_TUNING table + applyNetworkTuning(), folded into
getDifficultyConfig with precedence preset → tuning → env → customOverrides (so
operator DUNGEON_*/MONSTER_* still win; NETWORK_TUNING_DISABLED kills it).
Wired verification (explorer bot, 160 runs, 320ms): WOW/XMR/LTC/BTC/GRIN all land
in a ~66-76% band around target (was 48-65%). BTC 48%->73% via 112x56 + 2.2x
monster — on 10-min blocks the timer barely bites a 30s run, so the monster carries
the edge (caps ~64% maxed: a real property of block-timed play).
CAVEAT baked into the config comment: sim bots do not evade, so monsterSpeed is a
STARTING POINT to retune with live telemetry; the sizeScale half is bot-robust.
Deb is unaffected (WOW=reference scale 1.0; normal preset gets no monster tuning).
test/networkTuning_ultra.test.js: size scaling, casino-only monster, block-time
ordering, env-override-wins, kill-switch. Suite 60 suites / 428 tests green.
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.
A — RICH ISO. The old iso-dungeon was a 7-tile sliver of Kenney Isometric
Dungeon; wire the full set so town/dungeon are RICH and interchangeable:
- Real architecture: full stoneWall, stoneWallWindow (actual window),
stoneWallDoorOpen (real door), archway, wood column, stairs.
- Distinct furniture per kind: table -> tableShortChairs (table+chairs),
keg/barrel -> real barrel, crate -> woodenCrate, shelf -> woodenCrates,
chest -> chestClosed (added to the floor+prop compositor). No more
everything-is-a-chest.
Facing (was 90 deg off, twice): verified against live movement the sprites are
angled CLOCKWISE from NE (0=NE,2=SE,4=SW,6=NW), so up->Male_0, right->Male_2,
down->Male_4, left->Male_6. Now the run anim points the way you actually move.
ASCII picker no-op: ASCII draws glyphs and consumes no tile pack, so
projForMode(ascii)=null and the packbar hides for it (it only ever made sense
for Tiled/Iso).
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.
The engine behind 'add more 2D tilesets / iso packs / 3D skins as products'. A
pack is a self-contained render environment for a projection (topdown/iso/3d);
because scenes are kind-based, the SAME pack renders both town and dungeon
(interchangeable), and each maps to a catalog id (monetizable, entitlement-gated).
- packRegistry.js: registerPack / packsForProjection / unlockedPacks (canUsePack)
/ activePackId (saved-if-unlocked else first unlocked) / setActivePack /
activePackAssets. Per-projection active-pack selection, persisted. In-memory
store fallback so it's testable under Node.
- assetPacks: registers the 3 built-ins (roguelike-interior topdown, iso-dungeon,
kenney-3d-characters) + activeIsoAssets()/activeThreeAssets() resolvers; RK.theme()
now defaults to the active topdown pack.
- isoRenderer + avatarVisuals (iso/3d character lookup) read the ACTIVE pack.
- packRegistry loaded before assetPacks in tavern + game.
Backward-compatible: one pack per projection resolves to today's defaults — nothing
changes visually. Adding a pack now = registerPack(...) + assets + a catalog row.
Tests: +5 (register/list, unlock filter, active/saved/fallback, refuse locked).
Suite 58 suites / 419 tests green.
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.
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.
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.
- 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)