Files
wownerogue/docs/MONETIZATION.md
T
jwinterm ed24d43b56 chore: prepare the repository for public release
Removes operator-private material from the shipped tree, corrects documentation
that had drifted from the code, deletes dead code, and applies a consistent
prose style.

Private material moved out of the public tree (docs/private/, git-ignored):
  - the wallet keyfile recovery runbook and its helper scripts, which together
    published the mainnet wallet file path, the service account, backup snapshot
    filenames, and the wallet RPC listener state. The helper duplicated the same
    constants, so relocating the document alone would not have removed them.
  - the stagenet parallel-candidate runbook and its fixtures.
  - the dated production-state record, the internal remediation plan, and the
    TODO list. None are reference documentation for an outside reader.

Redacted or genericised:
  - operator home-directory paths in shipped scripts and units. The asset build
    script now takes KENNEY_ASSETS_DIR instead of a hard-coded path.
  - private LAN addresses in tests, env examples and deploy docs, replaced with
    RFC 5737 documentation addresses.
  - the default alert sender and the notification hub example.
  - the Monero wallet unit now matches the Wownero one: a dedicated service
    account and a config file, instead of the operator's login account with the
    wallet password in argv where any local user can read it from /proc.

Ignore rules now cover every .env variant rather than the bare name, plus local
agent state. The deployment docs instruct operators to create .env.mainnet and
.env.stagenet, which the exact-name rule would have allowed to be committed.

Documentation corrected against the code: per-chain dungeon level counts, the
premium unlock rule, Tavern chat scope, backup retention, the chain-profile
fallback, the environment template location, and several sections describing
shipped work as pending.

Dead code removed: configPersistence, rpccalls, a duplicate vendored ROT.js
bundle, the unused tile-picker overlay, the unreachable legacy game-size path
and its vestigial parameter, spectator stubs that shadowed the real
SpectatorManager, and commented-out debugging. nip98 now imports the shared
nostr loader rather than duplicating it.

Prose style: em-dashes removed throughout in favour of colons, semicolons and
commas; comments restated as present-tense descriptions of the code rather than
narration of past changes.

127 suites / 1015 tests pass.
2026-07-27 06:40:38 -04:00

167 lines
9.0 KiB
Markdown

