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.
This commit is contained in:
jwinterm
2026-07-10 22:24:37 -04:00
parent 4b78fec853
commit 343c1f689b
6 changed files with 436 additions and 1 deletions
+70
View File
@@ -0,0 +1,70 @@
# Architecture
Wowngeon is a provably-fair, block-timed roguelike that doubles as a **multi-chain on-ramp for the
Smirk wallet**. This is the index for the architecture docs; each subsystem below has its own page.
## The map
```
┌──────────── players (browser) ────────────┐
│ index.html · tavern.html · render kit │
└───────────────┬───────────────┬────────────┘
Socket.IO │ │ REST (auth, status)
┌────────────────▼───────────────▼────────────┐
│ src/network/* (socket handlers, chat, │
│ tavern, match, payments) │
├──────────────────────────────────────────────┤
game core │ src/game/* dungeon · rulesets · modes │ → RULESETS.md
money │ src/payments/* providers · wallet-rpc │ → PAYMENTS.md
cosmetics │ src/multiplayer/entitlements + catalog │ → MONETIZATION.md
chains │ src/chain/chainProfile.js │ → chain profiles (below)
chat │ src/network/chat/* local + nostr │ → CHAT_AND_NOSTR.md
└──────────────────────────────────────────────┘
│ │
PostgreSQL Wownero/Monero wallet-RPC + BTCPay/checkout relays
```
## The five pillars (2026-07 "10x" build)
| Pillar | What | Doc |
|--------|------|-----|
| **P1 — Credits & catalog** | Operator-owned cosmetic catalog + tiered unlock rule (free / grant / spend-threshold / tier) | [MONETIZATION.md](MONETIZATION.md) |
| **P2 — Chain profiles** | One registry for the 5 Smirk chains (decimals, block time, adapter family, URI scheme) | see *Chain profiles* below |
| **P3 — Modular payments** | Pluggable payment providers: BTCPay + xmr/wowcheckout (Greenfield) + native Monero, behind one registry | [PAYMENTS.md](PAYMENTS.md) |
| **P4 — Ruleset / PvP** | Gameplay as a data object; race / last-alive / score / co-op are rulesets on the match engine | [RULESETS.md](RULESETS.md) |
| **P5 — Nostr chat** | Global cross-server chat over `relay.smirk.cash`; bridge → per-player Smirk-npub identity | [CHAT_AND_NOSTR.md](CHAT_AND_NOSTR.md) |
All five are **behavior-preserving and env-gated** — each stays dormant (native/local defaults) until
its config is set, so `main` is always deployable.
## Chain profiles (P2)
`src/chain/chainProfile.js` is the single source of truth for per-chain constants across BTC, LTC,
XMR, WOW, GRIN: `decimalsFor`, `meanBlockTimeMsFor`, `familyFor` (monero / utxo / mimblewimble),
`uriSchemeFor`, `atomicDivisor` (BigInt). Unknown chains fall back to a WOW-shaped default. Money math
stays exact via `src/money/atomic.js` (BigInt, decimals-parameterized) — never floats.
> **Gotcha (open):** `difficultyConfig` threads `cryptoType` everywhere but doesn't yet key difficulty
> off mean block time — it keys off payment mode. Wiring block-time → dungeon size is a pending
> game-balance change (see RULESETS.md), and it bites when Grin (~1 min) / BTC (~10 min) ship.
## Regenerating the API reference (autodocs)
The code is heavily JSDoc-commented. To regenerate a Markdown API reference from those comments:
```bash
cd src && npm install # first time, pulls jsdoc-to-markdown (devDependency)
npm run docs:api # writes docs/API/*.md
```
Autodocs cover the **reference** layer (what each function/param is). The **why** — the seams and
flows in the pages above — is hand-authored and lives here in `docs/`. Keep both.
## Conventions worth knowing
- **Game id** is a UUID stored in `games.dungeon_seed`; `games.id` is a serial int. Match game objects
with `WHERE dungeon_seed = $N`, never `WHERE id = $N`.
- **Socket ids are volatile** (change on refresh). For stable identity use the DB `user_id` / session,
never `socket_id`.
- **WOW has 11 decimals** (atomic divisor 1e11), not Monero's 12.
- **Tests**: `cd src && npm test` (jest, `--runInBand`). Migrations live in `src/migrations/NNN_*.sql`.
+127
View File
@@ -0,0 +1,127 @@
# Chat & Nostr (P5)
Chat has two layers: **local delivery** (Socket.IO + Postgres history) and an optional **nostr fan-out**
that carries the same conversation across servers and into the Smirk ecosystem. Both sit behind one
seam so callers never change.
## The seam
`src/network/chat/ChatProvider.js` — the abstraction every chat caller uses:
```
publish(msg) deliver + (optionally) persist a message
getHistory({scope, limit}) recent messages, oldest → newest
relaySignedEvent(req) deliver a CLIENT-signed nostr event (per-player identity)
initialize() / shutdown()
```
Two implementations:
- **`SocketChatProvider`** — the default. Global scope → persist via `ChatHistoryManager` + broadcast
(`chat_broadcast`); room scope (tavern) → deliver to that Socket.IO room only.
- **`NostrChatProvider`** — a **decorator** around a local provider. Everything still delivers in-game +
persists; on top of that, global messages fan out to nostr relays, and messages arriving from a relay
are delivered locally-only (never re-published → no echo loops).
`buildChatProvider({local, env})` (`src/network/chat/index.js`) picks: with `NOSTR_CHAT_ENABLED` unset it
returns the plain local provider (behavior-preserving); enabled + `scope=global` wraps it in a
`NostrChatProvider`.
## Unified global chat
The **tavern shares the lobby's global chat** — `TavernManager` takes the lobby's `chatProvider` and
publishes to the `global` scope, and sends the global backlog (`chat_history`) on join. So lobby and
tavern are one conversation with persistent history, and when nostr is on, tavern chat rides the relay
too. Topology is always **client → server → relay**: the server stays the moderation authority (bans,
rate-limit, history in Postgres); the relay is transport + fan-out.
## Signer models
`NostrChatProvider` takes an injectable **signer** (`{ pubkey, sign(template) }`). That one object is the
whole difference between the two identity models:
### Phase 1 — bridge (shipped, live)
The server signs every global message with one allowlisted bridge npub; the player's name rides in an
`['n', name]` tag. Works for anyone (even anon), but on nostr it reads as one account talking for
everyone. `createBridgeSigner(NOSTR_BRIDGE_SK)` builds it; the npub must be write-allowlisted on the relay
(see *Relay policy*).
### Phase 2 — per-player signing (built, needs browser verification)
The **client** signs with the player's own Smirk npub and sends the finished event; the server verifies +
relays it. On nostr it's genuinely *them*.
```
client server (chatHandler) relay
│ build kind-1 event │ │
│ window.smirk.signNostrEvent │ │
│ ── chat_signed {event} ────► │ rate-limit │
│ │ verifyChatEvent(event, npub) │
│ │ sig ok? id ok? fresh? tag ok? │
│ │ pubkey === session npub? ◄── the │
│ │ impersonation guard │
│ │ escape + relaySignedEvent ──────────►│ (published under the player's npub)
│ ◄──────── chat_broadcast (in-game, all clients) ──────────────────│
```
- **`src/utils/verifyChatEvent.js`** — the security core. Same defenses as `verifyNip98Event` (rebuild a
clean event, independently recompute the id, verify schnorr, freshness) **plus** `event.pubkey` must
equal the session's authenticated npub (`users.smirk_public_key`, set at NIP-98 login). A valid signature
by *some other* key is rejected (`pubkey-mismatch`).
- **`chatHandler.handleSignedChatMessage`** — rate-limit → resolve session npub → verify → escape → relay.
- **`NostrChatProvider.relaySignedEvent`** — local delivery + `transport.publish(signedEvent)` (no
re-signing) + echo-dedupe on the event id.
- **`html/js/smirkChatSign.js`** — feature-detected client helper. Signs via `window.smirk.signNostrEvent`,
falls back to the unsigned send when Smirk isn't connected, so wiring it into a send site is always safe.
## The tier model (two relays)
Everyone signed-in **self-signs as themselves**; the tier is only which relay accepts them, and what perks
come with it (perks + cosmetics, never identity):
| Player | Signs | Lands on |
|--------|-------|----------|
| Anon | nobody | local only |
| Free "pleb" | own npub | `relay.smirk.cash` — open signup + PoW |
| Premium | own npub + ✦ badge | `relay.wowne.ro` — premium-post |
The game publishes each signed event to **all** `NOSTR_RELAYS` and lets each relay's policy accept or
reject — no per-player routing or allowlist in game code. Reading is the mirror (subscribe to all, dedupe
by event id). Premium membership also sets the player's **entitlement tier**, which unlocks cosmetics
(see [MONETIZATION.md](MONETIZATION.md)) — one subscription, two payoffs.
## Config (env)
| Var | Meaning |
|-----|---------|
| `NOSTR_CHAT_ENABLED` | master switch (unset ⇒ plain local chat) |
| `NOSTR_CHAT_SCOPE` | `global` (fan out) or `local` (plain provider) |
| `NOSTR_RELAYS` | CSV of relay URLs; transport publishes/subscribes to all |
| `NOSTR_CHAT_TAG` | shared channel topic (default `wowngeon-global`) |
| `NOSTR_BRIDGE_SK` | bridge secret key (nsec/hex); omit ⇒ receive-only |
## Relay policy (smirk-backend-core)
`relay.smirk.cash` is a nostr-rs-relay gated by the Smirk backend's admission oracle. Write policy
`premium-post` = only premium (or write-allowlisted) npubs may publish; `RELAY_WRITE_ALLOWLIST_NPUBS`
(comma-separated npub1…/hex) exempts specific keys — that's how the **bridge npub is registered**. PoW
(`RELAY_INBOUND_POW_BITS`) only gates non-registered authors, so allowlisted/premium npubs post freely.
Reading is open.
## Loader shim
`nostr-tools` pulls ESM-only deps. `src/utils/nostrLoader.js` `loadNostrTools()` require()s it in prod
Node 22 and falls back to the pre-bundled IIFE under CJS-only Jest. Chat + NIP-98 both use it.
## Files
```
src/network/chat/ChatProvider.js seam (+ relaySignedEvent default)
src/network/chat/SocketChatProvider.js local delivery + history
src/network/chat/NostrChatProvider.js decorator: local + nostr fan-out + signed relay
src/network/chat/index.js buildChatProvider (env → provider)
src/network/chat/nostr/NostrTransport.js SimplePool wrapper (multi-relay, degrades to noop)
src/network/chat/nostr/bridgeSigner.js server bridge signer
src/utils/verifyChatEvent.js verify a client-signed event (impersonation guard)
src/utils/nostrLoader.js nostr-tools loader shim
html/js/smirkChatSign.js client per-player signing helper
```
+71
View File
@@ -0,0 +1,71 @@
# Monetization — credits, catalog & entitlements (P1)
One unit of account (**credits**), one operator-owned **catalog**, and a tiered **entitlement** rule that
decides who has unlocked which cosmetics — and now doubles as the premium-subscription payoff.
## Credits
Credits are the unit of account. A direct/single-game entry is modeled as *buy 1 credit + spend 1 credit*
(`gameModeManager.recordDirectEntryPurchase`) so it advances `total_credits_purchased` and unlocks the same
threshold cosmetics as a real credit purchase. Payouts are orthogonal to credits.
## The catalog
`cosmetic_catalog` (migration `024`) is the operator-owned product table — it replaces the old hardcoded,
split-brain `PACKS` (server + client). Columns: `pack_id, label, kind, projection, tier, unlock_min_credits,
grant_only, sort_order, active, metadata`.
`src/services/catalogService.js` loads + caches it, falling back to `DEFAULT_CATALOG` if the table is
missing (42P01) or empty — so the game runs before the migration and never hard-fails on catalog reads.
The server ships `catalogSummary()` to the client, which merges it into `RK.PACKS` — the client no longer
owns a parallel catalog.
## The unlock rule
`src/multiplayer/entitlements.js` — `snapshotForUser(user, grants, catalog)`. A pack unlocks if **any** of:
```
1. it's free (tier 0, no gate)
2. an explicit grant (products → grants)
3. lifetime spend ≥ the pack's unlock_min_credits threshold
4. the user's premium TIER ≥ the pack's tier
```
`TIER_OF = { free:0, credits:0, supporter:1, premium:2, operator:3 }`. Clause 4 is the premium hook.
**Buying credits does NOT put you on the premium ladder** (`credits` → tier 0) — that was the old bug where
any purchase unlocked every premium pack.
## Premium subscription → cosmetics
`levelForUser(user)` returns the **higher** of the stored `premium_level` and an active
`subscription_tier`. So a live **wowne.ro premium subscription** sets the entitlement tier, which unlocks
tile/customization packs at/below it — **one subscription drives both chat perks and cosmetics** (see
[CHAT_AND_NOSTR.md](CHAT_AND_NOSTR.md)).
Everything downstream — pack gating, the served catalog, the client unlock UI — already consumes `tier`, so
"premium buys cosmetics" is a **data-source** change: populate `user.subscription_tier` from a subscription
check (against the premium backend / npub) when the user is loaded. Absent ⇒ unchanged legacy behavior.
```
active premium sub ──► user.subscription_tier ──► levelForUser ──► entitlement tier
├─ relay: chat perks (badge, no-PoW, VIP)
└─ catalog: tile / skin / customization unlocks
```
## Files
```
src/migrations/024_cosmetic_catalog.sql operator-owned catalog table
src/services/catalogService.js load/cache + DEFAULT_CATALOG fallback
src/multiplayer/entitlements.js snapshotForUser · levelForUser · TIER_OF · unlock rule
src/network/identityService.js entitlementsForUser (loads catalog + grants)
src/game/gameModeManager.js recordDirectEntryPurchase (direct entry = 1 credit)
html/js/render/assetPacks.js client consumes the served catalog
```
## Open decisions
- Premium tiers: one flat "premium," or a ladder (supporter/premium/operator) where each rung unlocks
progressively more cosmetics *and* chat perks? (`TIER_OF` already has the rungs.)
- Where `subscription_tier` is sourced (sync a `users` column vs. live oracle query against the premium
backend by npub).
+85
View File
@@ -0,0 +1,85 @@
# Payments (P3)
The game no longer implements per-chain wallet/daemon code inline. It talks to pluggable **payment
providers** behind one contract, so an operator routes each chain to whichever backend they run.
## The contract
`src/payments/providers/paymentProvider.js`:
```
createInvoice({chain, amountAtomic, description, userId, orderId})
→ {invoiceId, address, uri, amountAtomic, expiresAt, raw}
getInvoiceStatus(ref) → {status, complete, paidAtomic, confirmations, raw}
startWatch(ref, onUpdate, intervalMs) / stopWatch(ref)
sendPayout({chain, address, amountAtomic}) → {txids, raw}
validateAddress(chain, address) → {valid}
supportsChain(chain) → bool
```
`PaymentProviderRegistry` routes `chain → providerId` (operator-configurable) with a support-based
fallback: if the routed provider can't serve a chain, any registered provider that *can* is used.
## The providers
| Provider | id | Serves | Notes |
|----------|-----|--------|-------|
| **BTCPay Greenfield** | `btcpay` | BTC, LTC | `src/payments/providers/btcpayProvider.js` — real BTCPay Server. Invoice priced in the chain's own crypto; address read from the invoice payment-methods; `token` auth. |
| **xmrcheckout** | `xmrcheckout` | XMR | Same class, different endpoint — the checkout app exposes a **BTCPay-Greenfield-compatible** shim. |
| **wowcheckout** | `wowcheckout` | WOW | Ditto (matched pair to xmrcheckout). |
| **Native Monero** | `native-monero` | XMR, WOW | `src/payments/providers/nativeMoneroProvider.js` — wraps the existing `walletRPCService` (subaddress invoicing + `transfer_split` payouts). |
The key discovery: **BTCPay, xmrcheckout, and wowcheckout all speak one Greenfield contract**, so a single
`BTCPayProvider` class (constructor-configurable `id`) serves all three — just registered at different
endpoints. See the `btcpay-infra-topology` note for the operator's LAN endpoints.
## Bootstrap & routing
`buildProviderRegistry({env, walletService})` (`src/payments/providers/index.js`) is the single wiring seam:
- Native Monero/Wownero provider registers whenever a `walletService` exists.
- Each Greenfield gateway activates from its own env trio:
`BTCPAY_URL/_STORE_ID/_API_KEY` (chains from `BTCPAY_CHAINS`, default BTC,LTC),
`XMRCHECKOUT_URL/_STORE_ID/_API_KEY` (XMR), `WOWCHECKOUT_URL/_STORE_ID/_API_KEY` (WOW).
- Default routing: BTC/LTC → btcpay, XMR/WOW → their checkout gateway (else native). `PAYMENT_ROUTING`
(`CHAIN:provider,…` or JSON) overrides any mapping.
**Behavior-preserving:** with no gateway env set (prod today), only the native provider registers and
*every* chain routes to it — byte-for-byte the legacy flow.
## The hot path
- **Invoice creation** — `gameModeManager.createPaymentRequest` calls `getProvider(cryptoType).createInvoice(...)`.
The native provider delegates to the same `walletService.createPaymentRequest`, so the subaddress and
monitoring maps are identical to before.
- **Monitoring** — `network/paymentHandlers._monitorAddress` calls `provider.startWatch`. Native passes the
raw wallet status straight through (legacy path unchanged); `BTCPayProvider.getWalletStatus` maps a
Greenfield invoice to the same raw shape (`Settled → confirmed+complete`, `Processing → in_mempool`,
paid/required amounts from payment-methods via `ChainProfile` decimals). The confirmation logic is
extracted to `_handlePaymentStatus`, shared by native + gateway.
- **Payouts** stay native (the checkout apps are receive-only; XMR/WOW payouts remain on wallet-RPC).
## Turning a gateway on
1. Mint a store API key in the gateway's admin (money infra — a human step).
2. Set the `*_URL/_STORE_ID/_API_KEY` env on the deploy host, using an address the host can actually reach
(verify LAN reachability; a nebula/overlay IP may not route).
3. Restart. Invoices for that chain now create + confirm through the gateway; no code redeploy.
## Money math
All amounts are **atomic BigInt**, decimals-parameterized via `src/money/atomic.js` (`format`, `add`,
`mulByDecimal`, …) and `src/chain/chainProfile.js` (`decimalsFor`, `atomicDivisor`). Never floats. WOW = 11
decimals, XMR/BTC/LTC per their chain, GRIN = 9.
## Files
```
src/payments/providers/paymentProvider.js contract + registry
src/payments/providers/btcpayProvider.js Greenfield client (btcpay / xmrcheckout / wowcheckout)
src/payments/providers/nativeMoneroProvider.js wraps walletRPCService
src/payments/providers/index.js buildProviderRegistry (env → registry)
src/payments/walletRPCService.js Monero/Wownero wallet-RPC
src/game/gameModeManager.js createPaymentRequest (invoice seam)
src/network/paymentHandlers.js _monitorAddress / _handlePaymentStatus (monitoring seam)
```
+80
View File
@@ -0,0 +1,80 @@
# Rulesets & PvP (P4)
Gameplay is a **data object**. Instead of hardcoding "first to the exit wins," the match engine reads a
`Ruleset`, so new modes (PvP last-alive, score attack, co-op) are data — not new subsystems.
## The Ruleset
`src/game/rulesets/Ruleset.js` — `defineRuleset(spec)` normalizes a partial spec to a frozen, clamped
object:
```
id, label, mode
world { difficultyPreset, maps }
entities { monster, monsterCount, pvpCombat }
players { min, max }
winCondition { type } first-to-exit | last-alive | high-score | all-escape
timing { tickMs, minDurationMs, hardCeilingMs, blockDeadline }
economy { model, houseFeePercent, payoutMultipliers }
```
`registry.js` ships the built-ins — `solo-classic` (descriptor for the live single-player mode),
`race`, `last-alive`, `score-attack`, `coop-escape` — plus `getRuleset(id)`, `listRulesets()`, and
`rulesetFromMatchOpts()` (maps the legacy MatchRoom option bag to a `race` ruleset, preserving old
behavior).
## Win conditions as strategies
`src/game/rulesets/winConditions.js` — each condition is a small strategy the `MatchRoom` consults at
three decision points:
```
onExit(room, id) a player reached the exit — end the match? set a winner?
onDeath(room) a player died — is the match over?
rank(room) final ordering (best-first) for placement + winnerId
```
- **`FIRST_TO_EXIT`** is a *verbatim port* of the original race logic, so existing matches are
byte-identical.
- **`LAST_ALIVE`** (PvP): reaching the exit means you *survived* (safe, not an instant win); the match
ends when ≤1 contender remains; `rank` orders last-standing → escaped → died-later. Combined with
`entities.pvpCombat`, stepping onto a living rival **strikes them down** (attacker holds position).
- **`HIGH_SCORE`**: no instant win; rank by score at the end.
- **`ALL_ESCAPE`** (co-op): ends when everyone alive has escaped.
## Where it plugs in
`MatchRoom` (`src/multiplayer/MatchRoom.js`) resolves a ruleset from `opts.ruleset` / `opts.rulesetId` /
legacy opts, then delegates: `_checkResolution` → `winCondition.onExit`, `_killPlayer` →
`winCondition.onDeath`, `finalize` → `winCondition.rank`. It honors `entities.monster` (optional monster)
and `entities.pvpCombat`, and takes timing from the ruleset. `MatchEngine` is a thin tick driver
(`room.resolveTick()` on an interval).
**Scope note:** the *live single-player* engine (`src/game/game.js`) is intentionally **not** refactored to
consume rulesets (too risky to touch the deployed path) — `solo-classic` is a catalog descriptor only. PvP
modes are opt-in via `rulesetId`; the match scheduler still builds `race`, and match mode is gated by
`MATCH_ENABLED` (inert by default), so nothing changes until wired.
## Economy per mode
A ruleset's `economy` field (`model`, `houseFeePercent`, `payoutMultipliers`) maps onto the existing
`gameModeManager` fields (single-player: multiplier-on-entry-fee) and `matchPayoutService` (PvP:
winner-takes-pot-minus-house). See [MONETIZATION.md](MONETIZATION.md).
## Pending: block-time-aware difficulty
`difficultyConfig` presets (easy/normal/hard/casino) tune dungeon size, monster behavior, treasure, and a
target house-win-rate — but currently key off payment mode, **not** mean block time. Coupling difficulty
to `ChainProfile.meanBlockTimeMsFor(chain)` (so Grin ~1 min stays fair vs BTC ~10 min) is a pending
game-balance change, not just wiring.
## Files
```
src/game/rulesets/Ruleset.js defineRuleset + WIN/ECONOMY enums
src/game/rulesets/winConditions.js FIRST_TO_EXIT (race) / LAST_ALIVE / HIGH_SCORE / ALL_ESCAPE
src/game/rulesets/registry.js built-ins + getRuleset/listRulesets/rulesetFromMatchOpts
src/multiplayer/MatchRoom.js consumes the ruleset; PvP combat; delegates win/lose/rank
src/multiplayer/MatchEngine.js tick driver
src/game/difficultyConfig.js world presets (the ruleset's `world` half)
```
+3 -1
View File
@@ -10,7 +10,8 @@
"test": "jest --runInBand",
"db:create": "node scripts/setup_db.js",
"db:drop": "node scripts/setup_db.js drop",
"db:reset": "npm run db:drop && npm run db:create"
"db:reset": "npm run db:drop && npm run db:create",
"docs:api": "jsdoc2md --files game/**/*.js network/**/*.js payments/**/*.js multiplayer/*.js chain/*.js utils/*.js money/*.js services/*.js config/*.js > ../docs/API.md"
},
"config": {
"debug": {
@@ -44,6 +45,7 @@
},
"devDependencies": {
"jest": "^29.7.0",
"jsdoc-to-markdown": "^9.1.1",
"playwright-core": "^1.61.1"
}
}