Files
recflare/apps/econ
2026-08-10 19:02:40 -04:00
..
2026-08-10 19:02:40 -04:00
2026-08-10 19:02:40 -04:00
2026-06-11 02:10:01 -04:00
2026-07-20 17:59:37 -04:00
2026-08-10 19:02:40 -04:00
2026-06-11 02:10:01 -04:00
2026-06-30 18:52:37 -04:00

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 (hourly, per type)
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), a consumable into the consumable table (each buy stacks a new instance); 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).

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) Whether every challenge must be finished before the Gift is claimable.
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 StartAtEndAt (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:

// "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 (g5u0weNLmkCLeUXFUVn74Qc1b49b83-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)"). Nothing grants it — the reward is preview only (see Known gaps).

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.

The reward payload itself is a stub: a successful claim records the cooldown, logs a game reward claimed line, and grants nothing, so a claim and an on-cooldown ask both answer the same empty list the client already accepts. Paying one out is the claimed !== null branch in the handler. Getting eligibility right first is the point — it's what stops a repeat ask paying twice once there's something to pay.

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, 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.
  • buyItem grants avatar-item and consumable drops; currency/xp drops aren't granted.
  • 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 gate correctly but pay nothing out — see the reward_status section.
  • Weekly-challenge completion is persisted, but the rotation's Gift is never granted — nothing watches for the last challenge finishing, and there is no claim endpoint.