mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-19 23:28:32 +02:00
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:
@@ -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`.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user