Commit Graph
206 Commits
Author SHA1 Message Date
jwinterm fd4327a54f feat(sim): headless balance-calibration harness (drives the REAL engine)
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.
2026-07-11 19:43:12 -04:00
jwinterm 11de46a7e4 feat(fx): RK.fx juice engine — animated torch/hearth flame + glow, hazard tiles
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.
2026-07-11 17:00:43 -04:00
jwinterm 4d673748df feat(iso): orientation-aware walls + floor variety + juice (shadows/vignette)
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.
2026-07-11 15:11:26 -04:00
jwinterm be8594282b feat(iso): rich Kenney Isometric Dungeon set + correct facing + ASCII picker
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.
2026-07-11 14:53:24 -04:00
jwinterm ccaff7d29c feat(packs): Fancy dies as a mode → topdown packs; Roguelike Dungeon tile pack (B)
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.
2026-07-11 14:35:30 -04:00
jwinterm 4a91d91822 fix(iso): face the direction of visual movement (Kenney 45deg renders 0=NW..6=NE)
Male_0..7 are 45-degree renders (viewed: 0=NW, 2=SW, 4=SE, 6=NE). In this iso
projection grid up->NE, down->SW, left->NW, right->SE, so map facing to the visual
movement direction. Applies to both iso packs (shared characters).
2026-07-11 14:16:10 -04:00
jwinterm 3d9b5420ee feat(packs): Medieval Town iso pack (2nd iso environment) + pack picker
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.
2026-07-11 13:57:47 -04:00
jwinterm 11b7fb1272 feat(render): pack registry — multi-pack, interchangeable render environments
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.
2026-07-11 13:40:29 -04:00
jwinterm 6d0334f4f2 feat(iso): furniture props in isometric mode (bar/kegs, tables, chairs, barrels)
The iso renderer only drew floor+wall (bar/table hardcoded). Generalize the
floor-base + prop compositing to all furniture kinds (bar/table/chair/keg/shelf/
barrel/crate) and map them to the iso-dungeon tiles: stacked barrels = the bar +
kegs, chest = tables/crates/shelves, chair = chairs. Half walls kept (full walls
would occlude the front row). Same coherent iso-dungeon art set. Client-only.
2026-07-11 12:57:11 -04:00
jwinterm 0e553a49d6 feat(render): wheel + trackpad-pinch zoom in every mode
Reusable zoom control (zoomControl.js): RK.attachZoom(host) adds wheel/pinch zoom
to a render host; RK.applyZoom(host) scales whatever canvas is mounted (2D or
WebGL) via CSS transform (pixelated so pixel art stays crisp), re-applied on mode
switch. Trackpad pinch = ctrl+wheel, handled the same way; double-click resets.
Wired into the tavern stage, so it works across Tiled/ASCII/Fancy/Iso/3D. Client-only.
2026-07-11 08:02:40 -04:00
jwinterm 1b59d2f927 feat(catalog): graduated cosmetic ladder + per-session test-unlock
Step 1 — real tiered unlocks (was flat: any credit unlocked skins+iso+3D):
- entitlements DEFAULT_CATALOG + migration 025 graduate the ladder — unlock by
  lifetime credit spend OR subscription tier, whichever first:
    Fancy (generated-skins)       1 credit  / supporter (tier 1)
    Iso   (iso-dungeon)          10 credits / premium   (tier 2)
    3D    (kenney-3d-characters) 25 credits / operator  (tier 3)
- Migration 025 UPDATEs the already-seeded 024 rows (idempotent).
- Tests updated to assert the ladder (1cr -> Fancy only; 10 -> +Iso; 25 -> all).

Step 2 — test the premium modes without paying:
- renderModes: ?unlock=1 (sticky via localStorage) flips RENDER_MODE_TEST_UNLOCKS
  for THAT browser only, so Fancy/Iso/3D are all clickable; ?unlock=0 turns it off.

Suite 57 suites / 414 tests green.
2026-07-11 07:24:26 -04:00
jwinterm e7fe16fcaa fix(tavern): bigger cat + drop the green rug
- 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.)
2026-07-11 07:11:41 -04:00
jwinterm 6a2010a023 fix(tavern): use the wooden table tile (191), not the green one (312) 2026-07-11 07:08:42 -04:00
jwinterm cdd379c5e4 feat(render): composite object tiles over a base tile (pipeline) + bigger cat
The black boxes around furniture/windows were transparent object tiles drawn
straight onto the dark canvas. Fix at the pipeline level, not as a tavern one-off:

