Commit Graph
214 Commits
Author SHA1 Message Date
jwinterm cc0e092c1d fix(sp): revert dungeon to legacy renderer + fix splash (working game restored)
The render-kit SP dungeon shipped without a player-centered camera or entity sprites,
so it drew the whole level tiny in a corner with a red-dot monster. Disable it and fall
back to the proven legacy renderer (original tiles, real monster, centered view) until
the kit has a camera + sprites. RK.SPGame/spRenderPicker kept but dormant (one flag to
re-enable). Splash legend: draw every icon (incl. the player) as a uniform grid tile —
no avatar overlay — so the four rows align AND the player shows the original hero tile.
2026-07-12 10:30:19 -04:00
jwinterm 1325b49b42 feat(render): single-player dungeon renders through the render kit (packs in gameplay)
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.
2026-07-12 10:14:56 -04:00
jwinterm 42ed51af69 feat(monetization): lock the cosmetic ladder (loyalty model) + free Original pack
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.
2026-07-12 09:52:13 -04:00
jwinterm d8abe89cb7 fix(home): align splash legend + make the character creator discoverable
- Splash legend (screenManager.js): every row now uses a uniform 2-cell icon slot at
  a shared left edge and a single fixed text column, so the four icons + descriptions
  line up regardless of glyph width (was: per-row span math + leading-space hacks →
  ragged columns, player sprite offset).
- Character creator was already on the homepage but read like an admin control: the
  button showed the DEFAULT character label "Operator". Renamed the default to
  "Adventurer" (charCustomize.js), switched the icon 🧍→🎭, and gave it a clear
  "Create / customize your character" tooltip so it announces itself.
2026-07-12 08:55:24 -04:00
jwinterm 643bec4721 docs: pass for the 2026-07 build — balance sim, multi-level, render packs
- README: rewrite Difficulty section (per-network LEVELS ∝ block time, WOW=5min,
  multi-level descent, corrected casino size); document game_event + depth in the
  Socket.IO table.
- NEW docs/BALANCE_SIM.md: the block-timed edge math (exponential deadline), the
  headless sim over the real engine, what it proved (size self-cancels, fast monster
  fails on slow chains), and the per-network NETWORK_TUNING model.
- NEW docs/MULTI_LEVEL.md: descent mechanic, vault-only treasure, per-level
  provably-fair seeding, config, file map, open polish.
- NEW docs/RENDER_PACKS.md: modes-vs-packs, the RK.fx layer, iso/tiled/zoom/cat.
- docs/ARCHITECTURE.md: mark the block-time-difficulty gotcha RESOLVED; add render
  kit + balance/pacing sections and cross-links.
- docs/TODO.md: 2026-07 done/pending section.
2026-07-11 22:29:57 -04:00
jwinterm c5147de625 feat(game): multi-level dungeons — descend N levels, timer-driven edge
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.
2026-07-11 22:26:10 -04:00
jwinterm f982f56181 fix(chain): WOW block time is 5 min, not 2 (measured); drop cheating-monster tuning
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.
2026-07-11 20:24:51 -04:00
jwinterm 63d2f5281d feat(balance): per-network difficulty tuning — cryptoType finally shapes size+monster
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.
2026-07-11 20:02:39 -04:00
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