# Monetization: credits, catalog, and entitlements
Cosmetics are sold through three pieces: **credits** (the unit of account), an operator-owned
**catalog** of packs, and an **entitlement rule** that decides which packs a given user has unlocked.
Payouts are a separate system and are not affected by any of this.
## Credits
Two columns on `users` drive everything:
| Column | Meaning |
|---|---|
| `credits` | spendable balance; decremented when a game is entered with credits |
| `total_credits_purchased` | lifetime credits purchased, cumulative, never decremented by play |
Credit packages are declared in `src/config/paymentConfig.js` under `modes.credits.packages`
(`{ id, credits, price, bonus }`). On confirmation, `gameModeManager.processProductPaymentConfirmation`
adds `credits + bonus` to both columns in one transaction and writes a `credit_transactions` row.
A direct (single-game) entry is modeled as *buy one credit, immediately spend it*
(`gameModeManager.recordDirectEntryPurchase`): the balance nets to zero, `total_credits_purchased`
advances by 1, and two `credit_transactions` rows (`direct_entry` purchase, `game_entry` spend)
record the pair. A direct payment therefore unlocks the same threshold cosmetics as a credit purchase.
Refunds reverse what a payment granted. `src/services/paymentRefundService.js` deducts the granted
credits, purchase progression, and race entries, restores the previous `premium_level`, and revokes
pack grants. If the grant cannot be reversed exactly (credits already spent, progression already
consumed, ambiguous provenance on a bare direct entry), the refund is parked as `needs_review`
instead of leaving a cosmetic unlock behind.
## The catalog
`cosmetic_catalog` (migration `024`, repriced by migration `028`) is the operator-owned product
table and the single server-authoritative source for pack definitions:
```
pack_id, label, kind, projection, tier, unlock_min_credits,
grant_only, sort_order, active, metadata
```
`src/services/catalogService.js` loads active rows ordered by `sort_order`, caches them for 60
seconds, and exposes `invalidate()`. It falls back to the built-in `DEFAULT_CATALOG` in
`src/multiplayer/entitlements.js` when the table is missing (`42P01`), empty, or yields no valid
rows, so the game runs on a fresh or partly-migrated database and never hard-fails on a catalog read.
Rows without both `pack_id` and `label` are skipped.
`catalogSummary()` projects the catalog into the display-facing shape the client renders
(`id, label, kind, projection, tier, premium, unlockMinCredits`).
## The unlock rule
`snapshotForUser(user, grants, catalog)` in `src/multiplayer/entitlements.js`. A pack is unlocked if
**any** of:
1. it is free: `tier === 0`, `unlockMinCredits == null`, and not `grantOnly`
2. an explicit grant exists in `user_pack_entitlements` (unexpired)
3. `total_credits_purchased >= unlock_min_credits` (when the pack sets a threshold)
4. the user's premium tier is `>=` the pack's `tier` (when `tier > 0`)
`TIER_OF = { free: 0, credits: 0, supporter: 1, premium: 2, operator: 3 }`. Clause 4 is the
subscription hook. Buying credits maps to tier 0, so a purchase never blanket-unlocks the premium
tiers; it only advances the threshold in clause 3.
The returned snapshot is `{ premium, level, tier, credits, totalCreditsPurchased, packs, catalog }`,
where `packs` is a per-pack boolean map and `premium` is true when the user has any premium tier or
any non-free pack unlocked.
## The credit ladder
Clause 3 keys off lifetime credits *purchased*, cumulatively. It is not deducted by play, so buying
credits to play also walks the user up the cosmetic ladder and cosmetics never compete with the core
loop. Each catalog row is data: a tilepack or a character skin, at any rung and any tier. Adding a
pack means appending a catalog row, shipping the assets, and calling `RK.registerPack` on the client.
| Lifetime credits | Pack | Projection | Tier |
|---|---|---|---|
| free | `original` (bare original tiles) | topdown | 0 |
| 1 | `roguelike-interior` | topdown | 1 |
| 5 | `generated-skins` (character skins) | topdown | 1 |
| 10 | `iso-dungeon` | iso | 2 |
| 20 | `roguelike-dungeon` | topdown | 2 |
| 40 | `iso-medieval` | iso | 3 |
| 50 | `kenney-3d-characters` | 3d | 3 |
The `original` pack maps only the essentials (floor, wall, door, torch, features); furniture kinds
fall back to floor, so the free tavern reads deliberately plain.
Because the tier column is also live, a `premium` subscription (tier 2) unlocks every pack at tier
2 or below without any credit spend; tier 3 packs need `operator` or the credit threshold.
### Render techniques versus packs
`RK.RENDER_MODES` in `html/js/render/renderModes.js` defines four techniques: Tiled and ASCII are
free, Iso and 3D are premium. `RK.canUseMode` grants a premium technique once the user has **any**
unlocked pack for its projection, so the cheapest pack in a projection is effectively that
technique's gate. Plain ASCII stays free for accessibility.
Client-side gating is a cosmetic honour gate, not a security boundary: rendering happens in the
browser. The server-side boundary is `Entitlements.normalizeAppearance`, which resets an appearance
whose avatar pack the user has not unlocked before it is persisted or broadcast.
## Subscription tiers
`levelForUser(user)` returns the higher of the stored `premium_level` and any `subscription_tier`,
so an active subscription sets the entitlement tier and unlocks the packs at or below it. One
subscription drives both chat perks and cosmetics; the chat side is documented in
[CHAT_AND_NOSTR.md](CHAT_AND_NOSTR.md).
`identityService.entitlementsForUser` populates `subscription_tier` before taking the snapshot by
calling `SubscriptionService.tierForNpub(user.smirk_public_key)`. The call is best-effort: any
resolver failure leaves the field unset and the snapshot falls back to credit thresholds and grants.
```
active subscription -> tierForNpub(npub) -> user.subscription_tier -> levelForUser -> tier
|- relay: chat perks
|- catalog: pack unlocks
```
`src/services/subscriptionService.js` resolves a tier from two optional sources, in order, and caches
the result for 5 minutes. With neither configured it returns `null` and no one is auto-promoted.
| Environment variable | Meaning |
|---|---|
| `PREMIUM_NPUBS` | operator allowlist, `npub1…\|64hex[:tier],…`; npub entries are decoded to x-only hex to match `users.smirk_public_key` |
| `PREMIUM_DEFAULT_TIER` | tier for allowlist entries with no explicit `:tier` (default `premium`) |
| `SMIRK_PREMIUM_STATUS_URL` | optional HTTP endpoint answering premium-by-npub with `{ active, tier? }` |
| `SMIRK_PREMIUM_STATUS_KEY` | bearer token sent to that endpoint |
The Smirk backend's `/premium/status` is self-only (user-token auth), so it cannot answer a lookup
for an arbitrary npub. The allowlist covers that case with no backend dependency; the HTTP seam is
for a service-scoped endpoint where one exists.
## Reaching the client
The server emits `identity_update` with `{ appearance, entitlements }`, where `entitlements` carries
the `packs` map and the projected `catalog`. `RK.setEntitlementSnapshot` in
`html/js/render/assetPacks.js` merges the served catalog into `RK.PACKS` (merge, not replace, so a
client-side fallback a render mode depends on survives a catalog that omits it), recomputes the
per-pack unlock map, and persists the snapshot to `localStorage` for the next load. Operator-added
packs appear on the client without a client release.
`?unlock=1` sets a sticky per-browser QA bypass (`RK.renderModeTestUnlocks`) that unlocks premium
modes and packs locally; `?unlock=0` clears it. It affects only the browser that sets it.
## Operated product profiles
When `OPERATED_PRODUCT_PROFILE` is set, `src/config/operatedProductProfiles.js` rejects standalone
cosmetic products (`products.cosmetic`) and any credit package carrying a `grants` object. Those
deployments sell only the credits and bonus declared at the top level of a credit package, so the
credit ladder and subscription tiers are the only unlock paths. Independent deployments do not set
the variable and are not constrained by it.
## Files
```
src/migrations/024_cosmetic_catalog.sql catalog table
src/migrations/028_cosmetic_ladder.sql ladder rows (idempotent upsert)
src/services/catalogService.js load, cache, DEFAULT_CATALOG fallback
src/multiplayer/entitlements.js snapshotForUser, levelForUser, TIER_OF, unlock rule
src/services/subscriptionService.js premium tier by npub
src/services/paymentRefundService.js entitlement reversal and needs-review routing
src/network/identityService.js entitlementsForUser (catalog + grants + subscription tier)
src/game/gameModeManager.js recordDirectEntryPurchase, credit package confirmation
html/js/render/assetPacks.js client consumes the served catalog
html/js/render/renderModes.js technique registry and gating
```