- tileRenderer: a tile kind whose legend declares `over` (the ground it sits on)
  now draws that base tile FIRST, then the object on top — so the object's
  transparent areas show the floor/wall. Works for any scene, atlas or flat-colour
  fallback. Wall-grounding shadow strip now only applies to real walls, not objects.
- sceneModel TAVERN_LEGEND: declares over='floor' for bar/keg/shelf/table/chair/
  barrel/crate/rug and over='wall' for windows.
- catSprites: the cat occupies ~40% of its 50x50 frame, so scale to ~2x the cell
  and centre it — it now reads about tile-sized instead of tiny.

Client-only. Suite 57 suites / 413 tests green.
2026-07-11 07:07:55 -04:00
jwinterm be34bd2b2d feat(tavern): a real cat (Pet Cats Pack, CC-0)
The old 'cat' used a char-goblin sprite (green monster). Replaced with actual
pixel cats from the Pet Cats Pack (CC-0, commercial-ok):
- html/assets/cats/cat1..6-idle.png — 6 cats, 10-frame 50x50 idle strips.
- catSprites.js: loads + animates a cat sprite (RK.drawCatCanvas), flips to face left.
- tileRenderer: draws entities with kind 'cat' via the cat sprite path (shadow + frame).
- tavernLife: spawns a RANDOM cat that wanders the customer floor (not behind the
  bar), injected as kind 'cat' with its catSprite id. Degrades to a villager if
  catSprites.js isn't loaded.
- tavern.html loads catSprites.js.

