mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
443 lines
31 KiB
Markdown
443 lines
31 KiB
Markdown
# econ
|
||
|
||
Economy Worker served on the `econ` subdomain (`econ.recflare.net`). Hosts the
|
||
avatar/economy endpoints the game client calls on the `econ` service (distinct from the
|
||
main `api` worker, which also serves many of them — the client may call either host).
|
||
|
||
Balances, inventory, consumables, saved outfits, avatars, gift boxes, weekly-challenge
|
||
progress and game-reward eligibility are D1-backed; storefront catalogs and the weekly-challenge rotation are static
|
||
assets (`static/`), the storefronts served via the ASSETS binding. Several routes are still
|
||
empty-list stubs.
|
||
|
||
## Routes
|
||
|
||
`✓` = auth-gated (validates the Bearer JWT from the `auth` worker; empty-body 401 when
|
||
missing/invalid). `~` = optional auth: served to anyone, personalised for a valid bearer.
|
||
|
||
| Method | Path | Auth | Description |
|
||
| -------- | ---------------------------------------------------- | ---- | --------------------------------------- |
|
||
| GET | `/api/avatar/v1/defaultunlocked` | | Default-unlocked avatar items (static) |
|
||
| GET | `/api/avatar/v1/defaultbaseavataritems` | | Default base avatar items (stub `[]`) |
|
||
| GET | `/api/avatar/v4/items` | ✓ | Owned items + the default catalog |
|
||
| GET | `/econ/customAvatarItems/v1/owned` | ✓ | Owned custom avatar items (stub) |
|
||
| GET | `/api/objectives/v1/myprogress` | | Objectives progress (static) |
|
||
| GET/POST | `/api/objectives/v1/cleargroup` | | Clear an objectives group (no-op `[]`) |
|
||
| GET | `/api/avatar/v2` | ✓ | The player's own avatar |
|
||
| POST | `/api/avatar/v2/set` | ✓ | Save the player's avatar |
|
||
| GET | `/api/checklist/v1/current` | ✓ | NUX checklist (stub `[]`) |
|
||
| GET | `/api/itemWishlists/v1/wishlist/me` | ✓ | Item wishlist (stub `[]`) |
|
||
| GET | `/api/avatar/v3/saved` | ✓ | Saved outfits |
|
||
| POST | `/api/avatar/v3/saved/set` | ✓ | Save an outfit into a slot |
|
||
| GET | `/api/avatar/v2/gifts` | ✓ | Pending (unopened) gift boxes |
|
||
| POST | `/api/avatar/v2/gifts/consume` | | Open a gift box → success envelope |
|
||
| GET | `/api/avatar/v2/:id` | | Another player's avatar (render subset) |
|
||
| GET | `/api/equipment/v2/getUnlocked` | | Unlocked equipment (stub `[]`) |
|
||
| GET | `/api/roomconsumables/v1/roomConsumable/room/:id` | | Room consumables (stub `[]`) |
|
||
| GET | `/api/roomconsumables/v1/roomConsumable/room/:id/me` | | Caller's room consumables (stub `[]`) |
|
||
| GET | `/api/roomcurrencies/v1/currencies` | | Room currencies (stub `[]`) |
|
||
| GET | `/api/roomcurrencies/v1/getAllBalances` | | Room balances (stub `[]`) |
|
||
| POST | `/api/settings/v2/set` | ✓ | Persist settings (accept-and-ack) |
|
||
| GET | `/api/consumables/v2/getUnlocked` | ✓ | Unlocked consumables |
|
||
| POST | `/api/consumables/v1/consume` | ✓ | Consume an owned consumable |
|
||
| GET | `/api/storefronts/v4/balance/:currencyType` | ✓ | Currency balance |
|
||
| GET | `/api/storefronts/v3/giftdropstore/:id` | | Gift-drop storefront catalog |
|
||
| POST | `/api/storefronts/v2/buyItem` | ✓ | Buy a storefront item |
|
||
| GET | `/api/storefronts/v1/adcarouselitems` | | Ad-carousel items (static) |
|
||
| GET | `/api/challenge/v2/getCurrent` | ~ | Weekly rotation + the caller's progress |
|
||
| POST | `/api/challenge/v2/updateProgress` | ✓ | Report challenge progress |
|
||
| GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) |
|
||
| POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 25 XP + gift box |
|
||
| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) |
|
||
| GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) |
|
||
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | | Subscription lookup (both null) |
|
||
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
|
||
|
||
The app runs with `strict: false`, so trailing-slash variants match (the client posts
|
||
`/gifts/consume/` with a trailing slash).
|
||
|
||
## API documentation
|
||
|
||
`GET /openapi.json` serves a spec generated from `describeRoute` blocks alongside each
|
||
handler, with the schemas in `src/openapi.ts`. **Descriptive, not enforced** — same
|
||
rationale as the `auth`/`accounts`/`match` workers. A test asserts every route appears
|
||
in the spec, so adding one without documenting it fails.
|
||
|
||
## Purchases (`buyItem`)
|
||
|
||
The core flow. The client posts the storefront/item ids, the currency, and the
|
||
`RequestedPrice` it rendered; the handler:
|
||
|
||
1. looks the item up in `static/storefronts/sf{StorefrontType}.json`;
|
||
2. rejects a stale price (`409`) — this stops a stale or tampered client buying at a
|
||
price the catalog no longer offers;
|
||
3. debits the buyer **atomically** (`400` on insufficient balance);
|
||
4. grants the drop — an avatar item into the `inventory` table (own-once), equipment into
|
||
`equipment`, a consumable into `consumable` (each buy stacks a new instance), or, for a
|
||
query drop, whatever the roll lands on (below); currency/xp drops aren't granted yet;
|
||
5. returns a **gift box** and pushes a `StorefrontBalanceUpdate` over the socket.
|
||
|
||
Two things are easy to get wrong:
|
||
|
||
- **`Balance` in the response is the _change_ applied** (the negated price), not the
|
||
resulting total. The client reads its new total from `GET /balance/:type`.
|
||
- **Ownership is persisted at purchase**, not when the box is opened. Opening a box
|
||
(`/gifts/consume`) just deletes it — the item was already granted. So the grant never
|
||
waits on the cosmetic "open it" moment.
|
||
|
||
A `Gift` block routes the item (and box) to another player, but the caller always pays.
|
||
A self-buy or anonymous gift is attributed to the "Coach" system account (id 1).
|
||
|
||
## Query drops — the loot boxes (`IsQuery`)
|
||
|
||
A gift-drop with `IsQuery: true` is not an item, it is a **roll**: all of its item fields
|
||
(`AvatarItemDesc`, `EquipmentModificationGuid`, `ConsumableItemDesc`) are empty on purpose,
|
||
and what the player gets is picked at grant time. sf2's tooltip states the rule outright —
|
||
_"A random 4-star item that you don't have."_ Eight ship in the catalogs, two families of
|
||
the same ladder:
|
||
|
||
| sf2 "Star Boxes" (`ItemSetId` 44, `Unique`) | Rarity | sf3 "Random box" family |
|
||
| ------------------------------------------- | ------ | ----------------------- |
|
||
| — | 0 | Common Random box |
|
||
| 2-Star Unique Box | 10 | Uncommon Random box |
|
||
| 3-Star Unique Box | 20 | Rare Random box |
|
||
| 4-Star Unique Box | 30 | Epic Random box |
|
||
| — | 50 | Legendary Random box |
|
||
|
||
That table is the **star ↔ rarity ladder** (`STAR_RARITY` in `econ.app.ts`): sf2's three
|
||
boxes pin 2/3/4 → 10/20/30 by carrying both their name and their `QueryRedirectRarity`, and
|
||
sf3's five-name ladder fills in the ends. It's the same tier list twice, so read a rarity
|
||
number in either dialect.
|
||
|
||
`rollQueryDrop` resolves one inside `grantGiftDrop`, so both faucets — a purchase and the
|
||
weekly gift — hand over a real item rather than an unopenable box:
|
||
|
||
- **The pool is sf3**, the general store (`ROLL_STOREFRONT_TYPE`). It's the only catalog
|
||
with a real pool at every tier (1161 items against 8–40 in the themed ones), it's where
|
||
the Random box family itself sells, and "a random 4-star item" means the item universe,
|
||
not whichever seasonal shelf the box came off.
|
||
- **Filtered to what the player doesn't own**, which is the `Unique` promise and the only
|
||
reading of "an item you don't have" that means anything.
|
||
- **Avatar items and equipment only.** Other query drops are excluded (a box that rolls a
|
||
box), and so are consumables: they stack, so "don't have" never becomes false and they'd
|
||
crowd out the real prizes.
|
||
- **`QueryRedirectRarity` wins over `Rarity`** when present — sf2 carries both and they
|
||
agree; sf3's boxes carry only `Rarity`.
|
||
- **An empty pool grants nothing** (logged `query gift-drop rolled nothing`) — an owner of
|
||
every 4-star item still gets the box, just nothing in it. The `buyItem` response still
|
||
echoes the drop the player _bought_, i.e. the box; the rolled item shows up in the box
|
||
itself, via `GET /api/avatar/v2/gifts`.
|
||
|
||
## Consume envelopes
|
||
|
||
Both consume routes (`/gifts/consume`, `/consumables/consume`) always answer HTTP 200
|
||
with `{ error: "", success: true, value: null }` — even for a missing or already-gone
|
||
target. A captured real consume returns this envelope, not an empty body: the client
|
||
parses it to finish the action, so a bare 200 reads as a failure and the item never
|
||
finishes unlocking. Deletes are scoped to the caller, so an unauthenticated or
|
||
mismatched call is a harmless no-op (opening _another_ player's box is a 403).
|
||
|
||
## Weekly challenge (`static/weekly-challenge.json`)
|
||
|
||
Served by `GET /api/challenge/v2/getCurrent` (with each challenge's per-player `Complete`
|
||
stamped in — see Progress below). The server never evaluates the rules: the client reads
|
||
the rule tree in each challenge's `Config`, watches its own gameplay, and posts the tree
|
||
back to `/api/challenge/v2/updateProgress` with its verdict. So this file is the entire
|
||
definition of a week's challenges — ids, display strings, matching rules and the reward
|
||
preview.
|
||
|
||
Everything below was read off reference data (one captured live rotation), not a spec.
|
||
Field meanings marked _(inferred)_ are read from how the values line up with the strings
|
||
the client renders; the rest are pinned by the data itself. The file itself is edited
|
||
freely as rotations change — the examples here are the captured week, so expect the shipped
|
||
rotation to differ.
|
||
|
||
### Top level
|
||
|
||
| Field | Example | Notes |
|
||
| ---------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
||
| `ChallengeMapId` | `17` | Id of the rotation as a whole ("map" of challenges). Echoed back on `updateProgress`; bump it when you publish a new week. |
|
||
| `CompletedRequired` | `false` | _(inferred)_ All-or-nothing: `true` makes the `Gift` need every challenge, `false` the three-of-five threshold below. |
|
||
| `StartAt` / `EndAt` | `2026-03-25T21:00:00` | The window, 7 days apart, **no timezone suffix** — unlike `ServerTime`. Treat as UTC. |
|
||
| `ServerTime` | `2026-03-31T14:42:54.2754728Z` | .NET round-trip timestamp (7-digit fraction, `Z`). The client dates the countdown off this, so it is **frozen** — see below. |
|
||
| `Challenges` | array | The week's challenges, rendered in order. |
|
||
| `Gift` | object | The reward preview for finishing the set. |
|
||
| `FallbackGiftName` | `"4-Star Box"` | Shown when the client can't resolve `Gift` into a name. |
|
||
| `ChallengeThemeString` | a designer quote | Free text carried through from the captured rotation; a theme note, not a rendered UI string as far as we can tell. |
|
||
|
||
**The frozen clock:** `ServerTime` (Mar 31) sits _inside_ `StartAt`…`EndAt` (Mar 25 → Apr 1),
|
||
about a day before the end, and the file is static — so the client always sees an active
|
||
rotation with a ~1-day countdown rather than an expired one. If you edit the window, move
|
||
`ServerTime` inside the new one too, or the challenges may render as already over.
|
||
|
||
### A challenge entry
|
||
|
||
| Field | Notes |
|
||
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `ChallengeId` | Unique within the rotation, not sequential (`37, 38, 44, 49, 63`). Posted back on `updateProgress`. |
|
||
| `Name` | Internal slug, never displayed — and **not authoritative**: `63` is named `Complete3SpillwayGames` but its `Config` and description are Clearcut. Trust `Config`, not the name. |
|
||
| `Config` | The rule tree, as an **escaped JSON string** (not a nested object). See below. |
|
||
| `Description` | The one-line goal, e.g. `"Complete 10 games in ^Paintball"`. |
|
||
| `Tooltip` | The longer hint under it. |
|
||
| `Complete` | Per-player state, so always `false` in the file — `getCurrent` overwrites it per caller from `challenge_status`. |
|
||
|
||
`^Token` in `Description`/`Tooltip` is a client-side room link: the client resolves the
|
||
token to a room and renders a tappable name. Subrooms use a dotted path
|
||
(`^Paintball.Clearcut`). It is optional decoration, not markup the client requires — the
|
||
same rotation writes both `"Complete 10 games in ^Paintball"` and, plainly,
|
||
`"Complete 3 games of Paintball: Clear Cut"`.
|
||
|
||
### The `Config` rule tree
|
||
|
||
A tree of nodes, each with a numeric type in `ct`. Two node kinds appear:
|
||
|
||
- **Match** (`ct: 0`) — `wc` is a list of predicates that must _all_ hold for one game
|
||
result (AND).
|
||
- **Counter** (`ct: 1`) — `ctc` holds the child node to count and `t` is the target count.
|
||
|
||
Which slot a node uses (`wc` vs `ctc`) tells you what its children are; a node never has
|
||
both. `ipc` is `false` on every composite node in the reference data — purpose unknown, but
|
||
the client echoes it back, so keep emitting it.
|
||
|
||
Predicate leaves carry `vs`, a list of accepted values matched as OR:
|
||
|
||
| `ct` | Shape | Meaning |
|
||
| ---- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||
| `6` | `{"ct":6,"vs":[2]}` | _(inferred)_ The kind of event being matched — a finished game/session. Present in **every** leaf group and always `[2]`; nothing observed varying it, so treat it as required boilerplate. |
|
||
| `7` | `{"ct":7,"vs":[{"l":"<guid>"}]}` | Scene allow-list: each `l` is a subroom's `UnitySceneId` (see `apps/rooms`). Matches if the game happened in any of them. |
|
||
| `9` | `{"ct":9,"vs":[true],"v":"won"}` | A named session variable (`v`) equals one of `vs` — here, the player won. |
|
||
|
||
The two idioms in the file, unescaped:
|
||
|
||
```jsonc
|
||
// "Complete ^TheRiseOfJumbotron quest" — one winning session in one scene
|
||
{ "ct": 0, "ipc": false, "wc": [
|
||
{ "ct": 6, "vs": [2] },
|
||
{ "ct": 9, "vs": [true], "v": "won" },
|
||
{ "ct": 7, "vs": [{ "l": "acc06e66-…" }] } // TheRiseofJumbotron / Home
|
||
]}
|
||
|
||
// "Complete 5 Charades games" — count matching sessions to a target
|
||
{ "ct": 1, "ipc": false, "t": 5, "ctc": [
|
||
{ "ct": 0, "ipc": false, "wc": [
|
||
{ "ct": 6, "vs": [2] },
|
||
{ "ct": 7, "vs": [{ "l": "a673712c-…" }, { "l": "4078dfed-…" }] } // 3DCharades + Legacy3DCharades
|
||
]}
|
||
]}
|
||
```
|
||
|
||
Note the quest challenges have **no `t`** (one qualifying session is the whole goal) and
|
||
the counted ones have **no `won` predicate** (finishing counts, winning is irrelevant).
|
||
|
||
On `updateProgress` the client posts the same tree back with its own progress written into
|
||
it: **`cc`** on the counter node is the current count (`…,"t":5,"cc":1`), and **`c`** (`"c":true`)
|
||
marks a node it now considers satisfied. Neither appears in this file — they are progress,
|
||
not definition, which is why the tree isn't stored (only the top-level `Complete` is). The
|
||
count itself lives only in the client.
|
||
|
||
**Scene ids, not room ids.** Because `ct: 7` matches `UnitySceneId`, a screens room and its
|
||
VR twin share ids and both count: the captured "Complete 10 games in Paintball" listed six
|
||
scenes, which are the subrooms of _both_ `Paintball` and `PaintballVR` — and each is also a
|
||
standalone base room (`River`, `Clearcut`, …). One list covers every way in. Resolve a guid
|
||
against the `SubRooms[].UnitySceneId` values in `apps/rooms/migrations/0002_import_rooms.sql`;
|
||
a "one map only" challenge is the same shape with a single-entry list. Watch for one trap
|
||
this creates: `Soccer / Home` and `Stadium / Home` are the same scene, so a soccer challenge
|
||
also completes in the Stadium.
|
||
|
||
### The `Gift` block
|
||
|
||
Same item vocabulary as a storefront `GiftDrop` (`AvatarItemDesc` — a comma-separated list
|
||
of avatar-item guids, `AvatarItemType`, `ConsumableItemDesc`, `EquipmentPrefabName`,
|
||
`EquipmentModificationGuid`) plus `Xp`, `Level` and `StorefrontType`, but two fields are
|
||
**renamed**: a storefront's `Context`/`Rarity` are `GiftContext`/`GiftRarity` here. Don't
|
||
feed one shape to the other's reader.
|
||
|
||
`EquipmentModificationGuid` is the Rec Room packed guid — 22-char URL-safe base64 of the 16
|
||
guid bytes in .NET little-endian order, padding stripped (`g5u0weNLmkCLeUXFUVn74Q` →
|
||
`c1b49b83-4be3-409a-8b79-45c55159fbe1`). The reward is identified by prefab + that guid,
|
||
_not_ by `GiftDropId`: this block's `GiftDropId` is `3994`, while the same skin sells in
|
||
`sf3.json` as `2121` ("Camera Skin (Comic)").
|
||
|
||
**Granted when the set is finished** — see below. The grant path is `buyItem`'s, so the
|
||
block is translated into a storefront gift-drop first (`toChallengeGiftDrop`); the renamed
|
||
`GiftContext`/`GiftRarity` are exactly what that translation is for.
|
||
|
||
The block carries no display strings and a `GiftRarity` of `0` for an item that sells at
|
||
rarity `5`, so both are taken from the catalog entry selling the same item (matched on
|
||
equipment guid / avatar desc) — the reward reads as "Camera Skin (Comic)", not as the box it
|
||
might have arrived in. An explicit `FriendlyName`/`Tooltip` on the block wins over the
|
||
catalog if a rotation we publish sets them; neither is present in the captured one.
|
||
|
||
**`FallbackGiftName` is the other half of the reward, not just a label.** "4-Star Box" is
|
||
what the player gets _instead_ when they already own the item — the real game phrased it
|
||
"…or a 4-Star Box!" — so it is granted as a query drop (a roll) at the tier its star count
|
||
names, via the ladder in the query-drop section. Renaming it to `3-Star Box` retunes the
|
||
consolation tier with no code change; a name that doesn't parse falls back to 4 stars.
|
||
|
||
### Winning the gift (`challenge_gift`)
|
||
|
||
There is no claim endpoint and the client never asks: the reward is handed out from the
|
||
`updateProgress` call that reaches the threshold. Every completing report on the **live**
|
||
rotation re-reads the caller's completions and, once enough of `weekly-challenge.json`'s
|
||
challenges are there, grants the `Gift` the way a purchase grants a drop — the item into
|
||
`inventory`/`equipment`/`consumable`, plus a gift box (message
|
||
`Weekly challenge complete!`) the player finds in `GET /api/avatar/v2/gifts`.
|
||
|
||
**Three of five, not five of five** (`CHALLENGES_REQUIRED_FOR_GIFT`). A week publishes five
|
||
challenges and the gift is for playing most of them, so the two a player can't reach — a
|
||
quest they don't own, a mode they don't like — don't sink the whole week. The count is of
|
||
challenges the rotation still **publishes**: a live client can report an id an edited
|
||
rotation no longer lists, and three of those shouldn't buy a gift nobody worked for. A
|
||
rotation publishing fewer than three can only ask for what it has.
|
||
|
||
**The item, or a roll.** If the player already owns the `Gift`'s item — likely, since the
|
||
rotation's reward is one fixed item that sells in the store — they get the
|
||
`FallbackGiftName` box instead, rolled at its star tier. Finishing the week can't be worth
|
||
nothing. A `Gift` block carrying no ownable item at all (no avatar desc, no equipment guid)
|
||
counts as "already owned", so a rotation whose reward is _only_ a box is written by leaving
|
||
the block empty and naming the tier.
|
||
|
||
- **`challenge_gift` makes it happen once.** One row per (account, rotation); the row's
|
||
existence _is_ the grant. The client keeps reporting after the set is finished, so the
|
||
insert is the gate: `ON CONFLICT … DO NOTHING … RETURNING` claims it in one statement, and
|
||
a second report returns no row and grants nothing.
|
||
- **Claim first, grant second** — at-most-once. If the grant then fails the reward is lost
|
||
rather than doubled; it's logged (`failed to grant weekly challenge gift`) and re-granted
|
||
by hand if it ever happens. A faucet that sticks is easier to spot than one that leaks.
|
||
- **The response is unchanged; the socket carries the news.** `updateProgress` answers the
|
||
same four fields whether or not a gift was won, and a `GiftPackageReceivedImmediate` (31)
|
||
frame goes out over the hub with the box — that's what pops the reward panel the moment
|
||
the set is finished, instead of the player finding it on the next read of the gifts list.
|
||
The payload is the reference server's field-for-field (`Id`, `FromGiftDropId: 0`,
|
||
`FromPlayerId`, the item fields, `Platform`/`PlatformsToSpawnOn: -1`, `BalanceType: -2`,
|
||
`Message`), and it names the **rolled** item when the fallback box is what was granted.
|
||
`Immediate` (31) rather than `GiftPackageReceived` (30) is what the reference sends for a
|
||
box the server hands over unasked; the sender is Coach (1). Best-effort — a hub failure is
|
||
logged and swallowed, since the gift is already granted and stored.
|
||
- **`CompletedRequired: true` makes the rotation all-or-nothing** — the threshold becomes
|
||
every published challenge. That reading of the flag is still _inferred_ (it is `false` in
|
||
the captured rotation, which is the partial default), but it's the one its name and the
|
||
three-of-five rule agree on.
|
||
- **`Xp`/`Level` on the block are ignored**, as on a purchase — same gap, and both are `0`
|
||
in the captured rotation.
|
||
- **A report against an old rotation never wins anything**, and an empty `Challenges` array
|
||
earns nothing (its threshold clamps to zero, which every player would otherwise meet
|
||
without playing).
|
||
- **Players already past the threshold when this shipped still get it**: the client
|
||
re-reports completed challenges, and the first such report is a completing report.
|
||
|
||
### Progress (`challenge_status`)
|
||
|
||
`POST /api/challenge/v2/updateProgress` (auth-gated) upserts one row per (account,
|
||
challenge) into `challenge_status`, and `getCurrent` reads them back to stamp `Complete`.
|
||
The body is `{ ChallengeMapId, ChallengeId, Config, Complete }` with the ids as **strings**
|
||
and `Complete` as .NET's `"True"`/`"False"` — capitalized, so `Boolean(body.Complete)` reads
|
||
"not complete" as complete (`parseBool` handles both spellings and a real JSON `true`).
|
||
|
||
Only the completion is stored. `Config` is the catalog's own rule tree plus the client's
|
||
running count, so a per-player copy would just be a staler duplicate of static data — it is
|
||
echoed back untouched but never persisted. The response is the four posted fields, except
|
||
`Complete` is the **stored** value rather than the posted one, because:
|
||
|
||
- **Completion latches within a rotation.** The client reports repeatedly, and a later
|
||
report saying "not complete" (a fresh session, a retry arriving out of order) must not
|
||
un-finish something already finished.
|
||
- **A new rotation resets the row.** Challenge ids are only unique within a rotation, so
|
||
the same id in a later week would otherwise start out already complete. A report whose
|
||
`ChallengeMapId` differs from the stored one replaces the row instead of latching; reads
|
||
are scoped to the rotation for the same reason.
|
||
|
||
`getCurrent`'s auth is **optional** — an unauthenticated caller gets the static rotation
|
||
with every `Complete` false rather than a 401, since the rotation is public and a failure
|
||
on this route can stall the client's load. The overlay rebuilds the response object rather
|
||
than stamping the imported JSON in place: that import is module state shared across every
|
||
request an isolate serves, so mutating it would leak one player's completions to the next
|
||
caller.
|
||
|
||
## Game rewards (`reward_status`)
|
||
|
||
The client asks for a reward whenever it thinks one is due, posting a form body of the type
|
||
and the message to show for it:
|
||
|
||
```
|
||
rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day
|
||
rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Soccer
|
||
```
|
||
|
||
Since the client asks rather than the server offering, whether a reward is actually **owed**
|
||
is decided here, from `reward_status` — one row per (account, reward type) holding the last
|
||
claim and a count. One claim per type per hour (`REWARD_COOLDOWN_MS`), flat for every type
|
||
despite what a name like `FirstActivityOfDay` suggests; per-type windows would be a map
|
||
keyed by type.
|
||
|
||
- **The claim is one SQL statement** (`ON CONFLICT … DO UPDATE … WHERE`). The client fires
|
||
these off right after a match, so two can land together; a read-then-write would let both
|
||
see the same stale `granted_at` and pay out twice.
|
||
- **A rejected claim leaves `granted_at` alone.** If an on-cooldown ask pushed the timestamp
|
||
forward, a client that retries in a loop would never become eligible.
|
||
- **`giftContext` (the activity, e.g. `Soccer`) is accepted and ignored** — the cooldown is
|
||
per type, shared across activities, so it is not part of the key.
|
||
|
||
**What a claim pays: 25 XP, in a gift box.** The XP (`GAME_REWARD_XP`) is banked in
|
||
`progression` and the box is the wrapper the client shows for it — no item, every item field
|
||
empty, `GiftContext` 50 (`GameRewards`). The box wears the `Message` the client posted
|
||
(`First Game of the Day`), and a `GiftPackageReceivedImmediate` frame goes out with it, the
|
||
same push the weekly-challenge gift uses. XP is banked **before** the box is created, so a
|
||
failure can't leave a box promising XP nobody was credited.
|
||
|
||
- **One flat amount for every reward type**, matching the one flat cooldown they share.
|
||
Pricing `FirstActivityOfDay` differently from `PostGameActivity` is a map keyed by type,
|
||
the same shape the per-type cooldown would take.
|
||
- **The response stays `[]`.** It's what the client already accepts, and the reward is
|
||
delivered as a box, so there's nothing to put in the body. The reference answers its own
|
||
(different) flow with `{ error, success, value: null }`, not a list of rewards.
|
||
- **An on-cooldown ask pays nothing** — no XP, no box, no frame. That's the whole point of
|
||
getting eligibility right first: a client that retries in a loop must not mint boxes.
|
||
|
||
**Progression (`progression`) is shared.** `econ` writes it here; `api` reads it back for
|
||
`GET /api/players/v{1,2}/progression/…`. It lives in `@repo/domain` for that reason, the
|
||
same split as gift boxes. A player with no row reads as level 1 / 0 XP, so a GET never
|
||
inserts. `Level` is stored but never moves: the reference levels up by subtracting a tier's
|
||
`RequiredXp` from the running XP, with thresholds from a config file (`configv2.json`'s
|
||
`LevelProgressionMaps`) we don't have.
|
||
|
||
**Not ported:** the reference's `request` doesn't grant at all — it offers **three** drops,
|
||
pushes a `RewardSelectionReceived` frame and waits for `POST /api/gamerewards/v1/select` to
|
||
grant the one the player picked. We grant on request instead, so there is no selection state
|
||
and no `/select`. It also caps activity XP per day (`daily_xp_ledgers`); the hourly cooldown
|
||
is our cap.
|
||
|
||
`GET /api/gamerewards/v1/pending` stays `[]`: with rewards claimed on request, nothing sits
|
||
waiting to be collected.
|
||
|
||
## Bindings
|
||
|
||
| Binding | Type | Notes |
|
||
| ---------------------------- | -------------- | ---------------------------------------------------------- |
|
||
| `DB` | D1 | Shared `recflare` database — balances, inventory, XP, etc. |
|
||
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
|
||
| `ASSETS` | static assets | Serves `sf{N}.json` storefront catalogs |
|
||
| `RECFLARE_NOTIFICATIONS_HUB` | Durable Object | Cross-worker RPC to the `notify` worker's hub |
|
||
| `STARTING_TOKENS` | var | Optional; new-player token grant (default in balance-db) |
|
||
|
||
Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no code change.
|
||
|
||
## Known gaps
|
||
|
||
- Gifting to another player grants the item and box but does not notify the recipient — the
|
||
reference sends `GiftPackageReceivedImmediate` there too (`buy.go`, when the body carries
|
||
a `Gift`), and `pushGiftReceived` is now sitting right there to do it.
|
||
- `buyItem` grants avatar-item, equipment, consumable and query (box) drops; currency/xp
|
||
drops aren't granted.
|
||
- A query drop rolls uniformly across the tier and can't run at a rarity sf3 doesn't
|
||
publish; per-item weighting and a multi-catalog pool would both need a manifest of the
|
||
storefronts, which the ASSETS binding can't enumerate.
|
||
- Consumables are granted and listed but never spent by gameplay, so `Count` only grows.
|
||
- Several routes (room keys, wishlist, equipment, room consumables/currencies) are
|
||
empty-list stubs pending their own stores.
|
||
- Game rewards pay a flat 25 XP into `progression`; levelling never happens (no curve) and
|
||
there is no daily XP cap beyond the hourly cooldown.
|
||
- The weekly-challenge gift is granted but not announced: the box appears in the gifts list
|
||
with no `GiftPackageReceived` notification, so the player sees it the next time the client
|
||
reads that list rather than the moment they finish the set. Same gap as gifting to another
|
||
player, and the same reason — the frame's payload shape hasn't been captured.
|