Files
wownerogue/docs/MONETIZATION.md
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

9.0 KiB

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.

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