Client-only (no server change). Suite 57 suites / 413 tests green.
2026-07-11 07:02:01 -04:00
jwinterm 24e738c733 feat(tavern): proper tavern — real bar, kegs, bottles, tables+chairs, windows, rug
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.
2026-07-11 06:50:05 -04:00
jwinterm 3eb4959e9a fix(tavern): render the reliable procedural room, drop the goblin 'cat'
- 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.
2026-07-10 22:44:26 -04:00
jwinterm 588250e5a9 fix(render): default avatar renders as a real character, not a grey circle
A player who never customized arrives as avatar 'default' — not a char/skin/model,
so avatarVisuals resolved it to 'legacy' kind with no sprite and the renderer drew
the grey fallback circle (while NPCs with explicit char-* avatars rendered fine).
Coerce any unknown/'default' avatar to 'char-villager' on the 2D topdown path so
nobody is ever a grey dot in a room of people. iso/3d keep their own fallbacks.
2026-07-10 22:41:58 -04:00
jwinterm 4b92e70d4c feat: decaveat Phase 2 — subscription->tier data source + robust client signing
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.
2026-07-10 22:33:13 -04:00
jwinterm 343c1f689b docs: architecture pass for the 5-pillar build + autodocs setup
Hand-authored architecture docs (the 'why'/seams that autodocs can't capture):
- docs/ARCHITECTURE.md   index + system map + the 5 pillars + chain profiles + conventions
- docs/CHAT_AND_NOSTR.md ChatProvider seam, bridge vs per-player signing, two-relay
                         tier model, verifyChatEvent impersonation guard, relay policy
- docs/PAYMENTS.md       provider contract + registry, BTCPay/checkout/native, hot path
- docs/RULESETS.md       Ruleset data object, win-condition strategies, PvP, MatchRoom seam
- docs/MONETIZATION.md   credits, cosmetic_catalog, the four-way unlock rule, premium->tier

Autodocs (reference layer): added a docs:api npm script (jsdoc-to-markdown) that
regenerates docs/API.md from the existing JSDoc. Enable with 'cd src && npm install'
then 'npm run docs:api'. Hybrid by design — auto reference + hand-authored architecture.

No runtime code changed. Suite 56 suites / 407 tests green.
2026-07-10 22:24:37 -04:00
jwinterm 4b78fec853 feat(chat): per-player nostr identity (Phase 2) + premium subscription unlocks cosmetic tiers
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.
2026-07-10 22:20:10 -04:00
jwinterm ebf14560dd feat(tavern): unified global chat with persistent history in the tavern
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.
2026-07-10 19:54:38 -04:00
jwinterm f0f74f703f fix(render): no torch-flame on the tavern player + repaint when the tile atlas loads
Two tavern rendering bugs in Tiled mode:

1. The 'you' cell looked like a flaming dot. The warm torch light + drifting
   embers are a DUNGEON effect (the player carries the torch) but _composite/
   _ensureEmbers ran on every scene, so in the tavern they painted a flame on
   the 'you' avatar while other players rendered normally. Gate both to
   scene.isDungeon.

2. Tavern tiles showed as flat 'blocks' that never refreshed. The tile atlas
   image loads async; switching INTO the tiled renderer (e.g. entering the
   tavern) before it's ready paints the flat-colour fallback once and never
   repaints until the next scene update. Hook atlas.onReady to re-render the
   last scene the moment the atlas is ready.

Client-only; no server change. Deploy = git pull + restart (or just pull, it's
static assets).
2026-07-10 19:41:40 -04:00
jwinterm c7adc820b9 merge: 5-pillar 10x foundation (credits/catalog, chain profiles, modular payments, ruleset/PvP, nostr chat)
Lands the feature branch feat/credits-catalog-foundation. All five pillars are
behavior-preserving and dormant until their env flips on — native XMR/WOW stays
the live default, and BTCPay gateways, nostr global chat, and PvP rulesets are
code-ready seams.

  P1 credits + operator-owned cosmetic catalog + three-way unlock
  P2 ChainProfile registry for BTC/LTC/XMR/WOW/GRIN
  P3 modular payment providers — BTCPay + xmrcheckout/wowcheckout (Greenfield) +
     native Monero, invoice creation AND monitoring wired
  P4 Ruleset engine — gameplay as data; race preserved, PvP last-alive / score /
     co-op modes added on the match engine
  P5 NostrChatProvider — global cross-server chat over relay.smirk.cash

Suite 55 suites / 391 tests green.
2026-07-10 19:22:30 -04:00
jwinterm 5753e919e5 feat(game): Ruleset engine + PvP modes on the match engine (Pillar 4)
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.
2026-07-10 19:20:23 -04:00
jwinterm 12b887f8f1 feat(chat): NostrChatProvider — global cross-server chat over nostr (Pillar 5)
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.
2026-07-10 18:51:13 -04:00
jwinterm f7c9fb7366 feat(payments): wire gateway invoice monitoring through the provider registry
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).
2026-07-10 18:06:24 -04:00
jwinterm 74753c17b2 feat(payments): wire provider registry into invoice creation + fit real BTCPay infra
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.
2026-07-10 17:17:49 -04:00
jwinterm bcd00271c5 feat(payments): provider-registry bootstrap from env (single wiring seam)
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.
2026-07-10 16:58:56 -04:00
jwinterm 58ec541b42 feat(payments): modular payment-provider system — BTCPay (first plugin) + native Monero
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.
2026-07-10 16:57:35 -04:00
jwinterm f9d49f34ee feat(chain): ChainProfile registry for Smirk's 5 chains + centralize decimals
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.
2026-07-10 16:38:25 -04:00
jwinterm 6c7a677057 feat(ui): buy modal frames direct entry as '1 credit' + payout-honest
The single-game option now reads 'Single game · 1 credit' and notes it counts toward cosmetic unlocks (matching the backend: a direct entry advances total_credits_purchased). Fixes the direct payout copy to only show multipliers when payouts are actually enabled (was defaulting on).
2026-07-10 16:29:27 -04:00
jwinterm 3f12d8f047 feat(credits): client consumes served catalog; direct entry counts as a credit purchase
① 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.
2026-07-10 16:28:31 -04:00
jwinterm bdf7f19dcb feat(catalog): operator-owned cosmetic catalog + three-way unlock
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.
2026-07-10 16:13:35 -04:00
jwinterm b31a408789 feat(ui): consolidate help to a single credits model; make reward line payout-honest
Reframe the two 'Direct Payment / Credits Mode' cards as one 'Credits' model — 1 credit = 1 run, bought singly at base price or in discounted bundles. Adds the drop-in-timing note (jump in now or next block; block timing is memoryless so the expected wait is the same). Reward line is now config-driven: shows crypto multipliers only when payouts are actually enabled, otherwise the Hall-of-Champions framing — fixes the help advertising 2x/3x payouts on a PAYOUTS_ENABLED=false (prestige) deployment. Header renamed to 'Credits & Entry'.
2026-07-10 14:53:50 -04:00
jwinterm 20cbdd364a fix(smirk): sign first, grant Nostr scope only on demand — one approval for returning users
Previously always called getNostrPublicKey() then signNostrEvent() = two approvals every login. Now signNostrEvent() runs first; only if the wallet reports the origin lacks the Nostr scope (NOT_AUTHORIZED, thrown without a popup) do we call getNostrPublicKey() to grant it and retry. First-time still does the one-time scope grant; returning users get a single approval. (The double PASSWORD is the wallet's autoLockMinutes=0 default and is user-configurable, not dapp-controllable.)
2026-07-10 11:07:11 -04:00
jwinterm 65305bf7de fix(smirk): grant the Nostr scope via getNostrPublicKey() before signNostrEvent()
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.
2026-07-10 10:29:52 -04:00
jwinterm 057767152c fix(smirk): fall back to legacy connect() on a locked signNostrEvent (restores unlock-popup 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.
2026-07-10 09:55:01 -04:00
jwinterm 7e7e66e887 feat(auth): sign-in-with-wallet — adopt an existing wallet-linked account instead of erroring
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).
2026-07-10 09:35:08 -04:00
jwinterm 679fda93ce fix(ui): payout address textarea overflowed the modal — add box-sizing:border-box
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%.
2026-07-10 09:22:26 -04:00
jwinterm a243dd8fc6 fix(ui): real right gutter so play-area focus outline shows on the right; Smirk flow
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.)
2026-07-10 09:11:07 -04:00
jwinterm a05a10efd7 fix(ui): tavern floor, play-area focus border, and Smirk locked-wallet UX
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).
2026-07-10 08:46:38 -04:00
jwinterm 3643746c21 fix(deploy): default HOST to 0.0.0.0 — loopback bind 502'd the site behind the off-host proxy
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.
2026-07-10 08:05:20 -04:00
jwinterm a109f8f880 chore(deps): npm audit fix — patch axios/socket.io/ws/path-to-regexp/form-data (lockfile only, no breaking changes, suite green) 2026-07-10 07:54:45 -04:00
jwinterm f56ae7f7fe chore: remove stale root package-lock.json (source of 22 Dependabot alerts) 2026-07-10 07:31:09 -04:00
jwinterm d0d327f3ca feat(auth): NIP-98 nostr sign-in for Smirk; fix SHA256 fallback + /status BOLA
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.
2026-07-09 22:19:30 -04:00
jwinterm 71ce16a384 fix(char): render characters in native art on Tiles; drop sprite color tinting
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.
2026-07-09 21:47:49 -04:00
jwinterm a9fad69038 fix(match): reconcile no-address winnings on address set; age-guard boot recovery
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.
2026-07-09 19:18:34 -04:00
jwinterm a62eefb47b fix: resolve security/correctness review + AAA graphics pass
Backend (Tier 0/1/3): single-payout double-pay -> needs_review; admin-retry tx_hash guard; UNIQUE index on games.payment_id (migration 023) + atomic claim; reconnect no longer resets block-timeout; completeGame keys on user_id+dungeon_seed (not socket_id); token-gated /api/user/:socketId/* endpoints; payout min/max enforcement; orphaned-active-game boot recovery; sessionManager cleanup FK fix; XFF rightmost-hop; global uncaughtException/unhandledRejection guard; server binds 127.0.0.1; provably-fair client-seed (HMAC) + RNG [0,1) bound; difficulty preset ranges reach the digger; chat no longer leaks full socket.id; removed dangerous-keyword SQL theater.

Match mode (Tier 2, still MATCH_ENABLED-gated): fixed match_queue/match_move crash paths + scheduler dead-code + leaked ceiling timer; pot collected before start with abort+refund; BigInt/basis-point fee math, capped payout; no-address winner claimable; winnerId==placement[0]; finalize schema + payout-first isolation; reconnect re-key with ownership; refunds on all cancel paths; real countdown; map/timer cleanup. +16 tests.

Graphics: new overlay FX layer (torch light, vignette, embers, sparkle/burst/flash/shake, self-suspending rAF); gameplay juice; atmospheric screens; CSS design-token chrome with framed modals; richer Kenney tile renderer; imported Kenney dungeon/interior sheets + fx particles.

Verification: 45 suites / 312 tests pass; 40 changed JS files parse; 20 server modules load clean; no regressions.
2026-07-09 17:46:58 -04:00
jwinterm 0f68eec26b feat: real-time multiplayer race mode (free, prestige, crypto)
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.
2026-07-09 13:26:52 -04:00