mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-24 09:38:32 +02:00
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.
167 lines
9.0 KiB
Markdown
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
|
|
```
|