Compare commits

..

151 Commits

Author SHA1 Message Date
dukedotdev 5f34028267 [rooms] load Studio export dependencies (#142) 2026-10-25 17:01:42 -04:00
Devin Zuczek b02a646892 [sql] improvements 2026-10-25 13:35:18 -04:00
Devin Zuczek 8397997234 [studio] rollout support 2026-10-24 15:00:49 -04:00
Devin Zuczek 4e2cdd87eb [studio] move cloud_build_key to secrets 2026-10-24 01:16:49 -04:00
Devin Zuczek d1e69bfd14 [auth] discord login 2026-10-24 00:28:10 -04:00
Devin Zuczek 09e40af6b7 [api] fix slow rooms badge query 2026-10-23 22:57:53 -04:00
Jake Vice f9600c1836 Pumple Evil Edition >:) (#138)
* Add Halloween overlay and walking pumpkin

* Add Pumple chaos Halloween minigame

---------

Co-authored-by: Mrmadman1 <fusionthemadman@gmail.com>
Co-authored-by: Devin Zuczek <devin.zuczek@gmail.com>
2026-10-23 17:45:12 -04:00
dukedotdev 3da35a8692 [studio] avatar authoring, managed prefab builds (#140) 2026-10-23 15:25:31 -04:00
Devin Zuczek 16e5f61160 [leaderboard] update leaderboard for 2026 changes 2026-10-22 14:58:29 -04:00
Devin Zuczek 20a947dcad [leaderboard] fix 2026 display at least 2026-10-22 14:35:21 -04:00
Devin Zuczek fbf7c923a6 [econ] room consumable token/currency purchase 2026-10-22 14:16:18 -04:00
Devin Zuczek 1d0e71d651 [api] adjust to match images v5 derived shape 2026-10-22 13:08:29 -04:00
Devin Zuczek 76db89c30c [econ] fix missing owned keys endpoint 2026-10-22 12:51:53 -04:00
Devin Zuczek 208178494a [clubs] announce delete 2026-10-22 12:46:27 -04:00
Devin Zuczek 2222a545ff [econ] owner currency 0 amount purchase 2026-10-22 12:32:54 -04:00
Devin Zuczek 605a35c36b [img] hardening 2026-10-22 12:21:21 -04:00
Devin Zuczek 3cf4b4029e [econ] fix room owner key purchase 2026-10-22 12:11:27 -04:00
Devin Zuczek 337367da52 [api] turn off backtraces 2026-10-22 12:11:27 -04:00
Devin Zuczek 7abef376b6 [storage] upload hardening 2026-10-21 22:50:07 -04:00
Devin Zuczek ae360e1e83 [img] fix potential xss 2026-10-21 22:14:43 -04:00
dukedotdev a17183f15c Add hosted Studio template distribution (#139) 2026-10-21 17:32:56 -04:00
Devin Zuczek ed45f38137 [2026] split 2026 to tachy hosts 2026-10-20 21:21:24 -04:00
Devin Zuczek affdcb85e3 [rooms] fix slow engagement counters 2026-10-20 21:03:54 -04:00
Devin Zuczek 5f3cdc5922 [rooms] fix slow room tag query 2026-10-20 16:36:07 -04:00
Devin Zuczek eab86ec223 [rooms] fix slow room role query 2026-10-20 16:25:51 -04:00
Devin Zuczek 71b0215ed3 [api] up invention limit to match client 2026-10-20 12:52:28 -04:00
Devin Zuczek 38bb9cff73 [link] store share links 2026-10-20 12:22:52 -04:00
Devin Zuczek 6bf297ed98 [www] add share links from invention/image 2026-10-20 12:09:17 -04:00
Devin Zuczek f04b9bd66b [chat] fix favorite thread 2026-10-20 11:31:28 -04:00
Devin Zuczek 2091483cc3 [econ] fix consumable notification 2026-10-20 10:25:03 -04:00
Devin Zuczek d918936795 [econ] fix some storefronts 2026-10-20 10:25:03 -04:00
Devin Zuczek be1b275a0a [econ] catalog fix 2026-10-20 10:25:03 -04:00
Devin Zuczek b1628f28e2 [econ] update storefronts 2026-10-20 10:25:03 -04:00
Devin Zuczek 05d7fe4c32 [2026] rollout support, to be short lived I suppose 2026-10-19 22:54:28 -04:00
dukedotdev a3291bc9ce [studio] Complete editor uploads and queued cloud builds (#135)
* [studio] Complete editor uploads and queued cloud builds

* [studio] Protect unpublished source and remove unused notification RPC
2026-10-19 11:04:58 -04:00
Devin Zuczek 81c372135c [www] update quest url 2026-10-16 23:01:15 -04:00
Jake Vice 02ca828a69 [studio] add get-build-count-for-user route (#134)
Co-authored-by: Mrmadman1 <fusionthemadman@gmail.com>
2026-10-16 22:51:25 -04:00
Devin Zuczek 2ba2ad3a4b [econ] add missing items courtesy studio87 2026-10-16 21:42:03 -04:00
Devin Zuczek 4c1b9ca9da [api] sharecam 2026-10-16 17:40:45 -04:00
Devin Zuczek 33b74d02c5 [lists] fix saved rooms 2026-10-16 15:39:18 -04:00
Devin Zuczek f24e35224c [api] fix a few other queries > 100 items 2026-10-16 13:30:17 -04:00
Devin Zuczek 6d905e1811 [apps] possible inventions fix 2026-10-16 12:46:55 -04:00
Devin Zuczek 5eac7480ae [www] better ban display 2026-10-16 10:46:10 -04:00
Devin Zuczek d87e1ca5ae [www] gate delete room on developer 2026-10-16 10:34:49 -04:00
Devin Zuczek c52b37037e [match/list] actually record room visits 2026-10-15 23:24:45 -04:00
Devin Zuczek f8a6338eaa [studio] do not enforce limits for staff 2026-10-15 15:05:21 -04:00
Devin Zuczek 972432d4d3 [api] add xp boost stub endpoint 2026-10-15 15:01:21 -04:00
Devin Zuczek e225a8a2f1 [api] stub out progression event record 2026-10-15 13:38:31 -04:00
Devin Zuczek 2a9ff1337a [econ] add getBalance endpoint 2026-10-15 13:04:39 -04:00
Devin Zuczek e7ee9ed444 [www] show moderated image 2026-10-14 15:21:37 -04:00
Devin Zuczek 6f8e1182e8 [econ] tweak xp rewards 2026-10-14 14:49:28 -04:00
Devin Zuczek 0f757b2c4f [econ] change token award to once per activity per day 2026-10-14 14:22:10 -04:00
Devin Zuczek eb5c0d28ff [auth] allow staff in rollout 2026-10-14 12:10:33 -04:00
Devin Zuczek 9902de39a4 [www] reset dorm button for 2023 2026-10-14 11:48:12 -04:00
Devin Zuczek 94b106c12f [econ] stub for consumables that do not exist here 2026-10-14 00:00:24 -04:00
Devin Zuczek ee725056d2 [www] add displayname change 2026-10-13 21:47:34 -04:00
Devin Zuczek 4f36146912 [accounts] mirror regex from old client for display name 2026-10-13 21:47:34 -04:00
Devin Zuczek 5ba57210b9 [match] restrict matchmaking to event rooms only 2026-10-13 21:47:34 -04:00
Devin Zuczek ba6c703050 [2026] consumables 2026-10-13 21:47:34 -04:00
Devin Zuczek 33d235f8ad [clubs] revert this call 2026-10-13 21:47:34 -04:00
Devin Zuczek 6d4156bd67 [2026] fix a few wire shapes 2026-10-13 21:47:34 -04:00
dukedotdev bf817977ec [econ]: add 51 missing avatar item variants (#133) 2026-10-13 17:23:18 -04:00
Devin Zuczek cf6868a753 [api] testing real progression event 2026-10-13 10:52:48 -04:00
Devin Zuczek 810e3b8f27 [econ] stub out new consumables endpoint 2026-10-13 10:49:08 -04:00
Devin Zuczek 7d509f6230 [clubs] fix home 404 2026-10-13 10:48:52 -04:00
Devin Zuczek c48cefda26 [econ] some more lil stubs 2026-10-13 10:36:34 -04:00
Devin Zuczek c1444f906d [api] add progression events from https://github.com/Rec-Archive/RRArchive/tree/43c097e0255d8a6816af9f3842e060afe4c1e5f7/ProgressionEvents 2026-10-13 10:11:48 -04:00
Devin Zuczek 961dae5c7a [econ] add battle pass stub 2026-10-13 10:07:05 -04:00
Devin Zuczek 92c93b0a58 [match] 2026 verifylock endpoint 2026-10-13 09:44:14 -04:00
dukedotdev 1f4c3e5fc0 [cards] Authenticate channel routes and return channel lists (#121) 2026-10-13 00:52:03 -04:00
Devin Zuczek 6f9348222a [rooms] boosts stub 2026-10-13 00:48:30 -04:00
Devin Zuczek e06eba6929 [match] missing endpoint for 2026 that apparently breaks game 2026-10-12 22:17:51 -04:00
Devin Zuczek 1dd5fd409e [2026] relax versioncheck to major versions 2026-10-12 21:28:29 -04:00
Devin Zuczek bd15e30bfc [rooms] fix imported unity assets 2026-10-12 21:18:51 -04:00
Devin Zuczek dfe5071878 [www] fix role sync not grabbing all roles 2026-10-12 16:30:40 -04:00
Devin Zuczek a0345af765 [2026] rollout support 2026-10-12 16:17:14 -04:00
dukedotdev 4d6a88f20e [chat] Return the 2026 party message contract (#122) 2026-10-12 14:23:15 -04:00
dukedotdev aa5e83764e [commerce] Add the purchase restriction contract (#123) 2026-10-12 14:22:46 -04:00
dukedotdev 58a9b08548 [ai] Serve the 2026 companion fallback without realtime credentials (#118) 2026-10-12 14:21:25 -04:00
dukedotdev 0e699cab2b [match] Persist and broadcast presence visibility (#127)
* [match] Persist visibility changes and refresh own-player presence

* [match] Propagate saved visibility to friends
2026-10-12 14:20:29 -04:00
dukedotdev 2a41b93c13 [econ] Fix three 2026 response contracts (#125)
* [econ] Wrap 2026 room offer purchase counts in a result envelope

* [econ] Include the built-in identity flag on owned 2026 consumables

* [econ] Return the 2026 empty subscription season DTO
2026-10-12 14:19:23 -04:00
dukedotdev a3a5851373 [rooms] Support 2026 Studio loading of legacy baked bundles (#129)
* [rooms] Preserve legacy Unity bundle save DTOs for the Studio loader

* [rooms] Resolve JSON baked bundle requests with exact target and version
2026-10-12 14:17:43 -04:00
Devin Zuczek 585cf68637 [www] dev/mod sandbox support 2026-10-10 16:27:35 -04:00
Devin Zuczek dd687f02d7 [rooms] fix issue where dev/mod cant make private instance of unowned room 2026-10-10 15:13:22 -04:00
Devin Zuczek 9a3ab3e50c [rooms] admin switch to exclude rooms from lists 2026-10-10 14:55:45 -04:00
Devin Zuczek c049c8f14d [rooms] obey excludefromlists 2026-10-10 14:35:11 -04:00
Devin Zuczek ab948f1f28 [2026] alpha 2026-10-10 14:34:58 -04:00
Devin Zuczek e9af56aff4 [econ] add button for supporter boosts 2026-10-10 01:05:42 -04:00
Devin Zuczek ef65b505cc [studio] prefix assets 2026-10-09 01:37:28 -04:00
Mrmadman1 f9beba0dae Add Halloween overlay and walking pumpkin (#117) 2026-10-09 00:08:33 -04:00
Devin Zuczek 74bab0f552 [studio] tweaks for old unity multipart handling unquoted boundaries 2026-10-08 23:29:52 -04:00
Devin Zuczek 3916e40c2e [econ] influencer endpoints for fun 2026-10-08 14:53:31 -04:00
Devin Zuczek cebeafeb40 [api] add influencer badges 2026-10-08 14:34:04 -04:00
Devin Zuczek 6b8c908771 [studio] rate limit stub 2026-10-08 14:27:54 -04:00
Devin Zuczek 7ec295ef33 [econ] purchase offer delete 2026-10-08 13:03:26 -04:00
Devin Zuczek c6e035e4e1 [match] apply subroom capacity to instance cap 2026-10-08 11:45:12 -04:00
Devin Zuczek 1415ebcc06 [match] improve presence delete on expiry/logout 2026-10-08 10:37:18 -04:00
Devin Zuczek e54224dbd0 [rooms] fixup room save endpoint to return same as room endpoint (no effect, just QOL) 2026-10-08 00:44:42 -04:00
Devin Zuczek 909bcebb38 [econ] bypass purchase offers for contributors 2026-10-07 15:03:53 -04:00
Devin Zuczek f71d55c4d3 [rooms] room progression 2026-10-07 14:49:50 -04:00
Devin Zuczek 6885c2eec7 [match] tweak policy change for mitm 2026-10-07 14:43:21 -04:00
Devin Zuczek e56cfa802e [rooms] fix subroom permissions UI for some photon perms 2026-10-07 00:15:09 -04:00
Devin Zuczek 853050c2f0 [rooms] fix permissions update 2026-10-06 23:25:23 -04:00
Devin Zuczek d8a1c4b0da [rooms] fix subrooms permission gate 2026-10-06 22:06:10 -04:00
Devin Zuczek e1391b59b3 [rooms] fix photon access 2026-10-06 16:42:38 -04:00
Devin Zuczek 525e6ee24e [econ] stub active consumable endpoint (?) 2026-10-06 14:00:25 -04:00
Devin Zuczek 19fe7ac7c3 [www] allow changing unity asset ID 2026-10-06 11:19:14 -04:00
Devin Zuczek 7dd88a0198 [rooms] fix get subroom save endpoints for co-owners 2026-10-06 10:48:22 -04:00
Devin Zuczek ae0f2f4cc9 [www] increase role drop 2026-10-05 17:45:30 -04:00
Devin Zuczek aceaf54519 [www] discord link sync 2026-10-05 17:27:00 -04:00
Devin Zuczek 4e3301ade8 [rooms] support for mitm 2026-10-05 17:07:42 -04:00
Devin Zuczek 032e37733d [econ] wishlist 2026-10-05 16:22:55 -04:00
Devin Zuczek 53844c807f [studio] add missing migration script 2026-10-05 14:42:01 -04:00
Crasho 26270e9c25 [studio] beta studio (#114)
* rrs stuff

* studio service yay

* rrs whitelist

* moree studiooooooo fellowwww

* rec room studio room loading yay

* fixes yay

* fix 2 because idk  yayuh

* Update cdn.app.ts

* ccontent length fix?

* Preserve content-length and upgrade metadata

Return a real zero-filled byte array for CDN HEAD responses so the runtime preserves Content-Length while still sending no payload. In notify, re-create the Request with its original headers, set OWNER_HEADER from the validated token, and forward that Request to hub.fetch to avoid losing WebSocket upgrade metadata (which could trigger 500s in Durable Objects). These changes ensure the editor's size checks and WebSocket upgrades remain reliable.

* fix x524234

* shirt thing?

* [auth] remove grant table as we already have audit_log for now

* rename table to generic device_grant

* cleanup/lint

---------

Co-authored-by: Devin Zuczek <devin.zuczek@gmail.com>
2026-10-05 14:39:22 -04:00
Devin Zuczek 0900bcac25 [www] token drop performance improvement 2026-10-05 13:10:05 -04:00
Devin Zuczek 8ea72fcdc5 [match] put players with room roles in same private instance when unpublished 2026-10-05 11:29:15 -04:00
Devin Zuczek f7167358d8 [rooms] fix cloud variables 2026-10-03 21:53:23 -04:00
Devin Zuczek ec423b2eb9 [econ] fix currency name 2026-10-03 20:29:47 -04:00
Devin Zuczek 739176a3ed [econ] room storefront purchaseables 2026-10-03 13:57:32 -04:00
Devin Zuczek 41d0b0543a [api] add image moderation and v6 endpoint 2026-10-03 13:34:07 -04:00
Devin Zuczek 625cec113b [match] on 2023 do not room switch 2026-10-03 13:34:07 -04:00
Devin Zuczek 6d9a871e2c [rooms] record invention room usage 2026-10-03 13:34:07 -04:00
Devin Zuczek fd2e266e50 [econ] some invention stats 2026-10-03 13:34:07 -04:00
Devin Zuczek bfe88a9837 [inventions] cheer endpoint 2026-10-03 13:34:07 -04:00
Devin Zuczek a2f4e4959e [api] fix various issues with inventions 2026-10-03 13:34:07 -04:00
Devin Zuczek 6167925ee0 [www] enable plus for a user 2026-10-03 13:34:07 -04:00
Devin Zuczek 10973d9685 [match] fix issue where private room subroom join was not marked as private 2026-10-03 13:32:37 -04:00
Devin Zuczek 602d6cea90 [www] discord per-role token drop 2026-10-03 12:14:41 -04:00
Devin Zuczek 1458df1664 [econ] key purchasing 2026-10-01 14:35:34 -04:00
Devin Zuczek b09f014b6d [econ] fix key images 2026-10-01 13:29:04 -04:00
Devin Zuczek de607b7632 [econ] room key award 2026-10-01 00:29:11 -04:00
Devin Zuczek 0636f535f3 [events] account events endpoint 2026-09-30 23:55:09 -04:00
Devin Zuczek 4319f2942b [clubs] add account clubs endpoint 2026-09-30 23:45:08 -04:00
Devin Zuczek e00103c6b3 [msc] make playersettings more efficient 2026-09-30 18:04:56 -04:00
Devin Zuczek 90c808c4a3 [match] dont allow invites into unpublished rooms 2026-09-30 17:17:11 -04:00
Devin Zuczek 7e10143454 [rooms] better gating for unpublished rooms 2026-09-30 17:15:42 -04:00
Devin Zuczek 56ccb23412 [econ] fix missing hairs and other items 2026-09-30 12:09:42 -04:00
Devin Zuczek d543b301e2 [match] allow friends to join friends in public 2026-09-30 11:18:37 -04:00
Devin Zuczek fec3fc5258 [api] update progression stub 2026-09-30 10:52:43 -04:00
Devin Zuczek 08d06004ee [events] sort by attendance 2026-09-30 10:51:54 -04:00
Devin Zuczek a5ae46fac4 [api] add img delete and modify 2026-09-29 23:44:47 -04:00
Devin Zuczek ef5bfece59 [api] only show public images in slideshow 2026-09-29 23:09:36 -04:00
Devin Zuczek e8dcaa3051 [api] fix ban duration display 2026-09-29 23:05:46 -04:00
Devin Zuczek 94816b63b1 [api] prevent inflation via inventions 2026-09-28 00:23:56 -04:00
Devin Zuczek ef3c5100fe [econ] fighting inflation 2026-09-28 00:03:50 -04:00
Devin Zuczek 83ccb4bc12 [api] prevent money laundering 2026-09-27 23:54:12 -04:00
Devin Zuczek ab15eb9768 [www] flag to disable websignups 2026-09-27 23:02:42 -04:00
Devin Zuczek a499196692 [api] fix a relationship index 2026-09-27 22:24:28 -04:00
Devin Zuczek b090dc105a [auth] device id limits 2026-09-27 22:20:45 -04:00
Devin Zuczek 91db9ff82b [rooms] fix room instance index 2026-09-27 21:27:33 -04:00
Devin Zuczek 3324a935b4 [www] auto reward on join 2026-09-27 21:04:59 -04:00
267 changed files with 82313 additions and 15220 deletions
+105 -26
View File
@@ -37,27 +37,6 @@ RECFLARE_DOMAIN=rec.example.com
# spliced in at deploy time. Required to deploy any worker.
# RECFLARE_SECRETS_STORE=00000000-0000-0000-0000-000000000000
# OPTIONAL: run the workers on a libSQL server (Turso) INSTEAD of the D1 database above. Both
# are SQLite; the schema, the migrations and the workers are the same, and the D1 database
# stays bound (and RECFLARE_D1 still required) so switching back is unsetting this line and
# redeploying. Leave both unset to use D1, which is the default.
#
# The URL is the one Turso shows for the database (`turso db show <name> --url`), as-is:
#
# RECFLARE_LIBSQL_DB_URL=libsql://<database>-<org>.turso.io
#
# The token (`turso db tokens create <name>`) is the one entry in this file that IS a secret.
# It is never passed as a var: `just deploy` puts it as an encrypted worker secret, and
# `just migrate` / `runx admin --remote` read it from here to reach the database directly.
#
# RECFLARE_LIBSQL_DB_AUTH_TOKEN=eyJ...
#
# `just dev` and the tests ignore both and stay on the local D1. Setting the URL changes what
# `just migrate` and every `--remote` operator command talk to — the libSQL database, not D1 —
# so it is one switch for the whole deployment: the workers, their migrations and the tooling
# all move together, which is the only sane state for fifteen workers sharing one database.
# To carry existing data over, see "Using Turso instead of D1" in DEPLOYING.md.
# --- Server tuning (all optional; the shown value is the built-in default) ---
# Everything below is passed to the workers as a variable, named without the RECFLARE_
# prefix: RECFLARE_STARTING_TOKENS becomes STARTING_TOKENS. Every worker gets every knob —
@@ -83,8 +62,20 @@ RECFLARE_DOMAIN=rec.example.com
# ...PER_IP counts accounts per signup IP — coarse, since a household, NAT or campus
# network shares one address. This is the one to raise (or zero out) if real players
# report being locked out.
# ...PER_DEVICE_ID counts accounts per client device id (the game's per-install hash,
# sent as `device_id`) — narrower than an IP and unmoved by a VPN, which is what the
# IP cap alone can't stop. Client-chosen, so it catches the stock client, not a script.
# Signups that post no device id (the website's) aren't counted.
# RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID=3
# RECFLARE_MAX_ACCOUNTS_PER_IP=3
# RECFLARE_MAX_ACCOUNTS_PER_DEVICE_ID=3
# Whether accounts can be created from the WEBSITE (`auth` and `www`) — a password
# account with no Steam/Meta identity, which nothing but the caps above can limit, and a
# VPN or Tor exit resets the IP one. `on` or `off`; unset is OFF. Signing in to an
# existing password account is never affected, nor is creating one by launching the game.
# Turning it on also needs the Turnstile keypair in the Secrets Store (see www).
# RECFLARE_PASSWORD_SIGNUP=off
# How far a ban reaches beyond the account it was handed to (`match` and `auth`), as a
# comma-separated list out of `ip` and `platform` — or `off` for neither. Unset means
@@ -126,7 +117,8 @@ RECFLARE_DOMAIN=rec.example.com
# it came through the room list, a club's clubhouse, or a party. Substitution is a single
# hop (2=3,3=2 swaps the two rooms), a requested subroom is dropped in favour of the
# substitute's default one, and a target that doesn't exist leaves the original room in
# place. Following a friend or joining a specific instance is unaffected — those join a
# place. Only client builds newer than 20230414 are switched; the 2023 client and older
# enter the room they asked for. Following a friend or joining a specific instance is unaffected — those join a
# live instance, which is already in whichever room it was created in.
# RECFLARE_ROOM_REDIRECTS=2=MyHub
@@ -160,6 +152,21 @@ RECFLARE_DOMAIN=rec.example.com
# same format. Its ids are generated as `dev-1`, `dev-2`, …. Unset, sandbox accounts
# use the pool above.
# RECFLARE_TACHYON_HOST_PORT_SANDBOX=198.51.100.12:7777,198.51.100.12:7778
#
# The pool players on a build under staged rollout (RECFLARE_ROLLOUT_GAME_VERSIONS below,
# by default `20260323`; see RECFLARE_DISCORD_ROLLOUT_ROLE_IDS too) are served from, same format,
# ids `rollout-1`, `rollout-2`, … — the new build gets its own voice servers while it rolls
# out. A sandbox account on that build still gets the sandbox pool. Unset, rollout players
# use the pool above.
# RECFLARE_TACHYON_HOST_PORT_ROLLOUT=198.51.100.13:7777
#
# WHICH builds are served that rollout pool, when it should not simply follow the sign-in
# gate: same format as RECFLARE_ROLLOUT_GAME_VERSIONS (builds, commas; `off` for none;
# `20260323` and `20260323.14` name the same build). Unset, the rollout pool goes to the
# builds under staged rollout, so ending the rollout (ROLLOUT_GAME_VERSIONS=off) also moves
# those players onto the live pool. Set this to keep the 2026 build on its own voice
# servers after it opens to everyone — the 2025 and 2023 builds stay on the pool above.
# RECFLARE_TACHYON_ROLLOUT_GAME_VERSIONS=20260323
# The Photon region every session is pinned to (`match`). Unlike the app ids above this
# does default, to `us` (us-east1) — an instance stamped with an empty region is one the
@@ -220,9 +227,12 @@ RECFLARE_DOMAIN=rec.example.com
# RECFLARE_DISCORD_GUILD_ID=1077000000000000000
# RECFLARE_DISCORD_BENEFITS_ROLE_IDS=1077000000000000001,1077000000000000002
#
# Also add https://<your domain>/claim to the app's Redirects in the Discord developer
# portal, or the exchange is refused. Granting Plus takes effect on the player's NEXT
# sign-in. `runx admin grant-plus` sets it directly, with no Discord involved.
# Also add https://<your domain>/claim AND https://<your domain>/login/discord to the app's
# Redirects in the Discord developer portal, or the exchange is refused. The second is the
# login page's "Sign in with Discord" (same switch): it signs into the account the Discord is
# linked to, or CREATES one — the player then sets a password on the website to sign in to
# the game, including on a Quest with no Meta account. Granting Plus takes effect on the
# player's NEXT sign-in. `runx admin grant-plus` sets it directly, with no Discord involved.
#
# OPTIONAL: the daily role refresh (www cron) re-reads claimed members' roles through a
# bot token, so `platform_account.role` tracks the guild instead of the day they claimed.
@@ -235,6 +245,52 @@ RECFLARE_DOMAIN=rec.example.com
# The binding has to resolve for `www` to deploy, so create it with a placeholder even if
# you don't want the sweep — it then does nothing. It never touches hasPlus.
# Which client BUILDS this server serves (`auth` and `api`, with `match` reading the rollout
# list). Both are comma-separated lists of builds — the `YYYYMMDD` of the client version;
# a copied `20250718.01` is read as `20250718` — or `off` for an empty list. Unset means
# the defaults in packages/domain/src/presence-db.ts (SUPPORTED_GAME_VERSIONS: the 2023
# and 2025 builds plus the alphas; ROLLOUT_GAME_VERSIONS: `20260323`).
#
# A build outside SUPPORTED is told to update by `api`'s version check AND refused a token
# by `auth` (every grant, staff included), so dropping a build here is how its players are
# made to upgrade. A build in ROLLOUT is served too, whether or not SUPPORTED names it, but
# only to staff and the Discord roles below. The lifecycle these exist for:
# 1. Rollout (both unset): the 2026 build is for staff and supporters only.
# 2. General availability: ROLLOUT_GAME_VERSIONS=off — the 2026 build opens to everyone
# (and joins the live voice pool unless TACHYON_ROLLOUT_GAME_VERSIONS above keeps it).
# 3. Forced upgrade: SUPPORTED_GAME_VERSIONS=20230414,20260323 (with `off` above) — the
# 2025 build is refused and has to update.
# To pull a rollout back instead, list neither: SUPPORTED_GAME_VERSIONS=20230414,20250718
# with ROLLOUT_GAME_VERSIONS=off.
# RECFLARE_SUPPORTED_GAME_VERSIONS=20230414,20250718,20260323
# RECFLARE_ROLLOUT_GAME_VERSIONS=20260323
# OPTIONAL: a staged rollout of a new client build to Discord supporters (`auth`). The
# Discord ROLE ids whose holders may sign in on a ROLLOUT-GATED build — by default
# `20260323`, see RECFLARE_ROLLOUT_GAME_VERSIONS above. A login
# posting that build as `ver` is refused ("this build is limited to Discord supporters")
# unless the account is staff (developer or moderator — always let in) or its Discord link
# holds one of these roles; every other build is never touched. Same rules as ROLE_IDS above: snowflakes, not names; commas, no spaces; ANY one
# qualifies. Roles come from the snapshot the benefits claim and the daily sweep keep, so a
# player has to have claimed on the website, and a role granted today opens the gate once
# the sweep runs (or they re-claim). UNSET — or parsing to no roles — CLOSES the gate: nobody
# but staff signs in on the gated build until a role is named here. Accounts can't be CREATED on a
# gated build (a new account has no Discord link); players make one on a released build,
# link Discord at /claim, and come back.
# RECFLARE_DISCORD_ROLLOUT_ROLE_IDS=1077000000000000001,1077000000000000002
# OPTIONAL: a staged rollout of Rec Room Studio to Discord roles (`auth`, `studio`, `www`).
# The Discord ROLE ids whose holders get Studio upload access — `auth` stamps the
# `betastudio` role into their token exactly as it does for an account staff flagged
# `hasStudio` on the website, and `studio`'s access lookups and the website's Studio
# settings page say "granted" for them too. The flag keeps working beside this, so staff
# can still add individuals. Same rules as ROLE_IDS above: snowflakes, not names; commas, no
# spaces; ANY one qualifies; roles come from the snapshot the benefits claim and the daily
# sweep keep, so a newly granted role counts once the sweep runs (or the player re-claims),
# and the player's Studio token picks it up on their next sign-in or refresh. UNSET — or
# parsing to no roles — grants nobody: the flag alone decides.
# RECFLARE_STUDIO_ROLLOUT=1077000000000000001,1077000000000000002
# OPTIONAL: a token gift for Discord supporters (`econ` cron). Maps Discord ROLE ids to the
# RecCenterTokens a holder is handed in a gift box EVERY TIME THE CRON FIRES, as
# `<roleId>=<tokens>` pairs. How often is the schedule's call: `triggers.crons` in
@@ -244,5 +300,28 @@ RECFLARE_DOMAIN=rec.example.com
# snowflakes, not names; commas, no spaces. Roles are read from the snapshot the benefits
# claim and the daily sweep above keep, so it reaches only players who have claimed on the
# website, and is only as fresh as that sweep (with a bot, the morning's; without one, the
# day they claimed). Unset — or parsing to no roles — leaves the cron a logged no-op.
# day they claimed). The website also pays the same box ONCE, on the spot, the first time an
# account links its Discord, so a new supporter isn't waiting for the next run. Unset — or
# parsing to no roles — leaves the cron a logged no-op and the first-link gift off.
# RECFLARE_DISCORD_ROLE_TOKENS=1077000000000000001=2500,1077000000000000002=10000
# Rec Room Studio (`studio`). Cloud builds are configured OUTSIDE this file: the credential
# the build host (recflare/cloud-building, its STUDIO_WORKER_KEY) presents to claim jobs
# lives in the shared Secrets Store, like JWT_SECRET above — not as a var, since every var
# here reaches every worker in plaintext. The binding has to resolve for `studio` to
# deploy, so create it even if you run no build host: the literal `off` disables cloud
# builds (the editor is told "Cloud building is not configured").
#
# wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY \
# --scopes workers --remote
#
# The knobs ARE vars. TARGETS is the Unity build targets a job is built for (0 Windows,
# 2 Android), commas, no spaces; the build host needs the matching editor modules.
# TEMPLATE_VERSION is the Studio template a job is built against, and the build host
# claims ONLY jobs for its own configured version (171 by default there, 169 here), so
# a mismatch leaves jobs queued forever with no error on either side — set this to the
# template the host has installed. DISTRIBUTION_MANIFEST is the R2 key of the template
# catalog the editor downloads from; unset disables downloads (see apps/studio/README.md).
# RECFLARE_CLOUD_BUILD_TARGETS=0,2
# RECFLARE_CLOUD_BUILD_TEMPLATE_VERSION=169
# RECFLARE_STUDIO_DISTRIBUTION_MANIFEST=roomunityasset/studio-distribution/catalog-v171.json
+60 -21
View File
@@ -37,10 +37,6 @@
- Syncpack ensures dependency version consistency
- Turborepo enables parallel task execution and caching
- Workers configured via `wrangler.jsonc` with environment variables
- The shared `recflare` database is D1 by default, or a libSQL server (Turso) when
`LIBSQL_DB_URL` is set: `withDatabase` (`packages/domain/src/libsql-db.ts`) wraps each
worker's default export and swaps `env.DB` for an adapter with the D1 API, so query code
only ever sees `D1Database`. Migrations and `runx` tooling follow the same switch.
- Each worker has `context.ts` for typed environment bindings
- Integration tests in `src/test/integration/`
- Workers use `nodejs_compat` compatibility flag
@@ -51,15 +47,30 @@
<game-clients>
Supported client builds live in `SUPPORTED_GAME_VERSIONS`
(`packages/domain/src/presence-db.ts`); `GAME_VERSION` is the default the stack targets.
Those lists are DEFAULTS: the `SUPPORTED_GAME_VERSIONS` and `ROLLOUT_GAME_VERSIONS` worker
vars (`RECFLARE_*` in `.env`, reaching every worker) override them — read through
`supportedGameBuilds(env)` / `rolloutGameBuilds(env)`, never the constants, in anything
that gates on "is this build served / under rollout" (`auth` tokens, `api` versioncheck,
`match` rollout voice pool). A build outside the supported set is told to update by `api`
AND refused a token by `auth`; a rollout build is always served, but gated to staff and
Discord roles until `ROLLOUT_GAME_VERSIONS=off`. The rollout VOICE pool
(`TACHYON_HOST_PORT_ROLLOUT`) follows that list by default but `TACHYON_ROLLOUT_GAME_VERSIONS`
overrides it (`tachyonRolloutGameBuilds` in `match`), so a build can stay on its own Tachyon
servers after its rollout ends.
- `20230414` — default, official. Manifest `7859140924515540835` (2023).
- `20250718.01` — beta, official. Manifest `1151455856673601091`; reaches this server via
the [patch-2025](https://github.com/recflare/patch-2025) patch.
- `20250424.01`, `20231207`, `20230616` — alpha.
- `20250718` — beta, official (the `20250718.01` client). Manifest `1151455856673601091`;
reaches this server via the [patch-2025](https://github.com/recflare/patch-2025) patch.
- `20260323` (`20260323.14`), `20250424` (`20250424.01`), `20231207`, `20230616` — alpha.
Builds are date-stamped (`YYYYMMDD[.NN]`) so they order as plain strings; several
surfaces gate on "newer than `20230414`" (econ storefront catalog, `api` event tags,
`rooms` featured rooms) rather than on an explicit list.
A client reports `YYYYMMDD[.NN]`; the `.NN` is a same-day point release of the same build
and decides nothing. `gameBuild` strips it, the lists above hold bare dates, every gate
compares builds (`validateAndGetBuild` in `@repo/jwt` is the token form), and the
`room_instance` key is stored as the build so `20250718.01` and `20250718.2` share a
session. The token's `rn.ver` and presence's `appVersion` still echo the client's full
version. Builds order as plain strings; several surfaces gate on "newer than `20230414`"
(econ storefront catalog, `api` event tags, `rooms` featured rooms) rather than on an
explicit list.
</game-clients>
<code-style>
@@ -104,8 +115,8 @@ inconsistency here without checking the client first.
- The room save (`rooms`: `POST /subrooms/:sid/data`) is the ONE exception to that shape:
`value` is `{ room, subRoomDataSave }`, and `error` is NULL rather than `""`. The
`subRoomDataSave` is camelCase with a different field set from the PascalCase
`CurrentSave` embedded in the room (no persistence/OM/UGC versions, no moderation state,
no asset arrays; but `unityAsset`/`unityAssetHash`). Don't unify the two projections.
`CurrentSave` embedded in the room (no moderation state or asset arrays;
version fields are camelCase, alongside `unityAsset`/`unityAssetHash`). Keep the projections distinct.
- A subroom's saved scene loads from `CurrentSave.DataBlob` (`rooms`: `GET /rooms/:id`),
NOT the flat `DataBlob` on the subroom — a subroom with no `CurrentSave` silently loads
nothing. The key must be present (null before the first publish); read it via
@@ -119,15 +130,24 @@ inconsistency here without checking the client first.
`StagedSubRoomDataSaveId` carries no subroom context), and nothing is overwritten, so
`…/saves` is real history and `publish_save` doubles as restore-a-save. There
is no `GET …/subrooms/:sid/data`; only the POST (the room save) exists on that path.
`GET …/saves/:saveId` is the detail behind a list row, under the same gate, but in the
CAMELCASE projection the room save's response uses — not the PascalCase rows the list
serves. Three shapes of one save; keep them straight.
- Both save reads (`rooms`: `…/saves` and `…/saves/:saveId`) are auth-gated and readable by
the room's CREATOR or by anyone whose live `presence` row puts them in that room — not by
co-owners as such (a co-owner passes only by standing there). They list unpublished
staged saves, so they aren't public; but a visitor resolves which version an instance is
running from this list, so creator-only locks them out of loading the room. The grant
expires with the presence row.
`GET …/saves/:saveId` is the detail behind a list row, under the same gate, and a
PASCALCASE row like the list serves — NOT the camelCase projection the room save's
response uses (served that, the client showed a save with every value missing). It
differs from the list row only in how it names bundles: dependency entries in `UnitySubAssets`
are `{ UnityAssetId, UnityAsset, UnityAssetHash }`, the main bundle appears only at top-level
`UnityAsset`/`UnityAssetHash`, and there is no `Tags`. Three shapes of one save; keep
them straight.
- The save reads (`rooms`: `…/saves`, `…/saves/no_unity_assets` and `…/saves/:saveId`) are
auth-gated and readable by the room's creator, a CO-OWNER, anyone whose live `presence`
row puts them in that room, or STAFF (a `developer`/`moderator` token). They list
unpublished staged saves, so they aren't public; but a co-owner opens the save history from
the room's settings without being in the room (creator-only showed them the buttons and a
list that 403'd), and a visitor resolves which version an instance is running from this
list, so managers-only locks them out of loading the room. The presence grant expires with
the presence row. The client reads `…/saves/no_unity_assets` BEFORE it matchmakes a private
instance, when the caller has no presence in the room yet, so presence alone cannot let a
non-owner create one: a developer creating a private instance of a room they hold no role
on was 403'd there until staff were let through.
- A room save writes ONLY to the subroom and its save row — never to the room. Everything
the body carries describes that one revision: `Description` is the save comment shown in
`…/saves`, and `PersistenceVersion`/`InventionUsage` describe the scene just saved (the
@@ -273,6 +293,25 @@ inconsistency here without checking the client first.
every non-avatar drop with `AvatarItemType: null` for this reason; `boxAvatarItemType` applies
it on every surface, so don't coalesce it with `?? 0` anywhere a box is built. This holds for
every worker that mints a box, not only `econ`.
- A ban's `Duration` (`api`: `/api/PlayerReporting/v1/moderationBlockDetails`, and the
`ModerationKick` frame `www`'s staff ban sends) is the seconds LEFT as of the response — the
client counts it down from receipt. It is NOT the ban's full span paired with
`TimeoutStartedAt` (start + duration): served that way, the screen read correctly only in
the kick frame, sent at the instant the span and the remainder coincide, and every later
sign-in showed the whole ban still to run. `TimeoutStartedAt` stays null for a ban; where
other implementations set it at all, it names the start of a voice-chat timeout. Other
implementations with timed bans keep their set-time private and serve the remainder on
every read; `banned_at` here is the staff panel's audit trail, not the client's.
- `unity_asset` holds two kinds of bundle under one shape: Rec Room Studio builds (UGC, the
`studio` worker writes them, filenames `studio/<roomId>/…`) and FIRST-PARTY bundles captured
from the official game and loaded by hand (`unity/<roomId>/…`). Only the Studio ones may be
folded onto `CurrentSave.UnitySubAssets` (`rooms`: `GET /rooms/:id`, the save reads) —
`attachStudioUnityAssets` filters on `isStudioBundleFilename`. The 2025 client resolves a
first-party bundle through `POST /unity_assets/baked/bulk` (which serves both kinds); handed
it inline in the five-key `{ Target, Version, Filename, Hash }` shape it takes that instead,
cannot name the file (its decoder wants `UnityAsset`/`UnityAssetHash`), and the room loads
nothing. Every imported first-party room broke when the splice moved from the Studio-only
table to `unity_asset`, with every response looking correct.
- Accessibility is sent as the `RoomAccessibility` enum NAME on
`rooms` `PUT /rooms/:id/subrooms/:sid/accessibility` (`accessibility=Private`), not the
ordinal the room-level `/rooms/:id/accessibility` takes. The enum has five members
+41 -4
View File
@@ -51,6 +51,19 @@ bun runx admin grant-developer --account 1 --revoke
bun runx admin grant-moderator --username alice --remote
```
### `grant-studio` — grant or revoke Rec Room Studio upload access
Sets the account's `hasStudio` flag, which `auth` stamps into the token as the
`betastudio` role — the claim RecFlare Studio reads as permission to upload. Staff
normally manage this from the website (`/settings/recroomstudio`); this is the
operator's way in. Takes effect on the player's next Studio sign-in or refresh. To open
Studio to a whole Discord role instead, name it in `RECFLARE_STUDIO_ROLLOUT` (`.env`).
```sh
bun runx admin grant-studio --username alice
bun runx admin grant-studio --account 42 --revoke
```
### `reload-plus` — credit every Plus subscriber's tokens
Also exposed directly as `just reload-plus`.
@@ -76,17 +89,41 @@ This command targets the `balance` table, so it runs under `apps/econ`'s wrangle
with `--local` that is econ's dev D1 state, which needs both `balance` and a migrated
`account` table in it.
### `catalog load` — reload the item catalog
```sh
just catalog-load # sf3-2025.json → local (merge)
just catalog-load --remote # production
just catalog-load --replace # also drop rows the file no longer lists
just catalog-load --dry-run # build and validate, write nothing
bun runx catalog check # validate the store file without touching a database
```
Fills econ's `catalog` table from `apps/econ/static/storefronts/sf3-2025.json`, the 2025
store as the game is served it, and nothing else. Re-run it whenever that file changes (after
`runx storefront build`). Each row's `catalog_id` is its listing's `PurchasableItemId` and its
`listing` column the listing verbatim, which is what
`POST /api/storefronts/v1/purchasableitems` serves. Avatar items, consumables, skins and
first-party custom avatar items all load; loot boxes, token bundles and consumable packs do
not. A merge leaves rows the file no longer lists in place with no `catalog_id` or `listing`;
`--replace` deletes them. The inventory does not read this table, so either is safe.
### `cai-load` — load the first-party custom avatar items
```sh
bun runx admin cai-load # apps/econ/static/db/2025-1-cai.json → local
bun runx admin cai-load # 2025-1-cai.json + studio87-extras.json → local
bun runx admin cai-load --remote
bun runx admin cai-load --file path/to/export.json --dry-run
bun runx admin cai-load --file path/to/export.json --dry-run # --file takes one or more
just cai-load --remote # the same, as a recipe
```
Loads an export of `CustomAvatarItem` records (a JSON array, a `{ Results }` page, or one
record) into the `custom_avatar_item` table, each stored as the row's JSON with its
Loads exports of `CustomAvatarItem` records (each a JSON array, a `{ Results }` page, or one
record) into the `custom_avatar_item` table. With no `--file` it reads
`apps/econ/static/db/2025-1-cai.json`, then `studio87-extras.json` — the official-store items
the 2025 export lacks, fetched from another server's `customAvatarItems/v1/bulk` and priced
from the storefront dump (an item it lists with no price is free). A later export only ADDS:
an id an earlier one already holds is skipped, so the extras never overwrite a 2025 item. A
duplicate within one export is refused. Each record is stored as the row's JSON with its
`CreatorAccountId` forced to 1, the Coach account, whatever the export said: that is what
files it as stock content (the storefront tab searches by creator) and what a purchase
pays. Each save's `ThumbnailFileName` is put under `avatar/`, where this server serves
+11 -36
View File
@@ -140,6 +140,17 @@ wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes
> with `wrangler secrets-store secret update` — no redeploy needed, the worker reads
> the secret per request.
The `studio` worker binds one more, `CLOUD_BUILD_KEY`: the credential the Rec Room
Studio cloud-building host ([recflare/cloud-building](https://github.com/recflare/cloud-building),
its `STUDIO_WORKER_KEY`) presents to claim queued builds. Same rule — it must exist for
`studio` to deploy. Create it as the literal `off` to run without cloud builds; put a
long random string in later when you set up a build host, and give that host the same
string.
```bash
wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY --scopes workers --remote
```
Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
```bash
@@ -147,42 +158,6 @@ just migrate # migrate every worker that owns migrations
just migrate -F rooms # or scope to one worker
```
### Using Turso instead of D1
The workers can run on a [Turso](https://turso.tech) (libSQL) database instead of D1,
with nothing else changing: same schema, same migrations, same workers. D1 stays the
default and stays bound, so this is a switch, not a migration of the code. The
switch is two lines in `.env`:
```bash
turso db create recflare
turso db show recflare --url # -> RECFLARE_LIBSQL_DB_URL=libsql://...
turso db tokens create recflare # -> RECFLARE_LIBSQL_DB_AUTH_TOKEN=...
```
With both set, `just migrate` applies the workers' migrations to that database (with
the same per-worker history tables wrangler keeps on D1), `just deploy` hands every
worker the URL as a var and the token as an encrypted worker secret, and the
`--remote` operator commands (`just admin`, `just catalog`) act on it. Local dev and
the tests keep using the local D1 regardless.
To carry an existing D1 database across, export it and load the dump before the
first `just migrate` — the dump includes the migration history, so nothing is
re-applied:
```bash
wrangler d1 export recflare --remote --output recflare.sql # from any D1-backed app dir
turso db shell recflare < recflare.sql
```
To go back to D1, unset (or comment out) the two lines and run `just deploy`. The
token secret is left on the workers and ignored without a URL; remove it with
`wrangler secret delete LIBSQL_DB_AUTH_TOKEN` per worker if you want it gone.
> ⚠️ The URL is one switch for the whole deployment. Deploy every worker after
> changing it: a worker left on the old database would be reading and writing
> different data from the rest.
### R2 and Durable Objects
You only have to create the buckets:
+12 -3
View File
@@ -71,7 +71,7 @@ preview:
deploy *args:
bun turbo deploy "$@"
# Apply D1 migrations (rooms + auth own them). Defaults to --remote; pass `-- --local`
# Apply D1 migrations (every worker with a `migrate` script). Defaults to --remote; pass `-- --local`
# for the dev db. Scope with -F, e.g. `just migrate -F rooms`.
[group('2. local dev')]
[positional-arguments]
@@ -125,8 +125,8 @@ admin *args:
reload-plus *args:
bun runx admin reload-plus "$@"
# Load the first-party custom avatar items (apps/econ/static/db/2025-1-cai.json by default)
# into the custom_avatar_item table. Merges: existing ids are replaced, nothing is deleted.
# Load the first-party custom avatar items (apps/econ/static/db/2025-1-cai.json, then
# studio87-extras.json, by default) into the custom_avatar_item table. Merges: existing ids are replaced, nothing is deleted.
# Defaults to --local; pass --remote for production, --dry-run to only validate. Prices come
# from the storefront dump via apps/api/scripts/price-custom-avatar-items.ts. See CLI.md.
[group('4. utility')]
@@ -134,3 +134,12 @@ reload-plus *args:
[no-cd]
cai-load *args:
bun runx admin cai-load "$@"
# Merges by default; --replace drops rows the file no longer lists. Defaults to --local; pass
# --remote for production, --dry-run to only validate. Run it whenever sf3-2025.json changes.
# Reload the item catalog (econ `catalog` table) from apps/econ/static/storefronts/sf3-2025.json
[group('4. utility')]
[positional-arguments]
[no-cd]
catalog-load *args:
bun runx catalog load "$@"
+19 -7
View File
@@ -40,18 +40,30 @@ See [RecFlare Client](https://github.com/djdevin/recflare-client) for the offici
These game builds are supported:
| Build | Manifest | Support |
| ------------- | --------------------- | --------------------------------------------------------------------------------------- |
| `20230414` | `7859140924515540835` | **Default**, official — the 2023 build the rest of the stack targets |
| `20250718.01` | `1151455856673601091` | **Beta**, official — use the [patch-2025](https://github.com/recflare/patch-2025) patch |
| `20250424.01` | | Alpha |
| `20231207` | | Alpha |
| `20230616` | | Alpha |
| Build | Client version | Manifest | Support |
| ---------- | -------------- | --------------------- | --------------------------------------------------------------------------------------- |
| `20230414` | `20230414` | `7859140924515540835` | **Default**, official — the 2023 build the rest of the stack targets |
| `20250718` | `20250718.01` | `1151455856673601091` | **Beta**, official — use the [patch-2025](https://github.com/recflare/patch-2025) patch |
| `20260323` | `20260323.14` | | Alpha |
| `20250424` | `20250424.01` | | Alpha |
| `20231207` | `20231207` | | Alpha |
| `20230616` | `20230616` | | Alpha |
A build is the `YYYYMMDD` date of the client version; the `.NN` after it is a same-day
point release and is ignored everywhere, so `20250718.01`, `20250718.2` and `20250718`
are all the beta build and share game sessions.
Alpha builds get past the version check and largely work, but nothing else in the
stack targets them, so expect protocol differences. Other client or game versions
may expect different endpoints and response shapes and are not supported.
The table is the default. An operator narrows it with `RECFLARE_SUPPORTED_GAME_VERSIONS`
in `.env` (a build left out is told to update and refused a sign-in, which is how players
are moved to a newer build), and `RECFLARE_ROLLOUT_GAME_VERSIONS` names the builds under
staged rollout to Discord supporters — `20260323` by default, `off` to open it to
everyone (`RECFLARE_TACHYON_ROLLOUT_GAME_VERSIONS` keeps a build on the rollout voice
servers past that). See `.env.example`.
Generally speaking any client that effectively rewrites the nameserver with the
right mods (see [RecNet Plugin](https://github.com/djdevin/recnet-plugin), [2025 patch](https://github.com/recflare/patch-2025)) can be used with this server.
+38 -38
View File
@@ -20,41 +20,41 @@ A small `ns` worker itself serves this discovery document at the
apex/`ns` host and isn't listed within it. Each implemented worker has its own
`README.md` under `apps/<name>/` documenting its routes.
| Service | Subdomain | Worker | Notes |
| --------------------- | ----------------------- | ----------------------- | --------------------------------------------------------------------- |
| Accounts | `accounts` | `accounts` | Player accounts & profile reads/writes (D1) |
| AI | `ai` | `ai` | Game AI access check (always refuses — no model runs here) |
| API | `api` | `api` | Core Game API — config, social, avatar, rooms, image uploads (D1, R2) |
| Auth | `auth` | `auth` | OAuth token issuance (`/connect/token`); (D1) |
| BugReporting | `bugreporting` | — | Not yet implemented |
| Cards | `cards` | `cards` | Stub — deploys and answers, no card endpoints yet |
| CDN | `cdn` | `cdn` | Binary CDN — room data (R2) |
| Chat | `chat` | `chat` | Player chat service (not in room) |
| Clubs | `clubs` | `clubs` | Clubs, not yet implemented |
| CMS | `cms` | — | Not yet implemented |
| Commerce | `commerce` | `commerce` | Store / purchase endpoints |
| Data | `data` | — | Not yet implemented |
| DataCollection | `datacollection` | `datacollection` | Client telemetry / analytics sink |
| Discovery | `discovery` | `discovery` | Discovery page layouts (static assets) |
| Econ | `econ` | `econ` | Economy & avatar endpoints (separate from `api`) |
| GameLogs | `gamelogs` | — | Not yet implemented |
| Geo | `geo` | — | Not yet implemented |
| Images | `img` | `img` | Image storage & signed delivery (R2) |
| Leaderboard | `leaderboard` | `leaderboard` | Per-room stat leaderboards, one board per stat channel (D1) |
| Link | `link` | `link` | Stub — deploys and answers, no link endpoints yet |
| Lists | `lists` | `lists` | Curated & algorithmic discovery lists (canned — nothing ranks yet) |
| Matchmaking | `match` | `match` | Matchmaking & per-player presence (D1, KV) |
| Moderation | `moderation` | `moderation` | Stub — the reporting routes still live in `api`; see note below |
| Notifications | `notify` | `notify` | Real-time notifications over SignalR/WebSockets (Durable Object) |
| PlatformNotifications | `platformnotifications` | `platformnotifications` | Stub — deploys and answers, no notification endpoints yet |
| PlayerSettings | `playersettings` | `playersettings` | Per-player settings (KV) |
| RoomComments | `roomcomments` | `roomcomments` | Notes pinned in a room’s scene — read & create (D1) |
| RoomieIntegrations | `roomieintegrations` | — | Not yet implemented |
| Rooms | `rooms` | `rooms` | Room storage & queries; seeds the Dorm & Orientation rooms (D1) |
| Storage | `storage` | — | Room uploader |
| Strings | `strings` | — | Not yet implemented |
| StringsCDN | `strings-cdn` | — | Not yet implemented |
| Studio | `studio` | — | Not yet implemented |
| Thorn | `thorn` | — | Not yet implemented |
| Videos | `videos` | — | Not yet implemented |
| WWW | `www` | — | Website/Panel |
| Service | Subdomain | Worker | Notes |
| --------------------- | ----------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Accounts | `accounts` | `accounts` | Player accounts & profile reads/writes (D1) |
| AI | `ai` | `ai` | Game AI access check (always refuses — no model runs here) |
| API | `api` | `api` | Core Game API — config, social, avatar, rooms, image uploads (D1, R2) |
| Auth | `auth` | `auth` | OAuth tokens, plus Studio device login (`POST /connect/deviceauthorization`, device-code grant on `/connect/token`); (D1) |
| BugReporting | `bugreporting` | — | Not yet implemented |
| Cards | `cards` | `cards` | Stub — deploys and answers, no card endpoints yet |
| CDN | `cdn` | `cdn` | Binary CDN — room data, and Studio room bundles at `/unityasset/{filename}` (R2, D1 lookup). The editor's `studiocdn` host is this same app on a combined deploy |
| Chat | `chat` | `chat` | Player chat service (not in room) |
| Clubs | `clubs` | `clubs` | Clubs, not yet implemented |
| CMS | `cms` | — | Not yet implemented |
| Commerce | `commerce` | `commerce` | Store / purchase endpoints |
| Data | `data` | — | Not yet implemented |
| DataCollection | `datacollection` | `datacollection` | Client telemetry / analytics sink |
| Discovery | `discovery` | `discovery` | Discovery page layouts (static assets) |
| Econ | `econ` | `econ` | Economy & avatar endpoints (separate from `api`) |
| GameLogs | `gamelogs` | — | Not yet implemented |
| Geo | `geo` | — | Not yet implemented |
| Images | `img` | `img` | Image storage & signed delivery (R2) |
| Leaderboard | `leaderboard` | `leaderboard` | Per-room stat leaderboards, one board per stat channel (D1) |
| Link | `link` | `link` | Stub — deploys and answers, no link endpoints yet |
| Lists | `lists` | `lists` | Curated & algorithmic discovery lists (canned — nothing ranks yet) |
| Matchmaking | `match` | `match` | Matchmaking & per-player presence (D1, KV) |
| Moderation | `moderation` | `moderation` | Stub — the reporting routes still live in `api`; see note below |
| Notifications | `notify` | `notify` | Real-time notifications over SignalR/WebSockets (Durable Object) |
| PlatformNotifications | `platformnotifications` | `platformnotifications` | Stub — deploys and answers, no notification endpoints yet |
| PlayerSettings | `playersettings` | `playersettings` | Per-player settings (KV) |
| RoomComments | `roomcomments` | `roomcomments` | Notes pinned in a room’s scene — read & create (D1) |
| RoomieIntegrations | `roomieintegrations` | — | Not yet implemented |
| Rooms | `rooms` | `rooms` | Room storage & queries; seeds the Dorm & Orientation rooms (D1) |
| Storage | `storage` | — | Room uploader |
| Strings | `strings` | — | Not yet implemented |
| StringsCDN | `strings-cdn` | — | Not yet implemented |
| Studio | `studio` | `studio` | `GET /cloud-builds/for-room` lists that room's cloud builds. `POST /cloud-builds/from-editor` stores a locally built Windows and Android asset-bundle pair as a finished cloud build on the latest save, and sets `BecameRRStudioRoomAt` the first time. The game loads those bundles from **Rooms** (`CurrentSave.UnitySubAssets`, `GET …/unityasset`) and **CDN** `/unityasset/{filename}`. `GET /collaboration/owners-in-room` lists co-owners whose presence is in that room and subroom. Device login stays on **Auth**; `GET /account/me` stays on **Accounts**; the approval page is **WWW** `/device` |
| Thorn | `thorn` | — | Not yet implemented |
| Videos | `videos` | — | Not yet implemented |
| WWW | `www` | `www` | Website, including the Studio sign-in page at `/device` |
+38 -4
View File
@@ -11,7 +11,6 @@ import {
getAccountsByIds,
searchAccounts,
updateAccount,
withDatabase,
} from '@repo/domain'
import {
logger,
@@ -56,7 +55,7 @@ import { resolveWhitelistedEmoji, WHITELISTED_EMOJIS } from './whitelisted-emoji
import type { Context } from 'hono'
import type { DescribeRouteOptions } from 'hono-openapi'
import type { Account } from '@repo/domain'
import type { App, Env } from './context'
import type { App } from './context'
/**
* Account reads/writes are backed by the shared `accounts` table in D1 (schema
@@ -348,6 +347,40 @@ const app = new Hono<App>()
}
)
// Exact lookup by username — what the site's `/u/<name>` profile pages and the staff
// `@username` fields resolve a player through. An indexed equality on `username_lower`;
// before this existed they went through the prefix search above and filtered the
// exact hit client-side, which cost a 20-row index walk per profile view. Same path
// as the official API's `GET /account/username/{username}`.
.get(
'/account/username/:name',
describeRoute({
tags: ['Lookup'],
summary: 'A single public account, by exact username',
description:
'Case-insensitive exact match; a leading `@` is ignored. 404 when no account has the name.',
parameters: [
{
name: 'name',
in: 'path',
required: true,
description: 'The username, exactly (case-insensitive)',
schema: { type: 'string' },
},
],
responses: {
200: json(AccountDto, 'The public account'),
404: { description: 'No account has that username' },
},
}),
async (c) => {
const name = c.req.param('name').trim().replace(/^@/, '')
const account = name === '' ? null : await getAccountByUsername(c.env.DB, name)
if (!account) return c.json({ error: `There's no player called @${name}.` }, 404)
return c.json(toAccountDto(account))
}
)
// ---- Bulk / single lookup ------------------------------------------------
// Register the static `bulk` path before the `/account/:id` param route.
.get('/account/bulk', describeRoute(bulkRouteSpec('query')), bulkAccounts)
@@ -506,7 +539,8 @@ const app = new Hono<App>()
responses: {
200: json(SuccessResponse, 'Updated'),
400: {
description: 'Empty, over 15 characters, non-alphanumeric, or profane (empty body)',
description:
'Empty, over 15 characters, outside the display name charset, or profane (empty body)',
},
401: UNAUTHORIZED_RESPONSE,
},
@@ -857,4 +891,4 @@ app.get(
)
)
export default withDatabase<Env>(app)
export default app
+6 -5
View File
@@ -2,6 +2,7 @@ import { resolver } from 'hono-openapi'
import { z } from 'zod'
import {
displayNameRejection,
isValidBio,
isValidEmail,
MAX_DISPLAY_NAME_LENGTH,
@@ -181,12 +182,12 @@ export const CreateAccountRequest = z.object({
* sentence that explains it rather than two, and the swear check never sees the
* punctuation the charset rule has already refused.
*/
const nameCheck = (label: string, max: number) =>
const nameCheck = (label: string, rejectionFor: (value: string) => string | null) =>
z
.string()
.trim()
.superRefine((value, ctx) => {
const rejection = nameRejection(value, label, max)
const rejection = rejectionFor(value)
if (rejection !== null) {
ctx.addIssue({ code: 'custom', message: rejection })
} else if (nameContainsSwears(value)) {
@@ -197,15 +198,15 @@ const nameCheck = (label: string, max: number) =>
})
export const DisplayNameRequest = z.object({
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
displayName: nameCheck('display name', displayNameRejection)
.min(1)
.describe(
'Trimmed; letters and digits only, max 15, no profanity. Empty or invalid is rejected (400)'
`Trimmed; Latin letters, digits, spaces and .,'!?_&()#@:+- only, max ${MAX_DISPLAY_NAME_LENGTH}, no profanity. Empty or invalid is rejected (400)`
),
})
export const UsernameRequest = z.object({
username: nameCheck('username', MAX_USERNAME_LENGTH)
username: nameCheck('username', (value) => nameRejection(value, 'username', MAX_USERNAME_LENGTH))
.min(1, 'You must enter a username.')
.describe(
'Trimmed; letters and digits only, max 50, no profanity. Must be unique and changes must remain'
+51 -12
View File
@@ -153,6 +153,24 @@ describe('public endpoints', () => {
expect(await empty.json()).toEqual([])
})
test('GET /account/username/:name is an exact, case-insensitive lookup', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/username/coach`)
expect(res.status).toBe(200)
const account = (await res.json()) as { accountId: number; username: string }
expect(account).toMatchObject({ accountId: 1, username: 'Coach' })
// A leading `@` is tolerated — staff fields take names in that form.
const at = await exports.default.fetch(
`${ORIGIN}/account/username/${encodeURIComponent('@Coach')}`
)
expect(((await at.json()) as { accountId: number }).accountId).toBe(1)
// A prefix is NOT a match: that's what `/account/search` is for.
const prefix = await exports.default.fetch(`${ORIGIN}/account/username/coa`)
expect(prefix.status).toBe(404)
const missing = await exports.default.fetch(`${ORIGIN}/account/username/zzzznope`)
expect(missing.status).toBe(404)
expect(await missing.json()).toEqual({ error: "There's no player called @zzzznope." })
})
test('GET /account/:id/bio returns an empty bio', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/7/bio`)
expect(await res.json()).toEqual({ accountId: 7, bio: '' })
@@ -272,7 +290,7 @@ describe('auth-gated endpoints', () => {
...(await bearer('895')),
'Content-Type': 'application/x-www-form-urlencoded',
}
// A name carries no spaces, so the filter has to find the swear at the seam the
// These names carry no spaces, so the filter has to find the swear at the seam the
// player typed instead of one.
for (const displayName of ['fuck', 'ShitLord', 'Fucker123']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
@@ -689,6 +707,7 @@ describe('auth-gated endpoints', () => {
'GET /account/bulk',
'GET /account/me',
'GET /account/search',
'GET /account/username/{name}',
'GET /account/{id}',
'GET /account/{id}/bio',
'GET /accountprivacysettings/{id}',
@@ -741,8 +760,9 @@ describe('auth-gated endpoints', () => {
})
})
// The names a player chooses are alphanumeric and length-capped, by the same rule the
// `rooms` worker applies (see `nameRejection` in @repo/domain). The three limits come
// The names a player chooses are charset- and length-capped by the rules in @repo/domain:
// a username is alphanumeric (`nameRejection`), a display name takes the wider charset
// the client's own box does (`displayNameRejection`). The limits come
// from the client's own input boxes rather than a round number, so anything stored is
// something the game can render and re-edit.
//
@@ -787,25 +807,44 @@ describe('name, email and bio validation', () => {
expect(((await ok.json()) as { success: boolean }).success).toBe(true)
})
test('PUT /account/me/displayname refuses anything but letters and digits, max 15', async () => {
test('PUT /account/me/displayname holds a name to the client charset, max 15', async () => {
const headers = await authed('8802')
for (const displayName of ['has space', 'punct!', 'a'.repeat(16)]) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
const put = (displayName: string) =>
exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName }),
headers,
})
// The Rec Room build's own rule: ^[\p{Latin}0-9 .,'!?_&()#@:+-]+$
for (const displayName of ['has space', 'punct!', 'café', "O'Neil_Jr.", '(a)&b#1@c:+-,?']) {
expect((await put(displayName)).status, displayName).toBe(200)
}
for (const displayName of [
'semi;colon',
'star*',
'sla/sh',
'Привет',
'名前',
'smile😀',
'a'.repeat(16),
]) {
// An empty 400, matching what this route already answers for an empty name —
// it acks with a bare `{ success: true }` and has never sent the client a body
// on failure.
expect(res.status, displayName).toBe(400)
expect((await put(displayName)).status, displayName).toBe(400)
}
// Trimmed before it is checked, so a name of nothing but spaces is an empty one.
expect((await put(' ')).status).toBe(400)
// A swear set off by the punctuation the charset now allows is still a swear.
for (const displayName of ['shit lord', 'Shit_Lord', 'fuck.you', 'fuck!']) {
expect((await put(displayName)).status, displayName).toBe(400)
}
// 15 is the client's box, so it must fit.
const ok = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName: 'a'.repeat(15) }),
headers,
})
expect(ok.status).toBe(200)
expect((await put('a'.repeat(15))).status).toBe(200)
})
// Syntax comes from the `isemail` package rather than a pattern written here — this is
+28 -12
View File
@@ -3,7 +3,7 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { validateAndGetAccountId, validateAndGetBuild } from '@repo/jwt'
import {
AUTHED,
@@ -34,8 +34,8 @@ import type { App } from './context'
*
* - Game AI is a SERVER-side feature. This server cannot provide it, so both its reads are
* refused and the client hides the feature.
* - Roomie runs on the CLIENT and only asks this service what it may spend, so the budget
* reads are granted in full. The session that would actually reach a model
* - Roomie's native 2026 companion gets zero energy and no generated output audio;
* older clients keep the full budget. The session that would actually reach a model
* (`/realtime-session/create`) is where it stops.
* - Maker AI meters model usage in dollars. Nothing here bills, so every figure is zero.
*/
@@ -163,26 +163,27 @@ const app = new Hono<App>()
}
)
// Roomie AI's energy budget. Granted, unlike Game AI above: Roomie runs on the client
// and only asks this service how much energy it has, so the honest answer for a server
// that meters nothing is "as much as you can count".
// The 20260323 build (20260323.14) has native low-energy and no-energy companion support. Advertising
// full energy selects a conversation backend we do not provide. Grant access with
// zero energy and the client's recognized no-credit result instead. A successful
// access response still opens a realtime session even when its energy is zero.
// Preserve the older client's budget and recharge-field contract.
.get(
'/roomieai/user/access',
describeRoute({
tags: ['Roomie AI', '2025'],
summary: 'The caller’s Roomie AI energy budget',
description: [
'What Roomie may spend: an energy ceiling, what is left of it, and when it next',
'refills. Nothing here meters energy, so the budget is `int.MaxValue` and never',
'depletes — which is why `NextSubscriptionEnergyRechargeAt` is null, there being no',
'spend to recharge from.',
'The 20260323 build receives the recognized no-credit result and zero energy for its native',
'companion mode. Its DTO has no recharge field. Older clients retain the full',
'int32-max budget and null recharge time. No model session is created.',
'',
'The envelope is `{ success, error_id, error, value }`, NOT the flat body the Game AI',
'check answers with. The two are different shapes on purpose — don’t unify them.',
].join(' '),
security: AUTHED,
responses: {
200: json(RoomieAiAccess, 'The energy budget — always granted, always full'),
200: json(RoomieAiAccess, 'Build-specific conversation access and companion fallback'),
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -190,6 +191,21 @@ const app = new Hono<App>()
const id = await authedId(c)
if (id === null) return unauthorized(c)
// The build, not the version: every point release of 20260323 has the companion mode.
const build = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
if (build === '20260323') {
return c.json({
success: false,
error_id: 'AI.InsufficientRoomieAICreditNoSub',
error: 'AI conversation is disabled. Roomie companion mode is available.',
value: {
MaxEnergyFromSubscriptions: 0,
EnergyLeft: 0,
OutputAudioEnabled: false,
},
})
}
return c.json({
success: true,
error_id: null,
@@ -389,7 +405,7 @@ app.get(
'refusals, because the features fail at different points. Game AI is a server-side',
'feature this server cannot provide, so both its reads refuse. Roomie and Maker AI',
'only ask what the caller may SPEND, which nothing here meters, so those reads are',
'granted in full; the refusal lands instead on `POST /realtime-session/create`, the',
'granted with a build-specific budget; conversation refuses on `POST /realtime-session/create`, the',
'one call whose real answer is a working credential rather than a description of one.',
'',
'The refusals are 200s carrying `success: false`, which is the shape the client',
+9 -8
View File
@@ -71,22 +71,23 @@ export const HealthResponse = z.object({
/**
* The Roomie AI access envelope — the `{ success, error_id, error, value }` shape, unlike
* the flat Game AI refusal above. Roomie is granted here, with its energy budget pinned at
* the maximum a signed 32-bit int holds (see INT32_MAX).
* the flat Game AI refusal above. 2026 uses the no-credit fallback with zero energy;
* older builds retain their full signed-32-bit budget and recharge field.
*/
export const RoomieAiAccess = z.object({
success: z.literal(true),
error_id: z.null(),
error: z.null(),
success: z.boolean(),
error_id: z.string().nullable(),
error: z.string().nullable(),
value: z.object({
MaxEnergyFromSubscriptions: z
.int()
.describe('The energy ceiling a subscription buys — pinned to int32 max'),
EnergyLeft: z.int().describe('Energy remaining. Never spent here, so also int32 max'),
.describe('Zero for the 2026 companion; int32 max for older builds'),
EnergyLeft: z.int().describe('Zero for the 2026 companion; int32 max for older builds'),
NextSubscriptionEnergyRechargeAt: z
.string()
.nullable()
.describe('When the budget refills. Null — nothing depletes, so nothing recharges'),
.optional()
.describe('Null on older builds; omitted for the 2026 DTO'),
OutputAudioEnabled: z.boolean().describe('Whether Roomie may speak its replies'),
}),
})
+31 -2
View File
@@ -27,10 +27,10 @@ function b64url(input: ArrayBuffer | string): string {
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub = '42'): Promise<Record<string, string>> {
async function bearer(sub = '42', version?: string): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub, exp: now + 3600 })
JSON.stringify({ sub, exp: now + 3600, 'rn.ver': version })
)}`
const key = await crypto.subtle.importKey(
'raw',
@@ -96,6 +96,35 @@ describe('GET /gameai/user/access', () => {
})
describe('GET /roomieai/user/access', () => {
it('selects the 2026 no-credit companion fallback without model credentials', async () => {
const headers = await bearer('42', '20260323.14')
const res = await SELF.fetch(`${ORIGIN}/roomieai/user/access`, { headers })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
success: false,
error_id: 'AI.InsufficientRoomieAICreditNoSub',
error: 'AI conversation is disabled. Roomie companion mode is available.',
value: { MaxEnergyFromSubscriptions: 0, EnergyLeft: 0, OutputAudioEnabled: false },
})
// Entitlement to local activities must never mint a model credential.
const session = await SELF.fetch(`${ORIGIN}/realtime-session/create`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ AIType: 'Roomie' }),
})
expect(session.status).toBe(200)
expect(await session.json()).toMatchObject({ success: false, value: null })
})
it('preserves the older build budget', async () => {
const res = await SELF.fetch(`${ORIGIN}/roomieai/user/access`, {
headers: await bearer('42', '20250718.01'),
})
expect(await res.json()).toMatchObject({
value: { EnergyLeft: 2147483647, OutputAudioEnabled: true },
})
})
// Granted, unlike the Game AI check: Roomie runs on the client and only asks for its
// energy budget, which nothing here meters.
it('grants an int32-max energy budget', async () => {
+8
View File
@@ -10,3 +10,11 @@ declare namespace Cloudflare {
mainModule: MainModule
}
}
// Vite serves a `?raw` import as the file's text. Used by the tests to read a data
// migration (0032, the custom-item price cap) as a STRING and run its UPDATE against the
// schema they build from SCHEMA_DDL, which the migration files never touch.
declare module '*.sql?raw' {
const content: string
export default content
}
@@ -0,0 +1,32 @@
-- Restore the UNIQUE pair index on `relationship`.
--
-- Generated from packages/domain/src/relationships-db.ts (SCHEMA_DDL) — keep in sync.
-- Migration 0001 declared `idx_relationship` and 0030 counts on it staying ("it is the
-- pair constraint"), but the deployed table did not have it: an audit of sqlite_master
-- against every worker's migrations found it and the two `room_instance` unique indexes
-- (rooms 0022) missing, with every other declared index present. Every relationship
-- write is select-then-insert (`upsertPair`, `setRelationshipFlag`), so this index is the
-- only thing that stops a replayed or concurrent request from filing a pair twice —
-- which shows up as a doubled friend entry, with `findPair` reading whichever twin the
-- planner returns.
--
-- A UNIQUE index refuses to build over duplicates, so any are merged first: the oldest
-- row of each (requester_id, target_id) group survives, carrying the MAX of every field
-- across the group — for the flags that is an OR, and for `relationship_type` it is the
-- furthest-along state (3 Friend > 2 Received > 1 Sent > 0 None). Then the rest go. Both
-- statements are no-ops on a table with no duplicates.
UPDATE relationship SET
relationship_type = (SELECT MAX(r.relationship_type) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
requester_favorited = (SELECT MAX(r.requester_favorited) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
requester_ignored = (SELECT MAX(r.requester_ignored) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
requester_muted = (SELECT MAX(r.requester_muted) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
target_favorited = (SELECT MAX(r.target_favorited) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
target_ignored = (SELECT MAX(r.target_ignored) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
target_muted = (SELECT MAX(r.target_muted) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id)
WHERE id IN (SELECT MIN(id) FROM relationship GROUP BY requester_id, target_id HAVING COUNT(*) > 1);
DELETE FROM relationship
WHERE id NOT IN (SELECT MIN(id) FROM relationship GROUP BY requester_id, target_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_relationship ON relationship (requester_id, target_id);
@@ -0,0 +1,18 @@
-- Player-made custom shirts are capped at 1000 tokens (`MAX_PLAYER_ITEM_PRICE` in
-- src/custom-avatar-items-db.ts): the create and the edit now refuse a price above it, and
-- this lowers every existing player-made row that is already over it to exactly the cap.
--
-- Only PLAYER-MADE rows are touched, told apart by `BaseAvatarItemId`: a player-made shirt
-- is built on a base item, while a first-party item (imported from the official export,
-- Coach-authored) has it NULL and is priced from the storefront dump, where several sit
-- well above 1000. Those keep their prices.
--
-- `ModifiedAt` is bumped as the edit endpoint bumps it, since the record did change.
-- `price` is a generated column off `data`, so rewriting the JSON is the whole update.
UPDATE custom_avatar_item
SET data = json_set(data,
'$.Price', 1000,
'$.ModifiedAt', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
WHERE json_extract(data, '$.BaseAvatarItemId') IS NOT NULL
AND price > 1000;
@@ -0,0 +1,11 @@
-- Inventions are capped at 1000 tokens (`MAX_INVENTION_PRICE` in src/inventions-db.ts): the
-- three writes a creator has (`v3/publish`, `v4/publish`, `v1/updateprice`) now refuse a
-- price above it, and this lowers every existing row already over it to exactly the cap.
-- Drafts included — a draft's price is what its next publish would list it at.
--
-- `ModifiedAt` is left alone, as `v1/updateprice` leaves it: a reprice is not an edit of
-- the invention. `price` is not a generated column here, so the JSON is the whole update.
UPDATE invention
SET data = json_set(data, '$.Price', 1000)
WHERE json_extract(data, '$.Price') > 1000;
+16
View File
@@ -0,0 +1,16 @@
-- Reporting a saved IMAGE (`POST /api/images/v1/{id}/report`) reuses the report table, as
-- the event, invention, custom-avatar-item and chat-message reports beside it do: same
-- fields, same moderation life. Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `image_id` names the reported image (`image.id`). NULL on every other kind of report, and
-- mutually exclusive with `event_id`, `invention_id`, `custom_avatar_item_id` and
-- `chat_message_id`.
--
-- The request has no body, so the row is three facts: the reporter (the caller), the image,
-- and `reported_player_id` — the image's `PlayerId`, who took it, read from the image.
-- `report_category` stays 0 and `details` NULL: the client sends neither.
--
-- NOT indexed, like the other kind columns: written on every report of this kind and read by
-- nothing. Add an index with the query that needs it.
ALTER TABLE report ADD COLUMN image_id INTEGER;
@@ -0,0 +1,18 @@
-- Index the room-badge lookup that hydrates every room list (`attachBadges` in
-- packages/domain/src/rooms-db.ts). Every room page — feeds, search, the owned list, a single
-- room read — asks this table for the published badges of the rooms on the page, and without an
-- index that was a full scan of every custom avatar item (thousands of first-party rows) per
-- page. At the time of writing it was half of the database's row reads.
--
-- PARTIAL, over only the badge rows (`OutfitType` 1000), keyed by the badge's room. SQLite uses
-- a partial index only when the query's WHERE contains its term as written, and an expression
-- index only when the query names the same expression, so the query must keep BOTH
-- `outfit_type = 1000` and `json_extract(data, '$.CustomBadgeMetadata.RoomId')` exactly —
-- change either and the index silently stops being used. The key is an expression rather than
-- a new generated column so the query reads the same whether or not this migration has run yet
-- (the `rooms` worker can deploy ahead of `api`'s migrations).
-- Generated from src/custom-avatar-items-db.ts (SCHEMA_DDL) — keep in sync.
CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_badge_room
ON custom_avatar_item (json_extract(data, '$.CustomBadgeMetadata.RoomId'))
WHERE outfit_type = 1000;
+4 -3
View File
@@ -2,11 +2,11 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withDatabase } from '@repo/domain'
import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { accountRoutes } from './routes/account'
import { avatarRoutes } from './routes/avatar'
import { avatarStudioRoutes } from './routes/avatar-studio'
import { configRoutes } from './routes/config'
import { eventRoutes } from './routes/events'
import { gameplayRoutes } from './routes/gameplay'
@@ -17,7 +17,7 @@ import { progressionRoutes } from './routes/progression'
import { roomRoutes } from './routes/rooms'
import { socialRoutes } from './routes/social'
import type { App, Env } from './context'
import type { App } from './context'
/**
* The Game API surface. Endpoints that would be backed by a database or on-disk
@@ -57,6 +57,7 @@ const app = new Hono<App>({ strict: false })
.route('/', configRoutes)
.route('/', socialRoutes)
.route('/', progressionRoutes)
.route('/', avatarStudioRoutes)
.route('/', avatarRoutes)
.route('/', gameplayRoutes)
.route('/', eventRoutes)
@@ -106,4 +107,4 @@ app.get(
)
)
export default withDatabase<Env>(app)
export default app
+16
View File
@@ -17,6 +17,22 @@ export type Env = SharedHonoEnv & {
DOMAIN: string
/** Maximum accepted size of each API-owned image upload, in bytes. */
RECFLARE_MAX_API_UPLOAD_BYTES?: string
/**
* The client builds `/api/versioncheck/v4` answers "current" for, as a comma-separated
* list of builds (`20230414,20250718`), or `off` for none. Unset means the default
* `SUPPORTED_GAME_VERSIONS` in @repo/domain. Set for every worker from the root `.env`
* (`RECFLARE_SUPPORTED_GAME_VERSIONS`), so `auth` refuses a token to exactly the builds
* this flags for an update. Read through `supportedGameBuilds`, which also folds in
* every build under rollout (below).
*/
SUPPORTED_GAME_VERSIONS?: string
/**
* The builds under staged rollout (`RECFLARE_ROLLOUT_GAME_VERSIONS`), same format;
* `off` ends the rollout. Unset means the default `ROLLOUT_GAME_VERSIONS` in
* @repo/domain. A rollout build is always "current" here — it is `auth` that decides who
* may sign in on it — so the version check never sends a supporter to update.
*/
ROLLOUT_GAME_VERSIONS?: string
// Shared rooms database (schema/migrations owned by the `rooms` worker). Used
// read-only here to resolve room roles for `/api/rooms/v1/verifyRole`.
DB: D1Database
+23 -4
View File
@@ -29,7 +29,7 @@
import { COACH_ACCOUNT_ID } from './custom-avatar-items-load'
/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql). */
/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql and 0035_custom_avatar_item_badge_room.sql). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS custom_avatar_item (
data TEXT NOT NULL,
@@ -45,6 +45,9 @@ export const SCHEMA_DDL: string[] = [
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id)`,
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`,
// The room-badge lookup (`attachBadges`, packages/domain/src/rooms-db.ts) runs on every room
// page; partial over the badge rows, keyed by the badge's room (migrations/0035).
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_badge_room ON custom_avatar_item (json_extract(data, '$.CustomBadgeMetadata.RoomId')) WHERE outfit_type = 1000`,
]
/**
@@ -72,6 +75,9 @@ export interface CustomAvatarItemSave {
UnityAssetHash: string
UnityAsset2: string | null
UnityAsset2Hash: string | null
/** Studio builds persist separate target hashes; legacy imports omit these. */
QuestUnityAssetHash?: string | null
QuestUnityAsset2Hash?: string | null
}
/** A tag on an item (`{ TagType: 0, Value: "export" }` on the imported first-party items). */
@@ -131,6 +137,16 @@ export interface CustomAvatarItem {
*/
export const OUTFIT_TYPE_CUSTOM_SHIRT = 105
/**
* The most tokens a PLAYER-MADE item may sell for, inclusive. Enforced on the create and
* the edit (`POST`/`PUT /api/customAvatarItems/v1`), the only two writes a player has; the
* client has no ceiling of its own — its `minPriceForPublicItem` is a floor. First-party
* items are not bound by it: their prices come off the storefront dump and several sit
* above it (migrations/0032_custom_avatar_item_price_cap.sql clamps only the player-made
* rows, told apart by `BaseAvatarItemId`).
*/
export const MAX_PLAYER_ITEM_PRICE = 1000
/** What `POST /api/customAvatarItems/v1` needs to create an item. */
export interface CreateCustomAvatarItemInput {
/** The item's id. Chosen by the caller because the upload keys are derived from it. */
@@ -287,10 +303,11 @@ export async function getCustomAvatarItems(
ids: string[]
): Promise<CustomAvatarItem[]> {
if (ids.length === 0) return []
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id IN (${placeholders})`)
.bind(...ids)
.prepare(
`SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id IN (SELECT value FROM json_each(?1))`
)
.bind(JSON.stringify(ids))
.all<Row>()
const byId = new Map(results.map(toDto).map((item) => [item.CustomAvatarItemId, item]))
return ids.flatMap((id) => byId.get(id) ?? [])
@@ -345,6 +362,8 @@ export function toQuestCustomAvatarItem(item: CustomAvatarItem): CustomAvatarIte
...save,
UnityAsset: questAsset(save.UnityAsset),
UnityAsset2: questAsset(save.UnityAsset2),
UnityAssetHash: save.QuestUnityAssetHash ?? save.UnityAssetHash,
UnityAsset2Hash: save.QuestUnityAsset2Hash ?? save.UnityAsset2Hash,
})),
}
}
+101 -9
View File
@@ -18,6 +18,7 @@
*/
import {
Accessibility,
glyphLength,
MAX_EVENT_DESCRIPTION_LENGTH,
MAX_EVENT_DURATION_MS,
@@ -925,10 +926,9 @@ export async function getEventById(db: D1Database, eventId: number): Promise<Pla
*/
export async function getEventsByIds(db: D1Database, ids: number[]): Promise<PlayerEvent[]> {
if (ids.length === 0) return []
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT data FROM event WHERE id IN (${placeholders})`)
.bind(...ids)
.prepare(`SELECT data FROM event WHERE id IN (SELECT value FROM json_each(?1))`)
.bind(JSON.stringify(ids))
.all<EventRow>()
const byId = new Map<number, PlayerEvent>()
for (const r of results) {
@@ -953,6 +953,72 @@ export async function getEventsByCreator(
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}
/**
* One entry of a profile's `Responses` — the event beside the player's RSVP to it. The
* event is the 17-key BASE shape (the client's v1 PlayerEvent), not the stored record.
*/
export interface PlayerEventWithResponse {
PlayerEvent: PlayerEventBase
PlayerEventResponse: PlayerEventResponse
}
/** What anyone may see of a player's events — `GET /api/playerevents/v1/all/{playerId}`. */
export interface PlayerPublicEvents {
Created: PlayerEventBase[]
Responses: PlayerEventWithResponse[]
}
/**
* A player's PUBLIC events, as their profile shows them to anyone: the ones they created,
* and the ones they are attending — answered Going; a maybe, a decline and an unanswered
* invitation are nobody else's business — each soonest first. An event's creator holds a
* Going row for it, so their own events appear under both keys.
*
* Only `Accessibility` Public events are served, and FINISHED ones are left out, as the
* browse feed and the room shelf do: this is what the player is going to, not a history.
*/
export async function getPublicEventsByPlayer(
db: D1Database,
playerId: number,
now = Date.now()
): Promise<PlayerPublicEvents> {
const at = eventTime(now)
const [created, responses] = await db.batch<EventRow & Partial<EventAttendeeRow>>([
db
.prepare(
`SELECT data FROM event
WHERE creator_player_id = ?1 AND end_time >= ?2
AND json_extract(data, '$.Accessibility') = ?3`
)
.bind(playerId, at, Accessibility.Public),
db
.prepare(
`SELECT e.data AS data, a.rowid AS id, a.event_id, a.player_id, a.status, a.responded_at
FROM event_attendee a
JOIN event e ON e.id = a.event_id
WHERE a.player_id = ?1 AND a.status = ?2 AND e.end_time >= ?3
AND json_extract(e.data, '$.Accessibility') = ?4`
)
.bind(playerId, EVENT_RESPONSE.going, at, Accessibility.Public),
])
return {
Created: created.results
.map((r) => JSON.parse(r.data) as PlayerEvent)
.sort(bySoonest)
.map(toEventBase),
Responses: responses.results
.map((r) => ({
event: JSON.parse(r.data) as PlayerEvent,
response: toEventResponse(r as EventAttendeeRow),
}))
.sort((a, b) => bySoonest(a.event, b.event))
.map(({ event, response }) => ({
PlayerEvent: toEventBase(event),
PlayerEventResponse: response,
})),
}
}
/**
* The events belonging to a set of clubs — the events shelf on a club's page, soonest
* first. Selected on the indexed club_id column. An empty id list is an empty shelf
@@ -960,10 +1026,9 @@ export async function getEventsByCreator(
*/
export async function getEventsByClubs(db: D1Database, clubIds: number[]): Promise<PlayerEvent[]> {
if (clubIds.length === 0) return []
const placeholders = clubIds.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT data FROM event WHERE club_id IN (${placeholders})`)
.bind(...clubIds)
.prepare(`SELECT data FROM event WHERE club_id IN (SELECT value FROM json_each(?1))`)
.bind(JSON.stringify(clubIds))
.all<EventRow>()
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}
@@ -1012,9 +1077,35 @@ function bySoonest(a: PlayerEvent, b: PlayerEvent): number {
return a.StartTime.localeCompare(b.StartTime) || a.PlayerEventId - b.PlayerEventId
}
/**
* Best attended first — the count of players who answered Going (`AttendeeCount`, the
* number the client shows), which is exactly what the sort is meant to rank. Interested
* and Can't-go don't count, as they don't toward the displayed count either. Ties fall
* back to soonest first so the order stays stable across pages.
*/
function byAttendance(a: PlayerEvent, b: PlayerEvent): number {
return b.AttendeeCount - a.AttendeeCount || bySoonest(a, b)
}
/**
* The orders the search serves, by the `sort` the client sends: `StartTime` (soonest
* first, the default) and `Attendance` (most Going replies first).
*/
export type EventSort = 'StartTime' | 'Attendance'
/**
* Reads the `sort` query into an {@link EventSort}, case-insensitively. Anything
* unrecognised — or nothing at all — is the default order, soonest first, rather than
* a 400: the browse screen has always sent this param and been served regardless.
*/
export function parseEventSort(raw: string | undefined): EventSort {
return raw?.toLowerCase() === 'attendance' ? 'Attendance' : 'StartTime'
}
/**
* Event search — the browse query on the player-events screen. Term by term, an empty
* query browsing everything upcoming; paginated via skip/take, soonest first.
* query browsing everything upcoming; paginated via skip/take, in the order `sort`
* names ({@link EventSort}): soonest first by default, or most Going replies first.
*
* A term is matched one of two ways, and the `#` decides which:
*
@@ -1035,7 +1126,8 @@ export async function searchEvents(
db: D1Database,
query: string,
skip: number,
take: number
take: number,
sort: EventSort = 'StartTime'
): Promise<PlayerEvent[]> {
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
// A `#` prefix makes a term a tag; the rest are matched against the text. A bare `#`
@@ -1067,5 +1159,5 @@ export async function searchEvents(
)
}
return events.sort(bySoonest).slice(skip, skip + take)
return events.sort(sort === 'Attendance' ? byAttendance : bySoonest).slice(skip, skip + take)
}
+149 -28
View File
@@ -18,14 +18,19 @@
* into the caller's own list, and to rank the "top today" feed by what players actually
* picked up today. See @repo/domain's inventory-invention-db.ts.
*/
import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domain'
import {
getAccountByUsername,
getInventionAcquisitionCounts,
getOwnedInventionIds,
} from '@repo/domain'
/**
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql +
* 0008_invention_visibility.sql, sans any seed rows). `is_featured` backs the featured
* feed's query and `is_published`/`hide_from_player` most of the "may anyone see this"
* filter every feed shares (see `VISIBLE_IN_FEEDS`, which also excludes unlisted ones); json_extract of a JSON `true` is 1, so those columns are 1/0 — and
* NULL when the key is missing, which fails a `= 1` or `= 0` test either way.
* feed's query and `is_published`/`hide_from_player` most of the "is this in the store"
* filter every feed shares (see `VISIBLE_IN_FEEDS`, which also excludes unlisted ones);
* json_extract of a JSON `true` is 1, so those columns are 1/0 — and NULL when the key is
* missing, which fails a `= 1` or `= 0` test either way.
*/
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS invention (
@@ -321,11 +326,20 @@ export function toInventionV9(invention: SavedInvention): InventionV9Dto {
Name: invention.Name,
Description: invention.Description,
ImageName: invention.ImageName,
UgcVersion: invention.UgcVersion ?? 0,
// A record saved through `v6/save` stores no `UgcVersion`, and the client's byte
// defaults to 0 — a generation no client writes (every `v9/save` sends 1). The 2025
// client lists nothing that declares 0: these were on the wire and invisible.
UgcVersion: invention.UgcVersion ?? 1,
CurrentVersionNumber: invention.CurrentVersionNumber,
// One save, one version: the newest is the current one.
LatestVersionNumber: invention.CurrentVersionNumber,
Accessibility: invention.Accessibility,
// A published invention cannot be private. Everything published through
// `v3/publish` still stores the 0 a save mints (see INVENTION_ACCESSIBILITY), which
// this DTO — it has no `IsPublished` — would read as Private; serve it as Public.
Accessibility:
invention.IsPublished && invention.Accessibility === INVENTION_ACCESSIBILITY.private
? INVENTION_ACCESSIBILITY.public
: invention.Accessibility,
ForceCannotPublish: false,
ModifiedAt: invention.ModifiedAt,
CreatedAt: invention.CreatedAt,
@@ -582,8 +596,19 @@ export async function getInventionsByCreator(
* purchase, they are still on the shelf of the player who paid for them. An owned id
* with no invention row left (deleted) simply drops out. Newest first, like the other
* invention lists; not paginated.
*
* Each row is the stored record WITH the client's `RRInvention` laid over it
* ({@link toInventionV9}). The 2025 client decodes this list into that flat DTO, and a
* key the record lacks defaults silently: no `LatestVersionNumber` reads as a current
* version newer than the latest, no `UgcVersion` as generation 0 whatever was saved, and
* a published record's stored `Accessibility` 0 as Private. The stored keys that DTO has
* no member for (`CurrentVersion`, `IsPublished`, `Tags`, …) stay on the row: its decoder
* drops them, and the older client still reads them.
*/
export async function getMyInventions(db: D1Database, playerId: number): Promise<SavedInvention[]> {
export async function getMyInventions(
db: D1Database,
playerId: number
): Promise<ListedInvention[]> {
const [created, ownedIds] = await Promise.all([
getInventionsByCreator(db, playerId),
getOwnedInventionIds(db, playerId),
@@ -592,9 +617,51 @@ export async function getMyInventions(db: D1Database, playerId: number): Promise
const byId = new Map<number, SavedInvention>()
for (const invention of [...created, ...bought]) byId.set(invention.InventionId, invention)
return [...byId.values()].sort(
(a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId
)
return [...byId.values()]
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
.map(withClientInvention)
}
/** A stored record with the client's flat `RRInvention` laid over it — see {@link getMyInventions}. */
type ListedInvention = Omit<SavedInvention, keyof InventionV9Dto> & InventionV9Dto
function withClientInvention(invention: SavedInvention): ListedInvention {
return { ...invention, ...toInventionV9(invention) }
}
/** The most creators one `v1/fromcreators` call may name — D1 caps a statement's binds. */
const MAX_PORTFOLIO_CREATORS = 50
/**
* A creator's portfolio (`v1/fromcreators?id=…`) — the shelf on their profile, and the
* "from creators you follow" row. Newest first, paged in SQL via skip/take.
*
* Only PUBLISHED inventions (minus hidden ones), whoever asks — the creator included. A
* creator's drafts are on `v2/mine` and nowhere else; this is what they MADE and put out,
* not what they bought or are still working on. Unlisted ones ARE here: unlisted means
* "not in the store", and a portfolio is not the store.
*/
export async function getInventionsByCreators(
db: D1Database,
creatorPlayerIds: number[],
skip: number,
take: number
): Promise<ListedInvention[]> {
const creators = [...new Set(creatorPlayerIds)].slice(0, MAX_PORTFOLIO_CREATORS)
const limit = Math.max(take, 0)
if (creators.length === 0 || limit === 0) return []
const { results } = await db
.prepare(
`SELECT data FROM invention
WHERE creator_player_id IN (${creators.map((_, i) => `?${i + 3}`).join(', ')})
AND is_published = 1 AND hide_from_player = 0
ORDER BY json_extract(data, '$.CreatedAt') DESC, id DESC
LIMIT ?1 OFFSET ?2`
)
.bind(limit, Math.max(skip, 0), ...creators)
.all<InventionRow>()
return results.map((r) => withClientInvention(JSON.parse(r.data) as SavedInvention))
}
/**
@@ -664,6 +731,14 @@ export async function ownsAllInventions(
* and a tag appears in no name or description, so those searches find nothing. Matching tags
* needs them out of the JSON blob and into something indexable first; until then this stays a
* text search rather than one that scans every row to look at its tags.
*
* A value starting with `@` IS special: `@djdevin` is a search for that player's inventions —
* the public ones, the same set `v1/fromcreators` lists for their portfolio — resolved by
* username, case-insensitively, through the indexed `username_lower`. Any terms after the
* `@name` narrow that player's inventions as text, so `@djdevin sofa` works. A username nobody
* has matches nothing rather than falling back to a text search for "@djdevin": a player who
* typed an `@` meant a person, and a list of inventions whose descriptions mention them is not
* what they asked for.
*/
export async function searchInventions(
db: D1Database,
@@ -680,11 +755,20 @@ export async function searchInventions(
/** Bind a value and get its placeholder, so the numbering can't drift as terms are added. */
const bind = (v: string | number): string => `?${binds.push(v)}`
for (const term of value
const terms = value
.trim()
.toLowerCase()
.split(/[\s+]+/)
.filter(Boolean)) {
.filter(Boolean)
if (terms[0]?.startsWith('@')) {
const username = terms.shift()!.slice(1)
const creator = username ? await getAccountByUsername(db, username) : null
if (!creator) return []
where.push(`creator_player_id = ${bind(creator.accountId)}`)
}
for (const term of terms) {
const escaped = term.replace(/[\\%_]/g, (ch) => `\\${ch}`)
const pattern = bind(`%${escaped}%`)
where.push(
@@ -858,17 +942,20 @@ export const INVENTION_PERMISSION = {
} as const
/**
* Where a published invention may be FOUND, which `v4/publish` sets and nothing before it
* did — every record written before that endpoint carries 0, the value a save mints.
* Whether a PUBLISHED invention is in the store, which `v4/publish` sets and nothing before
* it did. The publish sheet sends one of two values, and both publish:
*
* Only `unlisted` is recovered from the client for certain; the other two mirror the room
* accessibility enum, which they match member-for-member, and the publish sheet sends 1 for
* an ordinary publish.
* - `public` (1) — published and in the store: search and the browse feeds list it.
* - `unlisted` (2) — published but NOT in the store. It is still published, so it is on the
* creator's portfolio (`v1/fromcreators`) and reachable by id; it just isn't for browsing.
*
* Note what that leaves ambiguous: a stored 0 is either "private" or "written before this
* enum meant anything", and the two are indistinguishable without a backfill. So the browse
* filter excludes `unlisted` by name rather than requiring `public` — the latter reads
* every invention published through `v3/publish` as private and empties the feeds.
* `private` (0) is what a save mints and what `v2/unpublish` puts back — the state of an
* invention that isn't published at all. Whether anyone else can see an invention is
* `IsPublished`; this field only decides, of the published ones, which the store shows.
*
* Every record published through the older `v3/publish` also still carries 0, since that
* endpoint names no accessibility. So the store filter excludes `unlisted` by name rather
* than requiring `public` — the latter would read all of those as private and drop them.
*/
export const INVENTION_ACCESSIBILITY = {
private: 0,
@@ -877,9 +964,9 @@ export const INVENTION_ACCESSIBILITY = {
} as const
/**
* The "anyone may come across this" test the browse feeds and search share: published, not
* hidden, and not unlisted. An unlisted invention is still reachable BY ID — that is what
* unlisted means — so the by-id reads deliberately don't apply it.
* The "is this in the store" test search and the browse feeds share: published, not hidden
* by moderation, and not unlisted. A creator's portfolio deliberately does NOT apply the
* last term (see `getInventionsByCreators`), nor do the by-id reads.
*
* `idx_invention_feed_newest` (SCHEMA_DDL, migrations/0027) is a partial index over exactly
* these terms — edit one here and edit it there, or the feed goes back to a full scan.
@@ -992,9 +1079,42 @@ export async function publishInvention(
return updated
}
/**
* Unpublish an invention (`v2/unpublish`) — back to what a save mints: not published, and
* `Accessibility` private, so the next publish sets both afresh. Buyers keep it (`v2/mine`
* lists a bought invention whatever its state), and `FirstPublishedAt` stays: it dates the
* FIRST publish, which still happened. The permission and price are left for the next
* publish to restate or keep. Returns the updated invention, or null when there's no such row.
*/
export async function unpublishInvention(
db: D1Database,
inventionId: number
): Promise<SavedInvention | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
const updated: SavedInvention = {
...invention,
IsPublished: false,
Accessibility: INVENTION_ACCESSIBILITY.private,
}
await writeInvention(db, updated)
return updated
}
/**
* The most tokens an invention may sell for, inclusive. Enforced on every price a player
* can send — `v3/publish`, `v4/publish` and `v1/updateprice` — by refusing the write; the
* client has no ceiling of its own. The same number as the custom-shirt cap
* (`MAX_PLAYER_ITEM_PRICE`), kept separate because they are separate policies that happen
* to agree. migrations/0033_invention_price_cap.sql lowered the rows already over it.
*/
export const MAX_INVENTION_PRICE = 1000
/**
* Set an invention's price (`v1/updateprice`). Returns the updated invention, or
* null when there's no such row; the caller rejects negative prices.
* null when there's no such row; the caller rejects negative prices and ones over
* {@link MAX_INVENTION_PRICE}.
*/
export async function setInventionPrice(
db: D1Database,
@@ -1158,10 +1278,11 @@ export async function getInventionsByIds(
inventionIds: number[]
): Promise<SavedInvention[]> {
if (inventionIds.length === 0) return []
const placeholders = inventionIds.map((_, i) => `?${i + 1}`).join(', ')
// One bound JSON array rather than a placeholder per id: D1 caps a statement at 100
// bound variables, and the top-today window or a big collection easily passes that.
const { results } = await db
.prepare(`SELECT data FROM invention WHERE id IN (${placeholders})`)
.bind(...inventionIds)
.prepare(`SELECT data FROM invention WHERE id IN (SELECT value FROM json_each(?1))`)
.bind(JSON.stringify(inventionIds))
.all<InventionRow>()
const byId = new Map<number, SavedInvention>()
+104 -35
View File
@@ -143,7 +143,7 @@ export const BacktraceConfig = z.object({
SampleRate: z.int(),
LogLineCount: z.int(),
CaptureNativeCrashes: z.int(),
AMRThresholdMS: z.int(),
ANRThresholdMs: z.int(),
MessageCount: z.int(),
MessageRegex: z.string(),
VersionRegex: z.string(),
@@ -310,6 +310,40 @@ export const ProgressionDto = z.object({
XP: z.int(),
})
/**
* The caller's progress through one progression event (`/api/progressionEvents/record/:id`).
* A stub for now: every counter is 0 and nothing is collected.
*/
export const ProgressionEventRecordDto = z.object({
AccountId: z.int().describe('The caller'),
ProgressionEventId: z.int().describe('The event requested'),
Xp: z.int(),
GameMinutesToday: z.int(),
RewardsCollected: z.int(),
BonusRewardsCollected: z.int(),
XpBoostLastPurchasedAt: z.string().nullable().describe('ISO-8601 UTC; null when never bought'),
PremiumRewardsCollected: z.int(),
LimitedRewardCollectionState: z.int(),
})
/**
* One XP boost a player bought and has running, as `GET /api/purchasableXpBoosts/activations`
* lists it. Documented from the reference shape; nothing here sells or stores boosts yet.
*/
export const XpBoostActivationDto = z.object({
PurchasableXpBoostLedgerEntryId: z.int().describe('The purchase this activation came from'),
PurchasableXpBoostId: z.string().describe('GUID of the boost product bought'),
XpMultiplier: z.number().describe('XP is multiplied by this while the boost runs, e.g. 2.0'),
StartTime: z.string().describe('ISO-8601 UTC'),
EndTime: z.string().describe('ISO-8601 UTC'),
})
/** `GET /api/purchasableXpBoosts/activations` — a paged list of the caller's active boosts. */
export const XpBoostActivationsResponse = z.object({
Results: XpBoostActivationDto.array(),
TotalResults: z.int().describe('The number of entries in `Results`'),
})
/**
* The form body of `POST /api/PlayerCheer/v1/create`. Nothing here is stored beyond the
* counter the cheer increments: `Anonymous` is spent on the notification it triggers and
@@ -361,7 +395,11 @@ export const CheerPlayerResponse = z.object({
export const CreateCustomAvatarItemMetadata = z.object({
Name: z.string(),
Description: z.string().optional(),
Price: z.number().int().optional(),
Price: z
.number()
.int()
.optional()
.describe('Tokens, 0–1000 (MAX_PLAYER_ITEM_PRICE); 0 when left out'),
BaseAvatarItemId: z.number().int(),
BaseAvatarItemColor: z.string().describe('Hex colour, e.g. `#F55C1A`'),
Accessibility: z.number().int().optional(),
@@ -434,7 +472,7 @@ export const CustomAvatarItemDto = z.object({
export const UpdateCustomAvatarItemRequest = z.object({
Name: z.string().nullable().optional(),
Description: z.string().nullable().optional(),
Price: z.number().int().nullable().optional(),
Price: z.number().int().nullable().optional().describe('Tokens, 0–1000 (MAX_PLAYER_ITEM_PRICE)'),
Accessibility: z.number().int().nullable().optional(),
})
@@ -563,7 +601,9 @@ export const InventionV9Dto = z.object({
Name: z.string(),
Description: z.string(),
ImageName: z.string(),
UgcVersion: z.int().describe('The UGC format the blob was written in; 0 when unsent'),
UgcVersion: z
.int()
.describe('The UGC format the blob was written in; 1 when the save named none'),
CurrentVersionNumber: z.int(),
LatestVersionNumber: z.int().describe('The same as CurrentVersionNumber on a fresh save'),
Accessibility: z.int(),
@@ -756,12 +796,22 @@ export const PublishInventionRequest = z.object({
.int()
.nullable()
.optional()
.describe('Private 0, Public 1, Unlisted 2. Unlisted stays out of browse and search'),
.describe(
'Public 1 publishes it into the store; Unlisted 2 publishes it but keeps it out of search and the feeds'
),
Price: z
.int()
.nullable()
.optional()
.describe('Price in tokens; null leaves it as it is, and a negative one is ignored'),
.describe(
'Price in tokens, at most 1000 (MAX_INVENTION_PRICE); null leaves it as it is, a ' +
'negative one is ignored, and one over the cap refuses the publish'
),
})
/** `POST /api/inventions/v2/unpublish` JSON body — the id and nothing else. */
export const UnpublishInventionRequest = z.object({
InventionId: z.int().describe('The invention to unpublish; the caller must have created it'),
})
/** `POST /api/inventions/v2/delete` JSON body — the id and nothing else. */
@@ -785,7 +835,7 @@ export const InventionDeleteResult = z.object({
/** `POST /api/inventions/v1/updateprice` JSON body. */
export const UpdatePriceRequest = z.object({
InventionId: z.int(),
Price: z.int().describe('Must be >= 0'),
Price: z.int().describe('Tokens, 0–1000 (MAX_INVENTION_PRICE)'),
})
/** `POST /api/inventions/v6/save` JSON body — camelCase, unlike the read shapes. */
@@ -1061,6 +1111,14 @@ export const SanitizeRequest = z.object({
/** `POST /api/sanitize/v1/isPure` — whether the text is free of profanity. */
export const IsPureResponse = z.object({ IsPure: z.boolean() })
/** Studio sends repeated Values form fields and consumes this result envelope. */
export const BulkAllowedRequest = z.object({ Values: z.array(z.string()) })
export const BulkAllowedResponse = z.object({
Success: z.boolean(),
Error: z.string().nullable(),
Value: z.object({ ArePure: z.array(z.boolean()) }).nullable(),
})
/** `GET /api/keepsakes/globalconfig` — the keepsake feature switches. */
export const KeepsakeConfig = z.object({
KeepsakeFeatureEnabled: z.boolean(),
@@ -1340,6 +1398,19 @@ export const PlayerEventsAll = z.object({
),
})
/**
* `GET /api/playerevents/v1/all/{playerId}` — a player's public events. Both keys carry
* the 17-key base event; a `Responses` entry pairs it with the player's RSVP.
*/
export const PlayerPublicEvents = z.object({
Created: z.array(PlayerEventBaseDto).describe('Public events the player created'),
Responses: z
.array(
z.object({ PlayerEvent: PlayerEventBaseDto, PlayerEventResponse: PlayerEventResponseDto })
)
.describe('Public events the player is attending, each beside their Going RSVP'),
})
/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
export const PlayerEventsPage = z.object({
ContinuationToken: z.string().describe('Empty = no next page'),
@@ -1363,8 +1434,9 @@ export const VoteToKickReason = z.object({
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — the caller's block. With an
* account-wide ban in force (a `report` row with `banned` set) it describes that ban:
* `IsBan` true, the report's `ReportCategory`, a fixed `Message` of "Rule violation", and
* its span as `TimeoutStartedAt` (the report's `created_at`) plus `Duration` (seconds to
* `ban_expires`; int32 max for a permanent ban). Otherwise it is the "not blocked" answer, mirroring the reference server's stub `ReturnModerationBlockDetails()`:
* `Duration` as the seconds LEFT as of the request (`ban_expires` minus now; int32 max for
* a permanent ban) — the client counts it down from receipt, so it is never the full span.
* `TimeoutStartedAt` stays null for a ban. Otherwise it is the "not blocked" answer, mirroring the reference server's stub `ReturnModerationBlockDetails()`:
* `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and
* `Message` is null — the client distinguishes "no message" from a blank one, so we send
* null where the reference sends an empty string. `IsVoiceModAutoban`/`TimeoutStartedAt`
@@ -1384,7 +1456,7 @@ export const ModerationBlockDetails = z.object({
Duration: z
.int()
.describe(
'Length of the block in seconds from `TimeoutStartedAt`; 2147483647 (int32 max) for a permanent ban; 0 when not blocked'
'Seconds LEFT on the block as of this request (the client counts it down from receipt); 2147483647 (int32 max) for a permanent ban; 0 when not blocked'
),
GameSessionId: z.int(),
IsHostKick: z.boolean().describe('Always false — no host kick is ever recorded here'),
@@ -1404,7 +1476,7 @@ export const ModerationBlockDetails = z.object({
.string()
.nullable()
.describe(
'When the block began — the ban’s report `created_at` (ISO-8601 UTC); `Duration` runs from it. Null when not blocked'
'Always null — the client does not pair it with `Duration` for a ban (it names the start of a voice-chat timeout, which this server never hands out)'
),
AssociatedAccountUsername: z.string().nullable().describe('Always null'),
ShowCreatorCodeOfConduct: z.boolean().describe('Always false'),
@@ -1572,29 +1644,6 @@ export const SavedImageDto = z.object({
CommentCount: z.int(),
})
/**
* `GET /api/images/v6` — an image's metadata by bucket key. A third projection of the same
* row: renamed like `ImagesPlayer` (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`)
* but carrying `ClubId`, and with no nullable fields — `RoomId`, `PlayerEventId` and
* `ClubId` are 0 where the row holds null, `Description` is `""`. Don't unify it with the
* other two.
*/
export const ImageMetadataDto = z.object({
SavedImageId: z.int(),
ImageName: z.string().describe('The bucket key the img worker serves it back by'),
PlayerId: z.int(),
RoomId: z.int().describe('0 when the photo was not taken in a room'),
PlayerEventId: z.int().describe('0 when it belongs to no event'),
ClubId: z.int().describe('Always 0 — nothing here associates an image with a club'),
Description: z.string().describe('Empty string, never null'),
Accessibility: z.int(),
AccessibilityLocked: z.boolean(),
SavedImageType: z.int().describe('1 = share camera, 3 = room, 4 = profile, …'),
CreatedAt: z.string(),
CheerCount: z.int(),
CommentCount: z.int(),
})
/**
* The client's `ImagesPlayer` projection — the same record with `Id` → `SavedImageId`,
* `Type` → `SavedImageType` and no `TaggedPlayerIds`. The player photo lists and feed
@@ -1615,6 +1664,14 @@ export const ImagesPlayerDto = z.object({
RoomId: z.int().nullable(),
})
/**
* The bulk lookup's projection (`GET /api/images/v5/bulk`): {@link ImagesPlayerDto} plus a
* `ClubId`, always null.
*/
export const ImagesBulkDto = ImagesPlayerDto.extend({
ClubId: z.int().nullable().describe('Always null — images are not filed under clubs'),
})
/** One entry in the anonymous slideshow feed, joined to its creator and room. */
export const SlideshowImageDto = z.object({
SavedImageId: z.int(),
@@ -1650,9 +1707,21 @@ export const UploadImageResponse = z.object({
ImageName: z.string().describe('The bucket key; the img worker serves the object by it'),
})
/** `DELETE /api/images/v1/deletesaved` JSON body. */
/** `POST /api/images/v1/deletesaved` JSON body. */
export const DeleteImageRequest = z.object({ ImageName: z.string() })
/** `POST /api/images/v2/modifyaccessibility` JSON body. */
export const ModifyImageAccessibilityRequest = z.object({
ImageName: z.string(),
Accessibility: z.int().describe('0 private, 1 public'),
})
/** `POST /api/images/v1/modifydescription` JSON body. */
export const ModifyImageDescriptionRequest = z.object({
ImageName: z.string(),
Description: z.string().nullable().describe('The new caption; null or empty clears it'),
})
/**
* `POST /api/images/v5/cheered/bulk` form body — the saved-image ids to report cheer state
* for, as a REPEATED `id` field (`id=651&id=570&…`), one value per id. The client sends a
+52 -33
View File
@@ -8,8 +8,8 @@
*
* The `api` worker owns this schema/migration (migrations/0004_report.sql,
* 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql,
* 0017_report_custom_avatar_item.sql, 0020_report_ban_audit.sql and
* 0025_report_chat_message.sql, applied under its own `migrations_table` so it doesn't clash
* 0017_report_custom_avatar_item.sql, 0020_report_ban_audit.sql,
* 0025_report_chat_message.sql and 0034_report_image.sql, applied under its own `migrations_table` so it doesn't clash
* with the other workers' migrations that share the database).
*
* The moderation-side READS here — `searchReports`, `getTopReported`, `getBansInForce` —
@@ -24,7 +24,9 @@
* CREATOR — see `POST /api/playerevents/v1/report`, `POST /api/inventions/v1/report` and
* `POST /api/customAvatarItems/v1/{id}/report`. A reported CHAT MESSAGE is the fourth kind:
* `chat_message_id`, with the message's SENDER as the reported player — see
* `POST /api/chatreport/createChatReport`. The four id columns are mutually exclusive; a row
* `POST /api/chatreport/createChatReport`. A reported IMAGE is the fifth: `image_id`, with
* the player who took it as the reported player — see `POST /api/images/v1/{id}/report`.
* The five id columns are mutually exclusive; a row
* with none of them is an ordinary player report. They are separate columns rather than one
* polymorphic id because the keys differ in TYPE (numbers and a guid) and in what they key.
*
@@ -42,9 +44,10 @@
/**
* Schema DDL (mirror of migrations/0004_report.sql + 0009_report_ban.sql +
* 0011_report_event.sql + 0016_report_invention.sql + 0017_report_custom_avatar_item.sql +
* 0020_report_ban_audit.sql + 0025_report_chat_message.sql + 0029_report_ban_lifted.sql).
* 0020_report_ban_audit.sql + 0025_report_chat_message.sql + 0029_report_ban_lifted.sql +
* 0034_report_image.sql).
*
* None of `event_id`, `invention_id`, `custom_avatar_item_id` or `chat_message_id` is
* None of `event_id`, `invention_id`, `custom_avatar_item_id`, `chat_message_id` or `image_id` is
* indexed: each is written on every report of its kind and read by nothing — no query here filters on any of them,
* and the reads that do exist go by player or by the ban flag. 0011's partial index over
* `event_id` was dropped in 0016 rather than mirrored. Add one back alongside the query that
@@ -71,7 +74,8 @@ export const SCHEMA_DDL: string[] = [
banned_at TEXT,
chat_message_id INTEGER,
unbanned_by_player_id INTEGER,
unbanned_at TEXT
unbanned_at TEXT,
image_id INTEGER
)`,
`CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id)`,
@@ -125,9 +129,10 @@ export interface ReportRow {
banned_by_player_id: number | null
/**
* ISO-8601 UTC instant the ban was HANDED DOWN — distinct from `created_at`, which is
* when the report was filed, and the two can be months apart. This is what the client's
* block screen counts the ban from (see `banBlockDetails`); rows written before
* 0020_report_ban_audit.sql carry NULL and fall back to `created_at`.
* when the report was filed, and the two can be months apart. The staff panel's audit
* trail and its ordering (`getBansInForce`); the client's block screen does NOT count
* from it (see `banBlockDetails`). Rows written before 0020_report_ban_audit.sql carry
* NULL and fall back to `created_at` where a date is needed.
*/
banned_at: string | null
/**
@@ -149,6 +154,13 @@ export interface ReportRow {
unbanned_by_player_id: number | null
/** ISO-8601 UTC instant the ban was lifted; NULL with `unbanned_by_player_id`. */
unbanned_at: string | null
/**
* The saved image (photo) this report is against, or NULL for any other kind — mutually
* exclusive with the four id columns above. See `POST /api/images/v1/{id}/report`: the
* request has no body, so `reported_player_id` is the image's `PlayerId` (who took it),
* read from the image, and there is no category or description to store.
*/
image_id: number | null
}
/**
@@ -174,6 +186,8 @@ export interface NewReport {
customAvatarItemId?: string | null
/** Set only when reporting a CHAT MESSAGE; never set alongside the three above. */
chatMessageId?: number | null
/** Set only when reporting an IMAGE; never set alongside the four above. */
imageId?: number | null
}
/** Record a submitted report, returning the stored row (with its assigned id). */
@@ -183,8 +197,8 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
`INSERT INTO report (
reporter_player_id, reported_player_id, report_category, details,
height_reporter, height_reported, room_id, room_instance_type, created_at,
event_id, invention_id, custom_avatar_item_id, chat_message_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
event_id, invention_id, custom_avatar_item_id, chat_message_id, image_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
RETURNING *`
)
.bind(
@@ -200,7 +214,8 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
input.eventId ?? null,
input.inventionId ?? null,
input.customAvatarItemId ?? null,
input.chatMessageId ?? null
input.chatMessageId ?? null,
input.imageId ?? null
)
.first<ReportRow>()
// RETURNING always yields the inserted row; the non-null assert keeps the caller
@@ -271,8 +286,8 @@ export async function isPlayerBanned(
* report itself intact.
*
* `bannedBy` is the acting moderator, and is recorded alongside `banned_at` — the instant
* the ban was handed down, which is NOT the report's `created_at` and is what the client's
* block screen counts from. A lift clears all four columns together: an unbanned row must
* the ban was handed down, which is NOT the report's `created_at`; the staff panel shows
* and sorts by it. A lift clears all four columns together: an unbanned row must
* not keep an audit trail saying a ban runs from somewhere, and `banned = 0` with a
* `banned_at` still set would read as a ban to anything checking the timestamp.
*
@@ -507,9 +522,9 @@ export async function getBansInForce(
// ---- Block details ----------------------------------------------------------
/**
* `Duration` on a permanent ban. The client's field is a 32-bit int of seconds that PAIRS
* with `TimeoutStartedAt` — start + duration is the end of the block — so a ban with no
* end gets the largest value the field holds, 68 years past its start.
* `Duration` on a permanent ban. The client's field is a 32-bit int of seconds LEFT, so a
* ban with no end gets the largest value the field holds — 68 years, which other
* implementations also send and the client's screen renders as permanent.
*/
export const PERMANENT_BAN_DURATION = 2_147_483_647
@@ -547,18 +562,24 @@ export const NOT_BLOCKED = {
* (the screen at sign-in) and `www`'s live `ModerationKick` frame (the screen when the ban is
* handed down mid-session). Built once here so both screens describe the same ban.
*
* `Duration` and `TimeoutStartedAt` are a PAIR in the client: the block runs from the
* start for the duration. The start is `banned_at`, the instant the ban was handed down
* (see 0020_report_ban_audit.sql), and the duration is the seconds from there to
* `ban_expires`, so the two sum to the expiry; or `PERMANENT_BAN_DURATION` when there is
* none.
* `Duration` is the seconds LEFT on the ban as of `now` — the time from the request to
* `ban_expires`, rounded up so a ban with a second to run never reads 0 — or
* `PERMANENT_BAN_DURATION` when there is no expiry. The client counts it down from the
* moment it receives it, with no reference to when the ban began: other implementations
* with timed bans keep the instant they set the block PRIVATE and serve `Duration` minus
* the elapsed time on every read.
* This once sent the FULL span from `banned_at` instead, on the theory that the client
* pairs it with `TimeoutStartedAt` (start + duration = end). It does not: the screen read
* correctly only at the instant the ban was handed down — when the full span IS the
* remainder, so the mid-session `ModerationKick` looked right — and every later sign-in
* showed the whole ban still to run.
*
* It falls back to the report's `created_at` for a row banned before that column existed.
* The two can be months apart, and using `created_at` as the start — which is what this
* did before there was anything else to use — misreports both halves of the pair: a 7-day
* ban applied to a 30-day-old report told the player their block began a month ago and
* ended three weeks ago. Every pre-migration row still reads exactly as it used to, which
* is the point of the fallback rather than a coalesce to now.
* `TimeoutStartedAt` stays null. Other implementations don't send it for a ban; where it is
* set at all it names the start of a voice-chat timeout, which is what the name is about.
* `banned_at` is the audit trail and the staff panel's, not the client's.
*
* `now` is a parameter so the caller can pass the same instant it resolved the ban with
* (`getActiveBan`), and a ban that was in force at the read can't come out as 0 left.
*
* The category is the one the report was
* filed under, so the client's ban screen names the reason. `Message` is a fixed "Rule
@@ -568,19 +589,17 @@ export const NOT_BLOCKED = {
* reported them. Everything else keeps its `NOT_BLOCKED` value: the other block kinds and
* screen dressings, none of which this server hands out.
*/
export function banBlockDetails(ban: ReportRow) {
const startedAtIso = ban.banned_at ?? ban.created_at
const startedAt = Date.parse(startedAtIso)
export function banBlockDetails(ban: ReportRow, now: Date = new Date()) {
const duration =
ban.ban_expires === null
? PERMANENT_BAN_DURATION
: Math.max(1, Math.ceil((Date.parse(ban.ban_expires) - startedAt) / 1000))
: Math.max(1, Math.ceil((Date.parse(ban.ban_expires) - now.getTime()) / 1000))
return {
...NOT_BLOCKED,
ReportCategory: ban.report_category,
Duration: duration,
IsBan: true,
Message: 'Rule violation',
TimeoutStartedAt: startedAtIso,
TimeoutStartedAt: null,
}
}
+390
View File
@@ -0,0 +1,390 @@
import { Hono } from 'hono'
import {
AVATAR_STUDIO_SLOTS,
avatarItemAccessBit,
avatarSaveDto,
avatarStudioFlags,
avatarStudioItem,
avatarStudioSave,
canManageRoomById,
publishAvatarSave,
} from '@repo/domain'
import { parseMultipart } from '../../../studio/src/multipart'
import { getCustomAvatarItem, toQuestCustomAvatarItem } from '../custom-avatar-items-db'
import { authedId, authedRoles } from '../http'
import type { Context } from 'hono'
import type { AvatarStudioSave } from '@repo/domain'
import type { App } from '../context'
const uuid = '[0-9a-fA-F-]{36}'
const ok = (value: unknown) => ({ success: true, error: null, value })
const fail = (error: string) => ({ success: false, error, value: null })
const slots = new Set(AVATAR_STUDIO_SLOTS)
function validTags(value: unknown): boolean {
return (
value == null ||
(Array.isArray(value) &&
value.length <= 64 &&
value.every(
(t) =>
t &&
Number.isInteger(t.TagType) &&
t.TagType >= 0 &&
t.TagType <= 255 &&
typeof t.Value === 'string' &&
t.Value.length > 0 &&
t.Value.length <= 64
))
)
}
async function allowed(c: Context<App>, accountId: number, slot: number) {
const roles = await authedRoles(c)
return (
roles?.includes('betastudio') &&
(![104, 203].includes(slot) || roles.includes('developer')) &&
((await avatarStudioFlags(c.env.DB, accountId)) & avatarItemAccessBit(slot)) !== 0
)
}
const safeName = (value: unknown): value is string =>
typeof value === 'string' &&
/^[a-zA-Z0-9_./-]+$/.test(value) &&
!value.split('/').some((p) => p === '..' || p === '.' || p === '')
export const avatarStudioRoutes = new Hono<App>()
.get('/api/customAvatarItems/v1/me', async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const rows = await c.env.DB.prepare(
'SELECT data FROM custom_avatar_item WHERE creator_account_id=? ORDER BY created_at DESC,custom_avatar_item_id'
)
.bind(id)
.all<{ data: string }>()
const all = rows.results.map((r) => JSON.parse(r.data))
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
return c.json({ Results: all.slice(skip, skip + take), TotalResults: all.length })
})
.get(`/api/customAvatarItems/v1/:id{${uuid}}`, async (c) => {
const item = await getCustomAvatarItem(c.env.DB, c.req.param('id'))
if (!item) return c.notFound()
if (item.Accessibility === 0 && item.CreatorAccountId !== (await authedId(c)))
return c.body(null, 403)
return c.json(c.req.query('unityAssetTarget') === '2' ? toQuestCustomAvatarItem(item) : item)
})
.post('/api/customAvatarItems/v1', async (c, next) => {
// Preserve the existing in-game custom-shirt contract, including its errors.
const form = await parseMultipart(c.req.raw.clone())
let body: Record<string, unknown>
try {
body = JSON.parse(String(form.metadata))
} catch {
return next()
}
if (
!body ||
typeof body !== 'object' ||
body.BaseAvatarItemId !== undefined ||
form.design ||
form.thumbnailImage
)
return next()
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const slot = Number(body.OutfitType)
if (!Number.isInteger(slot) || !slots.has(slot))
return c.json(fail('Unsupported avatar item type.'), 400)
if (!(await allowed(c, id, slot)))
return c.json(fail('Avatar Studio authorization is required.'), 403)
const badgeRoomId = Number(body.BadgeRoomId ?? 0)
if (
slot === 1000 &&
(!Number.isSafeInteger(badgeRoomId) ||
badgeRoomId < 0 ||
(badgeRoomId > 0 && !(await canManageRoomById(c.env.DB, badgeRoomId, id))))
)
return c.json(fail('Badge room is not managed by this account.'), 403)
if (
typeof body.Name !== 'string' ||
!body.Name.trim() ||
body.Name.length > 128 ||
(body.Description != null &&
(typeof body.Description !== 'string' || body.Description.length > 4000))
)
return c.json(fail('Invalid item name or description.'), 400)
if (
body.Price !== undefined &&
body.Price !== null &&
(!Number.isInteger(body.Price) || Number(body.Price) < 0 || Number(body.Price) > 1000)
)
return c.json(fail('Invalid item price.'), 400)
if (body.Accessibility !== undefined && ![0, 1, 2].includes(Number(body.Accessibility)))
return c.json(fail('Invalid accessibility.'), 400)
if (body.Accessibility && body.Accessibility !== 0)
return c.json(fail('Create the item privately and publish a built save first.'), 400)
if (!validTags(body.Tags)) return c.json(fail('Invalid avatar tags.'), 400)
const now = new Date().toISOString(),
itemId = crypto.randomUUID()
const item = {
CustomAvatarItemId: itemId,
CreatorAccountId: id,
Name: body.Name.trim(),
Description: body.Description ?? '',
Price: body.Price ?? 0,
Accessibility: body.Accessibility ?? 0,
ForceCannotPublish: false,
IsFeatured: false,
IsRecRoomApproved: false,
BaseAvatarItemId: null,
BaseAvatarItemColor: null,
DesignFilename: null,
ThumbnailImageFilename: null,
CreatedAt: now,
ModifiedAt: now,
PreviewOrientation: body.PreviewOrientation ?? 0,
RankingContext: null,
OutfitType: slot,
CurrentSaves: [],
Tags: body.Tags ?? [],
IsStudioAuthored: true,
CustomBadgeMetadata: slot === 1000 ? { RoomId: badgeRoomId } : null,
RankedEntityId: itemId,
PurchaseInfo: null,
}
await c.env.DB.prepare('INSERT INTO custom_avatar_item(data) VALUES (?)')
.bind(JSON.stringify(item))
.run()
return c.json(ok(item))
})
.put(`/api/customAvatarItems/v1/:id{${uuid}}`, async (c, next) => {
const itemId = c.req.param('id')
const existing = await avatarStudioItem(c.env.DB, itemId)
if (!existing?.IsStudioAuthored) return next()
const id = await authedId(c)
if (id === null) return c.body(null, 401)
if (existing.CreatorAccountId !== id || !(await allowed(c, id, existing.OutfitType)))
return c.body(null, 403)
const body = await c.req.json<Record<string, unknown>>().catch(() => null)
if (
!body ||
(body.Name != null &&
(typeof body.Name !== 'string' || !body.Name.trim() || body.Name.length > 128)) ||
(body.Description != null &&
(typeof body.Description !== 'string' || body.Description.length > 4000)) ||
(body.Price != null &&
(!Number.isInteger(body.Price) || Number(body.Price) < 0 || Number(body.Price) > 1000)) ||
(body.Accessibility != null && ![0, 1, 2].includes(Number(body.Accessibility))) ||
!validTags(body.Tags)
)
return c.json(fail('Invalid avatar metadata.'), 400)
for (let attempt = 0; attempt < 5; attempt++) {
const item = await avatarStudioItem(c.env.DB, itemId)
if (!item) return c.notFound()
if (body.Accessibility && !item.CurrentSaves?.length)
return c.json(fail('Publish a built save before making the item public.'), 400)
const prior = JSON.stringify(item)
for (const key of ['Name', 'Description', 'Price', 'Accessibility', 'Tags'])
if (body[key] != null) item[key] = key === 'Name' ? String(body[key]).trim() : body[key]
item.ModifiedAt = new Date().toISOString()
const result = await c.env.DB.prepare(
'UPDATE custom_avatar_item SET data=? WHERE custom_avatar_item_id=? AND json(data)=json(?)'
)
.bind(JSON.stringify(item), itemId, prior)
.run()
if (result.meta.changes) return c.json(ok(item))
}
return c.json(fail('The item changed concurrently. Retry the edit.'), 409)
})
.get(`/api/customAvatarItems/:id{${uuid}}/saves`, async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const itemId = c.req.param('id'),
item = await avatarStudioItem(c.env.DB, itemId)
if (!item) return c.notFound()
if (item.CreatorAccountId !== id) return c.body(null, 403)
const rows = await c.env.DB.prepare(
'SELECT * FROM avatar_studio_save WHERE item_id=? ORDER BY id DESC'
)
.bind(itemId)
.all<AvatarStudioSave>()
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
return c.json(
ok({
Results: await Promise.all(
rows.results.slice(skip, skip + take).map((r) => avatarSaveDto(c.env.DB, r))
),
TotalResults: rows.results.length,
})
)
})
.post(`/api/customAvatarItems/:id{${uuid}}/saves`, async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const itemId = c.req.param('id'),
item = await avatarStudioItem(c.env.DB, itemId)
if (!item) return c.notFound()
if (item.CreatorAccountId !== id || !(await allowed(c, id, item.OutfitType)))
return c.body(null, 403)
const body = await c.req.json<Record<string, unknown>>().catch(() => null)
if (
!body ||
!Number.isInteger(body.BodyType) ||
![0, 1, 2, 3, 4].includes(Number(body.BodyType)) ||
!safeName(body.ThumbnailFileName) ||
typeof body.AdditionalConfiguration !== 'string' ||
body.AdditionalConfiguration.length > 1024 * 1024 ||
(body.SaveDescription != null &&
(typeof body.SaveDescription !== 'string' || body.SaveDescription.length > 4000))
)
return c.json(fail('Invalid avatar save.'), 400)
try {
JSON.parse(body.AdditionalConfiguration)
} catch {
return c.json(fail('Invalid additional configuration JSON.'), 400)
}
let filename: string, hash: string, unityId: string
if (body.NewUnityAssetFile && typeof body.NewUnityAssetFile === 'object') {
const file = body.NewUnityAssetFile as Record<string, unknown>
if (!safeName(file.Filename)) return c.json(fail('Invalid source filename.'), 400)
const object = await c.env.CDN_ASSETS.head('avatarunityasset/' + file.Filename)
if (!object || object.customMetadata?.ownerId !== String(id))
return c.json(fail('Source upload does not belong to this account.'), 403)
const checksum = object.checksums.sha256
hash = checksum ? btoa(String.fromCharCode(...new Uint8Array(checksum))) : ''
if (!hash || file.Hash !== hash)
return c.json(fail('Source hash does not match uploaded bytes.'), 400)
filename = file.Filename
unityId = crypto.randomUUID()
} else if (typeof body.ExistingUnityAssetId === 'string') {
const prior = await c.env.DB.prepare(
'SELECT * FROM avatar_studio_save WHERE item_id=? AND unity_asset_id=? ORDER BY id DESC LIMIT 1'
)
.bind(itemId, body.ExistingUnityAssetId)
.first<AvatarStudioSave>()
if (!prior) return c.json(fail('No matching source revision on this item.'), 400)
filename = prior.source_filename
hash = prior.source_hash
// A source can be reused across builds, but compiled outputs belong to
// one body/configuration. Only metadata-only revisions share their output.
unityId =
prior.body_type === body.BodyType && prior.configuration === body.AdditionalConfiguration
? prior.unity_asset_id
: crypto.randomUUID()
} else return c.json(fail('A new or existing source asset is required.'), 400)
if (item.OutfitType === 1000) {
const config = JSON.parse(body.AdditionalConfiguration as string)
const badgeRoom = config?.d?.d?.r ?? 0
if (
config?.v !== 1 ||
config?.d?.o !== 1000 ||
!Number.isSafeInteger(badgeRoom) ||
badgeRoom < 0
)
return c.json(fail('Invalid badge configuration.'), 400)
if (badgeRoom > 0 && !(await canManageRoomById(c.env.DB, badgeRoom, id)))
return c.json(fail('Badge room is not managed by this account.'), 403)
if (body.BodyType !== 4)
return c.json(fail('Badge images require the body-agnostic body type.'), 400)
const source = await c.env.CDN_ASSETS.get('avatarunityasset/' + filename)
if (!source || source.size > 8 * 1024 * 1024)
return c.json(fail('Missing or oversized badge source.'), 400)
const manifest = await source
.json<{
Version: number
MainAssetProjectRelativePath: string
Files: Array<{ p: string; n: string }>
}>()
.catch(() => null)
if (
!manifest ||
manifest.Version !== 3 ||
!Array.isArray(manifest.Files) ||
!manifest.Files.length ||
manifest.Files.length > 100000 ||
!safeName(manifest.MainAssetProjectRelativePath) ||
!manifest.MainAssetProjectRelativePath.startsWith('Assets/') ||
!/\.(png|jpe?g)$/i.test(manifest.MainAssetProjectRelativePath) ||
manifest.Files.some(
(f) =>
!f ||
!safeName(f.p) ||
!/^(Assets|Packages)\//.test(f.p) ||
/\.(cs|dll|exe|so|dylib|rsp|asmdef|asmref)$/i.test(f.p) ||
!/^[a-z0-9]{40}$/.test(f.n)
) ||
new Set(manifest.Files.map((f) => f.p.toLowerCase())).size !== manifest.Files.length
)
return c.json(fail('Invalid badge image source manifest.'), 400)
const main = manifest.Files.find((f) => f.p === manifest.MainAssetProjectRelativePath)
const image = main ? await c.env.CDN_ASSETS.get('unityasset/' + main.n) : null
if (!image || image.size > 16 * 1024 * 1024)
return c.json(fail('Badge source image is missing or oversized.'), 400)
const signature = new Uint8Array(await image.arrayBuffer()).subarray(0, 8)
if (
!(
signature[0] === 137 &&
signature[1] === 80 &&
signature[2] === 78 &&
signature[3] === 71
) &&
!(signature[0] === 255 && signature[1] === 216 && signature[2] === 255)
)
return c.json(fail('Badge source must be a PNG or JPEG image.'), 400)
}
for (const name of [body.ThumbnailFileName, body.ModerationImageFileName].filter(
(v) => v !== undefined && v !== null
)) {
if (!safeName(name)) return c.json(fail('Invalid image filename.'), 400)
const image = await c.env.CDN_ASSETS.get('image/' + name)
if (!image || image.customMetadata?.ownerId !== String(id))
return c.json(fail('Image upload does not belong to this account.'), 403)
if (image.size > 16 * 1024 * 1024) return c.json(fail('Avatar image exceeds limit.'), 413)
// Storage's image upload and the Images service use different buckets.
// Keep the SDK filename unchanged when publishing creator-owned thumbnails.
await c.env.IMAGES.put(name, image.body, { httpMetadata: image.httpMetadata })
}
const row = await c.env.DB.prepare(
'INSERT INTO avatar_studio_save(item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,moderation_image,configuration,description,update_current,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?) RETURNING id'
)
.bind(
itemId,
body.BodyType,
unityId,
filename,
hash,
body.ThumbnailFileName,
typeof body.ModerationImageFileName === 'string' ? body.ModerationImageFileName : null,
body.AdditionalConfiguration,
body.SaveDescription ?? null,
body.UpdateCurrentSaveIfPrivate === true ? 1 : 0,
new Date().toISOString()
)
.first<{ id: number }>()
if (body.UpdateCurrentSaveIfPrivate === true && item.Accessibility === 0)
await publishAvatarSave(c.env.DB, itemId, row!.id, true)
return c.json(ok(row!.id))
})
.post(`/api/customAvatarItems/:id{${uuid}}/saves/:saveId{[1-9][0-9]*}/publish`, async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const item = await avatarStudioItem(c.env.DB, c.req.param('id'))
if (!item) return c.notFound()
if (item.CreatorAccountId !== id || !(await allowed(c, id, item.OutfitType)))
return c.body(null, 403)
const save = await avatarStudioSave(
c.env.DB,
item.CustomAvatarItemId,
Number(c.req.param('saveId'))
)
if (item.OutfitType === 1000 && save) {
const badgeRoom = JSON.parse(save.configuration)?.d?.d?.r ?? 0
if (badgeRoom > 0 && !(await canManageRoomById(c.env.DB, badgeRoom, id)))
return c.body(null, 403)
}
if (!save || !(await publishAvatarSave(c.env.DB, item.CustomAvatarItemId, save.id)))
return c.json(fail('Save has not completed a build.'), 400)
return c.json(ok(null))
})
+194 -43
View File
@@ -13,6 +13,7 @@ import {
MAX_BULK_OUTFIT_ACCOUNTS,
setOutfit,
} from '@repo/domain'
import { SNIFFED_IMAGE_FORMATS, sniffImage } from '@repo/hono-helpers'
import {
createCustomAvatarItem,
@@ -23,6 +24,7 @@ import {
listCustomAvatarItemsByCreator,
listFeaturedCustomAvatarItems,
listHotCustomAvatarItems,
MAX_PLAYER_ITEM_PRICE,
searchCustomAvatarItems,
toQuestCustomAvatarItem,
updateCustomAvatarItem,
@@ -33,6 +35,7 @@ import {
deleteInvention,
getFeaturedInventions,
getInventionById,
getInventionsByCreators,
getInventionsByIds,
getInventionsByRoom,
getInventionTagFilters,
@@ -44,6 +47,7 @@ import {
inventionDeleteResult,
inventionSaveV9Failure,
isInventionCheered,
MAX_INVENTION_PRICE,
normalizeInventionTags,
ownsAllInventions,
parsePermissionLevel,
@@ -54,6 +58,7 @@ import {
setInventionTags,
toSaveResult,
toSaveResultV9,
unpublishInvention,
updateInvention,
} from '../inventions-db'
import {
@@ -81,6 +86,7 @@ import {
InventionReportRequest,
InventionSaveResult,
InventionSaveV9Result,
InventionV9Dto,
InventionVersionDto,
json,
JsonArray,
@@ -104,6 +110,7 @@ import {
SuccessValueEnvelope,
TagFilters,
UNAUTHORIZED_RESPONSE,
UnpublishInventionRequest,
UpdateCustomAvatarItemRequest,
UpdateInventionMetadataRequest,
UpdateInventionRequest,
@@ -467,18 +474,27 @@ export const avatarRoutes = new Hono<App>({ strict: false })
summary: 'Create a custom avatar item',
description:
'Multipart: a `metadata` JSON text field plus two file parts, `thumbnailImage` ' +
'(PNG) and `design` (the design blob). Inserts a `custom_avatar_item` row owned ' +
'(PNG) and `design` (the design PNG). Inserts a `custom_avatar_item` row owned ' +
'by the caller and answers with it in the PascalCase `{ Value, Success, Error, ' +
'error_id }` envelope. The item is filed under `OutfitType` 105, the custom shirt, ' +
'which is what the store’s user-generated-content tab searches for.\n\n' +
'The two files go to the shared image bucket (`recflare-img`) under ' +
'`avatar-item/<date>/<id>-thumb.png` and `avatar-item/<date>/<id>-design.png`; those ' +
'keys are the `ThumbnailImageFilename` / `DesignFilename` on the row.',
'keys are the `ThumbnailImageFilename` / `DesignFilename` on the row. Each file is ' +
'identified by its bytes — the client posts them under `.bin` names — and must be a ' +
'PNG or JPEG; the key’s extension and the stored content-type come ' +
'from that signature, and anything else is refused with a 400 before either file is ' +
'written.\n\n' +
'`Price` is capped at 1000 tokens (`MAX_PLAYER_ITEM_PRICE`); one above it, or below ' +
'0, is refused with a 400 rather than clamped.',
security: AUTHED,
requestBody: form(CreateCustomAvatarItemRequest, 'The metadata and the two files'),
responses: {
200: json(CustomAvatarItemResponse, 'The created item'),
400: json(CustomAvatarItemResponse, 'Missing or malformed metadata / files'),
400: json(
CustomAvatarItemResponse,
'Missing or malformed metadata / files, a file that is not an image, or a `Price` outside 0–1000'
),
401: UNAUTHORIZED_RESPONSE,
413: json(CustomAvatarItemResponse, 'Either file exceeds the configured per-file limit'),
},
@@ -504,6 +520,13 @@ export const avatarRoutes = new Hono<App>({ strict: false })
if (typeof meta.BaseAvatarItemId !== 'number') return fail('BaseAvatarItemId is required')
if (typeof meta.BaseAvatarItemColor !== 'string')
return fail('BaseAvatarItemColor is required')
if (meta.Price !== undefined && meta.Price !== null) {
if (typeof meta.Price !== 'number' || !Number.isInteger(meta.Price))
return fail('Price must be an integer')
if (meta.Price < 0) return fail('Price must be >= 0')
if (meta.Price > MAX_PLAYER_ITEM_PRICE)
return fail(`Price must be <= ${MAX_PLAYER_ITEM_PRICE}`)
}
if (!(body.thumbnailImage instanceof File)) return fail('thumbnailImage is required')
if (!(body.design instanceof File)) return fail('design is required')
const limit = maxApiUploadBytes(c.env)
@@ -532,19 +555,30 @@ export const avatarRoutes = new Hono<App>({ strict: false })
)
}
// The parts' filenames and content-types are the uploader's claims (the client
// posts both as `.bin`); the `img` worker serves each back under whatever type is
// stored, so the extension and the stored type come from the bytes. Both are
// checked before either is written, so a refusal leaves nothing in R2.
const thumbnailBytes = new Uint8Array(await body.thumbnailImage.arrayBuffer())
const thumbnail = sniffImage(thumbnailBytes)
if (!thumbnail) return fail(`thumbnailImage is not a ${SNIFFED_IMAGE_FORMATS} image`)
const designBytes = new Uint8Array(await body.design.arrayBuffer())
const design = sniffImage(designBytes)
if (!design) return fail(`design is not a ${SNIFFED_IMAGE_FORMATS} image`)
// Both files go to the shared image bucket, foldered by upload date and keyed by
// the item's id (chosen here so the keys can carry it). The `img` worker serves
// them back by key.
const customAvatarItemId = crypto.randomUUID()
const prefix = `avatar-item/${new Date().toISOString().slice(0, 10)}/${customAvatarItemId}`
const thumbnailImageFilename = `${prefix}-thumb.png`
const designFilename = `${prefix}-design.png`
const thumbnailImageFilename = `${prefix}-thumb${thumbnail.extension}`
const designFilename = `${prefix}-design${design.extension}`
await Promise.all([
c.env.IMAGES.put(thumbnailImageFilename, await body.thumbnailImage.arrayBuffer(), {
httpMetadata: { contentType: body.thumbnailImage.type || 'image/png' },
c.env.IMAGES.put(thumbnailImageFilename, thumbnailBytes, {
httpMetadata: { contentType: thumbnail.contentType },
}),
c.env.IMAGES.put(designFilename, await body.design.arrayBuffer(), {
httpMetadata: { contentType: body.design.type || 'image/png' },
c.env.IMAGES.put(designFilename, designBytes, {
httpMetadata: { contentType: design.contentType },
}),
])
@@ -572,13 +606,14 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'A partial edit of `Name`, `Description`, `Price` and `Accessibility` — the client ' +
'sends every field and nulls the ones it is not changing, so null means "leave ' +
'alone". Only the creator may edit. `ModifiedAt` is bumped. Answers the updated ' +
'item in the same `{ Value, Success, Error, error_id }` envelope as the create.',
'item in the same `{ Value, Success, Error, error_id }` envelope as the create. ' +
'`Price` is bound the same way as on the create: 0 to 1000 tokens, or a 400.',
security: AUTHED,
parameters: [stringParam('id', 'The `CustomAvatarItemId`')],
requestBody: jsonBody(UpdateCustomAvatarItemRequest, 'The fields to change'),
responses: {
200: json(CustomAvatarItemResponse, 'The updated item'),
400: json(CustomAvatarItemResponse, 'Malformed body'),
400: json(CustomAvatarItemResponse, 'Malformed body, or a `Price` outside 0–1000'),
401: UNAUTHORIZED_RESPONSE,
403: json(CustomAvatarItemResponse, 'Not the creator'),
404: json(CustomAvatarItemResponse, 'No such item'),
@@ -621,6 +656,9 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
if (patch.name !== null && patch.name?.trim() === '')
return fail(400, 'Name must not be blank')
if (patch.price !== null && patch.price < 0) return fail(400, 'Price must be >= 0')
if (patch.price !== null && patch.price > MAX_PLAYER_ITEM_PRICE)
return fail(400, `Price must be <= ${MAX_PLAYER_ITEM_PRICE}`)
const item = await updateCustomAvatarItem(c.env.DB, itemId, patch)
if (!item) return fail(404, 'No such item')
@@ -1458,15 +1496,18 @@ export const avatarRoutes = new Hono<App>({ strict: false })
summary: 'Publish an invention',
description:
'What puts an invention into search and the feeds. Sets the permission other ' +
'players get (defaulting to UseOnly) and its price. Another GET that writes.',
'players get (defaulting to UseOnly) and its price. Another GET that writes. A ' +
'price over 1000 tokens (`MAX_INVENTION_PRICE`) refuses the whole publish with a ' +
'400 rather than being clamped or dropped.',
security: AUTHED,
parameters: [
intQuery('inventionId', 'Invention id; required'),
stringQuery('permissionLevel', 'A name like `useonly`, or the raw number'),
intQuery('price', 'Price in tokens; negative is ignored'),
intQuery('price', 'Price in tokens, at most 1000; negative is ignored'),
],
responses: {
200: json(InventionSaveResult, 'The published invention, in the save envelope'),
400: json(ErrorResponse, 'A price over 1000'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the caller’s invention'),
404: { description: 'No such invention' },
@@ -1478,6 +1519,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const permissionLevel = c.req.query('permissionLevel')
const price = Number.parseInt(c.req.query('price') ?? '', 10)
if (price > MAX_INVENTION_PRICE)
return c.json({ error: `Price must be <= ${MAX_INVENTION_PRICE}` }, 400)
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
permissionLevel:
@@ -1497,12 +1540,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
summary: 'Set an invention’s price',
description:
'Unlike update/publish, this one POSTs a JSON body. Creator only; a negative price ' +
'is rejected.',
'is rejected, as is one over 1000 tokens (`MAX_INVENTION_PRICE`).',
security: AUTHED,
requestBody: jsonBody(UpdatePriceRequest, 'The invention and its new price'),
responses: {
200: json(InventionSaveResult, 'The repriced invention, in the save envelope'),
400: json(ErrorResponse, 'Unparseable body, or a price below 0'),
400: json(ErrorResponse, 'Unparseable body, or a price below 0 or over 1000'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the caller’s invention'),
404: { description: 'No such invention' },
@@ -1518,6 +1561,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const price = typeof body.Price === 'number' ? body.Price : Number.NaN
if (Number.isNaN(price) || price < 0) return c.json({ error: 'Price must be >= 0' }, 400)
if (price > MAX_INVENTION_PRICE)
return c.json({ error: `Price must be <= ${MAX_INVENTION_PRICE}` }, 400)
const updated = await setInventionPrice(c.env.DB, gate.invention.InventionId, price)
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
@@ -1677,35 +1722,35 @@ export const avatarRoutes = new Hono<App>({ strict: false })
)
// Inventions by particular creators (`?id=207&id=…`) — what the client fills a creator's
// shelf, and the "from creators you follow" row, from.
// portfolio, and the "from creators you follow" row, from.
//
// STUB: an empty list for now. It is the honest answer rather than a placeholder, since
// the client reads it as "this creator has published nothing" and renders an empty
// shelf, where a 404 would read as a row that failed to load. When it becomes real it is
// a filter on the invention table's creator column, the same feed shape as `toptoday`
// and `featured` above — `id` is repeatable, and `skip`/`take` page it.
// Published only, for every caller — a creator's own drafts are `v2/mine`'s alone.
.get(
'/api/inventions/v1/fromcreators',
describeRoute({
tags: ['Inventions'],
summary: 'Inventions by particular creators (stub)',
summary: 'Inventions by particular creators',
description:
'The published inventions of the accounts named by `id` (repeatable), newest first — ' +
'a creator’s shelf, and the "from creators you follow" row. STUB: always an empty ' +
'array for now, which the client renders as "nothing published" rather than as a ' +
'failed load. `id`, `skip` and `take` are accepted and, for the moment, ignored.',
parameters: [
intQuery('id', 'Creator account id; repeatable. Accepted and ignored by the stub'),
...pageParams(100),
],
responses: { 200: json(InventionDto.array(), 'Empty — nothing is served here yet') },
'The inventions of the accounts named by `id` (repeatable), newest first — a ' +
'creator’s portfolio, and the "from creators you follow" row. Only PUBLISHED ' +
'inventions are listed, the caller’s own included: unpublished ones are served by ' +
'`v2/mine` alone. No `id` is an empty array.',
parameters: [intQuery('id', 'Creator account id; repeatable'), ...pageParams(100)],
responses: {
200: json(InventionDto.extend(InventionV9Dto.shape).array(), 'The creators’ inventions'),
},
}),
(c) => c.json([])
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
return c.json(await getInventionsByCreators(c.env.DB, inventionIdQuery(c), skip, take))
}
)
// Invention search/browse: published inventions matching `value` (matched against
// name + description; absent → browse everything published), newest first.
// Paginated via skip/take (take defaults to 100). Returns a bare array.
// name + description; absent → browse everything published; `@username` → that
// player's published inventions), newest first. Paginated via skip/take (take
// defaults to 100). Returns a bare array.
//
// Filtered, ordered and paged in SQL — it must not read the catalogue into memory to
// answer one page.
@@ -1718,10 +1763,16 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'Published inventions matching `value`, newest first. `value` is split into terms ' +
'and every term must match, each against the name and the description. An absent ' +
'`value` browses everything published — that is the browse screen’s initial ' +
'request. Tags are NOT searched: a `#tag` term from the browse screen’s filter ' +
'chips is treated as text and matches nothing.',
'request. A `value` starting with `@` names a player: `@djdevin` lists that ' +
'player’s PUBLISHED inventions (the same set `v1/fromcreators` serves), resolved by ' +
'username case-insensitively, with any further terms narrowing them as text; an ' +
'unknown username is an empty array. Tags are NOT searched: a `#tag` term from the ' +
'browse screen’s filter chips is treated as text and matches nothing.',
parameters: [
stringQuery('value', 'Search text; absent browses everything'),
stringQuery(
'value',
'Search text; absent browses everything; `@username` is a player’s inventions'
),
...pageParams(100),
],
responses: { 200: json(InventionDto.array(), 'The matching inventions') },
@@ -1750,7 +1801,10 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'invention listed even if it has since been unpublished or hidden. Not paginated.',
security: AUTHED,
responses: {
200: json(InventionDto.array(), 'The caller’s inventions'),
200: json(
InventionDto.extend(InventionV9Dto.shape).array(),
'The caller’s inventions — the stored record with the newer client’s flat DTO laid over it'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -1866,6 +1920,49 @@ export const avatarRoutes = new Hono<App>({ strict: false })
return c.json({ success: true, error: '' })
}
)
// The newer client's cheer: the same body as `v1/cheer` and the same write, at a new
// path. It answers the `{ Value, Success, Error, error_id }` envelope the other v2+
// invention routes use, refusals in-band — an ASSUMPTION: only the request has been
// observed, not what the client reads off the reply.
.post(
'/api/inventions/v2/cheer',
describeRoute({
tags: ['Inventions'],
summary: 'Cheer or un-cheer an invention (v2)',
description:
'`v1/cheer` for the newer client: persists the caller’s cheer state and resyncs ' +
'the invention’s `CheerCount`; repeating the same state is idempotent. Answers ' +
'the `{ Value, Success, Error, error_id }` envelope with `Value` null, and a ' +
'refusal — a bad body, an unknown invention — is `Success: false` with a message ' +
'rather than an error status.',
security: AUTHED,
requestBody: jsonBody(InventionCheerRequest, 'The invention and new cheer state'),
responses: {
200: json(InventionDeleteResult, 'The envelope, `Value` null either way'),
401: json(InventionDeleteResult, 'The same envelope, refused — not an empty body'),
},
}),
async (c) => {
const playerId = await authedId(c)
if (playerId === null) return c.json(inventionDeleteResult('Unauthorized'), 401)
const body = await c.req
.json<{ InventionId?: unknown; Cheer?: unknown }>()
.catch(() => ({}) as Record<string, unknown>)
const inventionId = body.InventionId
if (
typeof inventionId !== 'number' ||
!Number.isInteger(inventionId) ||
typeof body.Cheer !== 'boolean'
) {
return c.json(inventionDeleteResult('InventionId and Cheer are required'))
}
if ((await getInventionById(c.env.DB, inventionId)) === null) {
return c.json(inventionDeleteResult('No such invention'))
}
await setInventionCheer(c.env.DB, playerId, inventionId, body.Cheer)
return c.json(inventionDeleteResult())
}
)
// Report an invention. Stored in the `report` table the player and event reports use —
// same fields, same moderation life — with `invention_id` set. See
// migrations/0016_report_invention.sql.
@@ -2244,13 +2341,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
description:
'What puts an invention into search and the feeds. Creator only.\n\n' +
'`Permission` is the `GeneralPermission` other players get, as a raw ladder ' +
'number (the publish sheet sends 20, UseOnly). `Accessibility` says where it can ' +
'be found — 1 (Public) lists it, 2 (Unlisted) publishes it reachable by id but ' +
'keeps it out of browse and search. A null `Price` leaves the price alone rather ' +
'number (the publish sheet sends 20, UseOnly). `Accessibility` says whether it ' +
'is in the store — 1 (Public) lists it in search and the feeds, 2 (Unlisted) ' +
'publishes it onto the creator’s portfolio and reachable by id but keeps it out ' +
'of both. A null `Price` leaves the price alone rather ' +
'than zeroing it, so re-publishing something that was for sale doesn’t give it ' +
'away; every field but `InventionId` is nullable and an omitted one keeps what ' +
'the invention has.\n\n' +
'Publishing is not undone here, and re-publishing doesn’t re-date the first ' +
'the invention has. A price over 1000 tokens (`MAX_INVENTION_PRICE`) refuses the ' +
'publish in-band.\n\n' +
'Publishing is undone by `v2/unpublish`, and re-publishing doesn’t re-date the first ' +
'publish. Refusals answer `Success: false` with a null `Value`, the way ' +
'`v9/save` does.',
security: AUTHED,
@@ -2287,6 +2386,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const int = (key: string): number | undefined =>
typeof body[key] === 'number' && Number.isInteger(body[key]) ? body[key] : undefined
const price = int('Price')
if (price !== undefined && price > MAX_INVENTION_PRICE)
return c.json(inventionSaveV9Failure(`Price must be <= ${MAX_INVENTION_PRICE}`))
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
permissionLevel: int('Permission'),
@@ -2299,6 +2400,56 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// Unpublish an invention: `v4/publish` undone. Same body shape as `v2/delete` — the id
// and nothing else — and creator only.
//
// The reply is the `v9/save` envelope carrying the UPDATED invention, as `v4/publish`
// answers. That is an ASSUMPTION: what the client reads off this response has not been
// observed, only the request.
.post(
'/api/inventions/v2/unpublish',
describeRoute({
tags: ['Inventions'],
summary: 'Unpublish an invention',
description:
'Creator only. Takes the invention back out of search, the feeds and the ' +
'creator’s portfolio: `IsPublished` goes false and `Accessibility` back to 0, the ' +
'state a save mints, so the next `v4/publish` sets both afresh. Players who ' +
'already bought it keep it, the price and permission are left as they are, and ' +
'the first-publish date is not cleared.\n\n' +
'Answers the enveloped result `v4/publish` answers, carrying the unpublished ' +
'invention. Refusals are `Success: false` with a null `Value`.',
security: AUTHED,
requestBody: jsonBody(UnpublishInventionRequest, 'The invention to unpublish'),
responses: {
200: json(
InventionSaveV9Result,
'The envelope — the unpublished invention under `Value`, or `Success: false` ' +
'with `Error` when it was refused'
),
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
},
}),
async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
const gate = await creatorsInventionResult(
c,
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
)
if ('rejection' in gate) {
return gate.status === 401
? c.json(inventionSaveV9Failure(gate.rejection), 401)
: c.json(inventionSaveV9Failure(gate.rejection))
}
const unpublished = await unpublishInvention(c.env.DB, gate.invention.InventionId)
if (unpublished === null) return c.json(inventionSaveV9Failure('No such invention'))
return c.json(toSaveResultV9(unpublished, unpublished.Tags ?? []))
}
)
// Delete an invention. The newer client's shape: a POST with a PascalCase body
// carrying nothing but the id. Auth-gated, creator only — the only thing that may
// remove an invention is the account that made it, not a co-owner and not a buyer.
+22 -17
View File
@@ -1,8 +1,8 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { GAME_VERSION, isSupportedGameVersion } from '@repo/domain'
import { validateAndGetVersion } from '@repo/jwt'
import { GAME_VERSION, isSupportedGameVersion, supportedGameBuilds } from '@repo/domain'
import { validateAndGetBuild } from '@repo/jwt'
import apiConfigV2 from '../../static/api-config-v2.json'
import gameConfigsV1All2025 from '../../static/gameconfigs-v1-all-2025.json'
@@ -73,15 +73,15 @@ export const configRoutes = new Hono<App>({ strict: false })
}),
(c) =>
c.json({
ReportBudget: 125,
ReportBudget: 0,
FilterType: 0,
SampleRate: 1,
LogLineCount: 50,
CaptureNativeCrashes: 1,
AMRThresholdMS: 0,
MessageCount: 1000,
MessageRegex: '^.*$',
VersionRegex: '.*',
SampleRate: 0,
LogLineCount: 0,
CaptureNativeCrashes: 0,
ANRThresholdMs: 0,
MessageCount: 0,
MessageRegex: '',
VersionRegex: '',
})
)
// ShareBaseUrl is derived from the deploy-time base domain; the rest of the
@@ -106,13 +106,17 @@ export const configRoutes = new Hono<App>({ strict: false })
summary: 'Client version check',
description:
'Whether the client build is current. Compares the client’s `?v=` build against ' +
'the builds we serve (`SUPPORTED_GAME_VERSIONS`): `VersionStatus` is 0 when the ' +
'client is on one of them, 1 when it is on some other build.',
'the builds we serve — the operator’s `SUPPORTED_GAME_VERSIONS` var (or the ' +
'default list in `@repo/domain`) plus every build under rollout ' +
'(`ROLLOUT_GAME_VERSIONS`): `VersionStatus` is 0 when the client is on one of ' +
'them, 1 when it is on some other build. The same lists decide which builds ' +
'`auth` hands a token to, so a build dropped from them is told to update here ' +
'and refused there.',
responses: { 200: json(VersionCheck, 'Version status') },
}),
(c) =>
c.json({
VersionStatus: isSupportedGameVersion(c.req.query('v')) ? 0 : 1,
VersionStatus: isSupportedGameVersion(c.req.query('v'), supportedGameBuilds(c.env)) ? 0 : 1,
UpdateNotificationStage: 0,
IsVersionIslanded: false,
IsCrossPlayDisabled: false,
@@ -140,8 +144,9 @@ export const configRoutes = new Hono<App>({ strict: false })
// Which one a caller gets is decided by the token's `rn.ver` claim — the build the
// client posted at login — since the request itself carries no version. Anything NEWER
// than `GAME_VERSION` (20230414) gets the 2025 catalog; that build and anything older
// get the 2023 one. Builds are date-stamped (`20230414`, `20250718.01`), so they order
// as strings, the same comparison `match` makes for cross-build joins.
// get the 2023 one. Builds are date-stamped (`20230414`, `20250718`), so they order as
// strings, the same comparison `match` makes for cross-build joins — compared as BUILDS,
// so a same-day point release of the target (`20230414.01`) stays on the 2023 catalog.
//
// A request with no readable token version gets the 2023 catalog, the same body this
// route has always served: unauthenticated is not evidence of a newer client, and this
@@ -163,8 +168,8 @@ export const configRoutes = new Hono<App>({ strict: false })
responses: { 200: json(JsonObject, 'The game config catalog for the caller’s build') },
}),
async (c) => {
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
const newerThanTarget = version !== null && version > GAME_VERSION
const build = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
const newerThanTarget = build !== null && build > GAME_VERSION
return c.json(newerThanTarget ? gameConfigsV1All2025 : gameConfigsV1All)
}
)
+47 -9
View File
@@ -3,7 +3,7 @@ import { describeRoute } from 'hono-openapi'
import { Accessibility, createNotification, GAME_VERSION, MessageType } from '@repo/domain'
import { logger } from '@repo/hono-helpers'
import { validateAndGetVersion } from '@repo/jwt'
import { validateAndGetBuild } from '@repo/jwt'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
// as a value — the enum has no runtime dependencies.
@@ -23,9 +23,11 @@ import {
getEventTags,
getGoingPlayerIds,
getLiveEvents,
getPublicEventsByPlayer,
inviteToEvent,
isEventResponseType,
parseEventBody,
parseEventSort,
parseEventTags,
parseEventTime,
searchEvents,
@@ -63,6 +65,7 @@ import {
PlayerEventsPage,
PlayerEventTagsRequest,
PlayerEventTimeRequest,
PlayerPublicEvents,
stringQuery,
SuccessErrorEnvelope,
TagFilters,
@@ -303,15 +306,16 @@ async function pushEventInvitation(
* the split is the one `/api/gameconfigs/v1/all` already makes: anything NEWER than
* `GAME_VERSION` (20230414) gets the pairs; that build, anything older, and a request
* with no readable token version all get the 2023 names. Builds are date-stamped, so they
* order as strings. `TagModifyResult.Tags` is a name list to every build — only the
* inline field moves.
* order as strings — compared as BUILDS, with the client's point release dropped, so a
* same-day rebuild of the target stays on its side. `TagModifyResult.Tags` is a name list
* to every build — only the inline field moves.
*
* Like the other version gates here the claim is unverified — a client that lies about its
* build only empties its own tag chips.
*/
async function eventResult(c: Context<App>, event: PlayerEvent, tags: EventTag[]) {
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
const isModernBuild = version !== null && version > GAME_VERSION
const build = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
const isModernBuild = build !== null && build > GAME_VERSION
return toEventResult(event, tags, isModernBuild)
}
@@ -446,6 +450,31 @@ export const eventRoutes = new Hono<App>({ strict: false })
}
)
// Another player's events, as their profile shows them: PUBLIC events only, and under
// `Responses` only the ones they are attending (Going). Public. Unlike the caller's own
// `/all` above, both keys carry the client's 17-key base event, and a `Responses` entry
// is a PAIR — the event beside the RSVP record.
.get(
'/api/playerevents/v1/all/:playerId{[0-9]+}',
describeRoute({
tags: ['Events'],
summary: 'A player’s public events',
description:
'The events on a player’s profile: the PUBLIC ones they created (`Created`) and ' +
'the PUBLIC ones they are attending (`Responses`), each soonest first. Public (no ' +
'auth); finished events are left out, and a player with none — or an unknown id — ' +
'gets two empty lists.\n\n' +
'Events are the client’s 17-key BASE event, as on the browse feed. A `Responses` ' +
'entry is a pair, `{ PlayerEvent, PlayerEventResponse }`, and only Going RSVPs ' +
'(`Type` 0) are listed — a maybe, a decline or an unanswered invitation is not ' +
'attending. A creator is Going to their own event, so it appears under both keys.',
parameters: [idParam('playerId', 'Player id')],
responses: { 200: json(PlayerPublicEvents, 'The player’s public events') },
}),
async (c) =>
c.json(await getPublicEventsByPlayer(c.env.DB, Number.parseInt(c.req.param('playerId'), 10)))
)
// The tag filter chips on the player-events browse screen. Static: these are the
// categories the client offers when creating an event, so the list doesn't depend on
// what's stored. `TrendingFilters` is null even in the reference — it needs
@@ -600,14 +629,15 @@ export const eventRoutes = new Hono<App>({ strict: false })
'match and the two kinds combine, so `#workshops trigonometry` is the ' +
'workshops-tagged events whose text also mentions trigonometry.\n\n' +
'Events that have already finished are left out — a name match on something that ' +
'ended last month is noise on a browse screen. Soonest first, paginated via ' +
'ended last month is noise on a browse screen. Ordered by `sort` and paginated via ' +
'skip/take. A bare array.',
parameters: [
stringQuery('query', 'Search terms; `#tag` matches a tag, anything else the text'),
stringQuery(
'sort',
'Accepted and echoed by the client as `StartTime`, which is the only order ' +
'served (soonest first); any other value sorts the same way'
'`StartTime` (soonest first, the default) or `Attendance` (most Going replies ' +
'first — the event’s `AttendeeCount` — ties soonest first). Case-insensitive; ' +
'any other value is served as `StartTime`'
),
...pageParams(50),
],
@@ -616,7 +646,15 @@ export const eventRoutes = new Hono<App>({ strict: false })
async (c) => {
const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0
const take = Number.parseInt(c.req.query('take') ?? '', 10) || 50
return c.json(await searchEvents(c.env.DB, c.req.query('query') ?? '', skip, take))
return c.json(
await searchEvents(
c.env.DB,
c.req.query('query') ?? '',
skip,
take,
parseEventSort(c.req.query('sort'))
)
)
}
)
+41
View File
@@ -1,6 +1,8 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { validateStudioBuildValidationToken } from '@repo/jwt'
import charadesAprilWords from '../../static/charades-april.json'
import charadesWords from '../../static/charades.json'
import communityBoard from '../../static/community-board.json'
@@ -8,6 +10,9 @@ import { authedId, unauthorized } from '../http'
import {
AUTHED,
BareString,
BulkAllowedRequest,
BulkAllowedResponse,
form,
idParam,
IsPureResponse,
json,
@@ -144,6 +149,42 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
}
)
// Studio checks all component text in one request, preserving duplicates and order.
.post(
'/api/sanitize/v2/isAllowed/bulk',
describeRoute({
tags: ['Gameplay'],
summary: 'Check Studio component text in bulk',
description:
'Checks repeated Values form fields using the existing profanity policy. ArePure matches the input order, including duplicate and empty strings. Accepts an account token or a dedicated Studio build-validation credential with no account identity.',
security: AUTHED,
requestBody: form(BulkAllowedRequest, 'Repeated Values fields'),
responses: {
200: json(BulkAllowedResponse, 'Ordered results in a Studio result envelope'),
400: json(BulkAllowedResponse, 'Invalid form values'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
if (
(await authedId(c)) === null &&
!(await validateStudioBuildValidationToken(c.req.raw, await c.env.JWT_SECRET.get()))
)
return unauthorized(c)
const body = await c.req.parseBody({ all: true }).catch(() => null)
const field = body?.Values
const values = field === undefined ? [] : Array.isArray(field) ? field : [field]
if (body === null || values.some((value) => typeof value !== 'string')) {
return c.json({ Success: false, Error: 'Values must be strings.', Value: null }, 400)
}
return c.json({
Success: true,
Error: null,
Value: { ArePure: values.map((value) => !containsSwears(value as string)) },
})
}
)
// ---- Activities -----------------------------------------------------------
// Word bank for the Charades activity. The client requests the list by
// activity name (`.../words/Charades`); other activities have no data yet.
+230 -46
View File
@@ -5,19 +5,25 @@ import {
createImage,
deleteImage,
getCheeredImageIds,
getImageById,
getImageByName,
getImagesByIds,
getImagesByPlayer,
getImagesByRoom,
getPlayerFeed,
getSlideshowImages,
mergePlayerSettings,
readPlayerSettings,
SavedImageType,
setImageAccessibility,
setImageCheer,
setImageDescription,
SLIDESHOW_LIMIT,
SLIDESHOW_MAX_LIMIT,
toImageMetadata,
toImagesBulk,
toImagesPlayer,
} from '@repo/domain'
import { SNIFFED_IMAGE_FORMATS, sniffImage } from '@repo/hono-helpers'
import { authedId, unauthorized } from '../http'
import {
@@ -29,23 +35,27 @@ import {
ErrorResponse,
form,
idParam,
ImageMetadataDto,
ImagesBulkDto,
ImagesPlayerDto,
intQuery,
json,
JsonArray,
jsonBody,
ModifyImageAccessibilityRequest,
ModifyImageDescriptionRequest,
pageParams,
PhotoTaggingSettingRequest,
PhotoTaggingSettingResponse,
SavedImageDto,
SlideshowResponse,
stringQuery,
SuccessErrorEnvelope,
SuccessResponse,
UNAUTHORIZED_RESPONSE,
UploadImageRequest,
UploadImageResponse,
} from '../openapi'
import { createReport } from '../reports-db'
import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit'
import type { Context } from 'hono'
@@ -96,10 +106,7 @@ async function getPlayerSettings(
env: App['Bindings'],
accountId: number
): Promise<Record<string, string> | null> {
return env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
`player:${accountId}`,
'json'
).catch(() => null)
return readPlayerSettings(env.RECFLARE_PLAYER_SETTINGS, accountId).catch(() => null)
}
/** The caller's stored photo-tagging preference, or the default when they have none. */
@@ -115,19 +122,17 @@ async function readPhotoTaggingSetting(env: App['Bindings'], accountId: number):
*
* The write MERGES, as the `playersettings` worker's own PUT does: the map holds every
* setting the player has (OOBE state, tutorial mask, …), so storing this one on its own
* would wipe the rest. Read-modify-write on KV isn't atomic, but the same is true there,
* and racing writers here means one player toggling two of their own options at once.
* would wipe the rest. Re-posting the value already stored writes nothing — the client
* does that freely, and KV writes are the cost.
*/
async function writePhotoTaggingSetting(
env: App['Bindings'],
accountId: number,
setting: number
): Promise<void> {
const stored = (await getPlayerSettings(env, accountId)) ?? {}
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${accountId}`,
JSON.stringify({ ...stored, [PHOTO_TAGGING_KEY]: String(setting) })
)
await mergePlayerSettings(env.RECFLARE_PLAYER_SETTINGS, accountId, {
[PHOTO_TAGGING_KEY]: String(setting),
})
}
/**
@@ -215,12 +220,16 @@ export const imageRoutes = new Hono<App>({ strict: false })
'The `imgMeta` multipart field is a JSON `SavedImageMetaDTO` describing the upload; ' +
'malformed JSON is tolerated and the image is still stored, just untyped. A ' +
'`savedImageType` of 4 (profile thumbnail) additionally becomes the account’s ' +
'avatar, persisted on the account row.',
'avatar, persisted on the account row.\n\n' +
'The file is identified by its bytes, not by the part’s filename or content-type: ' +
'it must be a PNG or JPEG, and the stored key’s extension and ' +
'content-type come from that signature. Anything else is refused with a 400 — the ' +
'`img` worker serves only those two formats, so it would never display.',
security: AUTHED,
requestBody: form(UploadImageRequest, 'The image file plus its metadata'),
responses: {
200: json(UploadImageResponse, 'The stored bucket key'),
400: json(ErrorResponse, 'No file in the request'),
400: json(ErrorResponse, 'No file in the request, or the file is not an image'),
401: UNAUTHORIZED_RESPONSE,
413: json(ErrorResponse, 'The image exceeds the configured per-file limit'),
},
@@ -260,10 +269,12 @@ export const imageRoutes = new Hono<App>({ strict: false })
const roomId = num(meta.roomId)
const playerEventId = num(meta.playerEventId)
const valid = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']
const dot = file.name.lastIndexOf('.')
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : ''
const extension = valid.includes(ext) ? ext : '.jpg'
// The part's filename and content-type are the uploader's claims; the `img` worker
// serves the object back under whatever type is stored, so both the extension and
// the stored type come from the bytes. A body that is not an image is refused.
const bytes = new Uint8Array(await file.arrayBuffer())
const image = sniffImage(bytes)
if (!image) return c.json({ error: `image is not a ${SNIFFED_IMAGE_FORMATS} image` }, 400)
// Store the upload in the shared image bucket under a random key, foldered by
// the image type and then the upload date (e.g. `sharecamera/2026-06-15/`) so
@@ -271,10 +282,8 @@ export const imageRoutes = new Hono<App>({ strict: false })
// key (slashes and all), which is the returned ImageName.
const typePrefix = (typeFolder[savedImageType] ?? typeFolder[SavedImageType.None]) + '/'
const datePrefix = new Date().toISOString().slice(0, 10) + '/'
const name = typePrefix + datePrefix + crypto.randomUUID() + extension
await c.env.IMAGES.put(name, await file.arrayBuffer(), {
httpMetadata: { contentType: file.type || 'image/jpeg' },
})
const name = typePrefix + datePrefix + crypto.randomUUID() + image.extension
await c.env.IMAGES.put(name, bytes, { httpMetadata: { contentType: image.contentType } })
// A profile thumbnail becomes the account's avatar — persist it on the
// account row (a JSON blob in the shared accounts table) so it sticks.
@@ -308,8 +317,9 @@ export const imageRoutes = new Hono<App>({ strict: false })
// Delete one of the caller's saved images ({ ImageName }). Auth-gated. Looks the
// image up by name, refuses unless the caller took it (PlayerId), then removes the
// metadata row (and its cheers) and the object from R2. 404 for an unknown image,
// 403 for someone else's.
.delete(
// 403 for someone else's. A POST, not a DELETE: the client posts the JSON body to
// this path when a player deletes a photo from their gallery.
.post(
'/api/images/v1/deletesaved',
describeRoute({
tags: ['Images'],
@@ -349,6 +359,99 @@ export const imageRoutes = new Hono<App>({ strict: false })
}
)
// Make one of the caller's photos public or private ({ ImageName, Accessibility }).
// Auth-gated and owner-only, like deletesaved; additionally refuses an image whose
// accessibility moderation has locked. Answers the same `{ success: true }` ack.
.post(
'/api/images/v2/modifyaccessibility',
describeRoute({
tags: ['Images'],
summary: 'Make one of the caller’s photos public or private',
description:
'Looks the image up by name and refuses unless the caller took it (or its ' +
'accessibility is locked), then sets `Accessibility` on the stored record: 0 ' +
'private, 1 public.',
security: AUTHED,
requestBody: jsonBody(ModifyImageAccessibilityRequest, 'The image and its new accessibility'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: json(ErrorResponse, 'No ImageName, or Accessibility not 0 or 1'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the caller’s image, or its accessibility is locked'),
404: { description: 'No image by that name' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as {
ImageName?: unknown
Accessibility?: unknown
} | null
const imageName = typeof body?.ImageName === 'string' ? body.ImageName : ''
if (imageName === '') return c.json({ error: 'ImageName is required' }, 400)
const accessibility = body?.Accessibility
if (accessibility !== 0 && accessibility !== 1) {
return c.json({ error: 'Accessibility must be 0 or 1' }, 400)
}
const image = await getImageByName(c.env.DB, imageName)
if (!image) return c.notFound()
if (image.PlayerId !== id) return c.json({ error: 'Not your image' }, 403)
if (image.AccessibilityLocked) return c.json({ error: 'Accessibility is locked' }, 403)
await setImageAccessibility(c.env.DB, image, accessibility)
return c.json({ success: true })
}
)
// Set the caption on one of the caller's photos ({ ImageName, Description }).
// Auth-gated and owner-only, like deletesaved. An empty or null Description clears
// the caption (stored as null, the same as an upload without one).
.post(
'/api/images/v1/modifydescription',
describeRoute({
tags: ['Images'],
summary: 'Set the caption on one of the caller’s photos',
description:
'Looks the image up by name and refuses unless the caller took it, then sets ' +
'`Description` on the stored record. Null or empty clears it.',
security: AUTHED,
requestBody: jsonBody(ModifyImageDescriptionRequest, 'The image and its new caption'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: json(ErrorResponse, 'No ImageName, or Description not a string'),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'Not the caller’s image'),
404: { description: 'No image by that name' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as {
ImageName?: unknown
Description?: unknown
} | null
const imageName = typeof body?.ImageName === 'string' ? body.ImageName : ''
if (imageName === '') return c.json({ error: 'ImageName is required' }, 400)
const raw = body?.Description ?? null
if (raw !== null && typeof raw !== 'string') {
return c.json({ error: 'Description must be a string' }, 400)
}
const description = raw === null || raw.trim() === '' ? null : raw
const image = await getImageByName(c.env.DB, imageName)
if (!image) return c.notFound()
if (image.PlayerId !== id) return c.json({ error: 'Not your image' }, 403)
await setImageDescription(c.env.DB, image, description)
return c.json({ success: true })
}
)
// A room's photo feed — the public images taken in that room. `sort` orders the
// feed (1 = most cheered, else newest) and `filter` narrows by SavedImageType
// (0 = all). Paginated via skip/take (take defaults to 100). Returns a bare array.
@@ -468,10 +571,11 @@ export const imageRoutes = new Hono<App>({ strict: false })
}
)
// Global slideshow feed — the most recent publicly-listable ShareCamera photos
// (Accessibility 0 or 1, Type 1) across all rooms, newest first, each joined to its
// creator's username and room name. Public (no auth): it only surfaces already-public
// images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`,
// Global slideshow feed — the most recent public ShareCamera photos (Accessibility 1,
// Type 1) across all rooms, newest first, each joined to its creator's username and
// room name. Public (no auth): it only surfaces already-public images and backs the
// anonymous homepage slideshow. Private photos (Accessibility 0) never appear — the
// feed is anonymous, so there is no viewer it could be unlocked for. Returns `{ Images, ValidTill }`,
// where ValidTill is a short (2-minute) cache hint the client refreshes against.
// Serves 10 by default and never more than SLIDESHOW_MAX_LIMIT (100): it's public and
// unauthenticated, so an unclamped `take` would let anyone ask for the whole image
@@ -513,9 +617,9 @@ export const imageRoutes = new Hono<App>({ strict: false })
// with what it asked for; an id with no record (or one that isn't public) is simply
// absent, which is why this answers 200 with a short list rather than 404ing the lot.
//
// Serves the RAW `SavedImage`, like `v6` (metadata by filename) and the room feed — NOT
// the `ImagesPlayer` projection the player photo LISTS use. Those are a rendered grid,
// where the raw record comes up blank; this is a metadata lookup.
// Serves the `ImagesBulk` projection: the player lists' `ImagesPlayer` shape
// (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`) plus a `ClubId`, always null.
// NOT the raw `SavedImage` that `v6` and the room feed serve.
//
// Public-only, as every image read here is: ids are sequential, so honouring whatever
// id is named would hand out private photos to anyone who counts.
@@ -528,12 +632,13 @@ export const imageRoutes = new Hono<App>({ strict: false })
'The stored `SavedImage` records for the given ids (`?ids=207&ids=106`), as a bare ' +
'array in request order. An id with no record, or one that is not public, is absent ' +
'from the answer rather than an error — the list can be shorter than the request. ' +
'Serves the raw `SavedImage` (as `v6` does), not the `ImagesPlayer` projection the ' +
'player photo lists use.',
'Serves the `ImagesPlayer` projection (`SavedImageId`/`SavedImageType`, no ' +
'`TaggedPlayerIds`) plus a `ClubId` that is always null — not the raw `SavedImage` ' +
'that `v6` serves.',
parameters: [
intQuery('ids', 'Repeatable; each value may also be a comma-separated list of image ids'),
],
responses: { 200: json(SavedImageDto.array(), 'The matching records, in request order') },
responses: { 200: json(ImagesBulkDto.array(), 'The matching records, in request order') },
}),
async (c) => {
const ids =
@@ -542,27 +647,28 @@ export const imageRoutes = new Hono<App>({ strict: false })
?.flatMap((raw) => raw.split(','))
.map((raw) => Number.parseInt(raw.trim(), 10))
.filter((imageId) => !Number.isNaN(imageId)) ?? []
return c.json(await getImagesByIds(c.env.DB, ids))
return c.json((await getImagesByIds(c.env.DB, ids)).map(toImagesBulk))
}
)
// Image metadata by filename. Returns the stored SavedImage record, or 404 when
// there's no metadata row for that name.
// Image metadata by filename, or 404 when there's no metadata row for that name.
//
// Serves the RAW 13-key `SavedImage`, the same type the client decodes `v6/:id` into.
// This once answered a projection of its own (`SavedImageId`/`SavedImageType`, a
// `ClubId`, no `TaggedPlayerIds`, nulls as 0/""): the client's DTO has `Id` and `Type`
// and no `ClubId`, so it was silently dropping most of that.
.get(
'/api/images/v6',
describeRoute({
tags: ['Images'],
summary: 'Image metadata by filename',
description:
'An image’s metadata for a bucket key. 404s when the object exists but has no ' +
'metadata row.\n\n' +
'Its own projection: renamed like the player lists (`SavedImageId`/`SavedImageType`, ' +
'no `TaggedPlayerIds`) but carrying `ClubId`, and with nothing nullable — `RoomId`, ' +
'`PlayerEventId` and `ClubId` read 0 where the row holds null, `Description` reads ' +
'`""`. Three shapes of one row; keep them straight.',
'An image’s stored `SavedImage` record for a bucket key — the raw record, as ' +
'`v6/{id}` serves, with `Description`, `RoomId` and `PlayerEventId` ' +
'null where the row holds null. 404s when the object exists but has no metadata row.',
parameters: [stringQuery('name', 'The image name (bucket key); required')],
responses: {
200: json(ImageMetadataDto, 'The image’s metadata'),
200: json(SavedImageDto, 'The image record'),
400: json(ErrorResponse, 'No name given'),
404: { description: 'No metadata for that name' },
},
@@ -571,7 +677,85 @@ export const imageRoutes = new Hono<App>({ strict: false })
const name = c.req.query('name') ?? ''
if (name === '') return c.json({ error: 'name is required' }, 400)
const image = await getImageByName(c.env.DB, name)
return image ? c.json(toImageMetadata(image)) : c.notFound()
return image ? c.json(image) : c.notFound()
}
)
// One image by id — the client opening a single photo it holds only the id of.
//
// Serves the RAW 13-key `SavedImage` (`Id`/`Type`, `TaggedPlayerIds`, nulls left null),
// which is the type the client decodes this route into — the same record `v6?name=`
// serves. NOT the `ImagesPlayer` projection of the player lists.
//
// Public, or the caller's own: ids are sequential, so anyone else's private photo is a
// 404 here, the same as an id that doesn't exist.
.get(
'/api/images/v6/:id{[0-9]+}',
describeRoute({
tags: ['Images'],
summary: 'One image by id',
description:
'The stored `SavedImage` record for an image id — the raw record, as `v6?name=` ' +
'serves, with `Description`, `RoomId` and `PlayerEventId` null where the row holds ' +
'null. A private image is served only to the player who took it; to anyone else ' +
'it is a 404, like an unknown id.',
parameters: [idParam('id', 'Image id')],
responses: {
200: json(SavedImageDto, 'The image record'),
404: { description: 'No such image, or not the caller’s to see' },
},
}),
async (c) => {
const image = await getImageById(c.env.DB, Number.parseInt(c.req.param('id'), 10))
if (image === null) return c.notFound()
if (image.Accessibility !== 1 && image.PlayerId !== (await authedId(c))) return c.notFound()
return c.json(image)
}
)
// Report a saved image. Stored in the `report` table the player, event, invention, item
// and chat reports use — same moderation life — with `image_id` set. See
// migrations/0034_report_image.sql.
//
// The request has NO body: the path names the image and the token names the reporter,
// and that is the whole report. The reported player is whoever took the image, read from
// the image. The `{ success, error: "" }` reply is the envelope the other reports use,
// which is an ASSUMPTION here: what the client does with this response has not been
// observed.
.post(
'/api/images/v1/:id{[0-9]+}/report',
describeRoute({
tags: ['Images', 'Moderation'],
summary: 'Report a photo',
description:
'Files a report against a saved image, as a row in the same `report` table the ' +
'player, event, invention, custom-avatar-item and chat reports go to. There is no ' +
'request body: the row is the reporter (the caller), the image (`image_id`), and ' +
'`reported_player_id` — the image’s `PlayerId`, who took it. No category or ' +
'description is stored, since none is sent. Nothing dedupes the rows, and an ' +
'unknown image is refused rather than filed against nobody.',
security: AUTHED,
parameters: [idParam('id', 'Image id')],
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
401: UNAUTHORIZED_RESPONSE,
404: json(SuccessErrorEnvelope, 'No such image'),
},
}),
async (c) => {
const reporterId = await authedId(c)
if (reporterId === null) return unauthorized(c)
const imageId = Number.parseInt(c.req.param('id'), 10)
const image = await getImageById(c.env.DB, imageId)
if (image === null) return c.json({ success: false, error: 'No such image' }, 404)
await createReport(c.env.DB, {
reporterPlayerId: reporterId,
reportedPlayerId: image.PlayerId,
imageId,
})
return c.json({ success: true, error: '' })
}
)
+10 -8
View File
@@ -368,12 +368,11 @@ export const moderationRoutes = new Hono<App>({ strict: false })
'matchmake refuses on (login still issues a token, so the client can reach this ' +
'screen) — so a caller with one in force gets ' +
'`IsBan: true`, the `ReportCategory` the report was filed under, the fixed ' +
'`Message` “Rule violation”, and the block’s span as the pair the client reads ' +
'them as: `TimeoutStartedAt` is when the ban was handed down (`banned_at`, ' +
'falling back to the report’s `created_at` for a ban set before that column ' +
'existed) and `Duration` the ' +
'seconds from there to `ban_expires` (2147483647, the int32 max, for a permanent ' +
'ban). ' +
'`Message` “Rule violation”, and `Duration` as the seconds LEFT on the ban as of ' +
'this request — the client counts it down from when it receives it, so it is ' +
'`ban_expires` minus now, rounded up, never the ban’s full span (2147483647, the ' +
'int32 max, for a permanent ban). `TimeoutStartedAt` stays null: the client does ' +
'not pair it with `Duration` for a ban, and no reference server sends it. ' +
'`PlayerIdReporter` stays null: it names a kicking host, and the reporter is not ' +
'shown to the player they reported. Only the caller’s own account is consulted, ' +
'not the ban-evasion arms.\n\n' +
@@ -396,8 +395,11 @@ export const moderationRoutes = new Hono<App>({ strict: false })
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const ban = await getActiveBan(c.env.DB, id)
return c.json(ban ? banBlockDetails(ban) : NOT_BLOCKED)
// One instant for both: the ban resolved as in force at `now` is described as of
// `now`, so it can't come out with 0 left.
const now = new Date()
const ban = await getActiveBan(c.env.DB, id, now)
return c.json(ban ? banBlockDetails(ban, now) : NOT_BLOCKED)
}
)
// The reasons the client offers when a player starts a vote-to-kick. Order matters —
+165 -12
View File
@@ -13,6 +13,20 @@ import { logger } from '@repo/hono-helpers'
// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a
// value — the enum has no runtime dependencies.
import { NotificationType } from '../../../notify/src/notification-types'
import progressionEvent13 from '../../static/progression-events/Anime_Season_EventId_13.json'
import progressionEvent8 from '../../static/progression-events/Base_Event_EventId_8.json'
import progressionEvent2 from '../../static/progression-events/Coming_in_Hot_Progression_Event_Alpha_EventId_2.json'
import progressionEvent9 from '../../static/progression-events/Invasion_2_EventId_9.json'
import progressionEvent6 from '../../static/progression-events/Invasion_EventId_6.json'
import progressionEvent15 from '../../static/progression-events/Medieval_Season_EventId_15.json'
import progressionEvent5 from '../../static/progression-events/Progression_Event_Alpha_Repeat_EventId_5.json'
import progressionEvent3 from '../../static/progression-events/Rec_Room_EventId_3.json'
import progressionEvent12 from '../../static/progression-events/Science_Experiment_Restoration_EventId_12.json'
import progressionEvent7 from '../../static/progression-events/Science_Party_EventId_7.json'
import progressionEvent14 from '../../static/progression-events/Summer_Party_EventId_14.json'
import progressionEvent16 from '../../static/progression-events/Treat_Collection_Challenge_EventId_16.json'
import progressionEvent17 from '../../static/progression-events/Tricks_Challenge_EventId_17.json'
import progressionEvent18 from '../../static/progression-events/Yetis_Challenge_EventId_18.json'
import { authedId, parseFormIds, queryIds, unauthorized } from '../http'
import {
AUTHED,
@@ -20,15 +34,18 @@ import {
BulkIdsRequest,
CheerPlayerRequest,
CheerPlayerResponse,
SetSelectedCheerRequest,
form,
idParam,
intQuery,
json,
JsonArray,
JsonObject,
ProgressionDto,
ProgressionEventRecordDto,
ReputationDto,
SetSelectedCheerRequest,
UNAUTHORIZED_RESPONSE,
XpBoostActivationsResponse,
} from '../openapi'
import {
addCheer,
@@ -47,6 +64,32 @@ import type { ReputationPayload } from '../../../notify/src/notification-payload
import type { App } from '../context'
import type { Reputation } from '../reputation-db'
/**
* The archived official progression events, by `ProgressionEventId` — each the event record
* exactly as the official API served it. They are held as TEXT and served byte for byte, not
* imported as JSON: a keepsake room's `RoomId` is a 64-bit number (`8364812036745705361`)
* that a JS parse rounds to a different room. The archive has gaps (no 1, 4, 10, 11).
*
* The text import is a `rules` entry in wrangler.jsonc, which TypeScript cannot see — it
* types a `.json` import as the parsed object — hence the cast.
*/
const PROGRESSION_EVENTS = new Map([
[2, progressionEvent2],
[3, progressionEvent3],
[5, progressionEvent5],
[6, progressionEvent6],
[7, progressionEvent7],
[8, progressionEvent8],
[9, progressionEvent9],
[12, progressionEvent12],
[13, progressionEvent13],
[14, progressionEvent14],
[15, progressionEvent15],
[16, progressionEvent16],
[17, progressionEvent17],
[18, progressionEvent18],
] as Array<[number, unknown]> as Array<[number, string]>)
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
@@ -256,6 +299,28 @@ export const progressionRoutes = new Hono<App>({ strict: false })
}),
async (c) => c.json(await getReputation(c.env.DB, Number.parseInt(c.req.param('id'), 10)))
)
// How much XP the caller has earned today — what the client measures against its daily
// XP cap. A BARE INTEGER (the client decodes a plain `int`), not a progression record.
//
// Registered BEFORE `/progression/:id` below, which otherwise takes `xpEarnedToday` as
// an account id and answers `{ PlayerId: null, Level: 1, XP: 0 }` — the client's decoder
// threw on that (`expected:'Number Token', actual:'{'`).
//
// STUB: always 0. Nothing here records when XP was earned, only the running total. No
// auth while the answer is the same for everybody; it will need the caller once it is
// real, since the path names no player.
.get(
'/api/players/v1/progression/xpEarnedToday',
describeRoute({
tags: ['Progression'],
summary: 'XP the caller earned today (stub)',
description:
'The XP earned so far today, as a bare integer. Always `0` — XP is banked as a ' +
'running total and nothing records when it was earned.',
responses: { 200: json(BareInteger, 'A bare `0`') },
}),
(c) => c.json(0)
)
.get(
'/api/players/v1/progression/:id',
describeRoute({
@@ -524,23 +589,111 @@ export const progressionRoutes = new Hono<App>({ strict: false })
// The progression event running right now — the limited-time XP event the client shows
// a banner and a progress track for.
//
// STUB: a bare `-1`, the reference server's "no active event" value (the id of the
// active event, or -1 when there is none). The client reads it as "no event on" and
// skips the event UI entirely. That is the honest answer (nothing here runs events) and
// the safe one: a fabricated event would draw a track that never fills. No auth —
// whether an event is running is the same fact for everybody, and the client asks while
// loading.
// STUB: a bare `18` — the id of the active event, or 0 when there is none (this once
// served -1 for the same meaning). 18 is hardcoded: it names an event the archive above
// holds, so the client can fetch its definition from `/event/18`, but nothing here
// tracks progress through it yet. No auth — whether an event is running is the same
// fact for everybody, and the client asks while loading.
.get(
'/api/progressionEvents/active',
describeRoute({
tags: ['Progression'],
summary: 'Progression event currently running (stub)',
description:
'The limited-time XP event in progress, as a bare event id. Always `-1` — the ' +
'“no active event” value; nothing on this server runs one — which the client reads ' +
'as “no event” and skips the event UI, where a 404 would stall the load. No auth: ' +
'The limited-time XP event in progress, as a bare event id (`0` would mean “no ' +
'active event”). Always `18` for now — a hardcoded archived event; nothing on this ' +
'server tracks progress through it yet. A 404 here would stall the load. No auth: ' +
'it is the same answer for every player.',
responses: { 200: json(BareInteger, 'A bare `-1` — no event is running') },
responses: { 200: json(BareInteger, 'A bare `18` — the hardcoded active event') },
}),
(c) => c.json(-1)
(c) => c.json(18)
)
// One progression event's definition — its reward track, keepsake rooms, dates and boost
// settings — served from the archive of official events above. Reading a definition says
// nothing about whether the event is running (`/active` above is a hardcoded 18), and no
// auth for the same reason as there: it is the same record for everybody.
.get(
'/api/progressionEvents/event/:id{[0-9]+}',
describeRoute({
tags: ['Progression'],
summary: 'A progression event’s definition',
description:
'The archived official event with this `ProgressionEventId`, verbatim: rewards, ' +
'keepsake room lists, start/end/collection times and boost settings. 404 for an ' +
'id the archive does not hold. No auth.',
parameters: [idParam('id', 'The `ProgressionEventId`')],
responses: {
200: json(JsonObject, 'The progression event'),
404: { description: 'No such event (empty body)' },
},
}),
(c) => {
const event = PROGRESSION_EVENTS.get(Number(c.req.param('id')))
if (event === undefined) return c.body(null, 404)
return c.body(event, 200, { 'Content-Type': 'application/json' })
}
)
// The caller's progress through one progression event — the XP, minutes and rewards the
// client draws its event track from.
//
// STUB: every counter 0, for any event id, echoing the id requested. Nothing tracks
// progress through an event yet (see `/active` above). Auth-gated because the record is
// the CALLER's and the path names no player.
.get(
'/api/progressionEvents/record/:id{[0-9]+}',
describeRoute({
tags: ['Progression'],
summary: 'The caller’s progress through a progression event (stub)',
description:
'The caller’s record for the event with this `ProgressionEventId`. Always empty ' +
'for now — 0 XP, nothing collected, `XpBoostLastPurchasedAt` null — for any id: ' +
'nothing on this server tracks progress through an event yet.',
security: AUTHED,
parameters: [idParam('id', 'The `ProgressionEventId`')],
responses: {
200: json(ProgressionEventRecordDto, 'An empty record for the event requested'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({
AccountId: id,
ProgressionEventId: Number(c.req.param('id')),
Xp: 0,
GameMinutesToday: 0,
RewardsCollected: 0,
BonusRewardsCollected: 0,
XpBoostLastPurchasedAt: null,
PremiumRewardsCollected: 0,
LimitedRewardCollectionState: 0,
})
}
)
// The XP boosts the caller has bought and has running — the client multiplies the XP it
// shows by them and draws a timer for each.
//
// STUB: always the empty page. Nothing sells boosts yet, so nobody has one running. No
// auth while the answer is the same for everybody, like `xpEarnedToday` above; it will
// need the caller once it is real, since the path names no player.
.get(
'/api/purchasableXpBoosts/activations',
describeRoute({
tags: ['Progression'],
summary: 'The caller’s active XP boosts (stub)',
description:
'The XP boosts the caller has running, as `{ Results, TotalResults }`. Always the ' +
'empty page (`{ "Results": [], "TotalResults": 0 }`) — nothing on this server sells ' +
'boosts yet. Each entry, once there are any, names the purchase ' +
'(`PurchasableXpBoostLedgerEntryId`), the boost bought (`PurchasableXpBoostId`), its ' +
'`XpMultiplier`, and when it runs (`StartTime`/`EndTime`, ISO-8601 UTC).',
responses: {
200: json(XpBoostActivationsResponse, 'Always `{ "Results": [], "TotalResults": 0 }`'),
},
}),
(c) => c.json({ Results: [], TotalResults: 0 })
)
+7 -5
View File
@@ -75,11 +75,13 @@ const NAME_WORD_BOUNDARIES: Array<[RegExp, string]> = [
/**
* Whether `value`, read as a NAME, contains a swear.
*
* A username or display name is letters and digits only (`nameRejection`), so it carries
* no spaces — and the filter matches whole words. Handing one to {@link containsSwears}
* as-is therefore only refuses a name that IS a swear and nothing else: `Fucker123` and
* `ShitLord` sail through. So the name is split at the boundaries a player types instead
* of a space, and the pieces are checked as words.
* A username is letters and digits only (`nameRejection`), so it carries no spaces — and
* the filter matches whole words. Handing one to {@link containsSwears} as-is therefore
* only refuses a name that IS a swear and nothing else: `Fucker123` and `ShitLord` sail
* through. So the name is split at the boundaries a player types instead of a space, and
* the pieces are checked as words. A display name may carry real spaces and punctuation
* (`displayNameRejection`), which the filter already breaks words at; the split still
* applies to the runs in between.
*
* That keeps the library's trade-off rather than reaching for substring matching, which
* is the tempting fix and the wrong one: `Scunthorpe`, `assassin`, `Classic`,
+1215 -127
View File
@@ -38,11 +38,15 @@ import {
SYSTEM_SENDER_ID,
THREAD_SCHEMA_DDL,
} from '../../../../chat/src/thread-db'
import priceCapSql from '../../../migrations/0032_custom_avatar_item_price_cap.sql?raw'
import inventionPriceCapSql from '../../../migrations/0033_invention_price_cap.sql?raw'
import { banEvasionMatch, resolveBan } from '../../bans-db'
import {
createCustomAvatarItem,
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
getCustomAvatarItem,
importCustomAvatarItem,
MAX_PLAYER_ITEM_PRICE,
} from '../../custom-avatar-items-db'
import { customAvatarItemRowLiteral } from '../../custom-avatar-items-load'
import {
@@ -51,7 +55,7 @@ import {
getEventAttendees,
getEventResponse,
} from '../../events-db'
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL, MAX_INVENTION_PRICE } from '../../inventions-db'
import {
banFromReport,
createReport,
@@ -91,6 +95,9 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com'
/** The PNG signature — the least an upload route accepts as an image. */
const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
// `/api/rooms/v1/verifyRole` reads room roles from the shared recflare D1. Set
// up the schema (matching the rooms worker's migration) + a couple of rooms.
const TEST_ROOMS = [
@@ -282,7 +289,7 @@ describe('public endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/backtrace`)
expect(res.status).toBe(200)
const body = (await res.json()) as { ReportBudget: number; VersionRegex: string }
expect(body).toMatchObject({ ReportBudget: 125, VersionRegex: '.*' })
expect(body).toMatchObject({ ReportBudget: 0, ANRThresholdMs: 0, VersionRegex: '' })
})
test('GET /api/versioncheck/v4 reports current for the matching build', async () => {
@@ -297,6 +304,18 @@ describe('public endpoints', () => {
}
})
test('GET /api/versioncheck/v4 ignores the point release: every .NN of a supported build is current', async () => {
// The list holds bare dates; the client reports `YYYYMMDD.NN`, and the `.NN` is a
// same-day rebuild of the same build. All of these are the beta build.
for (const version of ['20250718.01', '20250718.2', '20250718', '20250718.99']) {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=${version}`)
expect(await res.json(), version).toMatchObject({ VersionStatus: 0 })
}
// ...and a point release of a build that ISN'T supported is still not.
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101.01`)
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
})
test('GET /api/versioncheck/v4 flags a mismatched build', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101`)
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
@@ -307,6 +326,56 @@ describe('public endpoints', () => {
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
})
// The list is the operator's SUPPORTED_GAME_VERSIONS var when set, plus every build
// under rollout (ROLLOUT_GAME_VERSIONS) — the same lists `auth` gates tokens on, so a
// build told to update here is refused there too.
test('GET /api/versioncheck/v4 reads the lists from vars', async () => {
const status = async (version: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=${version}`)
return ((await res.json()) as { VersionStatus: number }).VersionStatus
}
const originals = {
SUPPORTED_GAME_VERSIONS: env.SUPPORTED_GAME_VERSIONS,
ROLLOUT_GAME_VERSIONS: env.ROLLOUT_GAME_VERSIONS,
}
try {
// Forced upgrade: the 2025 build drops out, every point release of it.
env.SUPPORTED_GAME_VERSIONS = '20230414,20260323'
expect(await status('20250718.01')).toBe(1)
expect(await status('20250718')).toBe(1)
expect(await status(GAME_VERSION)).toBe(0)
expect(await status('20260323.14')).toBe(0)
// Alphas not named in the var are gone too: the var replaces the default list.
expect(await status('20231207')).toBe(1)
// A build under rollout is current even when the var omits it: who may sign in on
// it is `auth`'s call, and the version check must not send a supporter to update.
env.SUPPORTED_GAME_VERSIONS = '20230414'
expect(await status('20260323.14')).toBe(0)
env.ROLLOUT_GAME_VERSIONS = 'off'
expect(await status('20260323.14')).toBe(1)
env.ROLLOUT_GAME_VERSIONS = '20250718'
expect(await status('20250718.01')).toBe(0)
// Entries are builds: a copied point release matches, and `off` empties the list.
env.ROLLOUT_GAME_VERSIONS = undefined
env.SUPPORTED_GAME_VERSIONS = '20250718.01'
expect(await status('20250718.2')).toBe(0)
expect(await status(GAME_VERSION)).toBe(1)
env.SUPPORTED_GAME_VERSIONS = 'off'
env.ROLLOUT_GAME_VERSIONS = 'off'
for (const version of SUPPORTED_GAME_VERSIONS) expect(await status(version), version).toBe(1)
// Blank is unset: the default list.
env.SUPPORTED_GAME_VERSIONS = ''
env.ROLLOUT_GAME_VERSIONS = ''
for (const version of SUPPORTED_GAME_VERSIONS) expect(await status(version), version).toBe(0)
} finally {
env.SUPPORTED_GAME_VERSIONS = originals.SUPPORTED_GAME_VERSIONS
env.ROLLOUT_GAME_VERSIONS = originals.ROLLOUT_GAME_VERSIONS
}
})
// Two catalogs, picked off the token's `rn.ver`: a build newer than GAME_VERSION gets
// the 2025 one. `Avatars.AdvancedFaceCustomizationEnabled` is a key only that catalog
// has, so its presence identifies which body was served.
@@ -327,6 +396,9 @@ describe('public endpoints', () => {
test('GET /api/gameconfigs/v1/all serves the 2023 catalog to the target build', async () => {
expect(await gameConfigKeys(GAME_VERSION)).not.toContain(KEY_2025_ONLY)
// A same-day point release of the target is the target build: compared as a build,
// not as a string that happens to sort after `20230414`.
expect(await gameConfigKeys(`${GAME_VERSION}.01`)).not.toContain(KEY_2025_ONLY)
})
test('GET /api/gameconfigs/v1/all serves the 2023 catalog to an older build', async () => {
@@ -1719,8 +1791,8 @@ describe('public endpoints', () => {
...metadata,
})
)
form.set('thumbnailImage', new File([new Uint8Array([1])], 'f.bin', { type: 'image/png' }))
form.set('design', new File([new Uint8Array([2])], 'f.bin', { type: 'image/png' }))
form.set('thumbnailImage', new File([PNG_BYTES], 'f.bin', { type: 'image/png' }))
form.set('design', new File([PNG_BYTES], 'f.bin', { type: 'image/png' }))
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer(creator),
@@ -2062,12 +2134,73 @@ describe('public endpoints', () => {
expect(res.status).toBe(400)
})
test('GET /api/progressionEvents/active is a bare -1 (no auth)', async () => {
// -1 is the reference server's "no active event" value; the client reads it as "no event
// running" and skips the event UI. A 404 would stall its load instead.
test('GET /api/players/v1/progression/xpEarnedToday is a bare 0', async () => {
// A plain int on the client. It must not fall through to `/progression/:id`, which
// would read the path segment as an account id and answer a progression OBJECT.
const res = await exports.default.fetch(`${ORIGIN}/api/players/v1/progression/xpEarnedToday`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(0)
})
test('GET /api/progressionEvents/active is a bare 18 (no auth)', async () => {
// A hardcoded stub: the id of an archived event (0 would mean "no active event").
// A 404 would stall the client's load instead.
const res = await exports.default.fetch(`${ORIGIN}/api/progressionEvents/active`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(-1)
expect(await res.json()).toBe(18)
})
test('GET /api/progressionEvents/event/:id serves the archived event verbatim', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/progressionEvents/event/18`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('application/json')
expect(await res.json()).toMatchObject({
ProgressionEventId: 18,
Name: 'Yetis Challenge',
Rewards: [],
KeepsakeRoomLists: [],
EventLeaderboardId: 18,
})
// A keepsake room's id is 64-bit: it has to reach the client as written, not as the
// nearest double (…705361 parses to …705000), so the body is checked as text.
const base = await exports.default.fetch(`${ORIGIN}/api/progressionEvents/event/8`)
expect(await base.text()).toContain('"RoomId": 8364812036745705361')
// The archive has gaps, and an id it doesn't hold is a 404.
const missing = await exports.default.fetch(`${ORIGIN}/api/progressionEvents/event/4`)
expect(missing.status).toBe(404)
})
test('GET /api/progressionEvents/record/:id is an empty record for the caller', async () => {
const url = `${ORIGIN}/api/progressionEvents/record/18`
expect((await exports.default.fetch(url)).status).toBe(401)
const res = await exports.default.fetch(url, { headers: await bearer('2') })
expect(res.status).toBe(200)
expect(await res.json()).toStrictEqual({
AccountId: 2,
ProgressionEventId: 18,
Xp: 0,
GameMinutesToday: 0,
RewardsCollected: 0,
BonusRewardsCollected: 0,
XpBoostLastPurchasedAt: null,
PremiumRewardsCollected: 0,
LimitedRewardCollectionState: 0,
})
// The id is echoed, whichever event is asked for.
const other = await exports.default.fetch(`${ORIGIN}/api/progressionEvents/record/7`, {
headers: await bearer('2'),
})
expect(await other.json()).toMatchObject({ ProgressionEventId: 7 })
})
test('GET /api/purchasableXpBoosts/activations is the empty page', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/purchasableXpBoosts/activations`)
expect(res.status).toBe(200)
expect(await res.json()).toStrictEqual({ Results: [], TotalResults: 0 })
})
test('GET /api/rooms/v1/filters returns an object with filter arrays', async () => {
@@ -2119,6 +2252,35 @@ describe('public endpoints', () => {
expect(await res.json()).toEqual([])
})
test('GET /api/inventions/v2/mine serves a v3-published invention as public', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('5149')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Published the old way', inventionDataFilename: 'a.inv' }),
})
const { Invention } = (await save.json()) as InventionSaveResult
await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}`,
{ headers: await bearer('5149') }
)
// `v3/publish` names no accessibility, so the record keeps the 0 a save mints. The
// 2025 client has no `IsPublished` to read, only `Accessibility` — 0 is Private.
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('5149'),
})
expect((await mine.json()) as SavedInvention[]).toMatchObject([
{ InventionId: Invention.InventionId, IsPublished: true, Accessibility: 1 },
])
// Published, so it would otherwise turn up in the feeds the later tests count.
await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5149')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: Invention.InventionId }),
})
})
test('POST /api/inventions/v6/save persists the invention and lists it in mine', async () => {
const body = {
name: '071126 13:10:50',
@@ -2188,6 +2350,19 @@ describe('public endpoints', () => {
expect(mine.status).toBe(200)
const list = (await mine.json()) as SavedInvention[]
expect(list.map((i) => i.InventionId)).toContain(saved.InventionId)
// The 2025 client decodes this list into its flat `RRInvention`, so every key of
// that DTO is present even on a v6 record that stores none of them — and the keys
// the older client reads are still there beside them.
expect(list.find((i) => i.InventionId === saved.InventionId)).toMatchObject({
UgcVersion: 1,
LatestVersionNumber: 1,
ForceCannotPublish: false,
IsRecRoomApproved: false,
DisplayMetadataJson: null,
Accessibility: 0,
IsPublished: false,
CurrentVersion: { VersionNumber: 1 },
})
// The saved invention is fetchable by id via the v1 lookup.
const one = await exports.default.fetch(
@@ -2307,7 +2482,7 @@ describe('public endpoints', () => {
expect(res.status).toBe(200)
const value = ((await res.json()) as InventionSaveV9Result).Value
if (value === null) throw new Error('Value must not be null on a successful save')
expect(value.Invention.UgcVersion).toBe(0)
expect(value.Invention.UgcVersion).toBe(1)
expect(value.Invention.DisplayMetadataJson).toBeNull()
// A save always mints a version; the key is nullable only because econ's
// `v3/buyInvention` answers in this same envelope and a buy mints none.
@@ -2791,7 +2966,7 @@ describe('public endpoints', () => {
.run()
})
test('POST /api/inventions/v4/publish keeps an unlisted invention out of the feeds', async () => {
test('POST /api/inventions/v4/publish keeps an unlisted invention out of the store, and v2/unpublish undoes it', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
@@ -2811,8 +2986,8 @@ describe('public endpoints', () => {
const value = ((await res.json()) as InventionSaveV9Result).Value
expect(value?.Invention.Accessibility).toBe(2)
// Unlisted is published — it is reachable by id, which is the whole point of it —
// but it is not something anyone comes across.
// Unlisted is published — on the creator's portfolio and reachable by id — but it is
// not in the store: search and the feeds leave it out.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
@@ -2826,6 +3001,39 @@ describe('public endpoints', () => {
expect(await found.json()).toEqual([])
const room = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/room?id=4171`)
expect(await room.json()).toEqual([])
const portfolio = async (): Promise<number[]> =>
(
(await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?id=5171`)
).json()) as SavedInvention[]
).map((i) => i.InventionId)
expect(await portfolio()).toEqual([inventionId])
// Unpublishing is creator only, and puts back what a save mints: not published,
// Accessibility 0 — off the portfolio, still on the creator's own shelf.
const unpublish = async (who: string): Promise<InventionSaveV9Result> =>
(await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v2/unpublish`, {
method: 'POST',
headers: { ...(await bearer(who)), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
).json()) as InventionSaveV9Result
expect((await unpublish('5199')).Success).toBe(false)
expect(await portfolio()).toEqual([inventionId])
const undone = await unpublish('5171')
expect(undone.Success).toBe(true)
expect(undone.Value?.Invention.Accessibility).toBe(0)
expect(await portfolio()).toEqual([])
const stored = (await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('5171'),
})
).json()) as SavedInvention[]
const shelved = stored.find((i) => i.InventionId === inventionId)
expect(shelved).toMatchObject({ IsPublished: false, Accessibility: 0 })
expect(shelved?.FirstPublishedAt).not.toBeNull()
})
test('POST /api/inventions/v4/publish leaves a price and a first-publish date alone', async () => {
@@ -2860,6 +3068,20 @@ describe('public endpoints', () => {
// A negative price is dropped rather than stored.
expect((await publish({ Price: -5 })).Price).toBe(250)
// One over the cap refuses the publish in-band, and the price stands.
const over = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Price: 1001 }),
})
expect(over.status).toBe(200)
expect(await over.json()).toMatchObject({
Success: false,
Value: null,
Error: 'Price must be <= 1000',
})
expect((await publish({ Price: 1000 })).Price).toBe(1000)
// Out of the published catalogue again — see the note in the publish test above.
await env.DB.prepare('DELETE FROM invention WHERE id = ?1').bind(inventionId).run()
})
@@ -3365,9 +3587,9 @@ describe('public endpoints', () => {
body: JSON.stringify({ inventionDataFilename: 'a.inv', ...fields }),
})
// A name is 3–24 characters of letters, digits, spaces, dashes and colons.
// A name is 3–40 characters of letters, digits, spaces, dashes and colons.
expect((await save({ name: 'ab' })).status).toBe(400)
expect((await save({ name: 'a'.repeat(25) })).status).toBe(400)
expect((await save({ name: 'a'.repeat(41) })).status).toBe(400)
expect((await save({ name: 'Rocket!' })).status).toBe(400)
expect((await save({ name: 'Café Lamp' })).status).toBe(400)
const ok = await save({ name: 'Rocket Sofa-Bed 2' })
@@ -3578,6 +3800,55 @@ describe('public endpoints', () => {
expect(await miss.json()).toEqual([])
})
test('GET /api/inventions/v2/search with `@username` lists that player’s published inventions', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 8082, username: 'DJDevin' }))
.run()
const invention = (id: number, creator: number, name: string): SavedInvention =>
({
InventionId: id,
ReplicationId: crypto.randomUUID(),
CreatorPlayerId: creator,
Name: name,
Description: '',
ImageName: '',
CurrentVersionNumber: 1,
CurrentVersion: { InventionId: id, VersionNumber: 1, BlobName: '' },
IsPublished: true,
HideFromPlayer: false,
CreatedAt: `2026-07-${String(id - 100).padStart(2, '0')}T00:00:00Z`,
}) as unknown as SavedInvention
for (const inv of [
invention(111, 8082, 'Devin Sofa'),
invention(112, 8082, 'Devin Lamp'),
// Another creator mentioning the name is NOT this player's invention.
{ ...invention(113, 8083, 'Tribute to @djdevin'), Description: 'by @djdevin' },
// Unpublished and hidden ones stay private, even to a search for the creator.
{ ...invention(114, 8082, 'Devin Draft'), IsPublished: false },
{ ...invention(115, 8082, 'Devin Hidden'), HideFromPlayer: true },
]) {
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
.bind(JSON.stringify(inv))
.run()
}
const ids = async (value: string): Promise<number[]> => {
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v2/search?value=${encodeURIComponent(value)}&skip=0&take=100`
)
expect(res.status).toBe(200)
return ((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
}
// The player's published inventions, newest first; the username is case-insensitive.
expect(await ids('@djdevin')).toEqual([112, 111])
expect(await ids('@DJDevin')).toEqual([112, 111])
// Further terms narrow within that player's inventions.
expect(await ids('@djdevin lamp')).toEqual([112])
// An unknown player is nothing — not a text search for the literal "@nobody".
expect(await ids('@nobody')).toEqual([])
expect(await ids('@')).toEqual([])
})
test('GET /api/inventions/v2/search filters and pages in SQL, and does not search tags', async () => {
const published = (id: number, name: string, description: string, tags: string[]) =>
({
@@ -3821,6 +4092,50 @@ describe('public endpoints', () => {
expect(noId.status).toBe(400)
})
test('POST /api/inventions/v2/cheer is v1/cheer in the newer client’s envelope', async () => {
const saved = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('8210')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Cheerable Sofa', inventionDataFilename: 'cheerable2.inv' }),
})
const { InventionId } = ((await saved.json()) as InventionSaveResult).Invention
const cheer = async (body: unknown, sub?: string) =>
exports.default.fetch(`${ORIGIN}/api/inventions/v2/cheer`, {
method: 'POST',
headers: { ...(sub ? await bearer(sub) : {}), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const stored = async (): Promise<number> =>
(
(await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=${InventionId}`)
).json()) as SavedInvention
).CheerCount
const ok = { Value: null, Success: true, Error: null, error_id: null }
const anonymous = await cheer({ InventionId, Cheer: true })
expect(anonymous.status).toBe(401)
expect(await anonymous.json()).toMatchObject({ Value: null, Success: false })
expect(await (await cheer({ InventionId, Cheer: true }, '44')).json()).toEqual(ok)
expect(await (await cheer({ InventionId, Cheer: true }, '44')).json()).toEqual(ok)
expect(await stored()).toBe(1)
const personal = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/personaldetails/${InventionId}`,
{ headers: await bearer('44') }
)
expect(await personal.json()).toEqual({ IsCheering: true })
expect(await (await cheer({ InventionId, Cheer: false }, '44')).json()).toEqual(ok)
expect(await stored()).toBe(0)
// Refusals stay in the envelope, at 200.
const unknown = await cheer({ InventionId: 999999, Cheer: true }, '44')
expect(unknown.status).toBe(200)
expect(await unknown.json()).toMatchObject({ Success: false, Error: 'No such invention' })
expect(await (await cheer({ InventionId }, '44')).json()).toMatchObject({ Success: false })
})
test('POST /api/inventions/v1/cheer persists and personaldetails reflects it', async () => {
const saved = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
@@ -4137,6 +4452,21 @@ describe('public endpoints', () => {
expect(await search()).toEqual([Invention.InventionId])
// A price over the cap refuses the whole publish, leaving the invention as it was.
const over = await exports.default.fetch(
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}&price=1001`,
{ headers: await bearer('2121') }
)
expect(over.status).toBe(400)
expect(await over.json()).toEqual({ error: 'Price must be <= 1000' })
expect(
(await env.DB.prepare(
`SELECT json_extract(data, '$.Price') AS price FROM invention WHERE id = ?1`
)
.bind(Invention.InventionId)
.first<{ price: number }>())!.price
).toBe(250)
// Publishing with no permissionLevel defaults to UseOnly, and price to 0.
const other = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
@@ -4189,26 +4519,142 @@ describe('public endpoints', () => {
expect(
(await updateprice({ InventionId: Invention.InventionId, Price: 10 }, '9999')).status
).toBe(403)
// Capped at MAX_INVENTION_PRICE, inclusive: one over is refused and the price stands.
expect(MAX_INVENTION_PRICE).toBe(1000)
const over = await updateprice({ InventionId: Invention.InventionId, Price: 1001 })
expect(over.status).toBe(400)
expect(await over.json()).toEqual({ error: 'Price must be <= 1000' })
const atCap = await updateprice({ InventionId: Invention.InventionId, Price: 1000 })
expect(atCap.status).toBe(200)
expect(((await atCap.json()) as InventionSaveResult).Invention.Price).toBe(1000)
})
test('GET /api/inventions/v1/fromcreators is an empty feed for now', async () => {
// A stub: the client renders an empty array as "this creator has published nothing",
// where a 404 would read as a row that failed to load.
const res = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/fromcreators?id=207&skip=0&take=100`
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
// The cap arrived after creators had priced inventions. Migration 0033 lowers every row
// over it to exactly the cap, published or not, and leaves the rest alone. Tests build the
// schema from SCHEMA_DDL rather than the migrations, so the file's UPDATE is run by hand.
test('migration 0033 lowers existing invention prices to the cap', async () => {
const make = async (name: string, price: number): Promise<number> => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('1213')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name, inventionDataFilename: 'a.inv' }),
})
const id = ((await save.json()) as InventionSaveResult).Invention.InventionId
// Written straight into the row: no endpoint will store this any more.
await env.DB.prepare(
`UPDATE invention SET data = json_set(data, '$.Price', ?2) WHERE id = ?1`
)
.bind(id, price)
.run()
return id
}
const dear = await make('Dear Lamp', 25000)
const fair = await make('Fair Lamp', 1000)
const cheap = await make('Cheap Lamp', 50)
// The params are accepted and ignored, including a repeated `id` and none at all.
expect(
await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?id=1&id=2`)
).json()
).toEqual([])
expect(
await (await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators`)).json()
).toEqual([])
const statements = inventionPriceCapSql
.split('\n')
.filter((line) => !line.startsWith('--'))
.join('\n')
.split(';')
.map((sql) => sql.trim())
.filter(Boolean)
expect(statements).toHaveLength(1)
await env.DB.prepare(statements[0]!).run()
const price = async (id: number): Promise<number> =>
(await env.DB.prepare(
`SELECT json_extract(data, '$.Price') AS price FROM invention WHERE id = ?1`
)
.bind(id)
.first<{ price: number }>())!.price
expect(await price(dear)).toBe(1000)
expect(await price(fair)).toBe(1000)
expect(await price(cheap)).toBe(50)
})
test('GET /api/inventions/v1/fromcreators lists a creator’s public inventions', async () => {
const json = async (creator: string, path: string, body: unknown): Promise<Response> =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { ...(await bearer(creator)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const save = async (creator: string, name: string): Promise<number> => {
const res = await json(creator, '/api/inventions/v9/save', {
name,
inventionDataFilename: 'portfolio.inv',
ugcVersion: 1,
})
const id = ((await res.json()) as InventionSaveV9Result).Value?.Invention.InventionId
if (id === undefined) throw new Error('save failed')
return id
}
const publish = (creator: string, id: number, accessibility: number): Promise<Response> =>
json(creator, '/api/inventions/v4/publish', {
InventionId: id,
Permission: 20,
Accessibility: accessibility,
})
const draft = await save('5301', 'Portfolio Draft')
const listed = await save('5301', 'Portfolio Public')
const unlisted = await save('5301', 'Portfolio Unlisted')
const legacy = await save('5301', 'Portfolio Legacy')
const other = await save('5302', 'Other Creator')
await publish('5301', listed, 1)
await publish('5301', unlisted, 2)
await publish('5302', other, 1)
// `v3/publish` names no accessibility, so the record keeps the 0 a save mints.
await exports.default.fetch(`${ORIGIN}/api/inventions/v3/publish?inventionId=${legacy}`, {
headers: await bearer('5301'),
})
const shelf = async (query: string): Promise<SavedInvention[]> =>
(await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?${query}`)
).json()) as SavedInvention[]
// Published is what lists an invention here, whatever its accessibility: the draft
// is not on the shelf, the Unlisted one is — and the `v3/publish` one, which stores
// the 0 a save mints, is served as Public.
const mine = await shelf('id=5301&skip=0&take=100')
expect(mine.map((i) => i.InventionId)).toEqual([legacy, unlisted, listed])
expect(mine.map((i) => i.Accessibility)).toEqual([1, 2, 1])
// The creator gets the same shelf as anyone: their drafts are `v2/mine`'s alone.
const own = (await (
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?id=5301`, {
headers: await bearer('5301'),
})
).json()) as SavedInvention[]
expect(own.map((i) => i.InventionId)).not.toContain(draft)
// `id` repeats, newest first across creators, and skip/take page it.
expect((await shelf('id=5301&id=5302')).map((i) => i.InventionId)).toEqual([
other,
legacy,
unlisted,
listed,
])
expect((await shelf('id=5301&id=5302&skip=1&take=1')).map((i) => i.InventionId)).toEqual([
legacy,
])
// No creator, or one with nothing public, is an empty shelf rather than a 404.
expect(await shelf('')).toEqual([])
expect(await shelf('id=999999')).toEqual([])
// Published, so they would otherwise turn up in the feeds the later tests count.
for (const [creator, id] of [
['5301', listed],
['5301', unlisted],
['5301', legacy],
['5302', other],
] as const) {
await json(creator, '/api/inventions/v2/delete', { InventionId: id })
}
})
test('GET /api/inventions/v1/toptoday + v1/featured serve the invention feeds', async () => {
@@ -4363,6 +4809,59 @@ describe('public endpoints', () => {
})
})
describe('POST /api/sanitize/v2/isAllowed/bulk', () => {
const bulk = async (body: URLSearchParams | FormData, authed = true) =>
exports.default.fetch(`${ORIGIN}/api/sanitize/v2/isAllowed/bulk`, {
method: 'POST',
headers: authed ? await bearer('42') : {},
body,
})
test('401s without a token', async () => {
expect((await bulk(new URLSearchParams('Values=hello'), false)).status).toBe(401)
})
test('preserves repeated values, order, empty text and duplicates in the Studio envelope', async () => {
const body = new URLSearchParams()
for (const text of ['MakerRoom', 'fuck this', '', 'Scunthorpe', 'MakerRoom', 'sh1t'])
body.append('Values', text)
const res = await bulk(body)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
Success: true,
Error: null,
Value: { ArePure: [true, false, true, true, true, false] },
})
})
test('accepts a single multipart value and an empty batch', async () => {
const body = new FormData()
body.append('Values', 'a$$hole')
expect(await (await bulk(body)).json()).toEqual({
Success: true,
Error: null,
Value: { ArePure: [false] },
})
expect(await (await bulk(new URLSearchParams())).json()).toEqual({
Success: true,
Error: null,
Value: { ArePure: [] },
})
})
test('rejects file values rather than marking them clean', async () => {
const body = new FormData()
body.append('Values', new Blob(['hello']), 'text.txt')
const res = await bulk(body)
expect(res.status).toBe(400)
expect(await res.json()).toEqual({
Success: false,
Error: 'Values must be strings.',
Value: null,
})
})
})
describe('POST /api/sanitize/v1/isPure', () => {
const isPure = async (Value?: string, authed = true) =>
exports.default.fetch(`${ORIGIN}/api/sanitize/v1/isPure`, {
@@ -4470,11 +4969,8 @@ describe('custom avatar items', () => {
Accessibility: 0,
})
)
form.set(
'thumbnailImage',
new File([new Uint8Array([1, 2, 3])], 'file.bin', { type: 'image/png' })
)
form.set('design', new File([new Uint8Array([4, 5, 6])], 'file.bin', { type: 'image/png' }))
form.set('thumbnailImage', new File([PNG_BYTES], 'file.bin', { type: 'image/png' }))
form.set('design', new File([PNG_BYTES], 'file.bin', { type: 'image/png' }))
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer('205'),
@@ -4521,14 +5017,10 @@ describe('custom avatar items', () => {
// Both uploads landed in the image bucket under those keys.
const thumb = await env.IMAGES.get(body.Value.ThumbnailImageFilename as string)
expect(new Uint8Array((await thumb!.arrayBuffer()) as ArrayBuffer)).toEqual(
new Uint8Array([1, 2, 3])
)
expect(new Uint8Array((await thumb!.arrayBuffer()) as ArrayBuffer)).toEqual(PNG_BYTES)
expect(thumb!.httpMetadata?.contentType).toBe('image/png')
const design = await env.IMAGES.get(body.Value.DesignFilename as string)
expect(new Uint8Array((await design!.arrayBuffer()) as ArrayBuffer)).toEqual(
new Uint8Array([4, 5, 6])
)
expect(new Uint8Array((await design!.arrayBuffer()) as ArrayBuffer)).toEqual(PNG_BYTES)
// The row is the record as JSON, with the queried fields generated off it.
const row = await env.DB.prepare(
@@ -4606,6 +5098,150 @@ describe('custom avatar items', () => {
expect(await (await exports.default.fetch(url, { method: 'PUT' })).status).toBe(401)
})
// A player-made item may not sell for more than MAX_PLAYER_ITEM_PRICE (1000) tokens. The
// client has no ceiling of its own (`minPriceForPublicItem` is a floor), so both writes a
// player has enforce it — refusing, not clamping, so the creator sees what was set.
test('the price is capped at 1000 tokens on create and edit', async () => {
expect(MAX_PLAYER_ITEM_PRICE).toBe(1000)
const create = async (Price: unknown) => {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({ Name: 'Pricey', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff', Price })
)
form.set('thumbnailImage', new File([PNG_BYTES], 't.png', { type: 'image/png' }))
form.set('design', new File([PNG_BYTES], 'd.png', { type: 'image/png' }))
return exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer('205'),
body: form,
})
}
const over = await create(1001)
expect(over.status).toBe(400)
expect(await over.json()).toMatchObject({
Success: false,
Value: null,
Error: 'Price must be <= 1000',
})
expect((await create(-1)).status).toBe(400)
expect((await create(10.5)).status).toBe(400)
// Nothing was written for the refused ones.
expect(
await env.DB.prepare(
`SELECT count(*) AS n FROM custom_avatar_item WHERE name_lower = 'pricey'`
)
.first<{ n: number }>()
.then((r) => r!.n)
).toBe(0)
// The cap itself is allowed, inclusive; a missing price is still 0.
const atCap = await create(1000)
expect(atCap.status).toBe(200)
const { Value } = (await atCap.json()) as {
Value: { CustomAvatarItemId: string; Price: number }
}
expect(Value.Price).toBe(1000)
expect(
((await (await create(undefined)).json()) as { Value: { Price: number } }).Value.Price
).toBe(0)
// The edit is bound the same way, and a refused edit leaves the row as it was.
const url = `${ORIGIN}/api/customAvatarItems/v1/${Value.CustomAvatarItemId}`
const edit = async (Price: number) =>
exports.default.fetch(url, {
method: 'PUT',
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
body: JSON.stringify({ Name: null, Description: null, Price, Accessibility: null }),
})
const tooMuch = await edit(1001)
expect(tooMuch.status).toBe(400)
expect(await tooMuch.json()).toMatchObject({ Success: false, Error: 'Price must be <= 1000' })
expect((await edit(-1)).status).toBe(400)
expect((await getCustomAvatarItem(env.DB, Value.CustomAvatarItemId))!.Price).toBe(1000)
const lowered = await edit(999)
expect(lowered.status).toBe(200)
expect(((await lowered.json()) as { Value: { Price: number } }).Value.Price).toBe(999)
})
// The cap arrived after players had already priced shirts. Migration 0032 lowers every
// PLAYER-MADE row over it to exactly the cap and leaves first-party items — priced from the
// storefront dump, several above 1000 — alone; the two are told apart by `BaseAvatarItemId`.
// Tests build the schema from SCHEMA_DDL rather than the migrations, so the file's UPDATE
// is run here by hand.
test('migration 0032 lowers existing player-made prices to the cap', async () => {
const shirt = (name: string, price: number) =>
createCustomAvatarItem(
env.DB,
{
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name,
description: '',
price,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
accessibility: 1,
designFilename: 'd',
thumbnailImageFilename: 't',
},
new Date('2026-08-01T00:00:00Z')
)
const dear = await shirt('Dear', 5000)
const fair = await shirt('Fair', 1000)
const cheap = await shirt('Cheap', 50)
// A first-party item over the cap: no base item, Coach-authored.
const firstPartyId = crypto.randomUUID()
await importCustomAvatarItem(env.DB, {
CustomAvatarItemId: firstPartyId,
RankedEntityId: firstPartyId,
CreatorAccountId: 1,
Name: 'Studio Wings',
Description: '',
Price: 6000,
Accessibility: 1,
OutfitType: 100,
BaseAvatarItemId: null,
BaseAvatarItemColor: null,
DesignFilename: null,
ThumbnailImageFilename: null,
ForceCannotPublish: false,
IsFeatured: false,
IsRecRoomApproved: true,
PreviewOrientation: 0,
RankingContext: null,
CreatedAt: '2024-09-26T21:32:48.523Z',
ModifiedAt: '2025-02-19T05:19:28.328Z',
CurrentSaves: [],
Tags: [],
CustomBadgeMetadata: null,
})
const statements = priceCapSql
.split('\n')
.filter((line) => !line.startsWith('--'))
.join('\n')
.split(';')
.map((sql) => sql.trim())
.filter(Boolean)
expect(statements).toHaveLength(1)
await env.DB.prepare(statements[0]!).run()
const price = async (id: string) => (await getCustomAvatarItem(env.DB, id))!
expect((await price(dear.CustomAvatarItemId)).Price).toBe(1000)
// The lowered row is marked modified, as an edit would mark it; the untouched ones aren't.
expect((await price(dear.CustomAvatarItemId)).ModifiedAt).not.toBe(dear.ModifiedAt)
expect(await price(fair.CustomAvatarItemId)).toMatchObject({
Price: 1000,
ModifiedAt: fair.ModifiedAt,
})
expect(await price(cheap.CustomAvatarItemId)).toMatchObject({
Price: 50,
ModifiedAt: cheap.ModifiedAt,
})
expect((await price(firstPartyId)).Price).toBe(6000)
})
test('DELETE removes the creator’s item and its bucket objects', async () => {
// Create through the endpoint so the objects really exist in the bucket.
const form = new FormData()
@@ -4613,8 +5249,8 @@ describe('custom avatar items', () => {
'metadata',
JSON.stringify({ Name: 'Gone', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
)
form.set('thumbnailImage', new File([new Uint8Array([1])], 'file.bin', { type: 'image/png' }))
form.set('design', new File([new Uint8Array([2])], 'file.bin', { type: 'image/png' }))
form.set('thumbnailImage', new File([PNG_BYTES], 'file.bin', { type: 'image/png' }))
form.set('design', new File([PNG_BYTES], 'file.bin', { type: 'image/png' }))
const created = (await (
await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
@@ -4719,6 +5355,48 @@ describe('custom avatar items', () => {
}
})
// Both files are identified by their bytes — the client posts them as `.bin` under
// `image/png` — and the `img` worker serves each under its stored type, so a non-image
// is refused before either file or the row is written.
test('POST refuses a file whose bytes are not an image, writing nothing', async () => {
const objectsBefore = (await env.IMAGES.list({ prefix: 'avatar-item/' })).objects.length
const upload = async (thumbnail: string | Uint8Array, design: string | Uint8Array) => {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({ Name: 'not-image', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
)
form.set('thumbnailImage', new File([thumbnail], 'f.bin', { type: 'image/png' }))
form.set('design', new File([design], 'f.bin', { type: 'image/png' }))
return exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer('205'),
body: form,
})
}
const html = '<script>alert(1)</script>'
const badThumbnail = await upload(html, PNG_BYTES)
expect(badThumbnail.status).toBe(400)
expect(await badThumbnail.json()).toMatchObject({
Success: false,
Error: 'thumbnailImage is not a PNG or JPEG image',
})
const badDesign = await upload(PNG_BYTES, html)
expect(badDesign.status).toBe(400)
expect(await badDesign.json()).toMatchObject({
Success: false,
Error: 'design is not a PNG or JPEG image',
})
const row = await env.DB.prepare(
"SELECT COUNT(*) AS n FROM custom_avatar_item WHERE json_extract(data, '$.Name') = 'not-image'"
).first<{ n: number }>()
expect(row?.n).toBe(0)
expect((await env.IMAGES.list({ prefix: 'avatar-item/' })).objects).toHaveLength(objectsBefore)
})
test('POST 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
@@ -6015,17 +6693,16 @@ describe('player reports', () => {
expect(res.status).toBe(401)
})
// Duration and TimeoutStartedAt are a pair in the client — the block runs from the
// start for the duration. The start is `banned_at`, the instant the ban was handed
// down (see 0020_report_ban_audit.sql), NOT the report's created_at: the two can be
// months apart. A permanent ban runs for the largest span the int holds.
// `Duration` is the seconds LEFT — the client counts it down from receipt. A permanent
// ban runs for the largest span the int holds, and `TimeoutStartedAt` stays null: the
// client does not pair it with `Duration` for a ban.
test('describes a permanent ban', async () => {
await submit(
{ PlayerIdReported: '221', ReportCategory: '102', Details: 'slurs' },
await bearer()
)
const [row] = await getReportsAgainst(env.DB, 221)
const banned = await banFromReport(env.DB, row!.id)
await banFromReport(env.DB, row!.id)
expect(await details('POST', '221')).toEqual({
...NOT_BLOCKED,
@@ -6036,21 +6713,15 @@ describe('player reports', () => {
// the reporter is not shown to the player they reported (PlayerIdReporter
// stays null).
Message: 'Rule violation',
TimeoutStartedAt: banned!.banned_at,
})
})
// A timed ban's Duration is the seconds from the start to the expiry, so the pair
// sums to `ban_expires` — not the seconds left as of the request.
test('describes a timed ban as its banned_at plus the span to expiry', async () => {
// A fresh timed ban: the seconds left are (within the write's latency) the whole ban.
test('describes a timed ban as the seconds left as of the request', async () => {
await submit({ PlayerIdReported: '222', ReportCategory: '103' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 222)
// The expiry is set relative to NOW, which is what a moderator handing down a
// one-hour ban means — and now is `banned_at`, not the report's created_at.
const banExpires = new Date(Date.now() + 3600 * 1000)
const banned = await banFromReport(env.DB, row!.id, {
banExpires: banExpires.toISOString(),
})
await banFromReport(env.DB, row!.id, { banExpires: banExpires.toISOString() })
// `Duration` is asserted as a range below, so it is left out of the object match
// — NOT_BLOCKED carries a 0 for it, which would win over the real value.
@@ -6061,59 +6732,59 @@ describe('player reports', () => {
ReportCategory: 103,
IsBan: true,
Message: 'Rule violation',
TimeoutStartedAt: banned!.banned_at,
TimeoutStartedAt: null,
})
// Within a second of the hour: `banned_at` is stamped by the write, so the span
// is the hour minus however long the write took.
// Within a second of the hour: however long the write took has already elapsed.
expect(body.Duration).toBeGreaterThan(3595)
expect(body.Duration).toBeLessThanOrEqual(3600)
})
// The whole point of `banned_at`: a ban applied to an OLD report used to be
// described as having started when the report was filed, so a 7-day ban on a
// month-old report told the player their block began a month ago — and the duration,
// measured from there, came out as already served. Both halves of the pair now
// start from the ban.
test('counts a ban from when it was handed down, not from when the report was filed', async () => {
// The case the bug lived in: a player signing in AFTER the ban was handed down. The
// client counts `Duration` down from the moment it receives it, so it must be what is
// left NOW — not the ban's full span from `banned_at`, which read as the whole ban
// still to run at every sign-in (and only looked right in the mid-session kick frame,
// sent at the instant the span and the remainder are the same).
test('counts down: a ban handed down a day ago has a day less to run', async () => {
await submit({ PlayerIdReported: '224', ReportCategory: '102' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 224)
// Backdate the report a month, as if it had sat in the queue.
const filed = new Date(Date.now() - 30 * 86_400_000).toISOString()
await env.DB.prepare('UPDATE report SET created_at = ?2 WHERE id = ?1')
.bind(row!.id, filed)
// A seven-day ban handed down yesterday: six days left.
const banned = await banFromReport(env.DB, row!.id, {
banExpires: new Date(Date.now() + 6 * 86_400_000).toISOString(),
})
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
.bind(row!.id, new Date(Date.now() - 86_400_000).toISOString())
.run()
const banned = await banFromReport(env.DB, row!.id, {
banExpires: new Date(Date.now() + 7 * 86_400_000).toISOString(),
})
const body = (await details('GET', '224')) as Record<string, unknown>
expect(body.TimeoutStartedAt).toBe(banned!.banned_at)
expect(body.TimeoutStartedAt).not.toBe(filed)
// Seven days, not the negative-then-clamped span the report's date would give.
expect(body.Duration).toBeGreaterThan(7 * 86_400 - 5)
expect(body.Duration).toBeLessThanOrEqual(7 * 86_400)
expect(body.IsBan).toBe(true)
expect(body.Duration).toBeGreaterThan(6 * 86_400 - 5)
expect(body.Duration).toBeLessThanOrEqual(6 * 86_400)
// The start is not on the wire at all: null, not `banned_at`.
expect(body.TimeoutStartedAt).toBeNull()
expect(banned!.banned_at).not.toBeNull()
})
// A row banned BEFORE 0020 added the column carries no `banned_at`, and must keep
// reading exactly as it used to rather than falling back to "now" — which would
// silently restart every standing ban the moment this shipped.
test('falls back to created_at for a ban with no recorded banned_at', async () => {
// A row banned BEFORE 0020 added `banned_at` reads the same as any other: the
// remainder needs only `ban_expires`.
test('reads a ban with no recorded banned_at the same way', async () => {
await submit({ PlayerIdReported: '225', ReportCategory: '101' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 225)
await env.DB.prepare(
`UPDATE report SET banned = 1, ban_expires = ?2, banned_at = NULL WHERE id = ?1`
)
.bind(row!.id, new Date(Date.parse(row!.created_at) + 3600 * 1000).toISOString())
.bind(row!.id, new Date(Date.now() + 3600 * 1000).toISOString())
.run()
expect(await details('GET', '225')).toEqual({
...NOT_BLOCKED,
const { Duration: _duration, ...unblocked } = NOT_BLOCKED
const body = (await details('GET', '225')) as Record<string, unknown>
expect(body).toMatchObject({
...unblocked,
ReportCategory: 101,
Duration: 3600,
IsBan: true,
Message: 'Rule violation',
TimeoutStartedAt: row!.created_at,
})
expect(body.Duration).toBeGreaterThan(3595)
expect(body.Duration).toBeLessThanOrEqual(3600)
})
// A ban that has served its time is not a block, even though the row still says
@@ -6251,7 +6922,7 @@ describe('rooms', () => {
describe('images', () => {
test('POST /api/images/v4/uploadsaved stores the file in R2 and returns its name', async () => {
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4])
const bytes = new Uint8Array([...PNG_BYTES, 1, 2, 3, 4])
const fd = new FormData()
fd.append('imgMeta', JSON.stringify({ savedImageType: 1 })) // ShareCamera
fd.append('image', new File([bytes], 'avatar.png', { type: 'image/png' }))
@@ -6276,14 +6947,50 @@ describe('images', () => {
// A metadata row was created, and it's readable by name via /api/images/v6.
const meta = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
).json()) as { ImageName: string; PlayerId: number; SavedImageId: number; CheerCount: number }
).json()) as { ImageName: string; PlayerId: number; Id: number; CheerCount: number }
expect(meta.ImageName).toBe(ImageName)
expect(meta.PlayerId).toBe(42)
// `SavedImageId`, not `Id` — v6 renames like the player lists do.
expect(typeof meta.SavedImageId).toBe('number')
// `Id`, not `SavedImageId` — v6 serves the raw record, unlike the player lists.
expect(typeof meta.Id).toBe('number')
expect(meta.CheerCount).toBe(0)
})
// The part's filename and content-type are the uploader's claims, and the `img`
// worker serves the object under whatever type is stored — so an HTML body under an
// image name would be a page on the image origin. The bytes decide, and only images
// get in.
test('POST /api/images/v4/uploadsaved refuses a file whose bytes are not an image', async () => {
const objectsBefore = (await env.IMAGES.list()).objects.length
const fd = new FormData()
fd.append('imgMeta', JSON.stringify({ savedImageType: 1 }))
fd.append('image', new File(['<script>alert(1)</script>'], 'photo.jpg', { type: 'image/jpeg' }))
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
method: 'POST',
headers: await bearer(),
body: fd,
})
expect(res.status).toBe(400)
expect(await res.json()).toEqual({ error: 'image is not a PNG or JPEG image' })
expect((await env.IMAGES.list()).objects).toHaveLength(objectsBefore)
})
test('POST /api/images/v4/uploadsaved stores the type and extension the bytes say, not the claim', async () => {
const fd = new FormData()
fd.append('imgMeta', JSON.stringify({ savedImageType: 1 }))
// A real PNG posted as a JPEG under an HTML name.
fd.append('image', new File([PNG_BYTES], 'photo.html', { type: 'text/html' }))
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
method: 'POST',
headers: await bearer(),
body: fd,
})
expect(res.status).toBe(200)
const { ImageName } = (await res.json()) as { ImageName: string }
expect(ImageName).toMatch(/\.png$/)
const stored = await env.IMAGES.get(ImageName)
expect(stored?.httpMetadata?.contentType).toBe('image/png')
})
test('GET /api/images/v1/slideshow is public and joins username + room name', async () => {
// Seed a public image (Accessibility 1) taken in RecCenter (room 2) by account 42.
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
@@ -6327,6 +7034,37 @@ describe('images', () => {
})
})
// The feed is anonymous, so a private photo (Accessibility 0) must never reach it —
// there is no viewer it could be unlocked for. It once accepted 0 alongside 1.
test('GET /api/images/v1/slideshow leaves private photos out', async () => {
const pub = await createImage(env.DB, {
imageName: 'slidepublic.jpg',
playerId: 42,
accessibility: 1,
})
const priv = await createImage(env.DB, {
imageName: 'slideprivate.jpg',
playerId: 42,
accessibility: 0,
})
// A non-photo (room thumbnail) that happens to be public stays out too.
const thumb = await createImage(env.DB, {
imageName: 'slidethumb.jpg',
playerId: 42,
type: 3,
accessibility: 1,
})
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow?take=100`)
expect(res.status).toBe(200)
const ids = ((await res.json()) as { Images: Array<{ SavedImageId: number }> }).Images.map(
(i) => i.SavedImageId
)
expect(ids).toContain(pub.Id)
expect(ids).not.toContain(priv.Id)
expect(ids).not.toContain(thumb.Id)
})
// The feed is public and unauthenticated, so `take` is clamped rather than trusted:
// without the cap a single anonymous request could pull the whole image table through
// the two joins behind it.
@@ -6368,20 +7106,37 @@ describe('images', () => {
// Request order, not id order — the client lines the answers up with what it asked for.
const both = await bulk(`?ids=${two.Id}&ids=${one.Id}`)
expect(both.map((i) => i.Id)).toEqual([two.Id, one.Id])
// The RAW SavedImage: `Id`/`Type`, and TaggedPlayerIds present — not the
// SavedImageId/SavedImageType projection the player photo lists serve.
expect(both.map((i) => i.SavedImageId)).toEqual([two.Id, one.Id])
// The ImagesPlayer projection plus ClubId — not the raw SavedImage (`Id`/`Type`,
// `TaggedPlayerIds`).
expect(Object.keys(both[0]).sort()).toEqual(
[
'SavedImageId',
'ImageName',
'PlayerId',
'RoomId',
'PlayerEventId',
'ClubId',
'Description',
'Accessibility',
'AccessibilityLocked',
'SavedImageType',
'CreatedAt',
'CheerCount',
'CommentCount',
].sort()
)
expect(both[0]).toMatchObject({
Id: two.Id,
Type: two.Type,
SavedImageId: two.Id,
SavedImageType: two.Type,
ImageName: 'bulktwo.jpg',
PlayerId: 7102,
TaggedPlayerIds: [],
ClubId: null,
})
// An unknown id and a non-public one are absent rather than errors or holes, so the
// answer can be shorter than the request.
expect((await bulk(`?ids=${one.Id}&ids=999999&ids=${hidden.Id}`)).map((i) => i.Id)).toEqual([
expect((await bulk(`?ids=${one.Id}&ids=999999&ids=${hidden.Id}`)).map((i) => i.SavedImageId)).toEqual([
one.Id,
])
@@ -6390,7 +7145,7 @@ describe('images', () => {
// More ids than D1 will bind in one query (100) — the lookup has to split.
const many = [...Array.from({ length: 130 }, (_, i) => 800000 + i), one.Id, two.Id]
expect((await bulk(`?${many.map((id) => `ids=${id}`).join('&')}`)).map((i) => i.Id)).toEqual([
expect((await bulk(`?${many.map((id) => `ids=${id}`).join('&')}`)).map((i) => i.SavedImageId)).toEqual([
one.Id,
two.Id,
])
@@ -6488,6 +7243,13 @@ describe('images', () => {
// The setting is per-player.
expect(await (await read('710')).text()).toBe('2')
// Re-posting the stored value writes nothing to KV: the raw value is seeded with
// whitespace JSON.stringify never produces, and it survives the PUT untouched.
const padded = '{ "Recroom.OOBE": "77", "playerPhotoTaggingSetting": "1" }'
await env.RECFLARE_PLAYER_SETTINGS.put('player:712', padded)
expect(await (await write('712', { Setting: 1 })).text()).toBe('1')
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:712', 'text')).toBe(padded)
// A body with no readable Setting leaves the stored value alone rather than writing 0
// — and answers what the player still has.
expect(await (await write('710', { Nothing: true })).text()).toBe('2')
@@ -6587,7 +7349,7 @@ describe('images', () => {
expect(entries.filter((e) => e.IsCheered)).toHaveLength(1)
})
test('GET /api/images/v6 serves the metadata projection, nothing nullable', async () => {
test('GET /api/images/v6 serves the raw SavedImage by name', async () => {
const img = await createImage(env.DB, {
imageName: 'v6shape.jpg',
playerId: 7301,
@@ -6595,24 +7357,98 @@ describe('images', () => {
})
const res = await exports.default.fetch(`${ORIGIN}/api/images/v6?name=v6shape.jpg`)
expect(res.status).toBe(200)
// The client's 13-key DTO: `Id` and `Type`, `TaggedPlayerIds`, nulls left null — the
// same record `v6/:id` serves.
expect(await res.json()).toEqual({
SavedImageId: img.Id,
Id: img.Id,
ImageName: 'v6shape.jpg',
Description: null,
PlayerId: 7301,
// Nulls on the row come out as 0 / "" — the client's DTO has no null to put there.
RoomId: 0,
PlayerEventId: 0,
ClubId: 0,
Description: '',
RoomId: null,
PlayerEventId: null,
Accessibility: 1,
AccessibilityLocked: false,
SavedImageType: 1,
Type: 1,
CreatedAt: img.CreatedAt,
TaggedPlayerIds: [],
CheerCount: 0,
CommentCount: 0,
})
})
test('POST /api/images/v1/:id/report files a bodiless report against the photo’s author', async () => {
const img = await createImage(env.DB, { imageName: 'reported.jpg', playerId: 7310 })
const report = async (id: number, sub?: string) =>
exports.default.fetch(`${ORIGIN}/api/images/v1/${id}/report`, {
method: 'POST',
headers: sub ? await bearer(sub) : undefined,
})
expect((await report(img.Id)).status).toBe(401)
const unknown = await report(99999999, '7311')
expect(unknown.status).toBe(404)
expect(await unknown.json()).toEqual({ success: false, error: 'No such image' })
const res = await report(img.Id, '7311')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
const { results } = await env.DB.prepare('SELECT * FROM report WHERE image_id = ?1')
.bind(img.Id)
.all()
expect(results).toHaveLength(1)
expect(results[0]).toMatchObject({
reporter_player_id: 7311,
reported_player_id: 7310,
image_id: img.Id,
report_category: 0,
details: null,
event_id: null,
invention_id: null,
custom_avatar_item_id: null,
chat_message_id: null,
})
})
test('GET /api/images/v6/:id serves the raw SavedImage, private ones to their owner only', async () => {
const img = await createImage(env.DB, { imageName: 'v6byid.jpg', playerId: 7302 })
const get = async (id: number, sub?: string) =>
exports.default.fetch(
`${ORIGIN}/api/images/v6/${id}`,
sub ? { headers: await bearer(sub) } : undefined
)
// The 13-key record, nulls left null.
const res = await get(img.Id)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
Id: img.Id,
ImageName: 'v6byid.jpg',
Description: null,
PlayerId: 7302,
RoomId: null,
PlayerEventId: null,
Accessibility: 1,
AccessibilityLocked: false,
Type: 1,
CreatedAt: img.CreatedAt,
TaggedPlayerIds: [],
CheerCount: 0,
CommentCount: 0,
})
expect((await get(99999999)).status).toBe(404)
// Made private, it is its owner's alone — ids are sequential.
await env.DB.prepare(
"UPDATE image SET data = json_set(data, '$.Accessibility', 0) WHERE id = ?1"
)
.bind(img.Id)
.run()
expect((await get(img.Id)).status).toBe(404)
expect((await get(img.Id, '7303')).status).toBe(404)
expect((await get(img.Id, '7302')).status).toBe(200)
})
test('GET /api/images/v6 400s without a name and 404s for an unknown one', async () => {
expect((await exports.default.fetch(`${ORIGIN}/api/images/v6`)).status).toBe(400)
expect(
@@ -6633,7 +7469,7 @@ describe('images', () => {
accessibility: 2,
})
)
fd.append('image', new File([new Uint8Array([1, 2, 3])], 'pic.png', { type: 'image/png' }))
fd.append('image', new File([PNG_BYTES], 'pic.png', { type: 'image/png' }))
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
method: 'POST',
headers: await bearer('42'),
@@ -6644,23 +7480,20 @@ describe('images', () => {
const meta = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
).json()) as {
SavedImageType: number
Type: number
RoomId: number
Accessibility: number
PlayerEventId: number
ClubId: number
Description: string
PlayerEventId: number | null
TaggedPlayerIds: number[]
}
expect(meta.SavedImageType).toBe(1)
expect(meta.Type).toBe(1)
expect(meta.RoomId).toBe(777)
expect(meta.Accessibility).toBe(2)
// v6 carries no TaggedPlayerIds — the upload still records them, which the stored row
// below proves. Nothing on this projection is nullable: a "none" event reads 0, not
// null, and the club (which nothing here sets) reads 0 too.
expect(meta).not.toHaveProperty('TaggedPlayerIds')
expect(meta.PlayerEventId).toBe(0)
expect(meta.ClubId).toBe(0)
expect(meta.Description).toBe('')
// v6 serves the stored record as it is: the tagged players ride along, a "none"
// event is null rather than 0, and there is no `ClubId`.
expect(meta.TaggedPlayerIds).toEqual([5, 6])
expect(meta.PlayerEventId).toBeNull()
expect(meta).not.toHaveProperty('ClubId')
// The tagged players and the null event id, as actually stored.
const row = await env.DB.prepare('SELECT data FROM image WHERE image_name = ?1')
@@ -6671,7 +7504,7 @@ describe('images', () => {
PlayerEventId: number | null
}
expect(stored.TaggedPlayerIds).toEqual([5, 6])
// playerEventId 0 means "none" → stored as null, and serialized back out as 0.
// playerEventId 0 means "none" → stored as null.
expect(stored.PlayerEventId).toBeNull()
})
@@ -6701,7 +7534,7 @@ describe('images', () => {
expect(JSON.parse(row!.data).profileImage).toBe(ImageName)
})
test('DELETE /api/images/v1/deletesaved removes the owner’s image (row + cheers + R2)', async () => {
test('POST /api/images/v1/deletesaved removes the owner’s image (row + cheers + R2)', async () => {
const ImageName = 'sharecamera/2026-07-17/delete-me.jpg'
await env.IMAGES.put(ImageName, new Uint8Array([1, 2, 3]))
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
@@ -6729,7 +7562,7 @@ describe('images', () => {
const del = (headers: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, {
method: 'DELETE',
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify({ ImageName }),
})
@@ -6741,7 +7574,7 @@ describe('images', () => {
// Unknown image → 404.
const unknown = await exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, {
method: 'DELETE',
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(await bearer('42')) },
body: JSON.stringify({ ImageName: 'sharecamera/nope.jpg' }),
})
@@ -6757,6 +7590,113 @@ describe('images', () => {
expect(cheers!.n).toBe(0)
})
/** Seed one image row owned by the default bearer account (42). */
const seedOwnedImage = async (
Id: number,
ImageName: string,
extra: Record<string, unknown> = {}
) =>
env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
.bind(
JSON.stringify({
Id,
Type: 1,
Accessibility: 1,
AccessibilityLocked: false,
ImageName,
Description: null,
PlayerId: 42,
TaggedPlayerIds: [],
RoomId: null,
PlayerEventId: null,
CreatedAt: new Date().toISOString(),
CheerCount: 0,
CommentCount: 0,
...extra,
})
)
.run()
const postImageJson = (path: string, headers: Record<string, string>, body: unknown) =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
})
test('POST /api/images/v2/modifyaccessibility flips the owner’s image and gates on the owner', async () => {
const ImageName = 'sharecamera/2026-09-13/flip-me.jpg'
await seedOwnedImage(8101, ImageName)
const path = '/api/images/v2/modifyaccessibility'
// No token → 401; a different account → 403; the image is untouched.
expect((await postImageJson(path, {}, { ImageName, Accessibility: 0 })).status).toBe(401)
expect(
(await postImageJson(path, await bearer('43'), { ImageName, Accessibility: 0 })).status
).toBe(403)
expect((await getImageByName(env.DB, ImageName))!.Accessibility).toBe(1)
// Unknown image → 404; a bad accessibility or no name → 400.
const owner = await bearer('42')
expect(
(await postImageJson(path, owner, { ImageName: 'sharecamera/nope.jpg', Accessibility: 0 }))
.status
).toBe(404)
expect((await postImageJson(path, owner, { ImageName, Accessibility: 7 })).status).toBe(400)
expect((await postImageJson(path, owner, { Accessibility: 0 })).status).toBe(400)
// Owner → 200 and the record is now private; back to 1 makes it public again.
const res = await postImageJson(path, owner, { ImageName, Accessibility: 0 })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true })
expect((await getImageByName(env.DB, ImageName))!.Accessibility).toBe(0)
expect((await postImageJson(path, owner, { ImageName, Accessibility: 1 })).status).toBe(200)
expect((await getImageByName(env.DB, ImageName))!.Accessibility).toBe(1)
// A locked image refuses even its owner.
const locked = 'sharecamera/2026-09-13/locked.jpg'
await seedOwnedImage(8102, locked, { AccessibilityLocked: true, Accessibility: 0 })
expect((await postImageJson(path, owner, { ImageName: locked, Accessibility: 1 })).status).toBe(
403
)
expect((await getImageByName(env.DB, locked))!.Accessibility).toBe(0)
})
test('POST /api/images/v1/modifydescription sets the owner’s caption and gates on the owner', async () => {
const ImageName = 'sharecamera/2026-09-13/caption-me.jpg'
await seedOwnedImage(8103, ImageName)
const path = '/api/images/v1/modifydescription'
// No token → 401; a different account → 403; the caption is untouched.
expect((await postImageJson(path, {}, { ImageName, Description: 'x' })).status).toBe(401)
expect(
(await postImageJson(path, await bearer('43'), { ImageName, Description: 'x' })).status
).toBe(403)
expect((await getImageByName(env.DB, ImageName))!.Description).toBeNull()
// Unknown image → 404; a non-string description or no name → 400.
const owner = await bearer('42')
expect(
(await postImageJson(path, owner, { ImageName: 'sharecamera/nope.jpg', Description: 'x' }))
.status
).toBe(404)
expect((await postImageJson(path, owner, { ImageName, Description: 5 })).status).toBe(400)
expect((await postImageJson(path, owner, { Description: 'x' })).status).toBe(400)
// Owner → 200 and the caption is stored; the rest of the record is untouched.
const res = await postImageJson(path, owner, { ImageName, Description: 'tet' })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true })
const after = await getImageByName(env.DB, ImageName)
expect(after!.Description).toBe('tet')
expect(after!.Accessibility).toBe(1)
expect(after!.PlayerId).toBe(42)
// An empty description clears the caption back to null.
expect((await postImageJson(path, owner, { ImageName, Description: '' })).status).toBe(200)
expect((await getImageByName(env.DB, ImageName))!.Description).toBeNull()
})
test('POST /api/images/v4/uploadsaved 401s without a bearer token', async () => {
const fd = new FormData()
fd.append('image', new File([new Uint8Array([1, 2, 3])], 'avatar.png', { type: 'image/png' }))
@@ -8316,6 +9256,69 @@ describe('player events', () => {
expect(await search('?skip=1&take=1')).toEqual([all[1]])
})
test('GET /api/playerevents/v1/search?sort=Attendance ranks by Going replies', async () => {
const search = async (qs: string): Promise<PlayerEvent[]> =>
(await (await get(`/api/playerevents/v1/search${qs}`)).json()) as PlayerEvent[]
const respond = (id: number, type: number, sub: string): Promise<Response> =>
post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: type }, sub)
// Three events sharing a searchable word, created soonest-first so the default
// order is the OPPOSITE of the attendance order — the sort has to do something.
const quiet = await create({ RoomId: 3, Name: 'Sortable Quiet', StartTime: at(2 * HOUR) })
const busy = await create({ RoomId: 3, Name: 'Sortable Busy', StartTime: at(3 * HOUR) })
const packed = await create({ RoomId: 3, Name: 'Sortable Packed', StartTime: at(4 * HOUR) })
// Every creator is Going from create (1 each). `busy` gains one more Going; `packed`
// gains two. Interested and Can't-go on `quiet` don't move it: only a Yes counts.
for (const [id, type, sub] of [
[busy.PlayerEventId, 0, '43'],
[packed.PlayerEventId, 0, '43'],
[packed.PlayerEventId, 0, '44'],
[quiet.PlayerEventId, 1, '43'],
[quiet.PlayerEventId, 1, '44'],
[quiet.PlayerEventId, 2, '45'],
] as const) {
expect((await respond(id, type, sub)).status).toBe(200)
}
// Default and `StartTime`: soonest first.
const soonest = [quiet.PlayerEventId, busy.PlayerEventId, packed.PlayerEventId]
expect((await search('?query=sortable')).map((e) => e.PlayerEventId)).toEqual(soonest)
expect((await search('?query=sortable&sort=StartTime')).map((e) => e.PlayerEventId)).toEqual(
soonest
)
// `Attendance`: most Going first (3, 2, 1), case-insensitively.
const byAttendance = await search('?query=sortable&sort=Attendance')
expect(byAttendance.map((e) => e.PlayerEventId)).toEqual([
packed.PlayerEventId,
busy.PlayerEventId,
quiet.PlayerEventId,
])
expect(byAttendance.map((e) => e.AttendeeCount)).toEqual([3, 2, 1])
expect((await search('?query=sortable&sort=attendance')).map((e) => e.PlayerEventId)).toEqual(
byAttendance.map((e) => e.PlayerEventId)
)
// A tie in attendance falls back to soonest first: another 1-Going event, later
// than `quiet`, lands after it.
const late = await create({ RoomId: 3, Name: 'Sortable Late', StartTime: at(5 * HOUR) })
expect((await search('?query=sortable&sort=Attendance')).map((e) => e.PlayerEventId)).toEqual([
packed.PlayerEventId,
busy.PlayerEventId,
quiet.PlayerEventId,
late.PlayerEventId,
])
// Paging walks the sorted order, and an unknown sort is the default, not a 400.
expect(await search('?query=sortable&sort=Attendance&skip=1&take=1')).toEqual([byAttendance[1]])
const unknown = await get('/api/playerevents/v1/search?query=sortable&sort=Popularity')
expect(unknown.status).toBe(200)
expect(((await unknown.json()) as PlayerEvent[]).map((e) => e.PlayerEventId)).toEqual([
...soonest,
late.PlayerEventId,
])
})
test('GET /api/playerevents/v1 serves the browse feed as listings', async () => {
const res = await get('/api/playerevents/v1')
expect(res.status).toBe(200)
@@ -8536,6 +9539,77 @@ describe('player events', () => {
expect(theirs.Created.map((e) => e.PlayerEventId)).toEqual([liveEvent.PlayerEventId])
})
test('GET /api/playerevents/v1/all/:playerId serves a player’s PUBLIC events and Going RSVPs', async () => {
const respond = (id: number, type: number, sub: string): Promise<Response> =>
post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: type }, sub)
interface Profile {
Created: PlayerEvent[]
Responses: Array<{
PlayerEvent: Record<string, unknown>
PlayerEventResponse: Record<string, unknown>
}>
}
const profile = async (playerId: number | string): Promise<Profile> => {
// No token: the read is public.
const res = await get(`/api/playerevents/v1/all/${playerId}`)
expect(res.status).toBe(200)
return (await res.json()) as Profile
}
// 610 hosts a public event, a private one and a finished one; 611 is Going to the
// first two, Interested in a third public one, and went to the finished one.
const open = await create({ RoomId: 3, Name: 'Profile Open', StartTime: at(2 * HOUR) }, '610')
const closed = await create(
{ RoomId: 3, Name: 'Profile Closed', StartTime: at(3 * HOUR), Accessibility: 0 },
'610'
)
const maybe = await create({ RoomId: 3, Name: 'Profile Maybe', StartTime: at(HOUR) }, '610')
const over = await create(
{ RoomId: 3, Name: 'Profile Over', StartTime: at(-3 * HOUR), EndTime: at(-2 * HOUR) },
'610'
)
await respond(open.PlayerEventId, 0, '611')
await respond(closed.PlayerEventId, 0, '611')
await respond(maybe.PlayerEventId, 1, '611')
await respond(over.PlayerEventId, 0, '611')
// The attendee: exactly the public event they are Going to, as the pair.
const guest = await profile(611)
expect(guest.Created).toEqual([])
expect(guest.Responses).toHaveLength(1)
const [entry] = guest.Responses
expect(Object.keys(entry).sort()).toEqual(['PlayerEvent', 'PlayerEventResponse'])
expect(entry.PlayerEventResponse).toEqual({
PlayerEventResponseId: expect.any(Number),
PlayerEventId: open.PlayerEventId,
PlayerId: 611,
CreatedAt: expect.any(String),
Type: 0,
})
// The 17-key base event: no `State`, `ImageName` a string, the broadcast key present.
expect(Object.keys(entry.PlayerEvent)).toHaveLength(17)
expect(entry.PlayerEvent).toMatchObject({
PlayerEventId: open.PlayerEventId,
Name: 'Profile Open',
AttendeeCount: 2,
Accessibility: 1,
ImageName: '',
BroadcastingRoomInstanceId: null,
})
expect(entry.PlayerEvent).not.toHaveProperty('State')
// The host: their public unfinished events, soonest first, under both keys.
const host = await profile(610)
const ids = [maybe.PlayerEventId, open.PlayerEventId]
expect(host.Created.map((e) => e.PlayerEventId)).toEqual(ids)
expect(Object.keys(host.Created[0])).toHaveLength(17)
expect(host.Responses.map((r) => r.PlayerEvent.PlayerEventId)).toEqual(ids)
// Nobody's events, and a non-numeric id doesn't match.
expect(await profile(999999)).toEqual({ Created: [], Responses: [] })
expect((await get('/api/playerevents/v1/all/abc')).status).toBe(404)
})
test('POST /api/playerevents/v1/respond records an RSVP and recounts attendees', async () => {
const respond = async (body: unknown, sub = '42'): Promise<Response> =>
post('/api/playerevents/v1/respond', body, sub)
@@ -9248,6 +10322,8 @@ describe('player events', () => {
expect((await putForm(path, { name: 'n'.repeat(64) })).status).toBe(200)
})
// One create, fourteen gated calls, and a read. The default 5s budget is what
// this crossed on CI; the checks themselves are the same creator gate as above.
test('the single-field edits are creator-only, like the whole-event update', async () => {
const event = await create({ RoomId: 5, Name: 'Guarded' })
const id = event.PlayerEventId
@@ -9268,7 +10344,7 @@ describe('player events', () => {
// Nothing moved.
expect(await (await get(`/api/playerevents/v1/${id}`)).json()).toEqual(asRecord(event, null))
})
}, 20_000)
})
describe('openapi', () => {
@@ -9296,7 +10372,6 @@ describe('openapi', () => {
)
expect([...documented].sort()).toEqual([
'DELETE /api/customAvatarItems/v1/{id}',
'DELETE /api/images/v1/deletesaved',
'DELETE /api/playerevents/v2/delete/{eventId}',
'GET /api/CircuitChipLists/{list}',
'GET /api/PlayerReporting/v1/moderationBlockDetails',
@@ -9328,6 +10403,7 @@ describe('openapi', () => {
'GET /api/images/v5/cheered/bulk',
'GET /api/images/v5/player/{playerId}',
'GET /api/images/v6',
'GET /api/images/v6/{id}',
'GET /api/inventions/v1',
'GET /api/inventions/v1/details',
'GET /api/inventions/v1/featured',
@@ -9353,6 +10429,7 @@ describe('openapi', () => {
'GET /api/playerReputation/v2/bulk',
'GET /api/playerevents/v1',
'GET /api/playerevents/v1/all',
'GET /api/playerevents/v1/all/{playerId}',
'GET /api/playerevents/v1/bulk',
'GET /api/playerevents/v1/club/{clubId}',
'GET /api/playerevents/v1/clubs',
@@ -9364,9 +10441,13 @@ describe('openapi', () => {
'GET /api/playerevents/v1/{eventId}/responses',
'GET /api/playerevents/v2/{eventId}',
'GET /api/players/v1/playerPhotoTaggingSetting',
'GET /api/players/v1/progression/xpEarnedToday',
'GET /api/players/v1/progression/{id}',
'GET /api/players/v2/progression/bulk',
'GET /api/progressionEvents/active',
'GET /api/progressionEvents/event/{id}',
'GET /api/progressionEvents/record/{id}',
'GET /api/purchasableXpBoosts/activations',
'GET /api/quickPlay/v1/getandclear',
'GET /api/referee/files',
'GET /api/relationships/mutualfriends',
@@ -9409,6 +10490,10 @@ describe('openapi', () => {
'POST /api/customAvatarItems/v1/{id}/report',
'POST /api/gamesight/event',
'POST /api/images/v1/cheer',
'POST /api/images/v1/deletesaved',
'POST /api/images/v1/modifydescription',
'POST /api/images/v1/{id}/report',
'POST /api/images/v2/modifyaccessibility',
'POST /api/images/v4/uploadsaved',
'POST /api/images/v5/cheered/bulk',
'POST /api/inventions/v1/cheer',
@@ -9416,7 +10501,9 @@ describe('openapi', () => {
'POST /api/inventions/v1/settags',
'POST /api/inventions/v1/update',
'POST /api/inventions/v1/updateprice',
'POST /api/inventions/v2/cheer',
'POST /api/inventions/v2/delete',
'POST /api/inventions/v2/unpublish',
'POST /api/inventions/v2/update',
'POST /api/inventions/v4/publish',
'POST /api/inventions/v6/save',
@@ -9450,6 +10537,7 @@ describe('openapi', () => {
'POST /api/rooms/v1/verifyRole',
'POST /api/sanitize/v1',
'POST /api/sanitize/v1/isPure',
'POST /api/sanitize/v2/isAllowed/bulk',
'POST /api/v1/progression/bulk',
'POST /outfits/bulk',
'POST /statsigUserProperties',
@@ -0,0 +1,338 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, beforeEach, expect, it } from 'vitest'
import {
AVATAR_STUDIO_SCHEMA,
avatarSaveDto,
avatarStudioSave,
publishAvatarSave,
ROOM_SCHEMA_DDL,
UNITY_ASSET_SCHEMA_DDL,
} from '@repo/domain'
import { generateToken } from '@repo/jwt'
import { SCHEMA_DDL } from '../../custom-avatar-items-db'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const base = 'https://example.com/api/customAvatarItems'
async function headers(id = 42, roles = ['betastudio']) {
return {
Authorization: 'Bearer ' + (await generateToken(String(id), 'fixture', 0, 'test-key', roles)),
}
}
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key')
for (const sql of [
...SCHEMA_DDL,
...AVATAR_STUDIO_SCHEMA,
...UNITY_ASSET_SCHEMA_DDL,
...ROOM_SCHEMA_DDL,
])
await env.DB.prepare(sql).run()
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,1,'now','now')").run()
})
beforeEach(async () => {
await env.DB.prepare('DELETE FROM avatar_studio_save').run()
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
await env.DB.prepare('UPDATE avatar_studio_access SET flags=1 WHERE account_id=42').run()
})
it('saves and publishes image badges directly, while enforcing badge grants and room ownership', async () => {
expect((await create(42, ['betastudio'], 1000)).status).toBe(403)
await env.DB.prepare('UPDATE avatar_studio_access SET flags=5 WHERE account_id=42').run()
const foreign = new FormData()
foreign.set(
'metadata',
JSON.stringify({ Name: 'Foreign badge', OutfitType: 1000, BadgeRoomId: 98765 })
)
expect(
(await SELF.fetch(base + '/v1', { method: 'POST', headers: await headers(), body: foreign }))
.status
).toBe(403)
const { value: item } = await (
await create(42, ['betastudio'], 1000)
).json<{ value: { CustomAvatarItemId: string } }>()
const png = Uint8Array.from(
atob(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg=='
),
(c) => c.charCodeAt(0)
)
const main = 'b'.repeat(40)
const bytes = new TextEncoder().encode(
JSON.stringify({
Version: 3,
MainAssetProjectRelativePath: 'Assets/Badge.png',
Files: [{ n: main, p: 'Assets/Badge.png' }],
})
)
const digest = await crypto.subtle.digest('SHA-256', bytes)
await env.CDN_ASSETS.put('avatarunityasset/badge-source', bytes, {
sha256: digest,
customMetadata: { ownerId: '42' },
})
await env.CDN_ASSETS.put('unityasset/' + main, png)
await env.CDN_ASSETS.put('image/badge.png', png, {
customMetadata: { ownerId: '42' },
httpMetadata: { contentType: 'image/png' },
})
const url = base + '/' + item.CustomAvatarItemId + '/saves'
const save = await SELF.fetch(url, {
method: 'POST',
headers: { ...(await headers()), 'Content-Type': 'application/json' },
body: JSON.stringify({
NewUnityAssetFile: {
Filename: 'badge-source',
Hash: btoa(String.fromCharCode(...new Uint8Array(digest))),
},
BodyType: 4,
ThumbnailFileName: 'badge.png',
AdditionalConfiguration: JSON.stringify({
v: 1,
d: { n: 'Badge', o: 1000, d: { t: '000000' } },
}),
UpdateCurrentSaveIfPrivate: true,
}),
})
expect(save.status).toBe(200)
const { value: saveId } = await save.json<{ value: number }>()
const detail = await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
headers: await headers(),
})
expect(await detail.json()).toMatchObject({
ThumbnailImageFilename: 'badge.png',
CurrentSaves: [{ CustomAvatarItemSaveId: saveId, BodyType: 4, UnityAsset: null }],
})
expect(
(
await SELF.fetch(url + '/' + saveId + '/publish', {
method: 'POST',
headers: await headers(),
})
).status
).toBe(200)
expect(await (await env.IMAGES.get('badge.png'))!.arrayBuffer()).toEqual(png.buffer)
const published = await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
method: 'PUT',
headers: { ...(await headers()), 'Content-Type': 'application/json' },
body: JSON.stringify({ Accessibility: 1 }),
})
expect(published.status).toBe(200)
expect((await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId)).status).toBe(200)
})
async function create(id = 42, roles = ['betastudio'], outfitType = 0) {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({ Name: 'Studio Hat', OutfitType: outfitType, Accessibility: 0, Price: 0 })
)
return SELF.fetch(base + '/v1', { method: 'POST', headers: await headers(id, roles), body: form })
}
it('accepts the SDK create request with nullable metadata fields', async () => {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({
Name: 'SDK item',
OutfitType: 101,
BaseAvatarItemColor: '#101',
BadgeRoomId: null,
PreviewOrientation: null,
Description: null,
Tags: null,
Price: null,
Accessibility: null,
})
)
const response = await SELF.fetch(base + '/v1', {
method: 'POST',
headers: await headers(),
body: form,
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
value: { Description: '', Tags: [], Price: 0, Accessibility: 0, CurrentSaves: [] },
})
})
it('keeps grants per-account, rejects forbidden item categories, and creates metadata-only Studio items', async () => {
expect((await create(43)).status).toBe(403)
expect((await create(42, [])).status).toBe(403)
expect((await create(42, ['betastudio'], 500)).status).toBe(403)
const response = await create()
expect(response.status).toBe(200)
const { value: item } = await response.json<{
value: { CustomAvatarItemId: string; OutfitType: number; CurrentSaves: unknown[] }
}>()
expect(item.OutfitType).toBe(0)
expect(item.CurrentSaves).toEqual([])
expect((await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId)).status).toBe(403)
expect(
(await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, { headers: await headers() })).status
).toBe(200)
const page = await SELF.fetch(base + '/v1/me', { headers: await headers() })
expect(await page.json()).toMatchObject({
TotalResults: 1,
Results: [{ CustomAvatarItemId: item.CustomAvatarItemId }],
})
})
it('protects source history, verifies upload ownership and hash, and never publishes unbuilt revisions', async () => {
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
const url = base + '/' + item.CustomAvatarItemId + '/saves'
const bytes = new TextEncoder().encode('{"Version":3}'),
digest = await crypto.subtle.digest('SHA-256', bytes)
await env.CDN_ASSETS.put('avatarunityasset/2026-10-18/source', bytes, {
sha256: digest,
customMetadata: { ownerId: '42' },
})
await env.CDN_ASSETS.put('image/thumb.png', 'thumbnail', {
customMetadata: { ownerId: '42' },
httpMetadata: { contentType: 'image/png' },
})
const body = {
NewUnityAssetFile: {
Filename: '2026-10-18/source',
Hash: btoa(String.fromCharCode(...new Uint8Array(digest))),
},
BodyType: 1,
ThumbnailFileName: 'thumb.png',
AdditionalConfiguration: '{}',
UpdateCurrentSaveIfPrivate: true,
}
const post = async (value: unknown, id = 42) =>
SELF.fetch(url, {
method: 'POST',
headers: { ...(await headers(id)), 'Content-Type': 'application/json' },
body: JSON.stringify(value),
})
expect((await post(body, 43)).status).toBe(403)
expect(
(await post({ ...body, NewUnityAssetFile: { ...body.NewUnityAssetFile, Hash: 'wrong' } }))
.status
).toBe(400)
const response = await post(body)
expect(response.status).toBe(200)
const { value: saveId } = await response.json<{ value: number }>()
expect(await (await env.IMAGES.get('thumb.png'))?.text()).toBe('thumbnail')
expect((await SELF.fetch(url, { headers: await headers(43) })).status).toBe(403)
const history = await SELF.fetch(url, { headers: await headers() })
expect(await history.json()).toMatchObject({
success: true,
value: {
TotalResults: 1,
Results: [
{
CustomAvatarItemSaveId: saveId,
SourceUnityAssetFilename: body.NewUnityAssetFile.Filename,
UnityAsset: null,
},
],
},
})
expect(
(
await SELF.fetch(url + '/' + saveId + '/publish', {
method: 'POST',
headers: await headers(),
})
).status
).toBe(400)
expect(
(await post({ ...body, NewUnityAssetFile: null, ExistingUnityAssetId: crypto.randomUUID() }))
.status
).toBe(400)
})
it('allows creator metadata edits without exposing unbuilt items or changing ownership', async () => {
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
const edit = async (body: unknown, id = 42) =>
SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
method: 'PUT',
headers: { ...(await headers(id)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
expect((await edit({ Name: 'Other owner' }, 43)).status).toBe(403)
expect((await edit({ Accessibility: 1 })).status).toBe(400)
expect((await edit({ Tags: [{ TagType: 1, Value: '' }] })).status).toBe(400)
const response = await edit({
Name: ' Updated hat ',
Description: 'Edited in Studio',
Tags: [{ TagType: 1, Value: 'hat' }],
CreatorAccountId: 43,
})
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({
value: {
Name: 'Updated hat',
Description: 'Edited in Studio',
CreatorAccountId: 42,
Accessibility: 0,
Tags: [{ TagType: 1, Value: 'hat' }],
},
})
})
it('isolates outputs across reused-source bodies/configurations and preserves original restore', async () => {
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
const asset = crypto.randomUUID()
await env.DB.prepare(
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) VALUES (100,?,1,?,'same-source','same-hash','thumb.png','{}','now')"
)
.bind(item.CustomAvatarItemId, asset)
.run()
await env.DB.prepare(
"INSERT INTO unity_asset VALUES (?,0,5,'main','original.bundle','original-hash')"
)
.bind(asset)
.run()
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, 100)).toBe(true)
await env.CDN_ASSETS.put('image/thumb.png', 'fixture', { customMetadata: { ownerId: '42' } })
const url = base + '/' + item.CustomAvatarItemId + '/saves'
const save = async (bodyType: number, config: string) => {
const response = await SELF.fetch(url, {
method: 'POST',
headers: { ...(await headers()), 'Content-Type': 'application/json' },
body: JSON.stringify({
ExistingUnityAssetId: asset,
BodyType: bodyType,
AdditionalConfiguration: config,
ThumbnailFileName: 'thumb.png',
UpdateCurrentSaveIfPrivate: true,
}),
})
expect(response.status).toBe(200)
const { value: id } = await response.json<{ value: number }>()
return (await avatarStudioSave(env.DB, item.CustomAvatarItemId, id))!
}
const metadata = await save(1, '{}')
expect(metadata.unity_asset_id).toBe(asset)
const otherBody = await save(2, '{}'),
otherConfig = await save(1, '{"variant":2}')
for (const revision of [otherBody, otherConfig]) {
expect(revision.unity_asset_id).not.toBe(asset)
expect(revision.source_filename).toBe('same-source')
expect(revision.source_hash).toBe('same-hash')
expect((await avatarSaveDto(env.DB, revision)).UnityAsset).toBeNull()
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, revision.id)).toBe(false)
}
expect(otherBody.unity_asset_id).not.toBe(otherConfig.unity_asset_id)
await env.DB.prepare(
"INSERT INTO unity_asset VALUES (?,0,5,'main','other-body.bundle','other-hash')"
)
.bind(otherBody.unity_asset_id)
.run()
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, otherBody.id)).toBe(true)
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, 100)).toBe(true)
const stored = JSON.parse(
(await env.DB.prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id=?')
.bind(item.CustomAvatarItemId)
.first<{ data: string }>())!.data
)
expect(stored.CurrentSaves.find((s: { BodyType: number }) => s.BodyType === 1).UnityAsset).toBe(
'original.bundle'
)
expect(stored.CurrentSaves.find((s: { BodyType: number }) => s.BodyType === 2).UnityAsset).toBe(
'other-body.bundle'
)
})
@@ -0,0 +1,52 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, expect, it } from 'vitest'
import { generateStudioBuildValidationToken, validateAndGetAccountId } from '@repo/jwt'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('test-key')
})
it('allows build validation credentials to check text without granting an account identity', async () => {
const token = await generateStudioBuildValidationToken('123456', 'test-key')
const headers = { Authorization: 'Bearer ' + token }
expect(
await validateAndGetAccountId(new Request('https://example.com', { headers }), 'test-key')
).toBeNull()
const response = await SELF.fetch('https://example.com/api/sanitize/v2/isAllowed/bulk', {
method: 'POST',
headers,
body: new URLSearchParams('Values=hello&Values=fuck'),
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
Success: true,
Error: null,
Value: { ArePure: [true, false] },
})
expect(
(
await SELF.fetch('https://example.com/api/PlayerCheer/v1/create', {
method: 'POST',
headers,
body: new URLSearchParams(),
})
).status
).toBe(401)
})
it('rejects an invalid build validation signature', async () => {
const token = await generateStudioBuildValidationToken('123456', 'wrong-key')
expect(
(
await SELF.fetch('https://example.com/api/sanitize/v2/isAllowed/bulk', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token },
body: new URLSearchParams('Values=hello'),
})
).status
).toBe(401)
})
-6
View File
@@ -1,6 +0,0 @@
{
"OutfitSelections": "1fd69ef8-0b74-4962-af5a-67f0bf0358f2,,0;d0a9262f-5504-46a7-bb10-7507503db58e,,1",
"FaceFeatures": "{\"ver\":-1,\"eyeId\":\"AjGMoJhEcEehacRZjUMuDg\",\"eyePos\":{\"x\":0.0,\"y\":0.0},\"eyeScl\":0.0,\"mouthId\":\"FrZBRanXEEK29yKJ4jiMjg\",\"mouthPos\":{\"x\":0.0,\"y\":0.0},\"mouthScl\":0.0,\"hairPrimaryColorId\":\"\",\"hairSecondaryColorId\":\"0e_jaaObREWTf1AorAZ95g\",\"hairPatternId\":\"\",\"beardColorId\":\"0e_jaaObREWTf1AorAZ95g\",\"beardSecondaryColorId\":\"0e_jaaObREWTf1AorAZ95g\",\"beardPatternId\":\"\",\"faceShapeId\":\"yR4oYZr_AUSynXCgwS2lGw\",\"bodyShapeId\":\"bY1RGIph0kiAxbd6Shn9tQ\",\"useHatAnchorParams\":true,\"useHelmetHair\":1,\"hideEars\":true,\"hatAnchorParams\":{\"NormalizedPosition\":{\"x\":0.5,\"y\":0.5},\"HemisphereOffsets\":{\"x\":0.0,\"y\":0.0,\"z\":0.0},\"HemisphereRotations\":{\"x\":0.0,\"y\":0.0,\"z\":0.0}},\"baseAvatarType\":\"\"}",
"SkinColor": "Xac-W_R330KfOz-pQla9qg",
"HairColor": "0e_jaaObREWTf1AorAZ95g"
}
-2582
View File
@@ -1,2582 +0,0 @@
[
{
"AvatarItemDesc": "0088603e-ec3b-4478-8694-e6fb1989b3f2,,,",
"AvatarItemId": 620,
"AvatarItemType": 0,
"FriendlyName": "Angled Bob Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "04e348b7-107f-47b4-9331-ae3b9fa184ca,,,",
"AvatarItemId": 1980,
"AvatarItemType": 0,
"FriendlyName": "Braided Bun Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,,,",
"AvatarItemId": 1004,
"AvatarItemType": 0,
"FriendlyName": "Cat-Eye Glasses (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,cypMpMaVeUuRjm4FLH4czQ,PIjEKhXiCUqtA8FBhLBcmA,",
"AvatarItemId": 1883,
"AvatarItemType": 0,
"FriendlyName": "Cat-Eye Glasses (Teal)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,LTXy6g4xe0abuFzOLpVDUg,PIjEKhXiCUqtA8FBhLBcmA,",
"AvatarItemId": 1881,
"AvatarItemType": 0,
"FriendlyName": "Cat-Eye Glasses (Dark Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,tMsCUhDslkmdNBfogeSz7w,PIjEKhXiCUqtA8FBhLBcmA,",
"AvatarItemId": 1884,
"AvatarItemType": 0,
"FriendlyName": "Cat-Eye Glasses (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,whWKAUSGp0qprsJomq9Nwg,PIjEKhXiCUqtA8FBhLBcmA,",
"AvatarItemId": 1882,
"AvatarItemType": 0,
"FriendlyName": "Cat-Eye Glasses (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,",
"AvatarItemId": 2337,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (Beige)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,_T4zEki_wk-UIPdhSaBr_Q",
"AvatarItemId": 2341,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,5z3jZhYvq0CGmdJ9IcUUsg",
"AvatarItemId": 2340,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (Brown)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,EgEoJzG7A0aJ92qfInbX7g",
"AvatarItemId": 2342,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,NETGR8iysUyxcZM_oGmrwQ",
"AvatarItemId": 2344,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,OlfQDuz1NUOr88AL2n0jXg",
"AvatarItemId": 2343,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,VudVdOAjZUibHmtFeNkvSw",
"AvatarItemId": 2339,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "0753d7a4-8247-4fca-a6fc-359c26086140,,,",
"AvatarItemId": 993,
"AvatarItemType": 0,
"FriendlyName": "Fonzie Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "09177621-9ecd-4f6a-b6a5-64490139141d,,,",
"AvatarItemId": 81,
"AvatarItemType": 0,
"FriendlyName": "Flat Top Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "0abb6b08-20ce-444f-879e-0d1344df096c,,,",
"AvatarItemId": 1620,
"AvatarItemType": 0,
"FriendlyName": "Round Earrings",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "0d1ae26e-aaa4-4914-ad70-c9ab8cfabc61,,,",
"AvatarItemId": 1979,
"AvatarItemType": 0,
"FriendlyName": "Box Braids with Cuff Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,,,",
"AvatarItemId": 1740,
"AvatarItemType": 0,
"FriendlyName": "Basic Belt (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,6fmUy748hU2dgwgaijSkUA,rL9p5JY83Eyxj00Dieg7oA,",
"AvatarItemId": 1739,
"AvatarItemType": 0,
"FriendlyName": "Basic Belt (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,buuNFd8sK0eap78TL1gMNQ,-UFHTNvJs0CGDStb0PFkbQ,",
"AvatarItemId": 1741,
"AvatarItemType": 0,
"FriendlyName": "Basic Belt (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,dBR1uNgk206eVoWntVzVAg,rL9p5JY83Eyxj00Dieg7oA,",
"AvatarItemId": 1738,
"AvatarItemType": 0,
"FriendlyName": "Basic Belt (Brown)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,LIgagNf4gk68EYQdlDruxg,rL9p5JY83Eyxj00Dieg7oA,",
"AvatarItemId": 1736,
"AvatarItemType": 0,
"FriendlyName": "Basic Belt (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,zPGCmxM_b0yTt7tqMaomcw,rL9p5JY83Eyxj00Dieg7oA,",
"AvatarItemId": 1737,
"AvatarItemType": 0,
"FriendlyName": "Basic Belt (Tan)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,,,",
"AvatarItemId": 25,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 983,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 29,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (Light Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 31,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 30,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,724c79a3-822c-422f-9462-a5374ee0211c,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 26,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (White))",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 669,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 670,
"AvatarItemType": 0,
"FriendlyName": "Baseball Cap (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "193a3bf9-abc0-4d78-8d63-92046908b1c5,,,",
"AvatarItemId": 991,
"AvatarItemType": 0,
"FriendlyName": "Emo Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "1940c740-695d-4481-a802-bb11b6561635,",
"AvatarItemId": 2116,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Untucked Tee",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "1a71064b-794f-40fa-9109-8ad36602b6e1,,,",
"AvatarItemId": 95,
"AvatarItemType": 0,
"FriendlyName": "Shagg Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "1d27b674-f9e2-4ffc-9d8c-a58a1be06457,,,",
"AvatarItemId": 986,
"AvatarItemType": 0,
"FriendlyName": "Afro Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "1fd69ef8-0b74-4962-af5a-67f0bf0358f2,,,",
"AvatarItemId": 998,
"AvatarItemType": 0,
"FriendlyName": "Ponytail Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "21599b51-c50f-43d8-ac5f-62c30cd02ca5,,,",
"AvatarItemId": 994,
"AvatarItemType": 0,
"FriendlyName": "Lori Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "21caa68e-c3fa-474c-af5e-af1e742b7a60,,,",
"AvatarItemId": 1021,
"AvatarItemType": 0,
"FriendlyName": "Tennis Skirt (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "21caa68e-c3fa-474c-af5e-af1e742b7a60,758752bd-db2f-43d2-b580-55b3e1efffd5,b75ef67d-00c3-4ac1-9b72-212032460294,",
"AvatarItemId": 682,
"AvatarItemType": 0,
"FriendlyName": "Tennis Skirt (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "21caa68e-c3fa-474c-af5e-af1e742b7a60,c5deba2a-6e35-4b13-8e94-8ba5457f39df,b75ef67d-00c3-4ac1-9b72-212032460294,",
"AvatarItemId": 681,
"AvatarItemType": 0,
"FriendlyName": "Tennis Skirt (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,,,",
"AvatarItemId": 1022,
"AvatarItemType": 0,
"FriendlyName": "Zipper Dress (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,6d703981-2734-4c45-8983-cdd5f328902f,cfabdefe-0890-436e-b2a3-b5c712e22955,",
"AvatarItemId": 255,
"AvatarItemType": 0,
"FriendlyName": "Zipper Dress (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,830be2fa-60a5-48cc-931f-34b670eae4bd,cfabdefe-0890-436e-b2a3-b5c712e22955,",
"AvatarItemId": 254,
"AvatarItemType": 0,
"FriendlyName": "Zipper Dress (Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,bbfa08e3-8e6b-4e0f-b264-1b398d7cd44a,cfabdefe-0890-436e-b2a3-b5c712e22955,",
"AvatarItemId": 253,
"AvatarItemType": 0,
"FriendlyName": "Zipper Dress (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,",
"AvatarItemId": 2345,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Halo (Turquoise)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,0p_eXn2gXUKrshSEmrQx6Q",
"AvatarItemId": 2350,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Halo (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,3cPbhpDX-EqDQy6eN1E5Ug",
"AvatarItemId": 2346,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Halo (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,fAmEEm6ziUO2ZXTscQWtSg",
"AvatarItemId": 2351,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Halo (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,gEuE-X67b0-q5C0Tv11_nA",
"AvatarItemId": 2348,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Halo (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,gk_mQ-jsakmxhZYYNlRl_w",
"AvatarItemId": 2349,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Halo (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "241506f6-bf88-4b46-b5fe-513a225421f4,,,",
"AvatarItemId": 417,
"AvatarItemType": 0,
"FriendlyName": "Half Up Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "24a240f4-1574-420b-b898-a7e91f170759,,,",
"AvatarItemId": 582,
"AvatarItemType": 0,
"FriendlyName": "Back Bun Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2822aa50-4344-434d-b46f-fd9443f865b9,,,",
"AvatarItemId": 1981,
"AvatarItemType": 0,
"FriendlyName": "Curly Bob Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2cb4f372-3372-4583-8b57-c4e3988e3c28,,,",
"AvatarItemId": 999,
"AvatarItemType": 0,
"FriendlyName": "Punky Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,,,",
"AvatarItemId": 1009,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Plaid, Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,0iSsaY-HgkmLaRHCn5vEdw,PioQ0o3yP0a6szPZ4EKs2A,",
"AvatarItemId": 1014,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,55901f12-d5b5-4fa8-b4c8-e479689ee39d,f600037d-c9c0-43fa-b45b-02f456f9dd5f,",
"AvatarItemId": 674,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Denim)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,bf82f2f6-9af8-431e-a296-0890dea48ba7,d015cae7-a905-49e4-8823-6dec069689a6,",
"AvatarItemId": 672,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Argyle)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,EfdMcnfHt0mr0PQ_maaYOg,DRJcNhkqvkKFEaZpOguR6w,",
"AvatarItemId": 1016,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Flowers, Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,FAviMCQ_EE2Mpt6QPo5OEw,PioQ0o3yP0a6szPZ4EKs2A,",
"AvatarItemId": 1015,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,jGj28vhq8EGwP2RuM074aQ,PioQ0o3yP0a6szPZ4EKs2A,",
"AvatarItemId": 1012,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,kmj5zOjcwku_WWKroCeiVQ,PioQ0o3yP0a6szPZ4EKs2A,",
"AvatarItemId": 1011,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,MFrcSQ1DYUm8imvy4ypgvw,PioQ0o3yP0a6szPZ4EKs2A,",
"AvatarItemId": 1013,
"AvatarItemType": 0,
"FriendlyName": "Collared Shirt (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e7cbdb0-cf22-4938-964f-acca57d221b7,",
"AvatarItemId": 2878,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (BTE Earmold - Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e7cbdb0-cf22-4938-964f-acca57d221b7,aEsXeTI_SEOclbm82n17tg",
"AvatarItemId": 2880,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (BTE Earmold - Gray)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "2e7cbdb0-cf22-4938-964f-acca57d221b7,pZDvYABTN06KdJ5Ck60XLA",
"AvatarItemId": 2879,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (BTE Earmold - Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "36b2ccf4-4f42-4dc4-aeaf-f3d10b2a6d30,,,",
"AvatarItemId": 1982,
"AvatarItemType": 0,
"FriendlyName": "Etched Fade Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "37f7b94a-92ba-4f5e-b94c-de00acfa4722,,,",
"AvatarItemId": 1983,
"AvatarItemType": 0,
"FriendlyName": "Etched Flattop Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,,,",
"AvatarItemId": 6,
"AvatarItemType": 0,
"FriendlyName": "Headband (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,0ecb8a2a-cffc-47db-aeda-fb0684aef1e5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 11,
"AvatarItemType": 0,
"FriendlyName": "Headband (Grey)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 663,
"AvatarItemType": 0,
"FriendlyName": "Headband (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 12,
"AvatarItemType": 0,
"FriendlyName": "Headband (Light Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,018a5c07-e956-457d-a540-a5e2cd68da09,",
"AvatarItemId": 667,
"AvatarItemType": 0,
"FriendlyName": "Headband (Orange, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 13,
"AvatarItemType": 0,
"FriendlyName": "Headband (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 16,
"AvatarItemType": 0,
"FriendlyName": "Headband (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,6dd95046-acf8-42fe-ab78-80a334096a9d,56a92c8d-af53-413e-929e-4a9a3cfad780,",
"AvatarItemId": 668,
"AvatarItemType": 0,
"FriendlyName": "Headband (Red, White, Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 664,
"AvatarItemType": 0,
"FriendlyName": "Headband (Cream)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,8377ab96-c908-457f-9fee-b784c9a759f3,018a5c07-e956-457d-a540-a5e2cd68da09,",
"AvatarItemId": 20,
"AvatarItemType": 0,
"FriendlyName": "Headband (Red, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 7,
"AvatarItemType": 0,
"FriendlyName": "Headband (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,018a5c07-e956-457d-a540-a5e2cd68da09,",
"AvatarItemId": 666,
"AvatarItemType": 0,
"FriendlyName": "Headband (Pink, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 14,
"AvatarItemType": 0,
"FriendlyName": "Headband (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,018a5c07-e956-457d-a540-a5e2cd68da09,",
"AvatarItemId": 665,
"AvatarItemType": 0,
"FriendlyName": "Headband (Blue, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 15,
"AvatarItemType": 0,
"FriendlyName": "Headband (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,f8b0cfe8-e129-4578-8bb5-f60af5d38599,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 10,
"AvatarItemType": 0,
"FriendlyName": "Headband (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "45eaab67-19c2-4601-8f80-3565a4dceba4,,,",
"AvatarItemId": 1199,
"AvatarItemType": 0,
"FriendlyName": "Pompadour Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "45f5e714-8a5f-4385-a97f-675066167011,,,",
"AvatarItemId": 112,
"AvatarItemType": 0,
"FriendlyName": "Seventies Stache",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "48fdfbc6-fca4-424c-aa57-3fcea41e23dc,,,",
"AvatarItemId": 1742,
"AvatarItemType": 0,
"FriendlyName": "Leather Necklace (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "48fdfbc6-fca4-424c-aa57-3fcea41e23dc,gsHBUqA1gE2XNTPA3VvBLQ,FwjsTo0nb0uNU6l8XvllNA,",
"AvatarItemId": 1745,
"AvatarItemType": 0,
"FriendlyName": "Leather Necklace (Brown)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "48fdfbc6-fca4-424c-aa57-3fcea41e23dc,JUQn0_PLIkytCrkAg0FflQ,FwjsTo0nb0uNU6l8XvllNA,",
"AvatarItemId": 1744,
"AvatarItemType": 0,
"FriendlyName": "Leather Necklace (Tan)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "4d507dfa-4a99-4ac0-8537-229e9dc0eb4a,,,",
"AvatarItemId": 1017,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Tank Top (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "4eaaad39-aa44-4791-8b67-2eafa8305850,,,",
"AvatarItemId": 1977,
"AvatarItemType": 0,
"FriendlyName": "Afro Puffs Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "5beeb4c4-f276-4eae-87aa-9302e45b05b7,,,",
"AvatarItemId": 1480,
"AvatarItemType": 0,
"FriendlyName": "Cornrows Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "5cd08cfb-c729-4c30-96d9-6a99bb934d91,,,",
"AvatarItemId": 124,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Sash",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "62ce4109-8dee-4895-bf1b-bfa143db4c7e,,,",
"AvatarItemId": 149,
"AvatarItemType": 0,
"FriendlyName": "Slim Blazer (Teal)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "62ce4109-8dee-4895-bf1b-bfa143db4c7e,ad61c418-6d77-4a99-8ac5-9f10f5a3d42f,0f36bb97-c61b-4281-929f-ff1d0d11be86,",
"AvatarItemId": 468,
"AvatarItemType": 0,
"FriendlyName": "Slim Blazer (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "62ce4109-8dee-4895-bf1b-bfa143db4c7e,cd5d7285-202d-42d0-b93f-04245875793e,0f36bb97-c61b-4281-929f-ff1d0d11be86,",
"AvatarItemId": 469,
"AvatarItemType": 0,
"FriendlyName": "Slim Blazer (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,,,",
"AvatarItemId": 131,
"AvatarItemType": 0,
"FriendlyName": "Long Scarf (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,5c4a2b35-0e1c-44de-8c3a-96d4a6458b1b,cf119781-5bd9-4b85-9a0b-12e82e988c23,",
"AvatarItemId": 461,
"AvatarItemType": 0,
"FriendlyName": "Long Scarf (Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,6dd95046-acf8-42fe-ab78-80a334096a9d,cf119781-5bd9-4b85-9a0b-12e82e988c23,",
"AvatarItemId": 460,
"AvatarItemType": 0,
"FriendlyName": "Long Scarf (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,d6edbc00-3c1d-4f49-8412-3ef8c7c5f4c2,cf119781-5bd9-4b85-9a0b-12e82e988c23,",
"AvatarItemId": 459,
"AvatarItemType": 0,
"FriendlyName": "Long Scarf (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,,,",
"AvatarItemId": 193,
"AvatarItemType": 0,
"FriendlyName": "Collared Dress (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,6564acf1-4d70-4f92-92ac-08e2b76dbb6b,2c8924aa-68f8-4912-9759-18992f72f08a,",
"AvatarItemId": 196,
"AvatarItemType": 0,
"FriendlyName": "Collared Dress (Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,d66aa400-aa5a-4539-a25d-5f8ce94dc281,2c8924aa-68f8-4912-9759-18992f72f08a,",
"AvatarItemId": 195,
"AvatarItemType": 0,
"FriendlyName": "Collared Dress (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,f750de46-3758-4f7d-9709-0a84b1027009,2c8924aa-68f8-4912-9759-18992f72f08a,",
"AvatarItemId": 194,
"AvatarItemType": 0,
"FriendlyName": "Collared Dress (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "6e48363d-cc62-40a8-87f9-a69fae90c002,,,",
"AvatarItemId": 1978,
"AvatarItemType": 0,
"FriendlyName": "Box Braid Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "71921831-ba6f-408b-a00e-2fd97663636f,,,",
"AvatarItemId": 1024,
"AvatarItemType": 0,
"FriendlyName": "Wrist Tape (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "71921831-ba6f-408b-a00e-2fd97663636f,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 297,
"AvatarItemType": 0,
"FriendlyName": "Wrist Tape (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "71921831-ba6f-408b-a00e-2fd97663636f,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 296,
"AvatarItemType": 0,
"FriendlyName": "Wrist Tape (Cream)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "77d3c585-4928-4471-a425-89036efe7299,,,",
"AvatarItemId": 1001,
"AvatarItemType": 0,
"FriendlyName": "Spiky Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "79b90274-6eec-4664-acfb-4a123334661e,,,",
"AvatarItemId": 997,
"AvatarItemType": 0,
"FriendlyName": "Pig Tails Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,,,",
"AvatarItemId": 1020,
"AvatarItemType": 0,
"FriendlyName": "T-Shirt",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 230,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,48abd952-214f-48b2-a8f1-1146f6f69aa2,b78008e8-abbd-4ece-be34-9a911f721fcc,",
"AvatarItemId": 677,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Zebra)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 227,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,5c4a2b35-0e1c-44de-8c3a-96d4a6458b1b,9c03f381-7357-4d0f-8cda-8737d4c43d25,",
"AvatarItemId": 679,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Rainbow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,6d703981-2734-4c45-8983-cdd5f328902f,a0271cd0-e172-4d3f-aa2f-9806f21a82d2,",
"AvatarItemId": 676,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Camo)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 228,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,ad61c418-6d77-4a99-8ac5-9f10f5a3d42f,b292eb4b-07e3-4a48-99b5-3c6587a1e02e,",
"AvatarItemId": 678,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Dots)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 229,
"AvatarItemType": 0,
"FriendlyName": "Tank Top (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "7dd6f7b0-7ba0-429f-a04f-e32d3a79ee61,,,",
"AvatarItemId": 585,
"AvatarItemType": 0,
"FriendlyName": "Short Wavy Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "849cabc9-db40-4448-b923-defe4e700072",
"AvatarItemId": 5954,
"AvatarItemType": 0,
"FriendlyName": "Bride of Frank Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,,,",
"AvatarItemId": 198,
"AvatarItemType": 0,
"FriendlyName": "Stoll Dress (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,64850553-cdfe-455a-ac00-dafbe63d613e,91a451c1-b285-4c48-b14d-59ded8cc006f,",
"AvatarItemId": 199,
"AvatarItemType": 0,
"FriendlyName": "Stoll Dress (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,761a3193-60f0-4190-80c7-285b8192e794,91a451c1-b285-4c48-b14d-59ded8cc006f,",
"AvatarItemId": 200,
"AvatarItemType": 0,
"FriendlyName": "Stoll Dress (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,a819f49b-6c7a-49d3-9e6a-d9d79ef5019f,91a451c1-b285-4c48-b14d-59ded8cc006f,",
"AvatarItemId": 201,
"AvatarItemType": 0,
"FriendlyName": "Stoll Dress (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "880a3cc0-7407-4b61-b759-f9dd890fe9e5,,,",
"AvatarItemId": 987,
"AvatarItemType": 0,
"FriendlyName": "Bob Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "88b6ddeb-a455-460d-91d9-a4569ef6903c,,,",
"AvatarItemId": 1621,
"AvatarItemType": 0,
"FriendlyName": "Square Earrings ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "8aa79563-ace1-4ba7-ad0c-f3210a78142f,,,",
"AvatarItemId": 1018,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Shirt (V-Neck, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "8aa79563-ace1-4ba7-ad0c-f3210a78142f,95e4cc30-cb68-473d-a395-feadf5b51512,05f0ee6e-c824-470e-9178-5ed576c6fe0c,",
"AvatarItemId": 675,
"AvatarItemType": 0,
"FriendlyName": "Rec Room T-Shirt (V-Neck, Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "8b9f1413-e786-4a30-946c-9292f207875a,,,",
"AvatarItemId": 91,
"AvatarItemType": 0,
"FriendlyName": "Pulp Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "8c35c804-e8d5-49d2-8d5a-ea19fb70bfa6,,,",
"AvatarItemId": 1478,
"AvatarItemType": 0,
"FriendlyName": "Pencil Bun Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "8d10cc78-6b00-45f3-affb-205e9cc5b03f,,,",
"AvatarItemId": 1006,
"AvatarItemType": 0,
"FriendlyName": "Beard (Close)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "92302d9d-c527-418c-ac5d-1fa869727505,,,",
"AvatarItemId": 996,
"AvatarItemType": 0,
"FriendlyName": "Part Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "95ab7a7c-c35d-4da5-9955-0921064470b6,,,",
"AvatarItemId": 83,
"AvatarItemType": 0,
"FriendlyName": "Gekko Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9b5bde11-7408-4798-9fcb-c7ec175444df,,,",
"AvatarItemId": 1619,
"AvatarItemType": 0,
"FriendlyName": "Hoop Earrings",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9bf5d259-7774-4cbe-a90f-7f188cc0dce7,,,",
"AvatarItemId": 361,
"AvatarItemType": 0,
"FriendlyName": "Thick Goatee",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9bf75b23-84e9-436b-bbb8-4180ccd86b94,,,",
"AvatarItemId": 1985,
"AvatarItemType": 0,
"FriendlyName": "Wolf Cut Hair) ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9c20fdd5-acf1-426c-b85a-2220ceee9472,,,",
"AvatarItemId": 1987,
"AvatarItemType": 0,
"FriendlyName": "Short Curly Twists Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,,,",
"AvatarItemId": 150,
"AvatarItemType": 0,
"FriendlyName": "Button Top (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,49f5864f-9d40-497c-88c8-e87f64d41d74,dafa658e-753b-46cb-bd85-85c1de5e6ea7,",
"AvatarItemId": 151,
"AvatarItemType": 0,
"FriendlyName": "Button Top (Tan)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,c5deba2a-6e35-4b13-8e94-8ba5457f39df,dafa658e-753b-46cb-bd85-85c1de5e6ea7,",
"AvatarItemId": 153,
"AvatarItemType": 0,
"FriendlyName": "Button Top (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,e0397982-c2c2-4733-9a40-46e18675b5af,dafa658e-753b-46cb-bd85-85c1de5e6ea7,",
"AvatarItemId": 152,
"AvatarItemType": 0,
"FriendlyName": "Button Top (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9d9fadb6-97eb-480e-a224-4e0179082071,,,",
"AvatarItemId": 995,
"AvatarItemType": 0,
"FriendlyName": "Meatball Buns Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9db35412-7a08-49dd-b80d-59c34a4616ea,",
"AvatarItemId": 2328,
"AvatarItemType": 0,
"FriendlyName": "Brimless Cap (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9db35412-7a08-49dd-b80d-59c34a4616ea,hgLq0WyUAUSl2sboUrpTKQ",
"AvatarItemId": 2330,
"AvatarItemType": 0,
"FriendlyName": "Brimless Cap (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9db35412-7a08-49dd-b80d-59c34a4616ea,JzLPlVNG20G9mxG_4PF8Fg",
"AvatarItemId": 2332,
"AvatarItemType": 0,
"FriendlyName": "Brimless Cap (Navy)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9db35412-7a08-49dd-b80d-59c34a4616ea,M5uFQQpoQE-5CKMJnDv_LA",
"AvatarItemId": 2331,
"AvatarItemType": 0,
"FriendlyName": "Brimless Cap (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "9db35412-7a08-49dd-b80d-59c34a4616ea,VuMDnpnK50mzgUMwJ4F6Jw",
"AvatarItemId": 2333,
"AvatarItemType": 0,
"FriendlyName": "Brimless Cap (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a0e6fe4d-991d-4bd1-a1dc-ac4c7375fa4d,",
"AvatarItemId": 2359,
"AvatarItemType": 0,
"FriendlyName": "Head Wrap (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a0e6fe4d-991d-4bd1-a1dc-ac4c7375fa4d,0zldWzi4H0OQwsWPt6gl-w",
"AvatarItemId": 2364,
"AvatarItemType": 0,
"FriendlyName": "Head Wrap (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a0e6fe4d-991d-4bd1-a1dc-ac4c7375fa4d,a0A1q_e1q06qquiu2KaZhA",
"AvatarItemId": 2361,
"AvatarItemType": 0,
"FriendlyName": "Head Wrap (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a0e6fe4d-991d-4bd1-a1dc-ac4c7375fa4d,M_BhFve7SkC7IJoymLiCLg",
"AvatarItemId": 2363,
"AvatarItemType": 0,
"FriendlyName": "Head Wrap (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a0e6fe4d-991d-4bd1-a1dc-ac4c7375fa4d,rwESUxIrQEKA2hdQpcaPaA",
"AvatarItemId": 2362,
"AvatarItemType": 0,
"FriendlyName": "Head Wrap (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a12f724f-4a73-4ab8-aad4-6bfc662b4dd6,,,",
"AvatarItemId": 1002,
"AvatarItemType": 0,
"FriendlyName": "Undercut Long Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a52353bf-8693-45fb-a04a-19cf62a06482",
"AvatarItemId": 5955,
"AvatarItemType": 0,
"FriendlyName": "Curlers Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "a6cbfe76-534a-4655-a8a8-3fed13d001c7,,,",
"AvatarItemId": 726,
"AvatarItemType": 0,
"FriendlyName": "Bald Top Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b148cb1e-df81-442f-aea6-ab1727aad00e,,,",
"AvatarItemId": 990,
"AvatarItemType": 0,
"FriendlyName": "Chunky Afro Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,,,",
"AvatarItemId": 1772,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Black) ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,Aex0Zh1RQ0u9oTDFF1cFIg,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1781,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,El0s9F6cfkywcKOkqwbJtQ,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1779,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Grey)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,J9cK9BKtHUinG-DwAoPi3g,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1774,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Dreen)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,N0fBxjrCekO-HUqmc3MufQ,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1775,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,oNoEl-nDqk-HLsfz1538oA,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1776,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,pQ_3mZI7yEaxMw9n_Fr4Sg,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1778,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,TpFWTdEW2kqtpvc0sPv-dw,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1777,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,-VF3K4miGE28LxMgqVehRQ,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1773,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,vxZVeH2b_E6XlqyqCW0hBA,_o3mI1mjE02vekPVyJg8Aw,",
"AvatarItemId": 1780,
"AvatarItemType": 0,
"FriendlyName": "Pocket Tee (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,3a1oW3_MY0e4lDN2V1X4ig,za8Mz14Ww0KtC6wavUPXdg,",
"AvatarItemId": 1771,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Nonbinary Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,9jB8U3yxjE6hanEZgMdvtA",
"AvatarItemId": 2202,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - AAPI (AAPI Heritage Month)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,D_Xmo0rOzkS-kgq1CYXt3g,tnCJp2eDI0SwjVfJMhk3LQ,",
"AvatarItemId": 1458,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Trans Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,DIeasJUIj0KNetAzPzuIrg,o3Q2gADhh06dgaipkkdMsA,",
"AvatarItemId": 1757,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Bi Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,EkA09Hj2m0u3c5STynn3Bw",
"AvatarItemId": 2887,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - ANAM (Autism & Neurodiversity Awareness Month)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,G6Udm6kMdE28vmRY5KPanA,Fw79pqDPP0C8H07AnTY-Vg,",
"AvatarItemId": 1756,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Lesbian Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,MfF0wizDjUa7RUE-AVJpvA",
"AvatarItemId": 2194,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Intersex Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,PIwziHk1BUuYkAf6kuK6wA",
"AvatarItemId": 2059,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - BHM (Black History Month)\t",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,PRLBrEEZhUumm3VnYVJP6Q",
"AvatarItemId": 2192,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Aromantic Pride)\t",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,sf962XJ0tkis0svkMkZCXQ,4bI7QRvdnEansPkwx8Di9A,",
"AvatarItemId": 1791,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Pan Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,sxUE0iOSZEmezm54T7xI3Q,tlpa7195x0CkmSjpR1RArQ,",
"AvatarItemId": 1457,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Rainbow Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,tm7Cnd3LzkqWHA1JMrgoeA,nbLr3c3lOkiWhNg9IYbt6Q,",
"AvatarItemId": 1770,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Ace Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,Xt3Ndm8mC0qsLClPIbgwUw",
"AvatarItemId": 2193,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Hoodie - Pride (Genderfluid Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b8e485d4-b5c2-480e-908c-d9b500375759,",
"AvatarItemId": 2881,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (Cochlear Implant - Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b8e485d4-b5c2-480e-908c-d9b500375759,7ZRNVpxhWk-j8R3oj0oVXg",
"AvatarItemId": 2883,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (Cochlear Implant - Gray)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "b8e485d4-b5c2-480e-908c-d9b500375759,8WJI7RAuaEy4KvQiQhoKBw",
"AvatarItemId": 2882,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (Cochlear Implant - Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,",
"AvatarItemId": 2352,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Twist (Beige)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,irw2uj4c2Eil0ndPCTqodQ",
"AvatarItemId": 2355,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Twist (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,pPZ3sDw3B06mKiYsAiAt9w",
"AvatarItemId": 2358,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Twist (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,sO8e2epLrUud243Xi9odXg",
"AvatarItemId": 2357,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Twist (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,s-QTVT5eTECKHL9FnHFCAQ",
"AvatarItemId": 2354,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Twist (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,UnHAtXkd2kCdamzw6B3Mvg",
"AvatarItemId": 2356,
"AvatarItemType": 0,
"FriendlyName": "Head Scarf Twist (Turquoise)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c45ed7b8-99bd-4a4b-a9ff-e16edf5d7a18,,,",
"AvatarItemId": 583,
"AvatarItemType": 0,
"FriendlyName": "High Pony Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c6c08eb5-381a-4193-9722-80da95d62abe,,,",
"AvatarItemId": 1008,
"AvatarItemType": 0,
"FriendlyName": "Business Tie (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c70005d5-6276-4a98-acb3-6a77bc19379a,,,",
"AvatarItemId": 1005,
"AvatarItemType": 0,
"FriendlyName": "Glasses (Teal)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c70005d5-6276-4a98-acb3-6a77bc19379a,5H0I1anlkkuUYIM28_ZOlA,l_iJLV14q0eNgOsOI1u0pw,",
"AvatarItemId": 1879,
"AvatarItemType": 0,
"FriendlyName": "Glasses (Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c70005d5-6276-4a98-acb3-6a77bc19379a,9tZq7Xvfj0WhhLBAuAacxA,l_iJLV14q0eNgOsOI1u0pw,",
"AvatarItemId": 1878,
"AvatarItemType": 0,
"FriendlyName": "Glasses (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c70005d5-6276-4a98-acb3-6a77bc19379a,BqNgpjP7a0qmm3I3DNbKLA,l_iJLV14q0eNgOsOI1u0pw,",
"AvatarItemId": 1880,
"AvatarItemType": 0,
"FriendlyName": "Glasses (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "c855dcc3-96cb-470d-b159-d37a025a47d1,,,",
"AvatarItemId": 1200,
"AvatarItemType": 0,
"FriendlyName": "Dutch Braid Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "cc96f8a5-bc5b-4f89-83b7-ecd53905ada7,,,",
"AvatarItemId": 1007,
"AvatarItemType": 0,
"FriendlyName": "Beard (Thick)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "CTcrvbo3OEepIV4oW8bx4w,,,",
"AvatarItemId": 727,
"AvatarItemType": 0,
"FriendlyName": "Receding Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,,,",
"AvatarItemId": 1019,
"AvatarItemType": 0,
"FriendlyName": "Rec Room Shirt (Crew Neck, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,941c046e-4e95-49f8-a7d7-19071fcc3c94,0440f08f-ef1d-49d8-942b-523056e8bb45,703ff56b-560d-4ff4-8c63-d195e879a328",
"AvatarItemId": 571,
"AvatarItemType": 0,
"FriendlyName": "Alpaca Shirt",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": -1,
"ToolTip": ""
},
{
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,",
"AvatarItemId": 1749,
"AvatarItemType": 0,
"FriendlyName": "Rec Room T-Shirt (Crew Neck, Orange)\t",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,0c496f32-0011-4c4d-9778-59446f70c032",
"AvatarItemId": 565,
"AvatarItemType": 0,
"FriendlyName": "Rec Room T-Shirt (Crew Neck, Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba,3JrW3FFUvEe6HZ9VjOVjMw,ynPJe7asTk-x_GtcEBQbgA,",
"AvatarItemId": 1971,
"AvatarItemType": 0,
"FriendlyName": "Sweat Suit (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba,jeEWPFvzaEGmXaUrxTFvwg,ynPJe7asTk-x_GtcEBQbgA,",
"AvatarItemId": 1974,
"AvatarItemType": 0,
"FriendlyName": "Sweat Suit (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba,NjCLn5A5h0-V8jB72etCNQ,ynPJe7asTk-x_GtcEBQbgA,",
"AvatarItemId": 1973,
"AvatarItemType": 0,
"FriendlyName": "Sweat Suit (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba,OT5APcpbpEiMNNah8n4Txw,ynPJe7asTk-x_GtcEBQbgA,",
"AvatarItemId": 1972,
"AvatarItemType": 0,
"FriendlyName": "Sweat Suit (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba,t2fOeVWloUOKDlIaf4Sxnw,ynPJe7asTk-x_GtcEBQbgA,",
"AvatarItemId": 1970,
"AvatarItemType": 0,
"FriendlyName": "Sweat Suit (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d7730a9e-78a1-4356-bc09-6b066615850b,,,",
"AvatarItemId": 1477,
"AvatarItemType": 0,
"FriendlyName": "Afro Updo Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d8280c0c-d803-4513-be10-a0ba96d8821e,,,",
"AvatarItemId": 1000,
"AvatarItemType": 0,
"FriendlyName": "Flowhawk Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "d84c0ff9-8fbe-4ed8-abf3-7996e81888ab,,,",
"AvatarItemId": 556,
"AvatarItemType": 0,
"FriendlyName": "Large Afro Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "da4e7b34-2095-4a9e-801e-4f409039e0dd,,,",
"AvatarItemId": 989,
"AvatarItemType": 0,
"FriendlyName": "Buzz Cut Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,,,",
"AvatarItemId": 143,
"AvatarItemType": 0,
"FriendlyName": "Blazer (Blue, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,05ac07e1-67f0-486c-abf5-a62866475abb,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"AvatarItemId": 144,
"AvatarItemType": 0,
"FriendlyName": "Blazer (Black, Cream)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,0ffad843-d6c9-425a-8686-7217009c867e,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"AvatarItemId": 148,
"AvatarItemType": 0,
"FriendlyName": "Blazer (Green, Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,272fe8eb-5061-4729-a7a8-414ff667a82f,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"AvatarItemId": 145,
"AvatarItemType": 0,
"FriendlyName": "Blazer (Grey, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,6f2e74bf-1e95-463d-97db-d5d1a53b2c28,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"AvatarItemId": 147,
"AvatarItemType": 0,
"FriendlyName": "Blazer (Black, White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,9374bf66-2ee5-493b-8439-efce4b201904,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
"AvatarItemId": 146,
"AvatarItemType": 0,
"FriendlyName": "Blazer (Grey, Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "e286863c-2967-4d00-b837-b49487b9484a,,,",
"AvatarItemId": 992,
"AvatarItemType": 0,
"FriendlyName": "Fauxhawk Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "e36bcd98-7e85-43fa-89f8-57e4ec33823a,,,",
"AvatarItemId": 988,
"AvatarItemType": 0,
"FriendlyName": "Bob with Bangs Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "e5b83dfc-b2e1-4dcb-a4ab-9d3a4c8a34ae,,,",
"AvatarItemId": 584,
"AvatarItemType": 0,
"FriendlyName": "Long Wavy Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ea254491-116c-41c4-a9ca-1fa1fca76374,",
"AvatarItemId": 2875,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (BTE - Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ea254491-116c-41c4-a9ca-1fa1fca76374,1n-ekJos-EeViiz_zG4jEg",
"AvatarItemId": 2876,
"AvatarItemType": 0,
"FriendlyName": "Hearing Aids (BTE - Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "eb9611c6-bb50-41a2-93e9-7f959815a846,,,",
"AvatarItemId": 587,
"AvatarItemType": 0,
"FriendlyName": "Dreads Long Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ec474314-fd79-441d-852b-d8999274fe57,,,",
"AvatarItemId": 1986,
"AvatarItemType": 0,
"FriendlyName": "Wavy Middle Part Hair ",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,,,",
"AvatarItemId": 1023,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,0ecb8a2a-cffc-47db-aeda-fb0684aef1e5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 288,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Grey)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 684,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Black)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 289,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Light Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 685,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Orange)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,5rLtRfNWO0qT7X-0sJkUXg,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1859,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Lime)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 287,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Gold)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 292,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Cream)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 683,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,Ai85jH88H06ze74VdWolfw,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1863,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Pure Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 687,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 686,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,Dg3DxHr8GU2e190TGveXqw,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1858,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Baby Pink)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,DiJGFIO-5Ue8rkEhvF5-Mg,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1860,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Sienna)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,f8b0cfe8-e129-4578-8bb5-f60af5d38599,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 294,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,fWxn_AarpESryNA9UN8wvA,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1857,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Teal)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,M0nOQl44DkG_GjTXzAEQCQ,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1862,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Yellow)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,NRjJuinZ4ki8qaEPvP84tw,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1864,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Mint)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,sEKIlMfbJ0eED6VtcXcQrw,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1856,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Army Green)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,Sg5yKkqJEEulQeUvRhFgJg,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1861,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Prussian Blue)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,x3kGXCD0UEWkjfXqu_kuxg,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1855,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,Z2L6AqDdcUqxiN1mVw-X9g,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
"AvatarItemId": 1865,
"AvatarItemType": 0,
"FriendlyName": "Wristbands (Light Purple)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "edc7888c-dc42-48ab-8815-884d227760af,,,",
"AvatarItemId": 1984,
"AvatarItemType": 0,
"FriendlyName": "Short Locs Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "f9dd08f8-16d3-4c39-af4f-89f7bb6e80d3,,,",
"AvatarItemId": 1003,
"AvatarItemType": 0,
"FriendlyName": "Undercut Short Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "fcfcaf63-deb4-45f7-b711-c051c9ea45cb,,,",
"AvatarItemId": 390,
"AvatarItemType": 0,
"FriendlyName": "Top Bun Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "fe15ca53-c5b8-4acf-9309-ff3f4e610fc9,,,",
"AvatarItemId": 1459,
"AvatarItemType": 0,
"FriendlyName": "Winged Hat - Pride (Rainbow Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "fe15ca53-c5b8-4acf-9309-ff3f4e610fc9,knXPidb-Rkayfc3kSHfZeQ,1yMyo6oTjU-VAygoeWaohQ,",
"AvatarItemId": 1460,
"AvatarItemType": 0,
"FriendlyName": "Winged Hat - Pride (Trans Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "feb8b50b-d3da-4e45-b4d0-69aeeb5cfeb3,",
"AvatarItemId": 2334,
"AvatarItemType": 0,
"FriendlyName": "Headdress (White)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "feb8b50b-d3da-4e45-b4d0-69aeeb5cfeb3,1WBsT99QM0anps6ZoWE59Q",
"AvatarItemId": 2336,
"AvatarItemType": 0,
"FriendlyName": "Headdress (Red)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ffdda4d3-a768-4335-a883-aa3f9dc897bf,",
"AvatarItemId": 2995,
"AvatarItemType": 0,
"FriendlyName": "Trihawk Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "ffea7a65-613f-4835-921e-6dd15f357b7e,,,",
"AvatarItemId": 621,
"AvatarItemType": 0,
"FriendlyName": "Long Bangs Hair",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "oYvOo47DJE-DEPlpn5xNUw,whs9olNz9Uani7Gou31mvw,y6aASPUwDkas0W5Yl27ydQ,",
"AvatarItemId": 1450,
"AvatarItemType": 0,
"FriendlyName": "Belt Bag (Rainbow Pride)",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
},
{
"AvatarItemDesc": "-twtjyBdQ02EAdOfBGTiEw,,,",
"AvatarItemId": 728,
"AvatarItemType": 0,
"FriendlyName": "Van Dyke Beard",
"IsBaseAvatarItem": false,
"PlatformMask": -1,
"Rarity": 0,
"ToolTip": ""
}
]
+2 -2
View File
@@ -4782,8 +4782,8 @@
},
{
"Key": "ShareCam.VideoEnabled",
"Value": "false",
"ActiveExperiments": "video_recording_2025q2",
"Value": "true",
"ActiveExperiments": "",
"StartTime": null,
"EndTime": null
},
@@ -0,0 +1,981 @@
{
"ProgressionEventId": 13,
"Name": "Anime Season",
"IsEnabled": true,
"Rewards": [
{
"ProgressionEventRewardId": 83,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "asec16t4zlrlpn7ddct1tpjbn.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 10,
"RewardIndex": 0,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 84,
"ProgressionEventId": 13,
"GiftDropId": 15642,
"GiftDropIds": [
15642
],
"ImageName": "bhnowifgzj7yqn8yenud48wvk.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 10,
"RewardIndex": 1,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 85,
"ProgressionEventId": 13,
"GiftDropId": 14338,
"GiftDropIds": [
14338
],
"ImageName": "804hhdj7eugd0ebefgi7i450k.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 20,
"RewardIndex": 2,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 86,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "24m5t3glureyoj6qk0csg3gfa.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 20,
"RewardIndex": 3,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 87,
"ProgressionEventId": 13,
"GiftDropId": 14338,
"GiftDropIds": [
14338
],
"ImageName": "6t3bh0cysaaf8cq1985uykyqm.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 40,
"RewardIndex": 4,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 88,
"ProgressionEventId": 13,
"GiftDropId": 15579,
"GiftDropIds": [
15579
],
"ImageName": "9afiojqywq28a9hvw9k6z96u0.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 40,
"RewardIndex": 5,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 89,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "3hhyelwld7gr7xcydmtake5ka.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 70,
"RewardIndex": 6,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 90,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "dz9pqfvgkt8w3pruvqd5hws8j.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 70,
"RewardIndex": 7,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 91,
"ProgressionEventId": 13,
"GiftDropId": 14339,
"GiftDropIds": [
14339
],
"ImageName": "dkwv56rpw7df98mm5zgklj02m.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 100,
"RewardIndex": 8,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 92,
"ProgressionEventId": 13,
"GiftDropId": 15582,
"GiftDropIds": [
15582
],
"ImageName": "0xwod7lzm0zbqogrpdrvxwgxh.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 100,
"RewardIndex": 9,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 93,
"ProgressionEventId": 13,
"GiftDropId": 14338,
"GiftDropIds": [
14338
],
"ImageName": "49il0ry6hd75o26079qvysrw8.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 130,
"RewardIndex": 10,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 94,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "ckykgyrgxwf6guz83q4x25f1x.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 130,
"RewardIndex": 11,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 95,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "2mvonbbn7igpdcbkm9lmbrex6.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 160,
"RewardIndex": 12,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 96,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "3fmhzm3f8j8iwmikiq4tt8xq6.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 160,
"RewardIndex": 13,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 97,
"ProgressionEventId": 13,
"GiftDropId": 14338,
"GiftDropIds": [
14338
],
"ImageName": "ckahnsok8kfarvtqdhzbo42d6.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 200,
"RewardIndex": 14,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 98,
"ProgressionEventId": 13,
"GiftDropId": 15581,
"GiftDropIds": [
15581
],
"ImageName": "9hfyowdb6lrsayrv6rgfy45bh.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 200,
"RewardIndex": 15,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 99,
"ProgressionEventId": 13,
"GiftDropId": 14340,
"GiftDropIds": [
14340
],
"ImageName": "1o03eyr37gmp1zm4o7ofc936b.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 230,
"RewardIndex": 16,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 100,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "9mtv2qroauql1o57efaodyz0x.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 230,
"RewardIndex": 17,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 101,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "611lb9ddygir3fc96vjbc6lys.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 260,
"RewardIndex": 18,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 102,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "27x3n35qf9nlhha4pa9ut73m2.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 260,
"RewardIndex": 19,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 103,
"ProgressionEventId": 13,
"GiftDropId": 14339,
"GiftDropIds": [
14339
],
"ImageName": "4ko0och2lu6tz55r23czwxy3n.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 290,
"RewardIndex": 20,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 104,
"ProgressionEventId": 13,
"GiftDropId": 15578,
"GiftDropIds": [
15578
],
"ImageName": "777clrrjfvadn3a3p9j3r8lq4.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 290,
"RewardIndex": 21,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 105,
"ProgressionEventId": 13,
"GiftDropId": 14339,
"GiftDropIds": [
14339
],
"ImageName": "1a10x4p9w0wncea4v6hs2kz56.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 320,
"RewardIndex": 22,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 106,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "3fd6vliml6xffrztxgygnsx28.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 320,
"RewardIndex": 23,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 107,
"ProgressionEventId": 13,
"GiftDropId": 14338,
"GiftDropIds": [
14338
],
"ImageName": "5xtlbxccbhh366g5mbw30y1y0.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 350,
"RewardIndex": 24,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 108,
"ProgressionEventId": 13,
"GiftDropId": 15585,
"GiftDropIds": [
15585
],
"ImageName": "c7nboei2r02423p2tm32qh9kc.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 350,
"RewardIndex": 25,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 109,
"ProgressionEventId": 13,
"GiftDropId": 3880,
"GiftDropIds": [
3880
],
"ImageName": "1d5nimrm9oie7ig4wwr9f6fan.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 410,
"RewardIndex": 26,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 110,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "bj7usomic48jawtned9hjucyn.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 410,
"RewardIndex": 27,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 111,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "c185oly3ylat030qkw24aphhc.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 470,
"RewardIndex": 28,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 112,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "b732uynmakcfgcmbr8g1an6lu.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 470,
"RewardIndex": 29,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 113,
"ProgressionEventId": 13,
"GiftDropId": 14339,
"GiftDropIds": [
14339
],
"ImageName": "15aomg6mkmxf4cjqjhgs82mlg.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 530,
"RewardIndex": 30,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 114,
"ProgressionEventId": 13,
"GiftDropId": 15584,
"GiftDropIds": [
15584
],
"ImageName": "2o20kavqm9o2catpp2qx8kaqk.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 530,
"RewardIndex": 31,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 115,
"ProgressionEventId": 13,
"GiftDropId": 14339,
"GiftDropIds": [
14339
],
"ImageName": "1gs1u99c9bxme1azznn8ngvqq.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 590,
"RewardIndex": 32,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 116,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "1z5wp5lwqlffvt7h3fg1y378b.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 590,
"RewardIndex": 33,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 117,
"ProgressionEventId": 13,
"GiftDropId": 14340,
"GiftDropIds": [
14340
],
"ImageName": "05r0rmf9y4d67h6ye8jpite75.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 650,
"RewardIndex": 34,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 118,
"ProgressionEventId": 13,
"GiftDropId": 15580,
"GiftDropIds": [
15580
],
"ImageName": "0npm6i03bs8osho7686vjtzad.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 650,
"RewardIndex": 35,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 119,
"ProgressionEventId": 13,
"GiftDropId": 14338,
"GiftDropIds": [
14338
],
"ImageName": "1gxungkle9bvp0qdprldcnft1.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 710,
"RewardIndex": 36,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 125,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "5zavnpdapdem2xc0aq7axt5b5.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 710,
"RewardIndex": 37,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 124,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "8sszy7p5dlune762vn8lm1oy7.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 770,
"RewardIndex": 38,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 123,
"ProgressionEventId": 13,
"GiftDropId": 15583,
"GiftDropIds": [
15583
],
"ImageName": "e2ncor6ccwj5t6cqugmjug4c7.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 770,
"RewardIndex": 39,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 129,
"ProgressionEventId": 13,
"GiftDropId": 14339,
"GiftDropIds": [
14339
],
"ImageName": "bl1t78iofsjb1qtgdbklilbb0.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 850,
"RewardIndex": 40,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 128,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "10pqse99purq5xjws537orysb.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 850,
"RewardIndex": 41,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 127,
"ProgressionEventId": 13,
"GiftDropId": 14340,
"GiftDropIds": [
14340
],
"ImageName": "3knjal5w2lrict10ygu6xaumw.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 930,
"RewardIndex": 42,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 126,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "alk0chwlwxme9oxd1fytlamxa.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 930,
"RewardIndex": 43,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 135,
"ProgressionEventId": 13,
"GiftDropId": 14338,
"GiftDropIds": [
14338
],
"ImageName": "0t880a28a8uy40b3p1jyhri26.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1030,
"RewardIndex": 44,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 134,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "cus32m9rew5niammslay2x2eo.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1030,
"RewardIndex": 45,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 133,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "6l4df8qa6i77gs1ooq2k8uj1s.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1130,
"RewardIndex": 46,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 132,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "ee896zb9514fljxf4xzn5ez0a.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1130,
"RewardIndex": 47,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 131,
"ProgressionEventId": 13,
"GiftDropId": 14339,
"GiftDropIds": [
14339
],
"ImageName": "8b8n5mqgbmmbkmae81ssldy5y.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1250,
"RewardIndex": 48,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 130,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "c9ho9mdvnbks3r1iqga1ulon1.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1250,
"RewardIndex": 49,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 121,
"ProgressionEventId": 13,
"GiftDropId": 14340,
"GiftDropIds": [
14340
],
"ImageName": "e4es0hla8k9rmyuccuzjvxbrh.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1370,
"RewardIndex": 50,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 120,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "56j8qyv68hd1o8zsqp03vnuxl.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1370,
"RewardIndex": 51,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 141,
"ProgressionEventId": 13,
"GiftDropId": 14338,
"GiftDropIds": [
14338
],
"ImageName": "criqbrh93fdcug6o04za19zvs.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1510,
"RewardIndex": 52,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 140,
"ProgressionEventId": 13,
"GiftDropId": 15422,
"GiftDropIds": [
15422
],
"ImageName": "92uz0mh3vq4vv3ok672zcx0qy.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1510,
"RewardIndex": 53,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 139,
"ProgressionEventId": 13,
"GiftDropId": 14340,
"GiftDropIds": [
14340
],
"ImageName": "4fpa5no0vdm60jul2yu002nec.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1650,
"RewardIndex": 54,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 138,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "97ucjiycstjyholpsddtaq7er.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1650,
"RewardIndex": 55,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 137,
"ProgressionEventId": 13,
"GiftDropId": 14338,
"GiftDropIds": [
14338
],
"ImageName": "2tboxk0avvq9aozt1q5an6vmz.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1810,
"RewardIndex": 56,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 136,
"ProgressionEventId": 13,
"GiftDropId": 14341,
"GiftDropIds": [
14341
],
"ImageName": "0a70iz2k5gr2lbpi7x4izqr3l.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1810,
"RewardIndex": 57,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 142,
"ProgressionEventId": 13,
"GiftDropId": 15698,
"GiftDropIds": [
15698
],
"ImageName": "0eenxm5lf4eqh3po9qcdfllxk.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1970,
"RewardIndex": 58,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 143,
"ProgressionEventId": 13,
"GiftDropId": 15697,
"GiftDropIds": [
15697
],
"ImageName": "40nrm63jjeuxy5amf4xw0nr9n.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1970,
"RewardIndex": 59,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
}
],
"KeepsakeRoomLists": [],
"StartTime": "2025-05-30T14:00:00Z",
"EndTime": "2025-06-27T14:00:00Z",
"CollectionEndTime": "2025-06-28T14:00:00Z",
"UsesBoost": false,
"BoostDailyGameplayMinutesLimit": 0,
"BoostXpMultiplier": 0,
"PurchasableXpBoostId": null,
"ActiveExperiment": null,
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": "6e7cosfz20dokv5179njfqc6m.png",
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
@@ -0,0 +1,149 @@
{
"ProgressionEventId": 8,
"Name": "Base Event",
"IsEnabled": false,
"Rewards": [],
"KeepsakeRoomLists": [
{
"KeepsakeRoomListId": 30,
"ProgressionEventId": 8,
"UnlockItemAvatarItemId": null,
"UnlockItemGiftDropId": null,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 270,
"RoomId": 41854064,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 0
},
{
"KeepsakeRoomId": 265,
"RoomId": 8364812036745705361,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 1
},
{
"KeepsakeRoomId": 271,
"RoomId": 4678168151807803565,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 2
},
{
"KeepsakeRoomId": 267,
"RoomId": 8786672571011050982,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 3
},
{
"KeepsakeRoomId": 273,
"RoomId": 59058753,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 4
},
{
"KeepsakeRoomId": 266,
"RoomId": 41922506,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 5
},
{
"KeepsakeRoomId": 272,
"RoomId": 70225004,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 6
},
{
"KeepsakeRoomId": 274,
"RoomId": 50107153,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 7
},
{
"KeepsakeRoomId": 260,
"RoomId": 944261190986353689,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 8
},
{
"KeepsakeRoomId": 262,
"RoomId": 41854320,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 9
},
{
"KeepsakeRoomId": 264,
"RoomId": 1063552974137030672,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 10
},
{
"KeepsakeRoomId": 275,
"RoomId": 3662988090243921994,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 11
},
{
"KeepsakeRoomId": 269,
"RoomId": 31382618,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 12
},
{
"KeepsakeRoomId": 261,
"RoomId": 1752507419075074724,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 13
},
{
"KeepsakeRoomId": 263,
"RoomId": 74897901,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 14
},
{
"KeepsakeRoomId": 268,
"RoomId": 42201785,
"KeepsakeRoomListId": 30,
"Type": 0,
"Order": 15
}
],
"RoomUnlockStartOffsetTicks": 600000000,
"RoomUnlockIntervalTicks": 600000000,
"RoomUnlockBatchSize": 1,
"RoomType": 0,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:01:00",
"RoomUnlockInterval": "00:01:00"
}
],
"StartTime": "5555-02-06T00:00:00Z",
"EndTime": "5555-02-07T00:00:00Z",
"CollectionEndTime": "5555-02-08T00:00:00Z",
"UsesBoost": false,
"BoostDailyGameplayMinutesLimit": 0,
"BoostXpMultiplier": 0,
"PurchasableXpBoostId": null,
"ActiveExperiment": null,
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": null,
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
@@ -0,0 +1,117 @@
{
"ProgressionEventId": 2,
"Name": "Coming in Hot! Progression Event Alpha",
"IsEnabled": true,
"Rewards": [
{
"ProgressionEventRewardId": 1,
"ProgressionEventId": 2,
"GiftDropId": 3798,
"GiftDropIds": [
3798
],
"ImageName": "7w7xumgr5rtaouze5e5lwspea.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 30,
"RewardIndex": 0,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 2,
"ProgressionEventId": 2,
"GiftDropId": 3779,
"GiftDropIds": [
3779
],
"ImageName": "eynegc0nils797wgjqz96a2ds.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 60,
"RewardIndex": 1,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 3,
"ProgressionEventId": 2,
"GiftDropId": 3025,
"GiftDropIds": [
3025
],
"ImageName": "2ikt2bx6xwiyyr6s8qducxjr1.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 90,
"RewardIndex": 2,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 4,
"ProgressionEventId": 2,
"GiftDropId": 4079,
"GiftDropIds": [
4079
],
"ImageName": "dziv7z4fwbejheoy7yvrjlby1.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 130,
"RewardIndex": 3,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 6,
"ProgressionEventId": 2,
"GiftDropId": 4078,
"GiftDropIds": [
4078
],
"ImageName": "6fepaaug17ai3guqpazy3v86o.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 180,
"RewardIndex": 4,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 7,
"ProgressionEventId": 2,
"GiftDropId": 3974,
"GiftDropIds": [
3974
],
"ImageName": "7958ooqtj2o04rkcdqtyb3rko.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 250,
"RewardIndex": 5,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
}
],
"KeepsakeRoomLists": [],
"StartTime": "2022-09-13T23:00:00Z",
"EndTime": "2022-09-16T23:00:00Z",
"CollectionEndTime": "2022-09-17T23:00:00Z",
"UsesBoost": true,
"BoostDailyGameplayMinutesLimit": 20,
"BoostXpMultiplier": 3,
"PurchasableXpBoostId": null,
"ActiveExperiment": "growth_progression_events",
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": null,
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
@@ -0,0 +1,703 @@
{
"ProgressionEventId": 9,
"Name": "Invasion 2!",
"IsEnabled": true,
"Rewards": [
{
"ProgressionEventRewardId": 48,
"ProgressionEventId": 9,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "03msdpjjdn4rye4a5rzlb0iy4.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 60,
"RewardIndex": 0,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 49,
"ProgressionEventId": 9,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "eg02namtz6220on6tswdh1a7m.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 170,
"RewardIndex": 1,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 50,
"ProgressionEventId": 9,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "dhtb7pja4o4wau6a4nb9i6hmm.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 330,
"RewardIndex": 2,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 51,
"ProgressionEventId": 9,
"GiftDropId": 5000,
"GiftDropIds": [
5000
],
"ImageName": "8th0lp6vzerjmvesnk9rayofk.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 540,
"RewardIndex": 3,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 52,
"ProgressionEventId": 9,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "5173t89qwmeqyo5z1m6w87g59.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 750,
"RewardIndex": 4,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 53,
"ProgressionEventId": 9,
"GiftDropId": 4433,
"GiftDropIds": [
4433
],
"ImageName": "1q562e2xf83t8mk71tq6ivttb.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1070,
"RewardIndex": 5,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 54,
"ProgressionEventId": 9,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "bsyfz1t3uzuaorgvzosp47ryc.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1280,
"RewardIndex": 6,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 55,
"ProgressionEventId": 9,
"GiftDropId": 5047,
"GiftDropIds": [
5047
],
"ImageName": "1oysf2o2iqrvvkbzgxj2nt5ls.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1810,
"RewardIndex": 7,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 56,
"ProgressionEventId": 9,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "6efp1yda2ld5508dty6r7r2li.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 2020,
"RewardIndex": 8,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 57,
"ProgressionEventId": 9,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "9qtg9rduxkg1vwt10ta18qxoj.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 2870,
"RewardIndex": 9,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 58,
"ProgressionEventId": 9,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "7ylm9s3ipm79nb97tbidj9sfw.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 3080,
"RewardIndex": 10,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 59,
"ProgressionEventId": 9,
"GiftDropId": 4954,
"GiftDropIds": [
4954
],
"ImageName": "7cnvplzo7iqzvgwwyg13x9yul.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 3930,
"RewardIndex": 11,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 60,
"ProgressionEventId": 9,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "b6jdkpp062231ywmjppkc0skm.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 4140,
"RewardIndex": 12,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 61,
"ProgressionEventId": 9,
"GiftDropId": 5272,
"GiftDropIds": [
5272
],
"ImageName": "24624vyh7d3pkjzmpabdo884n.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 4990,
"RewardIndex": 13,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 62,
"ProgressionEventId": 9,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "5wyzjcnty2ejkmdhwrj9a179p.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 5200,
"RewardIndex": 14,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 63,
"ProgressionEventId": 9,
"GiftDropId": 5302,
"GiftDropIds": [
5302
],
"ImageName": "d3vuktasdn3tfvdjjzkcbwjd8.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 6050,
"RewardIndex": 15,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 64,
"ProgressionEventId": 9,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "0vhtp1bz55aj6cch5z922vhiy.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 300,
"RewardIndex": 16,
"IsBonus": true,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
}
],
"KeepsakeRoomLists": [
{
"KeepsakeRoomListId": 24,
"ProgressionEventId": 9,
"UnlockItemAvatarItemId": null,
"UnlockItemGiftDropId": null,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 202,
"RoomId": 79637721166461380,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 0
},
{
"KeepsakeRoomId": 205,
"RoomId": 7755621295025968191,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 1
},
{
"KeepsakeRoomId": 242,
"RoomId": 76890764,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 2
},
{
"KeepsakeRoomId": 200,
"RoomId": 8483543681332714280,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 3
},
{
"KeepsakeRoomId": 204,
"RoomId": 75208136,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 4
},
{
"KeepsakeRoomId": 201,
"RoomId": 7036488310533039471,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 5
},
{
"KeepsakeRoomId": 203,
"RoomId": 49268683,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 6
},
{
"KeepsakeRoomId": 207,
"RoomId": 58246484,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 7
},
{
"KeepsakeRoomId": 197,
"RoomId": 887680104196187339,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 8
},
{
"KeepsakeRoomId": 249,
"RoomId": 40613764,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 9
},
{
"KeepsakeRoomId": 251,
"RoomId": 72079035,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 10
},
{
"KeepsakeRoomId": 198,
"RoomId": 73692017,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 11
},
{
"KeepsakeRoomId": 206,
"RoomId": 4678168151807803565,
"KeepsakeRoomListId": 24,
"Type": 0,
"Order": 12
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 2592000000000,
"RoomUnlockBatchSize": 3,
"RoomType": 0,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "3.00:00:00"
},
{
"KeepsakeRoomListId": 25,
"ProgressionEventId": 9,
"UnlockItemAvatarItemId": 2713,
"UnlockItemGiftDropId": 5281,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 212,
"RoomId": 1629507034485430449,
"KeepsakeRoomListId": 25,
"Type": 1,
"Order": 0
},
{
"KeepsakeRoomId": 208,
"RoomId": 5786874011574248004,
"KeepsakeRoomListId": 25,
"Type": 1,
"Order": 1
},
{
"KeepsakeRoomId": 210,
"RoomId": 3207992100513208840,
"KeepsakeRoomListId": 25,
"Type": 1,
"Order": 2
},
{
"KeepsakeRoomId": 209,
"RoomId": 3200554354111267322,
"KeepsakeRoomListId": 25,
"Type": 1,
"Order": 3
},
{
"KeepsakeRoomId": 213,
"RoomId": 77570566,
"KeepsakeRoomListId": 25,
"Type": 1,
"Order": 4
},
{
"KeepsakeRoomId": 211,
"RoomId": 57707466,
"KeepsakeRoomListId": 25,
"Type": 1,
"Order": 5
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 1728000000000,
"RoomUnlockBatchSize": 2,
"RoomType": 1,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "2.00:00:00"
},
{
"KeepsakeRoomListId": 26,
"ProgressionEventId": 9,
"UnlockItemAvatarItemId": 2701,
"UnlockItemGiftDropId": 5269,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 245,
"RoomId": 26723336,
"KeepsakeRoomListId": 26,
"Type": 1,
"Order": 0
},
{
"KeepsakeRoomId": 243,
"RoomId": 4496117626402779973,
"KeepsakeRoomListId": 26,
"Type": 1,
"Order": 1
},
{
"KeepsakeRoomId": 219,
"RoomId": 64885245733710823,
"KeepsakeRoomListId": 26,
"Type": 1,
"Order": 2
},
{
"KeepsakeRoomId": 217,
"RoomId": 66460043,
"KeepsakeRoomListId": 26,
"Type": 1,
"Order": 3
},
{
"KeepsakeRoomId": 221,
"RoomId": 9004916221398793266,
"KeepsakeRoomListId": 26,
"Type": 1,
"Order": 4
},
{
"KeepsakeRoomId": 215,
"RoomId": 75570087,
"KeepsakeRoomListId": 26,
"Type": 1,
"Order": 5
},
{
"KeepsakeRoomId": 214,
"RoomId": 43401202,
"KeepsakeRoomListId": 26,
"Type": 1,
"Order": 6
},
{
"KeepsakeRoomId": 216,
"RoomId": 1751471322540793226,
"KeepsakeRoomListId": 26,
"Type": 1,
"Order": 7
}
],
"RoomUnlockStartOffsetTicks": 1728000000000,
"RoomUnlockIntervalTicks": 864000000000,
"RoomUnlockBatchSize": 1,
"RoomType": 1,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "2.00:00:00",
"RoomUnlockInterval": "1.00:00:00"
},
{
"KeepsakeRoomListId": 27,
"ProgressionEventId": 9,
"UnlockItemAvatarItemId": 2703,
"UnlockItemGiftDropId": 5271,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 226,
"RoomId": 7576729427803564326,
"KeepsakeRoomListId": 27,
"Type": 1,
"Order": 0
},
{
"KeepsakeRoomId": 258,
"RoomId": 31369470,
"KeepsakeRoomListId": 27,
"Type": 1,
"Order": 1
},
{
"KeepsakeRoomId": 229,
"RoomId": 70225004,
"KeepsakeRoomListId": 27,
"Type": 1,
"Order": 2
},
{
"KeepsakeRoomId": 230,
"RoomId": 8837447252376723733,
"KeepsakeRoomListId": 27,
"Type": 1,
"Order": 3
},
{
"KeepsakeRoomId": 256,
"RoomId": 73821809,
"KeepsakeRoomListId": 27,
"Type": 1,
"Order": 4
},
{
"KeepsakeRoomId": 222,
"RoomId": 3101788489080959468,
"KeepsakeRoomListId": 27,
"Type": 1,
"Order": 5
},
{
"KeepsakeRoomId": 227,
"RoomId": 10266603,
"KeepsakeRoomListId": 27,
"Type": 1,
"Order": 6
},
{
"KeepsakeRoomId": 228,
"RoomId": 5792488805990360638,
"KeepsakeRoomListId": 27,
"Type": 1,
"Order": 7
},
{
"KeepsakeRoomId": 257,
"RoomId": 8364883655950427728,
"KeepsakeRoomListId": 27,
"Type": 1,
"Order": 8
}
],
"RoomUnlockStartOffsetTicks": 2592000000000,
"RoomUnlockIntervalTicks": 2592000000000,
"RoomUnlockBatchSize": 3,
"RoomType": 1,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "3.00:00:00",
"RoomUnlockInterval": "3.00:00:00"
},
{
"KeepsakeRoomListId": 28,
"ProgressionEventId": 9,
"UnlockItemAvatarItemId": 2699,
"UnlockItemGiftDropId": 5267,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 250,
"RoomId": 7021872,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 0
},
{
"KeepsakeRoomId": 254,
"RoomId": 74897901,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 1
},
{
"KeepsakeRoomId": 241,
"RoomId": 64936037,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 2
},
{
"KeepsakeRoomId": 231,
"RoomId": 31518506,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 3
},
{
"KeepsakeRoomId": 239,
"RoomId": 189633977796607371,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 4
},
{
"KeepsakeRoomId": 255,
"RoomId": 4708745686835418998,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 5
},
{
"KeepsakeRoomId": 237,
"RoomId": 5628775642092102621,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 6
},
{
"KeepsakeRoomId": 235,
"RoomId": 351964245102334138,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 7
},
{
"KeepsakeRoomId": 240,
"RoomId": 1752507419075074724,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 8
},
{
"KeepsakeRoomId": 253,
"RoomId": 4747460318795739950,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 9
},
{
"KeepsakeRoomId": 238,
"RoomId": 47579443,
"KeepsakeRoomListId": 28,
"Type": 1,
"Order": 10
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 1728000000000,
"RoomUnlockBatchSize": 2,
"RoomType": 1,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "2.00:00:00"
}
],
"StartTime": "2023-03-02T20:33:00Z",
"EndTime": "2023-03-15T20:35:00Z",
"CollectionEndTime": "2023-03-16T20:35:00Z",
"UsesBoost": false,
"BoostDailyGameplayMinutesLimit": 0,
"BoostXpMultiplier": 0,
"PurchasableXpBoostId": "58e9a05b-6106-4d8b-9fce-de4fec6c4d5b",
"ActiveExperiment": null,
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": "1u0mceq0gypn478mqymsmc6es.png",
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
+557
View File
@@ -0,0 +1,557 @@
{
"ProgressionEventId": 6,
"Name": "Invasion",
"IsEnabled": false,
"Rewards": [
{
"ProgressionEventRewardId": 14,
"ProgressionEventId": 6,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "8p6bvkicn08fxm736v7vc6px8.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 60,
"RewardIndex": 0,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 15,
"ProgressionEventId": 6,
"GiftDropId": 4423,
"GiftDropIds": [
4423
],
"ImageName": "29d6vkhsoavxvy2fbub3p8623.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 90,
"RewardIndex": 1,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 16,
"ProgressionEventId": 6,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "9etpy89ul022jlnixh2pz1t81.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 120,
"RewardIndex": 2,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 17,
"ProgressionEventId": 6,
"GiftDropId": 377,
"GiftDropIds": [
377
],
"ImageName": "d6y2itbia3t43bcn2rlxjr9g2.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 160,
"RewardIndex": 3,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 18,
"ProgressionEventId": 6,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "388dhe2inzetzkupstnbfzelf.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 210,
"RewardIndex": 4,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 19,
"ProgressionEventId": 6,
"GiftDropId": 4433,
"GiftDropIds": [
4433
],
"ImageName": "05u5fjm5rdakjnoknn43uxjra.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 270,
"RewardIndex": 5,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 20,
"ProgressionEventId": 6,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "1vgxmpgz6tavxiy389rv89xtn.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 340,
"RewardIndex": 6,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 21,
"ProgressionEventId": 6,
"GiftDropId": 5039,
"GiftDropIds": [
5039
],
"ImageName": "960jtbwz6swvytlgschy82g2k.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 420,
"RewardIndex": 7,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 22,
"ProgressionEventId": 6,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "8gs80hrx6bp617nqc7o65g6s3.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 520,
"RewardIndex": 8,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 23,
"ProgressionEventId": 6,
"GiftDropId": 4433,
"GiftDropIds": [
4433
],
"ImageName": "c5zppw23v8jovvc5u356hl8or.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 650,
"RewardIndex": 9,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 24,
"ProgressionEventId": 6,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "537qmmlviwslvgc5gudhc9wgq.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 810,
"RewardIndex": 10,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 25,
"ProgressionEventId": 6,
"GiftDropId": 3729,
"GiftDropIds": [
3729
],
"ImageName": "d4cb6v5ck49ir3jqjhwrzxd92.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1020,
"RewardIndex": 11,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 26,
"ProgressionEventId": 6,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "9ihssplq503eqyybg1g0hc8n4.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1280,
"RewardIndex": 12,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 27,
"ProgressionEventId": 6,
"GiftDropId": 330,
"GiftDropIds": [
330
],
"ImageName": "d1rswffe1ma4i03f88yjztwna.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1610,
"RewardIndex": 13,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 28,
"ProgressionEventId": 6,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "8ci3t9f4o51uuvr7u140t0ejb.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 2040,
"RewardIndex": 14,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 29,
"ProgressionEventId": 6,
"GiftDropId": 5032,
"GiftDropIds": [
5032
],
"ImageName": "1cwj4ceyz3w8q0s7iz7p4euyh.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 2580,
"RewardIndex": 15,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 30,
"ProgressionEventId": 6,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "5r5wx9zmnpk1v6tj943nj45cs.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 230,
"RewardIndex": 16,
"IsBonus": true,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
}
],
"KeepsakeRoomLists": [
{
"KeepsakeRoomListId": 3,
"ProgressionEventId": 6,
"UnlockItemAvatarItemId": null,
"UnlockItemGiftDropId": null,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 83,
"RoomId": 26265683,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 0
},
{
"KeepsakeRoomId": 82,
"RoomId": 26003309,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 1
},
{
"KeepsakeRoomId": 94,
"RoomId": 41855338,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 2
},
{
"KeepsakeRoomId": 88,
"RoomId": 59046620,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 3
},
{
"KeepsakeRoomId": 85,
"RoomId": 59058753,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 4
},
{
"KeepsakeRoomId": 81,
"RoomId": 32217089,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 5
},
{
"KeepsakeRoomId": 89,
"RoomId": 9112061,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 6
},
{
"KeepsakeRoomId": 80,
"RoomId": 59100897,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 7
},
{
"KeepsakeRoomId": 95,
"RoomId": 32840405,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 8
},
{
"KeepsakeRoomId": 92,
"RoomId": 16860104,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 9
},
{
"KeepsakeRoomId": 98,
"RoomId": 59070317,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 10
},
{
"KeepsakeRoomId": 91,
"RoomId": 78042908,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 11
},
{
"KeepsakeRoomId": 100,
"RoomId": 31886274,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 12
},
{
"KeepsakeRoomId": 96,
"RoomId": 76714234,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 13
},
{
"KeepsakeRoomId": 90,
"RoomId": 5242677,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 14
},
{
"KeepsakeRoomId": 93,
"RoomId": 59058035,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 15
},
{
"KeepsakeRoomId": 97,
"RoomId": 25982226,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 16
},
{
"KeepsakeRoomId": 77,
"RoomId": 59045944,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 17
},
{
"KeepsakeRoomId": 78,
"RoomId": 42598725,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 18
},
{
"KeepsakeRoomId": 86,
"RoomId": 42146023,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 19
},
{
"KeepsakeRoomId": 75,
"RoomId": 54586671,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 20
},
{
"KeepsakeRoomId": 38,
"RoomId": 76890764,
"KeepsakeRoomListId": 3,
"Type": 0,
"Order": 21
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 504000000000,
"RoomUnlockBatchSize": 1,
"RoomType": 0,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "14:00:00"
},
{
"KeepsakeRoomListId": 4,
"ProgressionEventId": 6,
"UnlockItemAvatarItemId": 2371,
"UnlockItemGiftDropId": 4508,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 71,
"RoomId": 67219271,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 0
},
{
"KeepsakeRoomId": 43,
"RoomId": 59106455,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 1
},
{
"KeepsakeRoomId": 72,
"RoomId": 25251760,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 2
},
{
"KeepsakeRoomId": 84,
"RoomId": 77993488,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 3
},
{
"KeepsakeRoomId": 42,
"RoomId": 41885666,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 4
},
{
"KeepsakeRoomId": 74,
"RoomId": 41854064,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 5
},
{
"KeepsakeRoomId": 76,
"RoomId": 41892811,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 6
},
{
"KeepsakeRoomId": 41,
"RoomId": 31382618,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 7
},
{
"KeepsakeRoomId": 87,
"RoomId": 31370276,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 8
},
{
"KeepsakeRoomId": 79,
"RoomId": 69004612,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 9
},
{
"KeepsakeRoomId": 99,
"RoomId": 42201785,
"KeepsakeRoomListId": 4,
"Type": 1,
"Order": 10
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 1728000000000,
"RoomUnlockBatchSize": 2,
"RoomType": 1,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "2.00:00:00"
}
],
"StartTime": "2023-10-18T20:45:00Z",
"EndTime": "2023-11-01T23:00:00Z",
"CollectionEndTime": "2023-11-02T23:00:00Z",
"UsesBoost": false,
"BoostDailyGameplayMinutesLimit": 0,
"BoostXpMultiplier": 0,
"PurchasableXpBoostId": "9833aa2e-30f8-4fe6-9ba8-54ae3eff5dd8",
"ActiveExperiment": null,
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": "7cbeqeya66819q7jahtlldkax.png",
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
@@ -0,0 +1,661 @@
{
"ProgressionEventId": 15,
"Name": "Medieval Season",
"IsEnabled": true,
"Rewards": [
{
"ProgressionEventRewardId": 184,
"ProgressionEventId": 15,
"GiftDropId": 31965,
"GiftDropIds": [
31965
],
"ImageName": "119ujjiogkl6o8hvn0bv1e9v8.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 10,
"RewardIndex": 0,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 185,
"ProgressionEventId": 15,
"GiftDropId": 31964,
"GiftDropIds": [
31964
],
"ImageName": "2ekumj2jf1uz8yw8d7gmtnunm.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 10,
"RewardIndex": 1,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 186,
"ProgressionEventId": 15,
"GiftDropId": 18130,
"GiftDropIds": [
18130
],
"ImageName": "01vkyx5jx6em155gbfpdy4fxa.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 20,
"RewardIndex": 2,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 187,
"ProgressionEventId": 15,
"GiftDropId": 18178,
"GiftDropIds": [
18178
],
"ImageName": "4x07bt9riqjsnr8reyyuu1hh8.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 20,
"RewardIndex": 3,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 188,
"ProgressionEventId": 15,
"GiftDropId": 31966,
"GiftDropIds": [
31966
],
"ImageName": "523hupp6etxjopnc8eqrnihks.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 40,
"RewardIndex": 4,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 189,
"ProgressionEventId": 15,
"GiftDropId": 31965,
"GiftDropIds": [
31965
],
"ImageName": "55el0zslgj93g4g0ob2tlaiyx.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 40,
"RewardIndex": 5,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 190,
"ProgressionEventId": 15,
"GiftDropId": 2010,
"GiftDropIds": [
2010
],
"ImageName": "4z1sudgzj0fcvuzh85ygggbbs.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 60,
"RewardIndex": 6,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 191,
"ProgressionEventId": 15,
"GiftDropId": 18128,
"GiftDropIds": [
18128
],
"ImageName": "atoz95u6cuuavzkxx8ahbaqvd.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 60,
"RewardIndex": 7,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 196,
"ProgressionEventId": 15,
"GiftDropId": 31967,
"GiftDropIds": [
31967
],
"ImageName": "230klvvpb0fxmd9514h7ikvl9.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 90,
"RewardIndex": 8,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 197,
"ProgressionEventId": 15,
"GiftDropId": 31966,
"GiftDropIds": [
31966
],
"ImageName": "di0eruhrxnqcqfu8ovjxo02c3.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 90,
"RewardIndex": 9,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 198,
"ProgressionEventId": 15,
"GiftDropId": 18130,
"GiftDropIds": [
18130
],
"ImageName": "22j1uti988vvw686gzcu5ke17.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 130,
"RewardIndex": 10,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 199,
"ProgressionEventId": 15,
"GiftDropId": 31968,
"GiftDropIds": [
31968
],
"ImageName": "bmt82rnj5xrry556ifjo9u29f.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 130,
"RewardIndex": 11,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 200,
"ProgressionEventId": 15,
"GiftDropId": 15500,
"GiftDropIds": [
15500
],
"ImageName": "epdwizupz0rstj2bzod82kmza.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 180,
"RewardIndex": 12,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 201,
"ProgressionEventId": 15,
"GiftDropId": 31967,
"GiftDropIds": [
31967
],
"ImageName": "36t3tpm79wuydbdomdu26alkv.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 180,
"RewardIndex": 13,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 202,
"ProgressionEventId": 15,
"GiftDropId": 31965,
"GiftDropIds": [
31965
],
"ImageName": "6s3w0zksn3wwdbcq1ia77lyz7.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 240,
"RewardIndex": 14,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 203,
"ProgressionEventId": 15,
"GiftDropId": 18128,
"GiftDropIds": [
18128
],
"ImageName": "97dm76eryr3gwtl6ue1zcmacw.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 240,
"RewardIndex": 15,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 204,
"ProgressionEventId": 15,
"GiftDropId": 15572,
"GiftDropIds": [
15572
],
"ImageName": "1cwr07kjp9rvvi6pod9xhuugx.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 300,
"RewardIndex": 16,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 205,
"ProgressionEventId": 15,
"GiftDropId": 31965,
"GiftDropIds": [
31965
],
"ImageName": "4jnib0tx57plpgm6le0ek7c68.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 300,
"RewardIndex": 17,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 206,
"ProgressionEventId": 15,
"GiftDropId": 31965,
"GiftDropIds": [
31965
],
"ImageName": "evymtv1hf5uyibfglhp91ly5l.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 360,
"RewardIndex": 18,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 207,
"ProgressionEventId": 15,
"GiftDropId": 31969,
"GiftDropIds": [
31969
],
"ImageName": "93xulm1o1e5i8hepqfg6i6dld.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 360,
"RewardIndex": 19,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 208,
"ProgressionEventId": 15,
"GiftDropId": 2010,
"GiftDropIds": [
2010
],
"ImageName": "1l9oio4bq2umlhpom71jeka87.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 440,
"RewardIndex": 20,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 209,
"ProgressionEventId": 15,
"GiftDropId": 31966,
"GiftDropIds": [
31966
],
"ImageName": "4qrt06kn0qxikchcsb4xf7cp6.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 440,
"RewardIndex": 21,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 210,
"ProgressionEventId": 15,
"GiftDropId": 18130,
"GiftDropIds": [
18130
],
"ImageName": "5js3ybdcbnkwpaz68huelwanj.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 520,
"RewardIndex": 22,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 211,
"ProgressionEventId": 15,
"GiftDropId": 18128,
"GiftDropIds": [
18128
],
"ImageName": "bok9gvl9mp81pud1xcp1owzum.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 520,
"RewardIndex": 23,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 212,
"ProgressionEventId": 15,
"GiftDropId": 15558,
"GiftDropIds": [
15558
],
"ImageName": "a88y623frl8axhvp1equcqbw0.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 610,
"RewardIndex": 24,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 213,
"ProgressionEventId": 15,
"GiftDropId": 31966,
"GiftDropIds": [
31966
],
"ImageName": "2trla90had5222g1roc7myvys.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 610,
"RewardIndex": 25,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 214,
"ProgressionEventId": 15,
"GiftDropId": 18130,
"GiftDropIds": [
18130
],
"ImageName": "e226ung2j4vwcwhbrvqurenue.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 710,
"RewardIndex": 26,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 215,
"ProgressionEventId": 15,
"GiftDropId": 17030,
"GiftDropIds": [
17030
],
"ImageName": "br79hvrvevfdxjnw30auft6ps.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 710,
"RewardIndex": 27,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 216,
"ProgressionEventId": 15,
"GiftDropId": 31966,
"GiftDropIds": [
31966
],
"ImageName": "02ibg78pact35tmgo17h47r0d.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 810,
"RewardIndex": 28,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 217,
"ProgressionEventId": 15,
"GiftDropId": 31965,
"GiftDropIds": [
31965
],
"ImageName": "bx673w6t4y0sus5lqavuuhw34.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 810,
"RewardIndex": 29,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 218,
"ProgressionEventId": 15,
"GiftDropId": 18180,
"GiftDropIds": [
18180
],
"ImageName": "dmz15b9tuc95xxp4emqt2oemi.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 930,
"RewardIndex": 30,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 219,
"ProgressionEventId": 15,
"GiftDropId": 18128,
"GiftDropIds": [
18128
],
"ImageName": "0qaxg6x1j5k5m75d9gius4mdp.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 930,
"RewardIndex": 31,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 220,
"ProgressionEventId": 15,
"GiftDropId": 2010,
"GiftDropIds": [
2010
],
"ImageName": "9xjv03v8l6bme8dz3t6e3bzsr.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1050,
"RewardIndex": 32,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 221,
"ProgressionEventId": 15,
"GiftDropId": 31966,
"GiftDropIds": [
31966
],
"ImageName": "b6xga15ruxpacniaqcnjnv1er.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1050,
"RewardIndex": 33,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 222,
"ProgressionEventId": 15,
"GiftDropId": 18130,
"GiftDropIds": [
18130
],
"ImageName": "8c2h0r2swf6bpi9671j29drus.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1170,
"RewardIndex": 34,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 223,
"ProgressionEventId": 15,
"GiftDropId": 18179,
"GiftDropIds": [
18179
],
"ImageName": "bj47w995if9qpgwzs3h9b2nzb.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1170,
"RewardIndex": 35,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 224,
"ProgressionEventId": 15,
"GiftDropId": 31967,
"GiftDropIds": [
31967
],
"ImageName": "e2jryr3ypkzni6kkga234jfoi.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1300,
"RewardIndex": 36,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 225,
"ProgressionEventId": 15,
"GiftDropId": 31967,
"GiftDropIds": [
31967
],
"ImageName": "8ks2xl4ce9fz3xbpsp794vmjf.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1300,
"RewardIndex": 37,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 226,
"ProgressionEventId": 15,
"GiftDropId": 17065,
"GiftDropIds": [
17065
],
"ImageName": "3ldt5498un7cv6mt52ybkd7x1.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1440,
"RewardIndex": 38,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 227,
"ProgressionEventId": 15,
"GiftDropId": 17064,
"GiftDropIds": [
17064
],
"ImageName": "7x6406x170ca8k6xxn49gpp67.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1440,
"RewardIndex": 39,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
}
],
"KeepsakeRoomLists": [],
"StartTime": "2025-08-08T14:00:00Z",
"EndTime": "2025-08-25T14:00:00Z",
"CollectionEndTime": "2025-08-26T14:00:00Z",
"UsesBoost": true,
"BoostDailyGameplayMinutesLimit": 1440,
"BoostXpMultiplier": 1,
"PurchasableXpBoostId": "b86411a0-10c5-4d2b-9228-30e94a00eb68",
"ActiveExperiment": null,
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": "76lkj9zjul69p2jtjwwb3gtyp.png",
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
@@ -0,0 +1,117 @@
{
"ProgressionEventId": 5,
"Name": "Progression Event Alpha Repeat",
"IsEnabled": false,
"Rewards": [
{
"ProgressionEventRewardId": 8,
"ProgressionEventId": 5,
"GiftDropId": 3798,
"GiftDropIds": [
3798
],
"ImageName": "c6o7fs5n863b70ib8l72db471.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 30,
"RewardIndex": 0,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 9,
"ProgressionEventId": 5,
"GiftDropId": 3779,
"GiftDropIds": [
3779
],
"ImageName": "dr989ihctyomlfrq9cvy1m53o.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 60,
"RewardIndex": 1,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 10,
"ProgressionEventId": 5,
"GiftDropId": 3025,
"GiftDropIds": [
3025
],
"ImageName": "1kgv7gxn97k59qpd8ulfupg9p.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 90,
"RewardIndex": 2,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 11,
"ProgressionEventId": 5,
"GiftDropId": 4079,
"GiftDropIds": [
4079
],
"ImageName": "bg5irppfjfcvspbyg0xupdzzz.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 130,
"RewardIndex": 3,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 12,
"ProgressionEventId": 5,
"GiftDropId": 4078,
"GiftDropIds": [
4078
],
"ImageName": "cc5c98ttyl081t6wxadsaovh4.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 180,
"RewardIndex": 4,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 13,
"ProgressionEventId": 5,
"GiftDropId": 3974,
"GiftDropIds": [
3974
],
"ImageName": "213ynroo1di0tvm9najsuxwrw.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 250,
"RewardIndex": 5,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
}
],
"KeepsakeRoomLists": [],
"StartTime": "2022-09-20T23:00:00Z",
"EndTime": "2022-09-23T23:00:00Z",
"CollectionEndTime": "2022-09-24T23:00:00Z",
"UsesBoost": true,
"BoostDailyGameplayMinutesLimit": 20,
"BoostXpMultiplier": 3,
"PurchasableXpBoostId": null,
"ActiveExperiment": "growth_second_progression_event",
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": null,
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
+277
View File
@@ -0,0 +1,277 @@
{
"ProgressionEventId": 3,
"Name": "Rec Room",
"IsEnabled": false,
"Rewards": [],
"KeepsakeRoomLists": [
{
"KeepsakeRoomListId": 1,
"ProgressionEventId": 3,
"UnlockItemAvatarItemId": null,
"UnlockItemGiftDropId": null,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 6,
"RoomId": 41855338,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 0
},
{
"KeepsakeRoomId": 7,
"RoomId": 54586671,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 1
},
{
"KeepsakeRoomId": 27,
"RoomId": 76714234,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 2
},
{
"KeepsakeRoomId": 13,
"RoomId": 9112061,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 3
},
{
"KeepsakeRoomId": 5,
"RoomId": 59058753,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 4
},
{
"KeepsakeRoomId": 1,
"RoomId": 5242677,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 5
},
{
"KeepsakeRoomId": 26,
"RoomId": 59046620,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 6
},
{
"KeepsakeRoomId": 23,
"RoomId": 42598725,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 7
},
{
"KeepsakeRoomId": 37,
"RoomId": 59100897,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 8
},
{
"KeepsakeRoomId": 12,
"RoomId": 59058035,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 9
},
{
"KeepsakeRoomId": 24,
"RoomId": 78042908,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 10
},
{
"KeepsakeRoomId": 15,
"RoomId": 25982226,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 11
},
{
"KeepsakeRoomId": 3,
"RoomId": 59070317,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 12
},
{
"KeepsakeRoomId": 4,
"RoomId": 32840405,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 13
},
{
"KeepsakeRoomId": 8,
"RoomId": 42146023,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 14
},
{
"KeepsakeRoomId": 14,
"RoomId": 32217089,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 15
},
{
"KeepsakeRoomId": 22,
"RoomId": 76890764,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 16
},
{
"KeepsakeRoomId": 28,
"RoomId": 59045944,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 17
},
{
"KeepsakeRoomId": 25,
"RoomId": 26003309,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 18
},
{
"KeepsakeRoomId": 11,
"RoomId": 16860104,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 19
},
{
"KeepsakeRoomId": 2,
"RoomId": 26265683,
"KeepsakeRoomListId": 1,
"Type": 0,
"Order": 20
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 0,
"RoomUnlockBatchSize": 0,
"RoomType": 0,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "00:00:00"
},
{
"KeepsakeRoomListId": 2,
"ProgressionEventId": 3,
"UnlockItemAvatarItemId": null,
"UnlockItemGiftDropId": null,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 34,
"RoomId": 67219271,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 0
},
{
"KeepsakeRoomId": 30,
"RoomId": 41885666,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 1
},
{
"KeepsakeRoomId": 16,
"RoomId": 69004612,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 2
},
{
"KeepsakeRoomId": 31,
"RoomId": 25251760,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 3
},
{
"KeepsakeRoomId": 29,
"RoomId": 41892811,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 4
},
{
"KeepsakeRoomId": 18,
"RoomId": 31382618,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 5
},
{
"KeepsakeRoomId": 33,
"RoomId": 42201785,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 6
},
{
"KeepsakeRoomId": 36,
"RoomId": 77993488,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 7
},
{
"KeepsakeRoomId": 20,
"RoomId": 59106455,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 8
},
{
"KeepsakeRoomId": 32,
"RoomId": 31370276,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 9
},
{
"KeepsakeRoomId": 17,
"RoomId": 41854064,
"KeepsakeRoomListId": 2,
"Type": 1,
"Order": 10
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 0,
"RoomUnlockBatchSize": 0,
"RoomType": 1,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "00:00:00"
}
],
"StartTime": "2023-12-14T00:00:00Z",
"EndTime": "2023-12-15T00:00:00Z",
"CollectionEndTime": "2023-12-16T00:00:00Z",
"UsesBoost": false,
"BoostDailyGameplayMinutesLimit": 0,
"BoostXpMultiplier": 0,
"PurchasableXpBoostId": null,
"ActiveExperiment": null,
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": null,
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
@@ -0,0 +1,445 @@
{
"ProgressionEventId": 12,
"Name": "Science Experiment: Restoration",
"IsEnabled": true,
"Rewards": [
{
"ProgressionEventRewardId": 66,
"ProgressionEventId": 12,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "djfaxfi8a6af5mvtc3qmh3jow.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 20,
"RewardIndex": 0,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 67,
"ProgressionEventId": 12,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "6fx5ck93714znx3eyldgfxs0j.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 110,
"RewardIndex": 1,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 68,
"ProgressionEventId": 12,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "2hbq188nsz4wvh8qzbz60ciz4.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 300,
"RewardIndex": 2,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 69,
"ProgressionEventId": 12,
"GiftDropId": 3783,
"GiftDropIds": [
3783
],
"ImageName": "2w3tsxe7ocrn4smgkrocmiex1.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 580,
"RewardIndex": 3,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 70,
"ProgressionEventId": 12,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "5ox4r2qpvqwhxlpsh2fnysdpf.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 950,
"RewardIndex": 4,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 71,
"ProgressionEventId": 12,
"GiftDropId": 4433,
"GiftDropIds": [
4433
],
"ImageName": "2is18pivakktvlegkrth0acpw.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1410,
"RewardIndex": 5,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 72,
"ProgressionEventId": 12,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "2hzczu8kudpizq3sjmmq2klyp.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1960,
"RewardIndex": 6,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 73,
"ProgressionEventId": 12,
"GiftDropId": 5517,
"GiftDropIds": [
5517
],
"ImageName": "cb9y4mey6y94xd7tkfxz4znnh.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 2700,
"RewardIndex": 7,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 74,
"ProgressionEventId": 12,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "e9n6quz84ppvxkvszgaqhgjo1.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 3340,
"RewardIndex": 8,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 75,
"ProgressionEventId": 12,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "d6ika8z7hn8c245yt0hn0kgiz.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 4130,
"RewardIndex": 9,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 76,
"ProgressionEventId": 12,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "5ydvmfqzoiicmiuz4tvvraahh.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 4920,
"RewardIndex": 10,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 77,
"ProgressionEventId": 12,
"GiftDropId": 5524,
"GiftDropIds": [
5524
],
"ImageName": "37w5i7re60wjsgn4l1yocw425.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 5760,
"RewardIndex": 11,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 78,
"ProgressionEventId": 12,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "3xbb886g6ylgmhjm3c4ifzpua.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 6550,
"RewardIndex": 12,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 81,
"ProgressionEventId": 12,
"GiftDropId": 5528,
"GiftDropIds": [
5528
],
"ImageName": "bvhbfhwqpu5qj9pilb5i9thcl.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 7440,
"RewardIndex": 13,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 82,
"ProgressionEventId": 12,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "7hyz2xgjisqdimlbzshe6y5v0.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 800,
"RewardIndex": 14,
"IsBonus": true,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
}
],
"KeepsakeRoomLists": [
{
"KeepsakeRoomListId": 31,
"ProgressionEventId": 12,
"UnlockItemAvatarItemId": null,
"UnlockItemGiftDropId": null,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 276,
"RoomId": 76890764,
"KeepsakeRoomListId": 31,
"Type": 0,
"Order": 0
},
{
"KeepsakeRoomId": 279,
"RoomId": 59058753,
"KeepsakeRoomListId": 31,
"Type": 0,
"Order": 1
},
{
"KeepsakeRoomId": 280,
"RoomId": 70225004,
"KeepsakeRoomListId": 31,
"Type": 0,
"Order": 2
},
{
"KeepsakeRoomId": 281,
"RoomId": 41922506,
"KeepsakeRoomListId": 31,
"Type": 0,
"Order": 3
},
{
"KeepsakeRoomId": 278,
"RoomId": 1063552974137030672,
"KeepsakeRoomListId": 31,
"Type": 0,
"Order": 4
},
{
"KeepsakeRoomId": 277,
"RoomId": 8786672571011050982,
"KeepsakeRoomListId": 31,
"Type": 0,
"Order": 5
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 2556000000000,
"RoomUnlockBatchSize": 2,
"RoomType": 0,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "2.23:00:00"
},
{
"KeepsakeRoomListId": 32,
"ProgressionEventId": 12,
"UnlockItemAvatarItemId": 2942,
"UnlockItemGiftDropId": 5531,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 285,
"RoomId": 74897901,
"KeepsakeRoomListId": 32,
"Type": 1,
"Order": 0
},
{
"KeepsakeRoomId": 284,
"RoomId": 1752507419075074724,
"KeepsakeRoomListId": 32,
"Type": 1,
"Order": 1
},
{
"KeepsakeRoomId": 282,
"RoomId": 41854064,
"KeepsakeRoomListId": 32,
"Type": 1,
"Order": 2
},
{
"KeepsakeRoomId": 283,
"RoomId": 42201785,
"KeepsakeRoomListId": 32,
"Type": 1,
"Order": 3
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 2556000000000,
"RoomUnlockBatchSize": 1,
"RoomType": 1,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "2.23:00:00"
},
{
"KeepsakeRoomListId": 33,
"ProgressionEventId": 12,
"UnlockItemAvatarItemId": 2936,
"UnlockItemGiftDropId": 5525,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 286,
"RoomId": 944261190986353689,
"KeepsakeRoomListId": 33,
"Type": 1,
"Order": 0
},
{
"KeepsakeRoomId": 288,
"RoomId": 41854320,
"KeepsakeRoomListId": 33,
"Type": 1,
"Order": 1
},
{
"KeepsakeRoomId": 287,
"RoomId": 4678168151807803565,
"KeepsakeRoomListId": 33,
"Type": 1,
"Order": 2
}
],
"RoomUnlockStartOffsetTicks": 828000000000,
"RoomUnlockIntervalTicks": 2556000000000,
"RoomUnlockBatchSize": 3,
"RoomType": 1,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "23:00:00",
"RoomUnlockInterval": "2.23:00:00"
},
{
"KeepsakeRoomListId": 34,
"ProgressionEventId": 12,
"UnlockItemAvatarItemId": 2419,
"UnlockItemGiftDropId": 4953,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 289,
"RoomId": 8364812036745705361,
"KeepsakeRoomListId": 34,
"Type": 1,
"Order": 0
},
{
"KeepsakeRoomId": 292,
"RoomId": 50107153,
"KeepsakeRoomListId": 34,
"Type": 1,
"Order": 1
},
{
"KeepsakeRoomId": 290,
"RoomId": 31382618,
"KeepsakeRoomListId": 34,
"Type": 1,
"Order": 2
},
{
"KeepsakeRoomId": 291,
"RoomId": 3662988090243921994,
"KeepsakeRoomListId": 34,
"Type": 1,
"Order": 3
}
],
"RoomUnlockStartOffsetTicks": 864000000000,
"RoomUnlockIntervalTicks": 1692000000000,
"RoomUnlockBatchSize": 1,
"RoomType": 1,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "1.00:00:00",
"RoomUnlockInterval": "1.23:00:00"
}
],
"StartTime": "2023-05-17T20:25:00Z",
"EndTime": "2023-05-29T20:25:00Z",
"CollectionEndTime": "2023-05-30T20:25:00Z",
"UsesBoost": false,
"BoostDailyGameplayMinutesLimit": 0,
"BoostXpMultiplier": 0,
"PurchasableXpBoostId": "8869236d-830a-497d-a155-2f4d7ce88c13",
"ActiveExperiment": null,
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": "8z3cjwkqoa24yyu2ha059s28y.jpg",
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
@@ -0,0 +1,578 @@
{
"ProgressionEventId": 7,
"Name": "Science Party!",
"IsEnabled": true,
"Rewards": [
{
"ProgressionEventRewardId": 31,
"ProgressionEventId": 7,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "aaed5tx5tan53wnahv00j0dqp.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 60,
"RewardIndex": 0,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 32,
"ProgressionEventId": 7,
"GiftDropId": 5142,
"GiftDropIds": [
5142
],
"ImageName": "50hv6aaon37ldw0l9k6ujnyzd.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 100,
"RewardIndex": 1,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 33,
"ProgressionEventId": 7,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "05xtgqiljov5lqpfmtl0r229h.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 150,
"RewardIndex": 2,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 34,
"ProgressionEventId": 7,
"GiftDropId": 3788,
"GiftDropIds": [
3788
],
"ImageName": "07t4xzjqh8wsz34qzgau846ie.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 210,
"RewardIndex": 3,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 35,
"ProgressionEventId": 7,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "8unssdi33pepexzci1fa7ekzc.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 290,
"RewardIndex": 4,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 36,
"ProgressionEventId": 7,
"GiftDropId": 5142,
"GiftDropIds": [
5142
],
"ImageName": "bzalktli2kkbr5o0l8hwc85bp.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 390,
"RewardIndex": 5,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 37,
"ProgressionEventId": 7,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "cynkckele9webz9vxtpb4ub63.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 510,
"RewardIndex": 6,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 38,
"ProgressionEventId": 7,
"GiftDropId": 5034,
"GiftDropIds": [
5034
],
"ImageName": "1u4rcnhwj45z3jkia5gspofb2.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 660,
"RewardIndex": 7,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 39,
"ProgressionEventId": 7,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "4rofrk3908ubai7h6wl8k8mc3.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 840,
"RewardIndex": 8,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 40,
"ProgressionEventId": 7,
"GiftDropId": 5142,
"GiftDropIds": [
5142
],
"ImageName": "bn4b3ydufhuqd2kqu0ejhl8d3.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1060,
"RewardIndex": 9,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 41,
"ProgressionEventId": 7,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "b7263qy82jdh6nkxteqy3j7wl.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1320,
"RewardIndex": 10,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 42,
"ProgressionEventId": 7,
"GiftDropId": 3889,
"GiftDropIds": [
3889
],
"ImageName": "ea48ip5d566wyomm3vr76n8v4.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1620,
"RewardIndex": 11,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 43,
"ProgressionEventId": 7,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "6vvnks7ggj3ml5368tuxcmt3h.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1970,
"RewardIndex": 12,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 44,
"ProgressionEventId": 7,
"GiftDropId": 4959,
"GiftDropIds": [
4959
],
"ImageName": "ag5yesrz6z9zom62u84272srf.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 2370,
"RewardIndex": 13,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 45,
"ProgressionEventId": 7,
"GiftDropId": 4965,
"GiftDropIds": [
4965
],
"ImageName": "2bo973tl5fkp76pslpxd7myp2.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 2820,
"RewardIndex": 14,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 46,
"ProgressionEventId": 7,
"GiftDropId": 5106,
"GiftDropIds": [
5106
],
"ImageName": "5g7k91zycq4tcunlmhj277162.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 3320,
"RewardIndex": 15,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 47,
"ProgressionEventId": 7,
"GiftDropId": 5060,
"GiftDropIds": [
5060
],
"ImageName": "7kj6usg7wfr522j6gc3yqudej.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 240,
"RewardIndex": 16,
"IsBonus": true,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
}
],
"KeepsakeRoomLists": [
{
"KeepsakeRoomListId": 21,
"ProgressionEventId": 7,
"UnlockItemAvatarItemId": null,
"UnlockItemGiftDropId": null,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 140,
"RoomId": 31709424,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 0
},
{
"KeepsakeRoomId": 102,
"RoomId": 2330357920977922697,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 1
},
{
"KeepsakeRoomId": 109,
"RoomId": 2748754362604835048,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 2
},
{
"KeepsakeRoomId": 136,
"RoomId": 57228091,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 3
},
{
"KeepsakeRoomId": 106,
"RoomId": 77747629,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 4
},
{
"KeepsakeRoomId": 139,
"RoomId": 76890764,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 5
},
{
"KeepsakeRoomId": 133,
"RoomId": 4163663717250077171,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 6
},
{
"KeepsakeRoomId": 112,
"RoomId": 7638707323349236913,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 7
},
{
"KeepsakeRoomId": 107,
"RoomId": 25952571,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 8
},
{
"KeepsakeRoomId": 115,
"RoomId": 70047190,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 9
},
{
"KeepsakeRoomId": 114,
"RoomId": 36754794,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 10
},
{
"KeepsakeRoomId": 111,
"RoomId": 3981920779437373918,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 11
},
{
"KeepsakeRoomId": 103,
"RoomId": 41873233,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 12
},
{
"KeepsakeRoomId": 134,
"RoomId": 35221854,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 13
},
{
"KeepsakeRoomId": 116,
"RoomId": 68171853,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 14
},
{
"KeepsakeRoomId": 104,
"RoomId": 64386353,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 15
},
{
"KeepsakeRoomId": 132,
"RoomId": 37711570,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 16
},
{
"KeepsakeRoomId": 101,
"RoomId": 17830076,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 17
},
{
"KeepsakeRoomId": 137,
"RoomId": 52815862,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 18
},
{
"KeepsakeRoomId": 113,
"RoomId": 21478010,
"KeepsakeRoomListId": 21,
"Type": 0,
"Order": 19
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 1728000000000,
"RoomUnlockBatchSize": 3,
"RoomType": 0,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "2.00:00:00"
},
{
"KeepsakeRoomListId": 22,
"ProgressionEventId": 7,
"UnlockItemAvatarItemId": 2554,
"UnlockItemGiftDropId": 5108,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 127,
"RoomId": 9473003,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 0
},
{
"KeepsakeRoomId": 123,
"RoomId": 50107153,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 1
},
{
"KeepsakeRoomId": 128,
"RoomId": 8971419584305936263,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 2
},
{
"KeepsakeRoomId": 122,
"RoomId": 2402812124088696720,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 3
},
{
"KeepsakeRoomId": 130,
"RoomId": 57707466,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 4
},
{
"KeepsakeRoomId": 118,
"RoomId": 21582210,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 5
},
{
"KeepsakeRoomId": 117,
"RoomId": 4708745686835418998,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 6
},
{
"KeepsakeRoomId": 135,
"RoomId": 789919,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 7
},
{
"KeepsakeRoomId": 138,
"RoomId": 31422335,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 8
},
{
"KeepsakeRoomId": 120,
"RoomId": 21911777,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 9
},
{
"KeepsakeRoomId": 124,
"RoomId": 6005761176800757444,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 10
},
{
"KeepsakeRoomId": 126,
"RoomId": 72145061,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 11
},
{
"KeepsakeRoomId": 119,
"RoomId": 42288509,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 12
},
{
"KeepsakeRoomId": 121,
"RoomId": 11034150,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 13
},
{
"KeepsakeRoomId": 131,
"RoomId": 2134942440841839507,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 14
},
{
"KeepsakeRoomId": 125,
"RoomId": 649924194922273060,
"KeepsakeRoomListId": 22,
"Type": 1,
"Order": 15
}
],
"RoomUnlockStartOffsetTicks": 0,
"RoomUnlockIntervalTicks": 1728000000000,
"RoomUnlockBatchSize": 3,
"RoomType": 1,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:00",
"RoomUnlockInterval": "2.00:00:00"
}
],
"StartTime": "2022-12-21T18:35:00Z",
"EndTime": "2023-01-03T18:35:00Z",
"CollectionEndTime": "2023-01-04T18:35:00Z",
"UsesBoost": false,
"BoostDailyGameplayMinutesLimit": 0,
"BoostXpMultiplier": 0,
"PurchasableXpBoostId": "5c0a2aa2-f419-49cd-abf9-d4614c3e5036",
"ActiveExperiment": null,
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": "a5cscydpx5x2i6lphnsv5xfk6.jpg",
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
@@ -0,0 +1,661 @@
{
"ProgressionEventId": 14,
"Name": "Summer Party",
"IsEnabled": false,
"Rewards": [
{
"ProgressionEventRewardId": 144,
"ProgressionEventId": 14,
"GiftDropId": 3989,
"GiftDropIds": [
3989
],
"ImageName": "8t69rx7il3qacste29a6obz5w.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 10,
"RewardIndex": 0,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 145,
"ProgressionEventId": 14,
"GiftDropId": 17008,
"GiftDropIds": [
17008
],
"ImageName": "a5hfoi04rs8n7vaflsb0upy0n.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 10,
"RewardIndex": 1,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 146,
"ProgressionEventId": 14,
"GiftDropId": 14274,
"GiftDropIds": [
14274
],
"ImageName": "dyu1yu4zip5sae8q5gi5l72pv.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 20,
"RewardIndex": 2,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 147,
"ProgressionEventId": 14,
"GiftDropId": 17026,
"GiftDropIds": [
17026
],
"ImageName": "0hygq3q4gyaex5c3l7peqoj6a.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 20,
"RewardIndex": 3,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 148,
"ProgressionEventId": 14,
"GiftDropId": 17028,
"GiftDropIds": [
17028
],
"ImageName": "b7oxbidp1dcm03bt4pwxr8ur7.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 40,
"RewardIndex": 4,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 149,
"ProgressionEventId": 14,
"GiftDropId": 14274,
"GiftDropIds": [
14274
],
"ImageName": "9650alp4fzdx3vn6lyy13mtg3.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 40,
"RewardIndex": 5,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 150,
"ProgressionEventId": 14,
"GiftDropId": 17083,
"GiftDropIds": [
17083
],
"ImageName": "5yhva1w958hknw1vn7pt6ms6n.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 60,
"RewardIndex": 6,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 151,
"ProgressionEventId": 14,
"GiftDropId": 17026,
"GiftDropIds": [
17026
],
"ImageName": "61cdhwhloej6zy5ypdmogt248.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 60,
"RewardIndex": 7,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 152,
"ProgressionEventId": 14,
"GiftDropId": 17024,
"GiftDropIds": [
17024
],
"ImageName": "c6u9av1tjoeufnjpov3weoe2j.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 90,
"RewardIndex": 8,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 153,
"ProgressionEventId": 14,
"GiftDropId": 17078,
"GiftDropIds": [
17078
],
"ImageName": "734oescsnasxod5lj0bkp262i.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 90,
"RewardIndex": 9,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 154,
"ProgressionEventId": 14,
"GiftDropId": 17025,
"GiftDropIds": [
17025
],
"ImageName": "8g5gzg65fpt54tlgnlpebnqb7.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 130,
"RewardIndex": 10,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 155,
"ProgressionEventId": 14,
"GiftDropId": 14274,
"GiftDropIds": [
14274
],
"ImageName": "33qnr08uznr13tvua2rkybwaa.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 130,
"RewardIndex": 11,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 156,
"ProgressionEventId": 14,
"GiftDropId": 17058,
"GiftDropIds": [
17058
],
"ImageName": "aj7sxyq6u7kch8v4grh1ja5w9.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 180,
"RewardIndex": 12,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 157,
"ProgressionEventId": 14,
"GiftDropId": 17030,
"GiftDropIds": [
17030
],
"ImageName": "cjz3c14kgtz96cmckpncnifn6.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 180,
"RewardIndex": 13,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 158,
"ProgressionEventId": 14,
"GiftDropId": 14274,
"GiftDropIds": [
14274
],
"ImageName": "6uu6py0qs0gax9cvachj6910j.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 240,
"RewardIndex": 14,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 159,
"ProgressionEventId": 14,
"GiftDropId": 17079,
"GiftDropIds": [
17079
],
"ImageName": "2gq9xiv5ojy7682mc9xzld42p.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 240,
"RewardIndex": 15,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 160,
"ProgressionEventId": 14,
"GiftDropId": 17028,
"GiftDropIds": [
17028
],
"ImageName": "1fmvxbij1e3z4rsax1xaiejn8.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 300,
"RewardIndex": 16,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 161,
"ProgressionEventId": 14,
"GiftDropId": 17026,
"GiftDropIds": [
17026
],
"ImageName": "380z3zyut971wvwrzz8pj1z5b.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 300,
"RewardIndex": 17,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 162,
"ProgressionEventId": 14,
"GiftDropId": 17061,
"GiftDropIds": [
17061
],
"ImageName": "2gpauvs1hseg7ptr5pcd09za3.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 360,
"RewardIndex": 18,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 163,
"ProgressionEventId": 14,
"GiftDropId": 17081,
"GiftDropIds": [
17081
],
"ImageName": "6obtoj80pjn1g8cizwdbvxvxd.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 360,
"RewardIndex": 19,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 164,
"ProgressionEventId": 14,
"GiftDropId": 3989,
"GiftDropIds": [
3989
],
"ImageName": "c04l6nmz0z69jn6dfjfptcsi6.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 440,
"RewardIndex": 20,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 165,
"ProgressionEventId": 14,
"GiftDropId": 17030,
"GiftDropIds": [
17030
],
"ImageName": "035iqb84hbeo3hp0i9p9ov6ox.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 440,
"RewardIndex": 21,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 166,
"ProgressionEventId": 14,
"GiftDropId": 17059,
"GiftDropIds": [
17059
],
"ImageName": "3oodo9mhvy0kdac30tnzpp2wo.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 520,
"RewardIndex": 22,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 167,
"ProgressionEventId": 14,
"GiftDropId": 17080,
"GiftDropIds": [
17080
],
"ImageName": "f1jj5jvro9pw83kz9qrqhl3zq.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 520,
"RewardIndex": 23,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 168,
"ProgressionEventId": 14,
"GiftDropId": 3989,
"GiftDropIds": [
3989
],
"ImageName": "1yti0qsf9qp1a0gok2lve1tw8.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 610,
"RewardIndex": 24,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 169,
"ProgressionEventId": 14,
"GiftDropId": 17026,
"GiftDropIds": [
17026
],
"ImageName": "0osnuz105rvfe3bm6ktubtvjw.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 610,
"RewardIndex": 25,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 170,
"ProgressionEventId": 14,
"GiftDropId": 17060,
"GiftDropIds": [
17060
],
"ImageName": "1a3ehh1lt5u15dbsp7djj68y3.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 710,
"RewardIndex": 26,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 171,
"ProgressionEventId": 14,
"GiftDropId": 17082,
"GiftDropIds": [
17082
],
"ImageName": "36gvdi900qbrcvvugg4hqsncm.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 710,
"RewardIndex": 27,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 172,
"ProgressionEventId": 14,
"GiftDropId": 17024,
"GiftDropIds": [
17024
],
"ImageName": "dl5a8wlvl46sd20ujwkfqy6vs.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 810,
"RewardIndex": 28,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 173,
"ProgressionEventId": 14,
"GiftDropId": 14274,
"GiftDropIds": [
14274
],
"ImageName": "0s6pdag7186jrtozh4i8blgog.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 810,
"RewardIndex": 29,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 174,
"ProgressionEventId": 14,
"GiftDropId": 17028,
"GiftDropIds": [
17028
],
"ImageName": "bbtkhdvgh0akznofr9et71jn2.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 930,
"RewardIndex": 30,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 175,
"ProgressionEventId": 14,
"GiftDropId": 17026,
"GiftDropIds": [
17026
],
"ImageName": "9qjbcxizpxh8wv26ka1mkbfz0.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 930,
"RewardIndex": 31,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 176,
"ProgressionEventId": 14,
"GiftDropId": 17024,
"GiftDropIds": [
17024
],
"ImageName": "d41623xsr285d4ico556zx95f.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1050,
"RewardIndex": 32,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 177,
"ProgressionEventId": 14,
"GiftDropId": 14274,
"GiftDropIds": [
14274
],
"ImageName": "bdq4siuzhyxnc0ow0elurdu77.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1050,
"RewardIndex": 33,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 178,
"ProgressionEventId": 14,
"GiftDropId": 17025,
"GiftDropIds": [
17025
],
"ImageName": "9if9e7u9k24sfys7ui1mqovmx.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1170,
"RewardIndex": 34,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 179,
"ProgressionEventId": 14,
"GiftDropId": 17028,
"GiftDropIds": [
17028
],
"ImageName": "2zja5d03bdsc6gbwc1ymgl7m3.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1170,
"RewardIndex": 35,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 180,
"ProgressionEventId": 14,
"GiftDropId": 17026,
"GiftDropIds": [
17026
],
"ImageName": "80nsy4tf5ysrrxklukvn2pe2i.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1300,
"RewardIndex": 36,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 181,
"ProgressionEventId": 14,
"GiftDropId": 17030,
"GiftDropIds": [
17030
],
"ImageName": "7j3oshdr6u0rck15coy2p0l7j.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1300,
"RewardIndex": 37,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 182,
"ProgressionEventId": 14,
"GiftDropId": 17042,
"GiftDropIds": [
17042
],
"ImageName": "7jtl3d7xflfyhofzstw7jg9og.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1440,
"RewardIndex": 38,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 183,
"ProgressionEventId": 14,
"GiftDropId": 17041,
"GiftDropIds": [
17041
],
"ImageName": "dtnlvtn5z24lbskhx6jmzrgy7.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1440,
"RewardIndex": 39,
"IsBonus": false,
"IsRRPlusExclusive": true,
"MaxGlobalCollections": null
}
],
"KeepsakeRoomLists": [],
"StartTime": "2025-07-04T00:00:00Z",
"EndTime": "2025-07-21T00:00:00Z",
"CollectionEndTime": "2025-07-22T00:00:00Z",
"UsesBoost": true,
"BoostDailyGameplayMinutesLimit": 1440,
"BoostXpMultiplier": 1,
"PurchasableXpBoostId": "b86411a0-10c5-4d2b-9228-30e94a00eb68",
"ActiveExperiment": null,
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": "drimdw4wgoyxhywh87v8ul24o.png",
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
@@ -0,0 +1,227 @@
{
"ProgressionEventId": 16,
"Name": "Treat Collection Challenge",
"IsEnabled": true,
"Rewards": [
{
"ProgressionEventRewardId": 241,
"ProgressionEventId": 16,
"GiftDropId": 33148,
"GiftDropIds": [
33148
],
"ImageName": "5u8mhj7w2qpr1f6gv1i2j9agh.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 100,
"RewardIndex": 0,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 242,
"ProgressionEventId": 16,
"GiftDropId": 33274,
"GiftDropIds": [
33274
],
"ImageName": "524wcausenuws6tcy01f5kfj3.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 575,
"RewardIndex": 1,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 243,
"ProgressionEventId": 16,
"GiftDropId": 33282,
"GiftDropIds": [
33282
],
"ImageName": "0wr3qcgxfrotusbxasn4xqxll.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1150,
"RewardIndex": 2,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 244,
"ProgressionEventId": 16,
"GiftDropId": 33242,
"GiftDropIds": [
33242
],
"ImageName": "7ccyt74z6qe1a3gyhvgpajx6q.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 1600,
"RewardIndex": 3,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 245,
"ProgressionEventId": 16,
"GiftDropId": 33252,
"GiftDropIds": [
33252
],
"ImageName": "9oufkpwzzblczfxvkhm42qz8q.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 2000,
"RewardIndex": 4,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": null
},
{
"ProgressionEventRewardId": 247,
"ProgressionEventId": 16,
"GiftDropId": 33237,
"GiftDropIds": [
33237,
33238,
33239
],
"ImageName": "e416lpu3mlynh1h3ace7bb66k.png",
"ImageStream": null,
"ImageContentType": null,
"Xp": 2000,
"RewardIndex": 5,
"IsBonus": false,
"IsRRPlusExclusive": false,
"MaxGlobalCollections": 100
}
],
"KeepsakeRoomLists": [
{
"KeepsakeRoomListId": 35,
"ProgressionEventId": 16,
"UnlockItemAvatarItemId": null,
"UnlockItemGiftDropId": null,
"UnlockItemLockDurationTicks": null,
"KeepsakeRooms": [
{
"KeepsakeRoomId": 308,
"RoomId": 8055432965292997844,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 0
},
{
"KeepsakeRoomId": 303,
"RoomId": 8623922633989916245,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 1
},
{
"KeepsakeRoomId": 296,
"RoomId": 2720689679841941691,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 2
},
{
"KeepsakeRoomId": 306,
"RoomId": 1198369430637700542,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 3
},
{
"KeepsakeRoomId": 305,
"RoomId": 5060281615610518191,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 4
},
{
"KeepsakeRoomId": 299,
"RoomId": 5664046151556621084,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 5
},
{
"KeepsakeRoomId": 302,
"RoomId": 3579409834391380003,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 6
},
{
"KeepsakeRoomId": 300,
"RoomId": 7430395197140819449,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 7
},
{
"KeepsakeRoomId": 309,
"RoomId": 170126,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 8
},
{
"KeepsakeRoomId": 297,
"RoomId": 5988747458686605260,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 9
},
{
"KeepsakeRoomId": 301,
"RoomId": 8323646838241355522,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 10
},
{
"KeepsakeRoomId": 298,
"RoomId": 4055690517114419831,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 11
},
{
"KeepsakeRoomId": 304,
"RoomId": 1408763564617909051,
"KeepsakeRoomListId": 35,
"Type": 0,
"Order": 12
}
],
"RoomUnlockStartOffsetTicks": 10000000,
"RoomUnlockIntervalTicks": 10000000,
"RoomUnlockBatchSize": 100,
"RoomType": 0,
"UnlockItemLockDuration": null,
"RoomUnlockStartOffset": "00:00:01",
"RoomUnlockInterval": "00:00:01"
}
],
"StartTime": "2025-10-17T18:00:00Z",
"EndTime": "2025-10-23T18:00:00Z",
"CollectionEndTime": "2025-10-24T18:00:00Z",
"UsesBoost": false,
"BoostDailyGameplayMinutesLimit": 0,
"BoostXpMultiplier": 0,
"PurchasableXpBoostId": "af4c4b42-7d8b-4084-9e34-66d6da665f8f",
"ActiveExperiment": "liveops_q42025_rectober_treats",
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": "485daw4zpr3dwyhrayusrr5hv.png",
"EventLeaderboardId": null,
"InitialCurrencyGrant": 0
}
@@ -0,0 +1,20 @@
{
"ProgressionEventId": 17,
"Name": "Tricks Challenge",
"IsEnabled": true,
"Rewards": [],
"KeepsakeRoomLists": [],
"StartTime": "2025-10-29T18:00:00Z",
"EndTime": "2025-11-03T03:00:00Z",
"CollectionEndTime": "2025-11-04T03:00:00Z",
"UsesBoost": false,
"BoostDailyGameplayMinutesLimit": 0,
"BoostXpMultiplier": 0,
"PurchasableXpBoostId": "6b162910-f07d-409d-bae8-be4854358059",
"ActiveExperiment": "liveops_q42025_rectober_tricks",
"ChallengesIconImageName": "15g8dw5os81x3wyjp1fcq20ah.png",
"RewardsPipImageName": "0ibvu6e7i67j4cxkakbif3hwr.png",
"EventInfoImageName": "ew5owqjujje8a4cloy6lifzqh.png",
"EventLeaderboardId": 17,
"InitialCurrencyGrant": 275
}
@@ -0,0 +1,20 @@
{
"ProgressionEventId": 18,
"Name": "Yetis Challenge",
"IsEnabled": false,
"Rewards": [],
"KeepsakeRoomLists": [],
"StartTime": "2025-12-03T19:00:00Z",
"EndTime": "2025-12-08T03:00:00Z",
"CollectionEndTime": "2025-12-09T03:00:00Z",
"UsesBoost": false,
"BoostDailyGameplayMinutesLimit": 0,
"BoostXpMultiplier": 0,
"PurchasableXpBoostId": "e4a7b4d4-f609-4008-9874-f6cd01b896ea",
"ActiveExperiment": "liveops_2025q4_yeti",
"ChallengesIconImageName": null,
"RewardsPipImageName": null,
"EventInfoImageName": "d99f392w7hv5r2wzwxpcxtwhi.png",
"EventLeaderboardId": 18,
"InitialCurrencyGrant": 300
}
+3
View File
@@ -4,6 +4,9 @@
"main": "src/api.app.ts",
"compatibility_date": "2026-06-16",
"compatibility_flags": ["nodejs_compat"],
// The progression events are bundled as TEXT, not parsed: they are served byte for byte
// because their 64-bit room ids don't survive a JSON parse (see routes/progression.ts).
"rules": [{ "type": "Text", "globs": ["**/progression-events/*.json"], "fallthrough": true }],
// Shared `recflare` DB. Created manually with `wrangler d1 create recflare`; its
// id lives in RECFLARE_D1 (see .env) and the "local" placeholder below is
// spliced out at deploy time. The `api` worker owns the `relationships` table
+26 -10
View File
@@ -5,16 +5,19 @@ authenticates players and issues the JWTs every other worker verifies.
## Routes
| Method | Path | Description |
| ------ | ------------------------------------------ | ------------------------------------------------------ |
| GET | `/eac/challenge` | EAC handshake; a constant, JSON-quoted, as text |
| GET | `/cachedlogin/forplatformid/:platform/:id` | Accounts linked to a platform id, for the login screen |
| POST | `/cachedlogin/forplatformids` | Bulk cached-login lookup (friends resolution) |
| POST | `/connect/token` | OAuth token endpoint; issues a JWT + refresh token |
| POST | `/account/me/changepassword` | Change the caller's password (auth-gated) |
| GET | `/role/developer/:id` | Developer role lookup; a bare JSON boolean |
| GET | `/role/moderator/:id` | Moderator role lookup; a bare JSON boolean |
| GET | `/openapi.json` | Generated OpenAPI 3.1 spec (see below) |
| Method | Path | Description |
| ------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------ |
| GET | `/eac/challenge` | EAC handshake; a constant, JSON-quoted, as text |
| GET | `/cachedlogin/forplatformid/:platform/:id` | Accounts linked to a platform id, for the login screen |
| POST | `/cachedlogin/forplatformids` | Bulk cached-login lookup (friends resolution) |
| POST | `/connect/deviceauthorization` | Studio device login: mint a user code and point the browser at WWW `/device` |
| POST | `/connect/device/approve` | Website approves that code for the signed-in account |
| POST | `/connect/device/deny` | Website refuses it |
| POST | `/connect/token` | OAuth token endpoint; issues a JWT + refresh token. Studio polls this with the device-code grant |
| POST | `/account/me/changepassword` | Change the caller's password (auth-gated) |
| GET | `/role/developer/:id` | Developer role lookup; a bare JSON boolean |
| GET | `/role/moderator/:id` | Moderator role lookup; a bare JSON boolean |
| GET | `/openapi.json` | Generated OpenAPI 3.1 spec (see below) |
## API documentation
@@ -45,6 +48,10 @@ route without documenting it fails rather than silently shipping an incomplete s
identity `platform_auth` proves.
- **`refresh_token`** — redeems a stored single-use refresh token, rotating it.
30-day TTL; platform and platform id come from what was stored at issue time.
- **`urn:ietf:params:oauth:grant-type:device_code`** — Rec Room Studio polling a
code from `POST /connect/deviceauthorization`. While the player has not approved
it on the website, the response is HTTP 400 `authorization_pending`. Studio
treats `expires_in` on the token as seconds.
- **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies
the account by `username` or numeric `account_id` and requires the matching password
(PBKDF2-SHA256, `salt:hash`). An account with no stored hash cannot be logged into at
@@ -55,6 +62,15 @@ Access tokens live for 1 hour (`TOKEN_TTL_SECONDS` in `@repo/jwt`) and carry a `
claim, so developer/moderator powers refresh on every login and every refresh grant.
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
`betastudio` is a separate claim. RecFlare Studio treats it as permission to upload,
and developer does not include it. It is stamped from the account's `hasStudio` flag,
or from a Discord link holding a role named in `STUDIO_ROLLOUT`
(`RECFLARE_STUDIO_ROLLOUT` in the root `.env`) — a staged Studio rollout to a Discord
role, read from the same role snapshot as the build rollout gate. Staff add and remove
individual people at `/settings/recroomstudio` on the website (or
`runx admin grant-studio`). A player already signed into Studio keeps the old token
until the next sign-in or refresh.
### Verifiable platforms: Steam and Meta
Only an identity we can _prove_ is ever bound to an account, so any grant that
@@ -0,0 +1,19 @@
-- Device-login grants (owned by the auth worker). Named generically: Rec Room
-- Studio is the first device to use it, but any device-code login lands here. Studio
-- posts POST /connect/deviceauthorization here, then polls /connect/token with
-- grant_type=urn:ietf:params:oauth:grant-type:device_code. The browser page that
-- approves the code lives on www (`/device`); this table is only the handshake.
--
-- The device_code is a bearer secret until it is consumed, so only its SHA-256
-- is stored. The user_code is the short code the player sees and types, so it
-- has to be stored as itself. Kept in sync with STUDIO_DEVICE_SCHEMA_DDL in
-- src/studio-device.ts.
CREATE TABLE IF NOT EXISTS device_grant (
device_code_hash TEXT PRIMARY KEY,
user_code TEXT NOT NULL UNIQUE,
account_id INTEGER,
status TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
);
@@ -0,0 +1,15 @@
-- Expose the account's influencer flag (`isInfluencer` in the JSON blob) as an indexed
-- generated column, so "every influencer" is an index walk rather than a scan of every
-- account's JSON. The reader is econ's `GET /api/influencerpartnerprogram/influencers`,
-- which the client asks for the whole list on every launch so it can badge influencers
-- wherever they turn up.
--
-- Same construction as `has_plus` (0009): a PARTIAL index holding only the rows where the
-- flag is set, so it is as small as the influencer list. A query must say exactly
-- `is_influencer = 1` to use it. `json_extract` yields 1 for JSON true, 0 for false and
-- NULL when the key is absent, so neither a revoked nor a never-granted account is in it.
-- Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts (and its mirror in
-- apps/econ/src/avatar-db.ts).
ALTER TABLE account ADD COLUMN is_influencer INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInfluencer')) VIRTUAL;
CREATE INDEX IF NOT EXISTS idx_account_is_influencer ON account (is_influencer) WHERE is_influencer = 1;
+626 -27
View File
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { z } from 'zod'
import {
countAccountsByDeviceId,
countAccountsBySignupIp,
createAccount,
GAME_VERSION,
@@ -13,18 +14,22 @@ import {
getPasswordHash,
getRoomById,
hashPassword,
isRolloutGameVersion,
isSupportedGameVersion,
rolloutGameBuilds,
RoomInstanceType,
setLastLoginTime,
setLoginContext,
setPasswordHash,
setPresence,
subRoomDataBlob,
supportedGameBuilds,
updateAccount,
verifyPassword,
withDatabase,
writeAuditLog,
} from '@repo/domain'
import {
flagVar,
intVar,
logger,
withCleanSpec,
@@ -32,16 +37,26 @@ import {
withNotFound,
withOnError,
} from '@repo/hono-helpers'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
import {
generateToken,
TOKEN_TTL_SECONDS,
validateAndGetAccountId,
validateIdentityAssertion,
} from '@repo/jwt'
// The account-wide ban lives on a `report` row, whose table the api worker owns; its db
// module is plain D1 queries with no runtime deps, so it imports cleanly here.
import { banEvasionMatch, resolveBan } from '../../api/src/bans-db'
import { buildEndpoints } from '../../ns/src/endpoints'
import { verifyMetaNonce } from './meta-nonce'
import {
CachedLogin,
ChangePasswordRequest,
ChangePasswordResponse,
DeviceAuthorizationRequest,
DeviceAuthorizationResponse,
DeviceDecisionRequest,
DeviceDecisionResponse,
FakeCachedLogin,
form,
json,
@@ -55,14 +70,26 @@ import {
TokenResponse,
} from './openapi'
import {
accountHoldsDiscordRole,
countAccountsForPlatformIdentity,
getLinksForIdentity,
getLinksForPlatformId,
getLinksForPlatformIdentity,
hasStudioAccess,
isPlatformIdentityLinked,
linkPlatformIdentity,
parseRoleIdList,
} from './platform-db'
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
import { verifySteamTicket } from './steam-ticket'
import {
approveDeviceGrant,
beginDeviceGrant,
denyDeviceGrant,
DEVICE_CODE_GRANT,
pollDeviceGrant,
studioClientMatches,
} from './studio-device'
import type { Context } from 'hono'
import type { Account } from '@repo/domain'
@@ -89,6 +116,21 @@ const TOKEN_SCOPE =
*/
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
/**
* The website's "Sign in with Discord". `www` completes the Discord OAuth2 exchange (it
* alone holds the client secret), then posts an `assertion` — an HS256 token signed with
* the shared JWT_SECRET naming the platform identity it just proved (see `@repo/jwt`'s
* `generateIdentityAssertion`). This grant trusts the assertion the way `cached_login`
* trusts a Steam ticket, resolves it through the link table, and issues the ordinary
* session. No account linked to the identity means a NEW one is made and linked, so a
* Discord account is a way to join without Steam or Meta; the player then sets a password
* on the website and signs in with it anywhere — a sideloaded Quest included.
*/
const IDENTITY_ASSERTION_GRANT = 'identity_assertion'
/** What the grant answers when one identity opens several accounts; never true today. */
const AMBIGUOUS_IDENTITY_DESCRIPTION = 'platform identity is linked to more than one account'
/**
* The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded
* build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded
@@ -117,24 +159,106 @@ const FAKE_OCULUS_CACHED_LOGIN = {
* Signup caps, enforced on create_account only (never on login — an existing account
* always stays reachable, however many accounts its owner has since accumulated).
*
* Two independent arms, because they fail in opposite ways:
* Three independent arms, because they fail in different ways:
* - Per verified platform id (a Steam-proven SteamID64). The sharp one: it can't be
* spoofed and can't be reset by changing networks. Only binds on a platform
* create_account — the password/anonymous path has no platform identity to count,
* so the IP arm is the only thing standing between it and bulk signup.
* so the other two arms are all that stands between it and bulk signup.
* - Per signup IP. Coarse: households, NAT and shared campus/mobile networks put many
* legitimate players behind one address, so this WILL be the arm that produces false
* positives. It counts `signupIp` (immutable), so an abuser can't reset their own
* count by hopping networks.
* count by hopping networks — but a VPN gives them a fresh counter per exit, which
* is exactly how one player made 150 accounts, six per address, in a weekend.
* - Per client device id. Narrower than an IP (a household shares an address, not an
* install) and stable across VPN hops, which is the hole in the IP arm it covers. It
* counts the last-seen `deviceId`, which a login moves; see the field's notes in
* accounts-db for why an immutable copy isn't worth keeping. Client-chosen and
* unverified, so it catches a stock client, not a script that fabricates ids. Skipped
* when the signup posts no `device_id` (the website's don't).
*
* These are the defaults. An operator overrides either arm with the matching worker var
* (`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` in wrangler.jsonc `vars`), and
* setting one to 0 disables that arm entirely — which a small private server that trusts
* everyone it invites will want, and which the IP arm in particular is worth reaching for
* if a shared network is being locked out.
* These are the defaults. An operator overrides any arm with the matching worker var
* (`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` / `MAX_ACCOUNTS_PER_DEVICE_ID`
* in wrangler.jsonc `vars`), and setting one to 0 disables that arm entirely — which a
* small private server that trusts everyone it invites will want, and which the IP arm in
* particular is worth reaching for if a shared network is being locked out.
*/
const DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID = 3
const DEFAULT_MAX_ACCOUNTS_PER_IP = 3
const DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID = 3
/**
* Whether a `create_account` with no platform — a password account, which is what the
* website mints — is accepted at all (`PASSWORD_SIGNUP`; see context.ts). Off by default:
* such an account has no identity but the address it came from, so the caps above are
* the only thing limiting it, and a VPN or Tor exit resets the IP arm every N accounts.
* That is how one player made 150 accounts in a weekend. A game-client signup always
* carries a Steam or Meta identity and is untouched by this switch.
*
* Refused as `invalid_grant` with this description, which `www` translates for the form
* (see its auth-messages.ts) — keep the two in step.
*/
const DEFAULT_PASSWORD_SIGNUP = false
const PASSWORD_SIGNUP_DISABLED_DESCRIPTION = 'password signup is disabled'
/**
* The build gates on `/connect/token`, both keyed on the grant's posted `ver` and both
* driven by the operator's game-version vars (`SUPPORTED_GAME_VERSIONS`,
* `ROLLOUT_GAME_VERSIONS` — see context.ts and `supportedGameBuilds`/`rolloutGameBuilds`
* in @repo/domain, which `api` and `match` read the same way). A grant posting no `ver`
* (a refresh, the website) is untouched by either.
*
* 1. The SUPPORT gate: a build outside `supportedGameBuilds` is refused as `invalid_grant`
* with UNSUPPORTED_BUILD_DESCRIPTION, for every grant and every account — staff
* included. `api`'s `/api/versioncheck/v4` tells the same builds to update; this is what
* makes the update prompt stick, since a client that got a token anyway would play on.
* Dropping a build from the list is how its players are made to upgrade.
* 2. The ROLLOUT gate: a build under staged rollout is refused unless the account is STAFF
* (developer or moderator) or its Discord link holds a role named in
* `DISCORD_ROLLOUT_ROLE_IDS` (see `accountMayUseRolloutBuild`), as `invalid_grant` with
* ROLLOUT_GATED_DESCRIPTION. `create_account` is refused before anything is minted: a
* brand-new account has no Discord link to hold a role, so the player makes their
* account on a released build, links Discord on the website, and comes back. Ending the
* rollout (`ROLLOUT_GAME_VERSIONS=off`) opens the build to everyone.
*
* `ver` is client-reported and unverified, like everything else about the build. That is
* fine for what these are: rollout and upgrade gates, not a security boundary. A player
* who lies about their build gets a token the surfaces of the build they claim would have
* handed them anyway, and then runs a client the server isn't answering for.
*/
const ROLLOUT_GATED_DESCRIPTION =
'this build is limited to Discord supporters; link your Discord on the website first'
const UNSUPPORTED_BUILD_DESCRIPTION =
'this game version is not supported by this server; update your game'
/**
* `DISCORD_ROLLOUT_ROLE_IDS` as a list of role ids: snowflakes separated by commas and/or
* whitespace (`parseRoleIdList`, the grammar `www`'s parseRoleIds reads
* DISCORD_BENEFITS_ROLE_IDS with). Unset or blank parses to an empty list, which
* `accountMayUseRolloutBuild` treats as a gate closed to everyone but staff.
*/
const rolloutRoleIds = parseRoleIdList
/**
* Whether an account may sign in on a rollout-gated build. STAFF — a developer or moderator
* flag on the account, the same two `staff_login` audits — always may, configured roles or
* not: they are who tests a build before it reaches supporters, so a closed gate stays open
* to them. Anyone else needs a Discord link (platform 101, written by the website's benefits
* claim and refreshed by `www`'s daily sweep) recording at least one of the configured
* roles. The roles are a SNAPSHOT (see PlatformLink.roles), so a role granted in Discord
* today opens the gate once the player re-claims or the sweep runs — not instantly.
*
* An empty role list skips the link read: with no supporter role named, nobody is a
* supporter.
*/
async function accountMayUseRolloutBuild(
db: D1Database,
accountId: number,
roleIds: readonly string[]
): Promise<boolean> {
const account = await getAccount(db, accountId)
if (account && (holdsRole(account, 'developer') || holdsRole(account, 'moderator'))) return true
return accountHoldsDiscordRole(db, accountId, roleIds)
}
/** New players start in the Orientation room (RoomId 13) — the new-user flow. */
const ORIENTATION_ROOM_ID = 13
@@ -206,7 +330,9 @@ async function authedId(c: Context<App>): Promise<number | null> {
}
/** Whether one account row holds a role. Absent/false both mean no role. */
function holdsRole(account: Account, role: 'developer' | 'moderator'): boolean {
function holdsRole(account: Account, role: 'developer' | 'moderator' | 'screenshare'): boolean {
// Matches accountTokenRoles: screenshare is a feature gate on every account token.
if (role === 'screenshare') return true
return (role === 'developer' ? account.isDeveloper : account.isModerator) === true
}
@@ -216,7 +342,11 @@ function holdsRole(account: Account, role: 'developer' | 'moderator'): boolean {
* mirroring the reference API. An id that is not a number is answered as unknown (404) rather
* than as a 400: `/role/developer/nonsense` is just an id that matches nothing.
*/
async function roleAnswer(c: Context<App>, role: 'developer' | 'moderator', id: string) {
async function roleAnswer(
c: Context<App>,
role: 'developer' | 'moderator' | 'screenshare',
id: string
) {
logger.info(`${role} role lookup`, { id })
const accountId = Number.parseInt(id, 10)
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
@@ -255,17 +385,28 @@ async function roleFilterAnswer(c: Context<App>, role: 'developer' | 'moderator'
* (gameClient) are added by generateToken. `screenshare` rides on EVERY token — the
* client gates the screen-share feature on it and nothing grants it per-account, so it
* is unconditional (even with no account resolved). The rest are the operator-granted
* extras, plus `junior` off the account's own `isJunior` flag. Order is stable so
* tokens are deterministic.
* extras, plus `junior` off the account's own `isJunior` flag.
*
* `betastudio` rides on `studio` — the account's Studio access as `hasStudioAccess`
* resolves it: the `hasStudio` flag staff set from the website, OR a Discord link holding
* a role in the `STUDIO_ROLLOUT` var — and developer does not imply it. RecFlare Studio
* treats that exact claim as Full access, which is what lets the editor upload. The
* caller resolves it (it costs a link read) so this stays a pure projection of the
* account. `influencer` rides on `isInfluencer` (`runx admin grant-influencer`) and lives
* only in the token — no `/role/*` lookup answers it. Order is stable so tokens are
* deterministic: screenshare, developer, moderator, junior, betastudio, then influencer.
*/
function accountRoles(
account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior'> | null
account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior' | 'isInfluencer'> | null,
studio: boolean
): string[] {
const roles = ['screenshare']
if (!account) return roles
if (account.isDeveloper) roles.push('developer')
if (account.isModerator) roles.push('moderator')
if (account.isJunior) roles.push('junior')
if (studio) roles.push('betastudio')
if (account.isInfluencer) roles.push('influencer')
return roles
}
@@ -291,6 +432,226 @@ function accountPlatform(account: Pick<Account, 'platform'>): number {
return account.platform ?? 0
}
/**
* The browser page Studio opens lives on WWW, not on this worker. `ns` is what
* told the editor where WWW is, so the URI has to be built from the same domain
* and the same subdomain override or the tab opens on a host the player doesn't have.
*/
function studioVerificationUri(env: Env, userCode: string): { uri: string; complete: string } {
const www = buildEndpoints(env.DOMAIN, env.SUBDOMAINS).WWW
const uri = `${www}/device`
return { uri, complete: `${uri}?user_code=${encodeURIComponent(userCode)}` }
}
function oauthError(
c: Context<App>,
error: string,
error_description: string,
status: 400 | 500 = 400
) {
return c.json({ error, error_description }, status)
}
/**
* Issue the same token body every other grant returns, for an account that has
* already been authenticated — here, because the player approved a Studio code
* on the website. Studio reads `expires_in` as seconds (`TimeSpan.FromSeconds`).
*/
async function issueSession(
c: Context<App>,
accountId: number,
grantType: string,
/** Extra top-level fields on the token body — `created: true` on an identity signup. */
extra: Record<string, unknown> = {}
) {
const clientIp = c.req.header('cf-connecting-ip') ?? ''
const ban = await resolveBan(c.env.DB, accountId, {
identity: { ip: clientIp },
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
})
if (ban) {
logger.info('token issued to a blocked account', {
accountId,
grantType,
via: ban.via,
bannedAccountId: ban.bannedAccountId,
reportId: ban.ban.id,
})
}
const jwtSecret = await c.env.JWT_SECRET.get()
if (jwtSecret === '') {
logger.error('refusing to issue token: JWT_SECRET is empty')
return oauthError(c, 'server_error', 'token signing is not configured', 500)
}
const roleAccount = await getAccount(c.env.DB, accountId)
if (!roleAccount) return oauthError(c, 'invalid_grant', 'account no longer exists')
const accessToken = await generateToken(
String(accountId),
roleAccount.platformId ?? '',
accountPlatform(roleAccount),
jwtSecret,
accountRoles(roleAccount, await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT)),
accountPrivileges(roleAccount),
GAME_VERSION,
roleAccount.hasPlus === true
)
const refreshToken = await issueRefreshToken(c.env.DB, accountId)
await setLastLoginTime(c.env.DB, accountId, new Date().toISOString())
await setLoginContext(c.env.DB, accountId, { ip: clientIp })
const staffRoles = (['developer', 'moderator'] as const).filter((role) =>
holdsRole(roleAccount, role)
)
if (staffRoles.length > 0) {
try {
await writeAuditLog(c.env.DB, {
playerId: accountId,
action: 'staff_login',
data: {
ip: clientIp,
roles: staffRoles,
grantType,
username: roleAccount.username ?? null,
deviceId: '',
deviceClass: 0,
version: null,
},
})
} catch (err) {
logger.error('could not write a staff_login audit log row', {
accountId,
grantType,
error: err instanceof Error ? err.message : String(err),
})
}
}
return c.json({
access_token: accessToken,
expires_in: TOKEN_TTL_SECONDS,
token_type: 'Bearer',
refresh_token: refreshToken,
scope: TOKEN_SCOPE,
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
...extra,
})
}
/**
* The `identity_assertion` grant: sign into — or create — the account behind a platform
* identity another worker has verified and signed for. See IDENTITY_ASSERTION_GRANT.
*
* Handled before the password fallback and outside the version/rollout gates, like the
* Studio device grant: nothing here posts a `ver`, and the credential is the assertion
* alone. The assertion names an IDENTITY, not an account; which account that is (or that
* there is none yet) is decided here, from the same table `cached_login` reads, so the
* website can never choose an account id for itself.
*
* Creating an account when the identity opens none is deliberate — it is the Discord
* sign-up — and takes the same guards `create_account` does for a verified platform
* identity: the evasion arms (a banned player's next move is a new account), the per-IP
* cap (`www` forwards the browser's real address over the service binding) and the
* per-identity cap. NOT the PASSWORD_SIGNUP switch: that exists because a password account
* has no identity but its address, and this one has a Discord user Discord itself vouched
* for, one account per user. The new account carries no `platform`/`platformId` on its
* blob — only the link — so its DTO and token read like a password account's and the
* Discord snowflake stays off the wire, exactly as the benefits claim keeps it.
*/
async function identityAssertionGrant(c: Context<App>, body: Record<string, unknown>) {
const assertion = typeof body.assertion === 'string' ? body.assertion : ''
const jwtSecret = await c.env.JWT_SECRET.get()
if (jwtSecret === '') {
logger.error('refusing an identity assertion: JWT_SECRET is empty')
return oauthError(c, 'server_error', 'token signing is not configured', 500)
}
const identity = assertion ? await validateIdentityAssertion(assertion, jwtSecret) : null
if (identity === null) {
return oauthError(c, 'invalid_grant', 'invalid or expired identity assertion')
}
const links = await getLinksForIdentity(c.env.DB, identity.platform, identity.platformId)
if (links.length > 1) {
// The benefits claim keeps a Discord identity on one account, so this is a data
// problem rather than a player's choice to make; refused rather than guessed.
logger.error('identity assertion names an identity linked to several accounts', {
platform: identity.platform,
accountIds: links.map((l) => l.accountId),
})
return oauthError(c, 'invalid_grant', AMBIGUOUS_IDENTITY_DESCRIPTION)
}
if (links.length === 1) {
return issueSession(c, links[0].accountId, IDENTITY_ASSERTION_GRANT)
}
// Nobody yet: the sign-up. Everything below mirrors create_account's platform path.
const clientIp = c.req.header('cf-connecting-ip') ?? ''
const blocked = await resolveBan(c.env.DB, null, {
identity: { ip: clientIp, platform: identity.platform, platformId: identity.platformId },
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
})
if (blocked) {
logger.info('identity signup refused: player banned', {
via: blocked.via,
bannedAccountId: blocked.bannedAccountId,
ip: clientIp,
platform: identity.platform,
})
return oauthError(c, 'invalid_grant', BLOCKED_DESCRIPTION)
}
const maxPerPlatformId = intVar(
c.env.MAX_ACCOUNTS_PER_PLATFORM_ID,
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
)
if (
maxPerPlatformId > 0 &&
(await countAccountsForPlatformIdentity(c.env.DB, identity.platform, identity.platformId)) >=
maxPerPlatformId
) {
logger.info('identity signup rejected: platform account limit', { platform: identity.platform })
return oauthError(c, 'invalid_grant', 'account limit reached for this platform account')
}
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
if (
maxPerIp > 0 &&
clientIp !== '' &&
(await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp
) {
logger.info('identity signup rejected: per-IP account limit', { ip: clientIp })
return oauthError(c, 'invalid_grant', 'too many accounts created from this network')
}
const account = await createAccount(c.env.DB, {
platforms: 0,
lastLoginTime: new Date().toISOString(),
signupIp: clientIp || undefined,
lastLoginIp: clientIp || undefined,
})
await linkPlatformIdentity(c.env.DB, account.accountId, identity.platform, identity.platformId)
await placeNewPlayerInOrientation(c.env, account.accountId, 0)
logger.info('created an account from an identity assertion', {
accountId: account.accountId,
platform: identity.platform,
ip: clientIp,
})
return issueSession(c, account.accountId, IDENTITY_ASSERTION_GRANT, { created: true })
}
async function decideDeviceCode(c: Context<App>, decide: typeof approveDeviceGrant) {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const userCode = typeof body.user_code === 'string' ? body.user_code : ''
const result = await decide(c.env.DB, userCode, id)
if (result.kind === 'ok') return c.json({ ok: true as const })
if (result.kind === 'expired')
return oauthError(c, 'expired_token', 'that code has expired. Start the login in Studio again.')
if (result.kind === 'used') return oauthError(c, 'invalid_grant', 'that code was already used.')
return oauthError(c, 'invalid_grant', 'that code was not found.')
}
/**
* Project a linked account into the client's CachedLogin DTO — the account-picker
* entry on the login screen. The client posts the chosen `accountId` back as a
@@ -597,6 +958,83 @@ const app = new Hono<App>()
}
)
// Studio editor device login. The POST the editor makes is
// `https://auth.<domain>/connect/deviceauthorization` — Auth is service enum 0,
// and the path is the literal `connect/deviceauthorization`. The browser half
// of the handshake is the verification_uri, which points at WWW `/device`.
.post(
'/connect/deviceauthorization',
describeRoute({
tags: ['Token'],
summary: 'Start a Rec Room Studio device login',
description: [
'RFC 8628 device authorization, called by Rec Room Studio (not the game).',
'The editor POSTs `client_id=recroom.studio` and its embedded client secret',
'to the Auth host. The response `verification_uri` is the website (`WWW /device`);',
'`verification_uri_complete` is that page with `?user_code=` and is what Studio',
'opens in the browser. Studio then polls `POST /connect/token` with',
'`grant_type=urn:ietf:params:oauth:grant-type:device_code`.',
].join(' '),
requestBody: form(DeviceAuthorizationRequest, '`client_id` and `client_secret`'),
responses: {
200: json(DeviceAuthorizationResponse, 'Codes and the WWW page to open'),
400: json(OAuthError, 'client_id or client_secret was not the Studio editor'),
},
}),
async (c) => {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
if (!studioClientMatches(body)) {
return oauthError(c, 'invalid_client', 'unknown studio client')
}
const grant = await beginDeviceGrant(c.env.DB)
const { uri, complete } = studioVerificationUri(c.env, grant.userCode)
return c.json({
device_code: grant.deviceCode,
user_code: grant.userCode,
verification_uri: uri,
verification_uri_complete: complete,
expires_in: grant.expiresIn,
interval: grant.interval,
})
}
)
.post(
'/connect/device/approve',
describeRoute({
tags: ['Token'],
summary: 'Approve a Studio device code',
description:
'Called by the website (`WWW /device`) with the signed-in player’s bearer token and the `user_code` Studio is showing. The editor’s next poll of `/connect/token` then receives that account’s token.',
security: [{ bearerAuth: [] }],
requestBody: form(DeviceDecisionRequest, 'The user_code from the editor'),
responses: {
200: json(DeviceDecisionResponse, 'The code is approved'),
400: json(OAuthError, 'Unknown, expired, or already used code'),
401: { description: 'Missing or invalid bearer token (empty body)' },
},
}),
(c) => decideDeviceCode(c, approveDeviceGrant)
)
.post(
'/connect/device/deny',
describeRoute({
tags: ['Token'],
summary: 'Deny a Studio device code',
description:
'Same caller as approve. Studio’s next poll fails with `access_denied` instead of signing in.',
security: [{ bearerAuth: [] }],
requestBody: form(DeviceDecisionRequest, 'The user_code from the editor'),
responses: {
200: json(DeviceDecisionResponse, 'The code is denied'),
400: json(OAuthError, 'Unknown, expired, or already used code'),
401: { description: 'Missing or invalid bearer token (empty body)' },
},
}),
(c) => decideDeviceCode(c, denyDeviceGrant)
)
// OAuth token endpoint — accepts a form-urlencoded body and issues a JWT.
.post(
'/connect/token',
@@ -604,14 +1042,18 @@ const app = new Hono<App>()
tags: ['Token'],
summary: 'OAuth token endpoint — issues a JWT',
description: [
'Issues an access token (plus a single-use refresh token) for one of four grants,',
'Issues an access token (plus a single-use refresh token) for one of six grants,',
'selected by `grant_type`. Every grant returns the same body on success.',
'',
'**`create_account`** — mints a new account with an auto-assigned random username',
'(players do not pick one initially) and places it in the Orientation room. A posted',
'`password` becomes the login credential. Subject to two independent signup caps,',
'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /',
'`MAX_ACCOUNTS_PER_IP`; either disabled by setting it to 0). If it asserts a',
'`password` becomes the login credential. Without a `platform` this is a PASSWORD',
'account (what the website makes), accepted only while `PASSWORD_SIGNUP` is on —',
'off by default, refused as `invalid_grant` "password signup is disabled". Subject',
'to three independent signup caps,',
'per verified platform id, per signup IP and per client `device_id`',
'(`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` / `MAX_ACCOUNTS_PER_DEVICE_ID`;',
'any disabled by setting it to 0). If it asserts a',
'`platform`, that platform must be verifiable (Steam or Meta) and its `platform_auth`',
'must verify.',
'',
@@ -623,6 +1065,17 @@ const app = new Hono<App>()
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
'platform and platform id come from what was stored at issue time, not the body.',
'',
'**`identity_assertion`** — the website’s "Sign in with Discord". `www` completes the',
'Discord OAuth2 code exchange (it alone holds the client secret) and posts an',
'`assertion`: an HS256 token signed with the shared JWT_SECRET naming the platform',
'(`101`) and platform id it proved, good for a minute. The identity is resolved through',
'the same link table as `cached_login`: one linked account is signed into; none means a',
'NEW account is created and linked (the body then carries `created: true`), under the',
'evasion check and the per-IP and per-identity signup caps but NOT the PASSWORD_SIGNUP',
'switch — the Discord user is the identity a password account lacks. The new account',
'has no password until its owner sets one on the website; with one, it signs in',
'anywhere the `password` grant does. Several linked accounts is refused.',
'',
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
'identifies the account by `username` or numeric `account_id` and requires the',
'matching `password`. An account with no stored hash cannot be logged into at all,',
@@ -651,7 +1104,11 @@ const app = new Hono<App>()
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
'powers refresh on every login and every refresh grant. `junior` rides along for an',
'account flagged `isJunior`, and `screenshare` is on every token — it is a feature',
'gate the client reads, not a privilege anyone is granted. A junior also carries',
'gate the client reads, not a privilege anyone is granted. `betastudio` is stamped',
'from the account’s `hasStudio` flag (staff set it on the website) or from a Discord',
'link holding a role in the `STUDIO_ROLLOUT` var. RecFlare Studio',
'treats that claim as permission to upload; `developer` does not confer it. A junior',
'also carries',
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
'client reads it as a claim beside `role`, and it is absent for everyone else.',
'',
@@ -674,6 +1131,23 @@ const app = new Hono<App>()
'refused BEFORE it mints anything: making a NEW account to evade with is a different',
'act from signing into one. The description is deliberately vague — the signup',
'refused may belong to a housemate of the banned player rather than to them.',
'',
'**Support gate.** A grant whose `ver` names a build this server does not serve —',
'outside the operator’s `SUPPORTED_GAME_VERSIONS` (default: the list in `@repo/domain`)',
'and not under rollout — is refused as `invalid_grant` "this game version is not',
'supported by this server; update your game", for every grant and every account.',
'`api`’s `/api/versioncheck/v4` flags the same builds, so dropping a build from the',
'list is how its players are made to upgrade. Grants posting no `ver` are untouched.',
'',
'**Rollout gate.** A grant whose `ver` names a build under staged rollout',
'(`ROLLOUT_GAME_VERSIONS`, by default `20260323` — every point release of it; `off` ends',
'the rollout and opens the build to everyone) is refused as `invalid_grant` "this build is limited to',
'Discord supporters" unless the account is staff (developer or moderator) or its',
'Discord link (made by the website’s benefits claim) holds one of the roles in the',
'operator’s `DISCORD_ROLLOUT_ROLE_IDS`. With none configured the gate is closed to',
'everyone but staff. `create_account` on such a',
'build is refused before it mints anything, since a new account has no Discord link.',
'Grants posting no `ver` (a refresh, the website) and every other build are untouched.',
].join('\n'),
requestBody: form(
TokenRequest,
@@ -704,6 +1178,35 @@ const app = new Hono<App>()
// form body.
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const grantType = typeof body.grant_type === 'string' ? body.grant_type : ''
// Studio's poll. Handled before the password fallback, which would otherwise
// treat this grant_type as a username/password login and 400 it.
if (grantType === DEVICE_CODE_GRANT) {
if (!studioClientMatches(body)) {
return oauthError(c, 'invalid_client', 'unknown studio client')
}
const deviceCode = typeof body.device_code === 'string' ? body.device_code : ''
const polled = await pollDeviceGrant(c.env.DB, deviceCode)
if (polled.kind === 'pending') {
return oauthError(
c,
'authorization_pending',
'the player has not approved this login yet'
)
}
if (polled.kind === 'denied') {
return oauthError(c, 'access_denied', 'the player denied this login')
}
if (polled.kind === 'expired') {
return oauthError(c, 'expired_token', 'the device code expired')
}
if (polled.kind !== 'approved') {
return oauthError(c, 'invalid_grant', 'unknown device code')
}
return issueSession(c, polled.accountId, DEVICE_CODE_GRANT)
}
// The website's Discord sign-in, likewise before the fallback — and before the
// version gates, which it never posts a `ver` for. See identityAssertionGrant.
if (grantType === IDENTITY_ASSERTION_GRANT) return identityAssertionGrant(c, body)
// `platform`/`platform_id` come from the body for a fresh login; a refresh
// grant overrides them below with what was stored when the token was issued.
let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
@@ -727,9 +1230,9 @@ const app = new Hono<App>()
// The client's own build (`ver`, e.g. `20250718.01`), stamped into the token's
// `rn.ver` claim so everything downstream reports the build the player is ACTUALLY
// running rather than this server's GAME_VERSION — `match` reads it back off the
// token when it writes presence. Unverified like the device fields, and only ever
// echoed, never trusted for a decision (the version CHECK is `api`'s
// `/api/versioncheck/v4`, against its own list).
// token when it writes presence. Unverified like the device fields: echoed, and
// gated (below, and the rollout gate further down), but never trusted as a
// credential.
//
// A grant that posts none — a refresh, or a caller that isn't the game — leaves it
// undefined and generateToken falls back to GAME_VERSION. An empty string is
@@ -743,6 +1246,23 @@ const app = new Hono<App>()
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
const clientIp = c.req.header('cf-connecting-ip') ?? ''
// The support gate (see UNSUPPORTED_BUILD_DESCRIPTION): a posted build this server
// doesn't serve gets no token, whatever the grant and whoever the account. Decided
// before any credential is read — it depends on nothing but the operator's list, so
// there is nothing about an account to probe here, and a closed door costs no D1
// round trip. The same list `api`'s `/api/versioncheck/v4` flags for an update.
if (version !== undefined && !isSupportedGameVersion(version, supportedGameBuilds(c.env))) {
logger.info('token refused: game version is not supported', {
grantType,
version,
ip: clientIp,
})
return c.json(
{ error: 'invalid_grant', error_description: UNSUPPORTED_BUILD_DESCRIPTION },
400
)
}
// A platform-authenticated login proves who you are with the platform itself, and
// we can verify exactly two: Steam (0), from its Steam-signed platform_auth ticket,
// and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth (see
@@ -753,7 +1273,7 @@ const app = new Hono<App>()
// - cached_login authenticates purely by platform identity.
// - create_account that asserts a platform: we won't bind an identity we can't
// prove. (create_account with NO platform is the password-account path —
// allowed, but binds no platformId.)
// binds no platformId, and is open only while PASSWORD_SIGNUP says so.)
//
// A password grant is NOT gated: the password already proved who it is. It posts
// its platform proof too, and if that verifies we LINK the identity to the account
@@ -842,6 +1362,32 @@ const app = new Hono<App>()
// via create_account or /account/me/changepassword.
let accountId: string
if (grantType === 'create_account') {
// A rollout-gated build can't sign UP: the account it would mint has no Discord
// link, so it could never hold the role the gate wants. Refused before anything
// is read or minted, like the switch below — no row left behind, no cap slot
// burned.
if (isRolloutGameVersion(version, rolloutGameBuilds(c.env))) {
logger.info('signup refused: build is limited to Discord supporters', {
version,
ip: clientIp,
})
return c.json(
{ error: 'invalid_grant', error_description: ROLLOUT_GATED_DESCRIPTION },
400
)
}
// The password-account path (no platform) is a switch, off by default: see
// DEFAULT_PASSWORD_SIGNUP. Checked first, before anything is read or minted —
// a closed door costs no D1 round trip and burns no cap slot.
if (!platformAsserted && !flagVar(c.env.PASSWORD_SIGNUP, DEFAULT_PASSWORD_SIGNUP)) {
logger.info('signup refused: password signup is disabled', { ip: clientIp })
return c.json(
{ error: 'invalid_grant', error_description: PASSWORD_SIGNUP_DISABLED_DESCRIPTION },
400
)
}
// A banned player's next move is a new account, so the evasion arms are checked
// BEFORE one is minted — against the only identity a signup has, the IP it came
// from and the platform identity it just proved. Refusing after the fact (as the
@@ -873,7 +1419,7 @@ const app = new Hono<App>()
// Signup caps. Checked before minting anything, so a rejected signup leaves no
// account behind. Each arm is skipped when it's disabled (var <= 0) or when its
// identity is unknown (no verified platform id / no client IP) — an unattributable
// identity is unknown (no verified platform id / no client IP / no device id) — an unattributable
// signup can't be counted against anyone, and lumping them together would lock out
// real players. The disabled check comes first so a disabled arm costs no D1 read.
const maxPerPlatformId = intVar(
@@ -881,6 +1427,10 @@ const app = new Hono<App>()
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
)
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
const maxPerDeviceId = intVar(
c.env.MAX_ACCOUNTS_PER_DEVICE_ID,
DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID
)
if (
maxPerPlatformId > 0 &&
verifiedPlatformId !== null &&
@@ -915,6 +1465,20 @@ const app = new Hono<App>()
400
)
}
if (
maxPerDeviceId > 0 &&
deviceId !== '' &&
(await countAccountsByDeviceId(c.env.DB, deviceId)) >= maxPerDeviceId
) {
logger.info('signup rejected: per-device account limit', { deviceId, ip: clientIp })
return c.json(
{
error: 'invalid_grant',
error_description: 'too many accounts created from this device',
},
400
)
}
// Bind the platform identity ONLY when the platform proved it (a Steam ticket or
// a Meta-validated nonce). A password/anonymous create_account (no platform)
@@ -1040,6 +1604,33 @@ const app = new Hono<App>()
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
}
// The rollout gate: a build under staged rollout (`rolloutGameBuilds`) is for staff
// and Discord supporters only, so the account signing in on it must be staff or hold
// one of the configured roles.
// Deliberately AFTER the credential checks, like the ban lookup below — a wrong
// password on a gated build is still "invalid account_id or password", so the
// gate can't be used to probe whether an account exists. Unlike a ban, this IS a
// refusal: there is no block screen to reach, and the client surfaces the
// description on its sign-in failure.
if (isRolloutGameVersion(version, rolloutGameBuilds(c.env))) {
const roleIds = rolloutRoleIds(c.env.DISCORD_ROLLOUT_ROLE_IDS)
if (!(await accountMayUseRolloutBuild(c.env.DB, Number(accountId), roleIds))) {
logger.info('login refused: build is limited to Discord supporters', {
accountId,
grantType,
version,
// The COUNT, not the ids: zero here means the gate is closed because
// nothing is configured, which reads very differently from "not a
// supporter".
roleIdCount: roleIds.length,
})
return c.json(
{ error: 'invalid_grant', error_description: ROLLOUT_GATED_DESCRIPTION },
400
)
}
}
// EVERY account gets its token, banned or not — a ban of its own, or a link to
// somebody else's through the evasion arms. The client needs one to reach `api`'s
// moderationBlockDetails, which is where a banned player is TOLD they are banned
@@ -1109,7 +1700,10 @@ const app = new Hono<App>()
platformId,
platform,
jwtSecret,
accountRoles(roleAccount),
accountRoles(
roleAccount,
await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT)
),
accountPrivileges(roleAccount),
version,
// Rec Room Plus, off the same account read as the roles above — `econ` decides the
@@ -1295,6 +1889,11 @@ const app = new Hono<App>()
roleFilterAnswer(c, 'moderator')
)
// Studio queries each room owner's screenshare role before validating video players.
.get('/role/screenshare/:id', describeRoute(roleLookup('screenshare')), (c) =>
roleAnswer(c, 'screenshare', c.req.param('id'))
)
// @guess Oculus nonce. The client asks for this before a Meta login; the exact shape
// it expects hasn't been observed, so this mints a fresh 64-char hex nonce (the length
// Meta's own `GetUserProof` nonces have) and answers it as a bare JSON string. Nothing
@@ -1352,4 +1951,4 @@ app.get(
)
)
export default withDatabase<Env>(app)
export default app
+68
View File
@@ -26,6 +26,17 @@ export type Env = SharedHonoEnv & {
// read them through `intVar`, never as a bare number.
MAX_ACCOUNTS_PER_PLATFORM_ID?: string | number
MAX_ACCOUNTS_PER_IP?: string | number
MAX_ACCOUNTS_PER_DEVICE_ID?: string | number
/**
* Whether `create_account` accepts a signup with NO platform identity — the password
* account the website makes (`www` `/api/signup`), or anything else that posts the
* grant without a `platform`. `on`/`off` (or true/false, 1/0); unset means OFF, so a
* server opens web signup deliberately. Read through `flagVar`. The `www` worker reads
* the same knob to hide its form and refuse its endpoint, so the two agree for free.
* Platform signups (Steam, Meta) are never governed by this — they always have an
* identity to authenticate with later, and the caps above are what limit them.
*/
PASSWORD_SIGNUP?: string | number | boolean
/**
* Which linked arms a ban is enforced through, as a comma-separated list out of `ip`
* and `platform` — or `off` for neither. Unset means BOTH: a ban reaches the accounts
@@ -38,6 +49,63 @@ export type Env = SharedHonoEnv & {
* Read through `banEvasionMatch`; the `match` worker reads the same knob.
*/
BAN_EVASION_MATCH?: string
/**
* The Discord role ids that may sign in on a ROLLOUT-GATED client build (see
* `ROLLOUT_GAME_VERSIONS` in @repo/domain) — numeric snowflakes, separated by commas
* and/or whitespace, exactly like `www`'s DISCORD_BENEFITS_ROLE_IDS. ANY one of them
* qualifies. A login whose posted `ver` is a gated build is refused unless the account's
* Discord link (`platform_account`, platform 101, written by the website's benefits
* claim) records one of these roles — or the account is STAFF (developer or moderator),
* who are always let in, configured roles or not.
*
* Unset — or parsing to no ids — leaves the gate CLOSED: nobody but staff gets a token on
* a gated build. That is the safe reading of "supporters only" when no supporter role has been
* named, and it keeps a build that isn't ready from being reachable by accident.
* Logins on every other build are never touched by this.
*/
DISCORD_ROLLOUT_ROLE_IDS?: string
/**
* The Discord role ids whose holders get Rec Room Studio upload access — stamped into the
* token as `betastudio` exactly as the account's `hasStudio` flag is, so a whole Discord
* role can be let into Studio without touching anyone's account. Same grammar as
* DISCORD_ROLLOUT_ROLE_IDS above (snowflakes, commas and/or whitespace; ANY one qualifies),
* read from the same Discord link snapshot. Set for every worker from the root `.env`
* (`RECFLARE_STUDIO_ROLLOUT`): `studio` and `www` read it through the same
* `hasStudioAccess`, so what the token says, what the editor's access lookup says and
* what the website's `/api/studio-access` says agree. Unset, or naming no role, grants
* nobody — the flag alone decides. The flag still works beside it.
*/
STUDIO_ROLLOUT?: string
/**
* The client builds a `/connect/token` grant may post as `ver` and still get a token:
* a comma-separated list of builds (`20230414,20250718`), or `off` for none. Unset means
* the default `SUPPORTED_GAME_VERSIONS` in @repo/domain. Set for every worker from the
* root `.env` (`RECFLARE_SUPPORTED_GAME_VERSIONS`), so this refuses exactly the builds
* `api`'s `/api/versioncheck/v4` tells to update — dropping a build from the list is how
* its players are made to upgrade. Read through `supportedGameBuilds`, which also folds
* in every build under rollout (below). A grant posting no `ver` is never gated.
*/
SUPPORTED_GAME_VERSIONS?: string
/**
* The builds under staged rollout — the ones the Discord-role gate above applies to —
* overriding `ROLLOUT_GAME_VERSIONS` in @repo/domain: same format, or `off` to end the
* rollout and open those builds to every account. Set for every worker
* (`RECFLARE_ROLLOUT_GAME_VERSIONS`), so `match`'s rollout voice pool follows the same
* list. A build here is served whether or not `SUPPORTED_GAME_VERSIONS` names it. Read
* through `rolloutGameBuilds`.
*/
ROLLOUT_GAME_VERSIONS?: string
/**
* Base domain, e.g. `recflare.net`. Injected for every worker at deploy time
* (`--var DOMAIN`). Studio's device-login response points the browser at
* `www.<domain>/device`, so auth has to know the same domain `ns` advertises.
*/
DOMAIN: string
/**
* The same `SUBDOMAINS` JSON `ns` applies. A `www` override has to move the
* verification URI with the host the editor was told WWW lives on.
*/
SUBDOMAINS: string
}
/** Variables can be extended */
+74 -9
View File
@@ -105,7 +105,16 @@ export const FakeCachedLogin = CachedLogin.extend({
/** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */
export const OAuthError = z.object({
error: z.enum(['invalid_grant', 'invalid_request', 'server_error']),
error: z.enum([
'invalid_grant',
'invalid_request',
'invalid_client',
'server_error',
// RFC 8628. Studio polls until it sees something other than authorization_pending.
'authorization_pending',
'expired_token',
'access_denied',
]),
error_description: z.string(),
})
@@ -128,8 +137,19 @@ export const TokenResponse = z.object({
*/
export const TokenRequest = z.object({
grant_type: z
.enum(['create_account', 'cached_login', 'refresh_token', 'password'])
.describe('Anything unrecognised (including absent) is treated as a password grant'),
.enum([
'create_account',
'cached_login',
'refresh_token',
'password',
'urn:ietf:params:oauth:grant-type:device_code',
'identity_assertion',
])
.describe(
'Anything unrecognised (including absent) is treated as a password grant, except the ' +
'Studio device-code URN, which polls a code from POST /connect/deviceauthorization, ' +
'and identity_assertion, the website’s Discord sign-in'
),
account_id: z.string().optional().describe('Numeric account id, as a string'),
username: z
.string()
@@ -156,6 +176,22 @@ export const TokenRequest = z.object({
'Meta: `{"Nonce":…,"AppId":…,"Source":…}`'
),
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
client_id: z.string().optional().describe('Studio device grant: `recroom.studio`'),
client_secret: z
.string()
.optional()
.describe('Studio device grant: the secret embedded in the editor'),
device_code: z
.string()
.optional()
.describe('Studio device-code grant: the device_code from /connect/deviceauthorization'),
assertion: z
.string()
.optional()
.describe(
'identity_assertion grant: the HS256 assertion `www` minted with the shared JWT_SECRET ' +
'after completing a Discord OAuth2 exchange, naming the platform and platform_id it proved'
),
device_id: z
.string()
.optional()
@@ -171,6 +207,33 @@ export const TokenRequest = z.object({
),
})
/** `POST /connect/deviceauthorization` form body. Studio's editor sends exactly these two. */
export const DeviceAuthorizationRequest = z.object({
client_id: z.string().describe('`recroom.studio`'),
client_secret: z.string().describe('The secret compiled into Rec Room Studio'),
})
/** RFC 8628 response. Property names match Studio's `DeviceAuthorizationResponse`. */
export const DeviceAuthorizationResponse = z.object({
device_code: z.string(),
user_code: z.string().describe('Shown in the editor; also on verification_uri_complete'),
verification_uri: z.string().describe('WWW `/device`, without the code'),
verification_uri_complete: z
.string()
.describe('The URL Studio opens in the browser, `verification_uri?user_code=`'),
expires_in: z.int().describe('Seconds until the code dies. Studio treats this as seconds'),
interval: z.int().describe('Seconds Studio waits between polls'),
})
/** `POST /connect/device/approve` and `/connect/device/deny` form body. */
export const DeviceDecisionRequest = z.object({
user_code: z.string().describe('The code Studio is showing, spaces and hyphens ignored'),
})
export const DeviceDecisionResponse = z.object({
ok: z.literal(true),
})
/** `POST /account/me/changepassword` form body. */
export const ChangePasswordRequest = z.object({
newPassword: z.string().describe('Required; empty is rejected'),
@@ -232,16 +295,18 @@ export const RestrictionDto = z.object({
DisplayReason: z.string().nullable().describe('Reason shown to the player. Free text'),
})
export function roleLookup(role: 'developer' | 'moderator') {
export function roleLookup(role: 'developer' | 'moderator' | 'screenshare') {
return {
tags: ['Roles'],
summary: `Whether a player has the ${role} role`,
description:
`Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` +
`granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` +
`in the access token's \`role\` claim, so the client rarely needs this route.\n\n` +
`This is the ONE-player form. \`/role/${role}?id=\` is a different question with a ` +
`different answer shape — which of several players hold the role, as an array.`,
role === 'screenshare'
? 'Returns a bare JSON boolean. Screenshare is enabled for every existing account, matching the access-token role policy; unknown accounts return 404. Studio uses this to validate room-owner video permissions.'
: `Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` +
`granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` +
`in the access token's \`role\` claim, so the client rarely needs this route.\n\n` +
`This is the ONE-player form. \`/role/${role}?id=\` is a different question with a ` +
`different answer shape — which of several players hold the role, as an array.`,
parameters: [
{
name: 'id',
+141
View File
@@ -314,6 +314,147 @@ export async function getLinksForPlatformId(
return results.map(toLink)
}
/**
* Every account a platform identity is linked to, on ANY platform — including the ones
* {@link CACHED_LOGIN_PLATFORMS} keeps out of the picker.
*
* This is the unfiltered twin of {@link getLinksForPlatformIdentity}, and it must stay off
* every public route: the filter there exists so nobody can learn which account a Discord
* user owns by naming their snowflake. The one caller is `auth`'s `identity_assertion`
* grant, where the identity being asked about has just been PROVEN by the worker that
* minted the assertion — the website's Discord sign-in — so the answer goes to the person
* it is about and nobody else. Add a caller only under the same condition.
*/
export async function getLinksForIdentity(
db: D1Database,
platform: number,
platformId: string
): Promise<PlatformLink[]> {
if (platformId === '') return []
const { results } = await db
.prepare(
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
)
.bind(platform, platformId)
.all<LinkRow>()
return results.map(toLink)
}
/**
* Whether any of the account's links on `platform` has roles RECORDED — a non-null `role`
* column, which only the website's benefits claim (and the sweep behind it) ever writes.
*
* This is what "has this account claimed its Discord benefits" means now that a Discord
* link can exist WITHOUT a claim: signing in with Discord (`auth`'s `identity_assertion`
* grant) links the identity on a brand-new account with `role` NULL. The claim's one-time
* gift and its page both ask this rather than "is there a link", so a player who made
* their account from Discord is still owed, and still shown, their first claim.
* An EMPTY role list counts as recorded: that is a claim that found no roles.
*/
export async function hasRecordedRoles(
db: D1Database,
accountId: number,
platform: number
): Promise<boolean> {
const row = await db
.prepare(
`SELECT 1 AS ok FROM platform_account
WHERE account_id = ?1 AND platform = ?2 AND role IS NOT NULL LIMIT 1`
)
.bind(accountId, platform)
.first<{ ok: number }>()
return row !== null
}
/**
* Record roles on an existing link that has NONE yet (`role` NULL), atomically. True when
* this call was the one that did it; false when the link is missing or already carries a
* role list — in which case nothing is written, and {@link linkPlatformIdentity}'s refresh
* is the caller's next step.
*
* The one-shot flip is the point: the benefits claim pays its first-claim gift on the
* INSERT of a fresh link, which two racing claims cannot both win. A link made by a Discord
* sign-in is already inserted by the time the claim comes, so this gives the claim the
* same single-winner write on the roles column instead.
*/
export async function recordFirstRoles(
db: D1Database,
accountId: number,
platform: number,
platformId: string,
roles: readonly string[]
): Promise<boolean> {
if (platformId === '') return false
const res = await db
.prepare(
`UPDATE platform_account SET role = ?4
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3 AND role IS NULL`
)
.bind(accountId, platform, platformId, JSON.stringify([...roles]))
.run()
return res.meta.changes > 0
}
/**
* A list of Discord role ids out of an operator var — numeric snowflakes separated by
* commas and/or whitespace, the grammar every `*_ROLE_IDS`-shaped var here shares
* (`auth`'s DISCORD_ROLLOUT_ROLE_IDS, `STUDIO_ROLLOUT`; `www` reads
* DISCORD_BENEFITS_ROLE_IDS the same way). Unset or blank parses to an empty list.
*/
export function parseRoleIdList(raw: string | undefined): string[] {
return (raw ?? '')
.split(/[\s,]+/)
.map((id) => id.trim())
.filter((id) => id !== '')
}
/**
* Whether an account's Discord link records ANY of `roleIds`. False with none configured —
* the link read is skipped, so a var that names no role grants nobody anything. Roles are
* the SNAPSHOT the website's benefits claim and `www`'s daily sweep keep (see
* {@link PlatformLink.roles}), so a role granted in Discord today counts once the player
* re-claims or the sweep runs — not instantly.
*/
export async function accountHoldsDiscordRole(
db: D1Database,
accountId: number,
roleIds: readonly string[]
): Promise<boolean> {
if (roleIds.length === 0) return false
const links = await getLinksForAccount(db, accountId)
return links.some(
(link) => link.platform === PlatformType.Discord && link.roles.some((r) => roleIds.includes(r))
)
}
/**
* Whether an account has Rec Room Studio upload access — what `auth` stamps into the
* token as the `betastudio` role, `studio` answers on its access lookups and `www`'s
* `/api/studio-access` reports. Two grants, either suffices:
*
* - the account's own `hasStudio` flag, set by staff on the website or
* `runx admin grant-studio`;
* - the STUDIO ROLLOUT: a Discord link holding a role named in the `STUDIO_ROLLOUT` var
* (`RECFLARE_STUDIO_ROLLOUT` in the root `.env`, reaching every worker), which hands a
* whole Discord role the same access without touching anyone's account. Unset, or
* naming no role, it grants nobody — the flag alone decides, as before.
*
* Every reader goes through this so the three surfaces cannot disagree: a role holder
* whom `auth` stamps `betastudio` for must also be told "granted" by the website, and
* `studio`'s lookups (which rebuild the role from the account rather than a token) must
* see the same answer. The flag is checked first so an account that has it costs no
* link read.
*/
export async function hasStudioAccess(
db: D1Database,
account: { accountId: number; hasStudio?: boolean } | null | undefined,
studioRolloutRoleIds: string | undefined
): Promise<boolean> {
if (!account) return false
if (account.hasStudio === true) return true
return accountHoldsDiscordRole(db, account.accountId, parseRoleIdList(studioRolloutRoleIds))
}
/** Every platform identity linked to an account (a player's PC and headset, say). */
export async function getLinksForAccount(
db: D1Database,
+211
View File
@@ -0,0 +1,211 @@
/**
* Rec Room Studio's device-login handshake.
*
* The editor (RecRoom.Studio.Editor.Core, `MKLLKHBNNLP`) POSTs
* `connect/deviceauthorization` to the **Auth** service — service enum value 0,
* the host `ns` advertises as `Auth` — with `client_id=recroom.studio` and the
* client secret embedded in that assembly. It then opens `verification_uri_complete`
* in the browser and polls `POST connect/token` on the same Auth host with
* `grant_type=urn:ietf:params:oauth:grant-type:device_code` until the code is
* approved or `expires_in` seconds pass.
*
* `GET account/me` after a successful poll is a different service: enum 11,
* **Accounts**. This module does not serve that.
*
* The page the editor opens is **WWW** `/device`. Auth only mints the codes and,
* once that page has approved one, the token.
*/
/** What Studio posts as `client_id` on the device grant. */
export const STUDIO_CLIENT_ID = 'recroom.studio'
/**
* The matching `client_secret` compiled into the Studio editor. It is not a
* server secret — anyone with the editor binary has it — but checking it keeps
* a random POST from minting codes.
*/
export const STUDIO_CLIENT_SECRET = '54oc5C4BH7jpqp42KhChkNZOLP0U3yTx'
/** `grant_type` Studio polls with. RFC 8628. */
export const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
/** How long the player has to approve, in seconds. Studio reads this as seconds. */
export const DEVICE_TTL_SECONDS = 10 * 60
/** Minimum poll spacing Studio is told to use, in seconds. */
export const DEVICE_POLL_INTERVAL_SECONDS = 5
/** Unambiguous alphabet. Studio pretty-prints a code only when it is exactly 6 chars. */
const USER_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
const USER_CODE_LENGTH = 8
export const STUDIO_DEVICE_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS device_grant (
device_code_hash TEXT PRIMARY KEY,
user_code TEXT NOT NULL UNIQUE,
account_id INTEGER,
status TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)`,
]
type GrantStatus = 'pending' | 'approved' | 'denied' | 'consumed'
export interface DeviceGrant {
userCode: string
deviceCode: string
expiresIn: number
interval: number
}
export type DevicePoll =
| { kind: 'pending' }
| { kind: 'denied' }
| { kind: 'expired' }
| { kind: 'invalid' }
| { kind: 'approved'; accountId: number }
interface GrantRow {
account_id: number | null
status: GrantStatus
expires_at: number
}
function nowSeconds(): number {
return Math.floor(Date.now() / 1000)
}
async function sha256(value: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
}
function randomUserCode(): string {
const bytes = crypto.getRandomValues(new Uint8Array(USER_CODE_LENGTH))
let code = ''
for (const byte of bytes) code += USER_CODE_ALPHABET[byte % USER_CODE_ALPHABET.length]
return code
}
function randomDeviceCode(): string {
const bytes = crypto.getRandomValues(new Uint8Array(32))
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')
}
/** Strip spaces and hyphens and uppercase, so a code typed as Studio displays it still matches. */
export function normalizeUserCode(raw: string): string {
return raw.replace(/[\s-]/g, '').toUpperCase()
}
export function studioClientMatches(body: Record<string, unknown>): boolean {
return body.client_id === STUDIO_CLIENT_ID && body.client_secret === STUDIO_CLIENT_SECRET
}
export async function beginDeviceGrant(db: D1Database): Promise<DeviceGrant> {
const now = nowSeconds()
const deviceCode = randomDeviceCode()
const deviceCodeHash = await sha256(deviceCode)
for (let attempt = 0; attempt < 5; attempt++) {
const userCode = randomUserCode()
try {
await db
.prepare(
`INSERT INTO device_grant
(device_code_hash, user_code, account_id, status, created_at, expires_at)
VALUES (?1, ?2, NULL, 'pending', ?3, ?4)`
)
.bind(deviceCodeHash, userCode, now, now + DEVICE_TTL_SECONDS)
.run()
return {
userCode,
deviceCode,
expiresIn: DEVICE_TTL_SECONDS,
interval: DEVICE_POLL_INTERVAL_SECONDS,
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (!message.includes('UNIQUE')) throw err
}
}
throw new Error('could not allocate a studio device code')
}
async function readByHash(db: D1Database, deviceCode: string): Promise<GrantRow | null> {
return db
.prepare(`SELECT account_id, status, expires_at FROM device_grant WHERE device_code_hash = ?1`)
.bind(await sha256(deviceCode))
.first<GrantRow>()
}
/**
* One poll from Studio. An approved code is consumed exactly once: two polls
* racing each other cannot both walk away with a session.
*/
export async function pollDeviceGrant(db: D1Database, deviceCode: string): Promise<DevicePoll> {
const row = await readByHash(db, deviceCode)
if (!row) return { kind: 'invalid' }
if (row.expires_at <= nowSeconds()) return { kind: 'expired' }
if (row.status === 'pending') return { kind: 'pending' }
if (row.status === 'denied') return { kind: 'denied' }
if (row.status !== 'approved' || row.account_id == null) return { kind: 'invalid' }
const consumed = await db
.prepare(
`UPDATE device_grant SET status = 'consumed'
WHERE device_code_hash = ?1 AND status = 'approved' AND expires_at > ?2`
)
.bind(await sha256(deviceCode), nowSeconds())
.run()
if ((consumed.meta.changes ?? 0) < 1) return { kind: 'invalid' }
return { kind: 'approved', accountId: row.account_id }
}
export type DeviceDecision =
{ kind: 'ok' } | { kind: 'missing' } | { kind: 'expired' } | { kind: 'used' }
async function decide(
db: D1Database,
userCode: string,
accountId: number,
status: 'approved' | 'denied'
): Promise<DeviceDecision> {
const code = normalizeUserCode(userCode)
if (code === '') return { kind: 'missing' }
const updated = await db
.prepare(
`UPDATE device_grant
SET status = ?2, account_id = ?3
WHERE user_code = ?1 AND status = 'pending' AND expires_at > ?4`
)
.bind(code, status, accountId, nowSeconds())
.run()
if ((updated.meta.changes ?? 0) > 0) return { kind: 'ok' }
const row = await db
.prepare(`SELECT status, expires_at, account_id FROM device_grant WHERE user_code = ?1`)
.bind(code)
.first<{ status: GrantStatus; expires_at: number; account_id: number | null }>()
if (!row) return { kind: 'missing' }
if (row.expires_at <= nowSeconds()) return { kind: 'expired' }
// Approving again from the same account is the player double-clicking Allow.
if (status === 'approved' && row.status === 'approved' && row.account_id === accountId)
return { kind: 'ok' }
return { kind: 'used' }
}
export function approveDeviceGrant(
db: D1Database,
userCode: string,
accountId: number
): Promise<DeviceDecision> {
return decide(db, userCode, accountId, 'approved')
}
export function denyDeviceGrant(
db: D1Database,
userCode: string,
accountId: number
): Promise<DeviceDecision> {
return decide(db, userCode, accountId, 'denied')
}
+824 -1
View File
@@ -7,6 +7,7 @@ import '../../auth.app'
import {
AUDIT_LOG_SCHEMA_DDL,
GAME_VERSION,
getAccount,
getAccountsByDeviceId,
hashPassword,
PRESENCE_SCHEMA_DDL,
@@ -14,9 +15,16 @@ import {
SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
updateAccount,
} from '@repo/domain'
import { TOKEN_TTL_SECONDS } from '@repo/jwt'
import {
generateIdentityAssertion,
generateToken,
TOKEN_TTL_SECONDS,
validateIdentityAssertion,
} from '@repo/jwt'
import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db'
import {
banFromReport,
createReport,
@@ -31,6 +39,11 @@ import {
PLATFORM_SCHEMA_DDL,
} from '../../platform-db'
import { consumeRefreshToken, issueRefreshToken, REFRESH_SCHEMA_DDL } from '../../refresh-db'
import {
STUDIO_CLIENT_ID,
STUDIO_CLIENT_SECRET,
STUDIO_DEVICE_SCHEMA_DDL,
} from '../../studio-device'
import type { Env } from '../../context'
@@ -96,6 +109,10 @@ beforeAll(async () => {
// Report table (owned by the api worker) — a ban is a report row with `banned` set;
// the token grant reads it for the evasion arms.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Custom avatar items (owned by the api worker) — every room read joins the published
// room badges from this table.
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of STUDIO_DEVICE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
/**
@@ -681,6 +698,8 @@ describe('auth worker routes', () => {
expect(payload.role).not.toContain('developer')
expect(payload.role).not.toContain('moderator')
expect(payload.role).not.toContain('junior')
// Studio upload access is a whitelist, not a default role.
expect(payload.role).not.toContain('betastudio')
// No privileges to carry, so the claim is absent rather than an empty array.
expect(payload['rn.privilege']).toBeUndefined()
// Same for Plus: omitted rather than `false`, so a non-subscriber's token is
@@ -722,6 +741,138 @@ describe('auth worker routes', () => {
.run()
const payload = await tokenFor(`account_id=91&password=${LOGIN_PASSWORD}`)
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator']))
// developer is a different grant. It does not set the account's hasStudio flag.
expect(payload.role).not.toContain('betastudio')
})
// RecFlare Studio treats the role claim `betastudio` as permission to upload.
// It is stamped from the account's hasStudio flag on every mint path — password,
// refresh, and the Studio device grant — and a refresh after a revoke drops it. A
// token that kept the role for the rest of the day would leave a removed player
// uploading.
test('POST /connect/token stamps betastudio only while the account has hasStudio', async () => {
// 9811/9812, not 9601–9605: those ids belong to the staff-login audit tests,
// which insert with OR IGNORE and then count `staff_login` rows.
await seedAccount(9811, 'StudioPlayer')
expect((await tokenFor(`account_id=9811&password=${LOGIN_PASSWORD}`)).role).not.toContain(
'betastudio'
)
await updateAccount(env.DB, 9811, { hasStudio: true })
expect((await tokenFor(`account_id=9811&password=${LOGIN_PASSWORD}`)).role).toEqual([
'gameClient',
'screenshare',
'betastudio',
])
// developer and moderator stay in front of betastudio, so the claim order is stable.
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 9812,
username: 'StudioStaff',
passwordHash: await hashPassword(LOGIN_PASSWORD),
isDeveloper: true,
isModerator: true,
hasStudio: true,
})
)
.run()
expect((await tokenFor(`account_id=9812&password=${LOGIN_PASSWORD}`)).role).toEqual([
'gameClient',
'screenshare',
'developer',
'moderator',
'betastudio',
])
const client = `client_id=${STUDIO_CLIENT_ID}&client_secret=${STUDIO_CLIENT_SECRET}`
const started = await exports.default.fetch(`${ORIGIN}/connect/deviceauthorization`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: client,
})
expect(started.status).toBe(200)
const codes = (await started.json()) as { device_code: string; user_code: string }
const session = await postToken(
`grant_type=password&username=StudioPlayer&password=${LOGIN_PASSWORD}`
)
expect(session.status).toBe(200)
const approved = await exports.default.fetch(`${ORIGIN}/connect/device/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${session.json.access_token as string}`,
},
body: `user_code=${codes.user_code}`,
})
expect(approved.status).toBe(200)
const signedIn = await postToken(
`${client}&grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:device_code')}&device_code=${codes.device_code}`
)
expect(signedIn.status).toBe(200)
expect(decodePayload(signedIn.json.access_token as string).role).toEqual([
'gameClient',
'screenshare',
'betastudio',
])
// Revoke between issue and refresh. The refresh re-reads the account.
const login = await postToken(`account_id=9811&password=${LOGIN_PASSWORD}`)
await updateAccount(env.DB, 9811, { hasStudio: false })
await updateAccount(env.DB, 9812, { hasStudio: false })
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(login.json.refresh_token as string)}`
)
expect(refreshed.status).toBe(200)
expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio')
})
// STUDIO_ROLLOUT opens Studio to a Discord ROLE: a holder is stamped `betastudio` exactly
// as a flagged account is, with no account flag involved, from the same Discord link
// snapshot the build rollout gate reads. Unset, the flag alone decides.
test('POST /connect/token stamps betastudio for a Discord role in STUDIO_ROLLOUT', async () => {
const studioRole = '1077000000000000031'
const otherRole = '1077000000000000032'
await seedAccount(9813, 'StudioRoleHolder')
await linkPlatformIdentity(env.DB, 9813, PlatformType.Discord, '500000000000009813', [
studioRole,
])
await seedAccount(9814, 'StudioRoleOutsider')
await linkPlatformIdentity(env.DB, 9814, PlatformType.Discord, '500000000000009814', [
otherRole,
])
const login = (id: number) => tokenFor(`account_id=${id}&password=${LOGIN_PASSWORD}`)
const original = env.STUDIO_ROLLOUT
try {
// Unset, or naming no role: the link grants nothing.
delete env.STUDIO_ROLLOUT
expect((await login(9813)).role).not.toContain('betastudio')
env.STUDIO_ROLLOUT = ' , '
expect((await login(9813)).role).not.toContain('betastudio')
// Same list grammar as DISCORD_ROLLOUT_ROLE_IDS; any one named role qualifies.
env.STUDIO_ROLLOUT = ` ${otherRole}x ,\n${studioRole} `
expect((await login(9813)).role).toEqual(['gameClient', 'screenshare', 'betastudio'])
// A Discord link holding only other roles is not in the rollout.
expect((await login(9814)).role).not.toContain('betastudio')
// The account flag never moved: this is the var, not a grant written anywhere.
expect((await getAccount(env.DB, 9813))?.hasStudio).toBeUndefined()
// The refresh grant re-resolves it, so ending the rollout drops the claim on the
// next refresh — the same path a revoked flag takes.
const session = await postToken(`account_id=9813&password=${LOGIN_PASSWORD}`)
delete env.STUDIO_ROLLOUT
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(session.json.refresh_token as string)}`
)
expect(refreshed.status).toBe(200)
expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio')
} finally {
if (original === undefined) delete env.STUDIO_ROLLOUT
else env.STUDIO_ROLLOUT = original
}
})
// Rec Room Plus rides on the token as `rn.plus`, stamped from `account.hasPlus` — which
@@ -801,6 +952,29 @@ describe('auth worker routes', () => {
expect(payload.scope).not.toContain('rn.privilege')
})
test('POST /connect/token stamps the influencer role for an isInfluencer account', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 95,
username: 'InfluencerPlayer',
passwordHash: await hashPassword(LOGIN_PASSWORD),
isInfluencer: true,
})
)
.run()
const payload = await tokenFor(`account_id=95&password=${LOGIN_PASSWORD}`)
expect(payload.role).toEqual(
expect.arrayContaining(['gameClient', 'screenshare', 'influencer'])
)
expect(payload.role).not.toContain('developer')
// Off the flag, off the token — no other flag implies it.
await updateAccount(env.DB, 95, { isInfluencer: false })
const revoked = await tokenFor(`account_id=95&password=${LOGIN_PASSWORD}`)
expect(revoked.role).not.toContain('influencer')
})
test('POST /connect/token 400s when no account_id is posted (never defaults to 1)', async () => {
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, { method: 'POST' })
expect(res.status).toBe(400)
@@ -1035,6 +1209,405 @@ describe('auth worker routes', () => {
expect(other.status).toBe(200)
})
test('POST /connect/token caps the accounts created from one device', async () => {
// One install, many networks: the VPN-hopping signup the IP cap can't see. Each
// signup posts a fresh IP so only the device arm can be what refuses it.
const deviceId = 'dev-capped-install'
for (let i = 0; i < 3; i++) {
const ok = await postToken(
`grant_type=create_account&platform_id=steam-devcap${i}&device_id=${deviceId}&device_class=2`,
`198.51.100.${40 + i}`
)
expect(ok.status).toBe(200)
}
// The 4th signup from that device is refused — the cap is 3.
const capped = await postToken(
`grant_type=create_account&platform_id=steam-devcap3&device_id=${deviceId}&device_class=2`,
'198.51.100.43'
)
expect(capped.status).toBe(400)
expect(capped.json.error).toBe('invalid_grant')
expect(capped.json.error_description).toMatch(/device/)
// Another device is unaffected, and a signup that posts no device id (the website's)
// can't be attributed to one, so the arm skips it rather than lumping it in.
const other = await postToken(
'grant_type=create_account&platform_id=steam-devcap4&device_id=dev-other-install&device_class=2',
'198.51.100.44'
)
expect(other.status).toBe(200)
const deviceless = await postToken(
'grant_type=create_account&platform_id=steam-devcap5',
'198.51.100.45'
)
expect(deviceless.status).toBe(200)
// The capped device can still LOG IN to what it has — the cap is on signup only.
const login = await postToken(
`grant_type=password&username=Player42&password=${LOGIN_PASSWORD}&device_id=${deviceId}&device_class=2`,
'198.51.100.46'
)
expect(login.status).toBe(200)
})
test('PASSWORD_SIGNUP=off refuses a no-platform create_account and mints nothing', async () => {
// The switch the website's signup hangs off. Every other create_account test here
// runs with it ON (see vitest.config.ts); this one is the closed door.
const original = env.PASSWORD_SIGNUP
try {
env.PASSWORD_SIGNUP = 'off'
const before = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
n: number
}>()
const refused = await postToken(
'grant_type=create_account&password=hunter2&device_id=dev-web&device_class=2',
'198.51.100.60'
)
expect(refused.status).toBe(400)
expect(refused.json.error).toBe('invalid_grant')
expect(refused.json.error_description).toBe('password signup is disabled')
const after = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
n: number
}>()
expect(after?.n).toBe(before?.n)
// Logging in is not what the switch gates: an existing password account still gets
// its token, whatever the switch says.
const login = await postToken(
`grant_type=password&username=Player42&password=${LOGIN_PASSWORD}`,
'198.51.100.60'
)
expect(login.status).toBe(200)
// A platform signup is a different door: it still reaches platform verification
// (and fails THERE, on the missing ticket) rather than being refused by the switch.
const steam = await postToken(
'grant_type=create_account&platform=0&platform_id=76561198000000001',
'198.51.100.60'
)
expect(steam.status).toBe(400)
expect(steam.json.error_description).not.toBe('password signup is disabled')
// Unset is off too — the default an operator has to opt out of.
delete env.PASSWORD_SIGNUP
const unset = await postToken('grant_type=create_account&password=hunter2', '198.51.100.61')
expect(unset.status).toBe(400)
expect(unset.json.error_description).toBe('password signup is disabled')
// And `on` reopens it.
env.PASSWORD_SIGNUP = 'on'
const open = await postToken('grant_type=create_account&password=hunter2', '198.51.100.62')
expect(open.status).toBe(200)
} finally {
env.PASSWORD_SIGNUP = original
}
})
// The rollout gate: build 20260323.14 is for Discord supporters only. A login posting it
// as `ver` is refused unless the account's Discord link holds a role named in
// DISCORD_ROLLOUT_ROLE_IDS; every other build, and a grant posting no `ver`, is untouched.
describe('rollout gate on build 20260323.14', () => {
const GATED = '20260323.14'
const REFUSED =
'this build is limited to Discord supporters; link your Discord on the website first'
const supporter = '1077000000000000001'
const booster = '1077000000000000002'
const outsider = '1077000000000000009'
async function seedAccount(id: number, username: string) {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: id,
username,
passwordHash: await hashPassword(LOGIN_PASSWORD),
})
)
.run()
}
const login = (username: string, ver?: string) =>
postToken(
`grant_type=password&username=${username}&password=${LOGIN_PASSWORD}` +
(ver === undefined ? '' : `&ver=${ver}`)
)
async function withRoleIds<T>(value: string | undefined, run: () => Promise<T>): Promise<T> {
// `env` is shared by every test in this file, so the override is restored in
// `finally` rather than leaking a configured gate into the tests around this one.
const original = env.DISCORD_ROLLOUT_ROLE_IDS
if (value === undefined) delete env.DISCORD_ROLLOUT_ROLE_IDS
else env.DISCORD_ROLLOUT_ROLE_IDS = value
try {
return await run()
} finally {
if (original === undefined) delete env.DISCORD_ROLLOUT_ROLE_IDS
else env.DISCORD_ROLLOUT_ROLE_IDS = original
}
}
beforeAll(async () => {
await seedAccount(9301, 'gate_supporter')
await linkPlatformIdentity(env.DB, 9301, PlatformType.Discord, '500000000000000001', [
supporter,
])
await seedAccount(9302, 'gate_outsider')
await linkPlatformIdentity(env.DB, 9302, PlatformType.Discord, '500000000000000002', [
outsider,
])
await seedAccount(9303, 'gate_unlinked')
// A Steam link is not a Discord link — it carries no roles to hold.
await linkPlatformIdentity(env.DB, 9303, PlatformType.Steam, '76561198000009303')
// Staff, with no Discord link at all.
for (const [id, username, flag] of [
[9304, 'gate_developer', 'isDeveloper'],
[9305, 'gate_moderator', 'isModerator'],
] as const) {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: id,
username,
passwordHash: await hashPassword(LOGIN_PASSWORD),
[flag]: true,
})
)
.run()
}
})
test('staff are let in with no Discord link, even with the gate closed', async () => {
for (const roleIds of [undefined, supporter]) {
await withRoleIds(roleIds, async () => {
expect((await login('gate_developer', GATED)).status).toBe(200)
expect((await login('gate_moderator', GATED)).status).toBe(200)
})
}
})
test('with no roles configured the gate is closed to every non-staff account', async () => {
await withRoleIds(undefined, async () => {
const res = await login('gate_supporter', GATED)
expect(res.status).toBe(400)
expect(res.json).toEqual({ error: 'invalid_grant', error_description: REFUSED })
})
// A value that parses to no ids is the same as unset.
await withRoleIds(' , ', async () => {
expect((await login('gate_supporter', GATED)).status).toBe(400)
})
})
test('a configured role lets its holder in, and the token carries the build', async () => {
await withRoleIds(`${booster},${supporter}`, async () => {
const res = await login('gate_supporter', GATED)
expect(res.status).toBe(200)
const payload = decodePayload(res.json.access_token as string)
expect(payload.sub).toBe('9301')
expect(payload['rn.ver']).toBe(GATED)
})
})
test('the list is split on commas and/or whitespace, like DISCORD_BENEFITS_ROLE_IDS', async () => {
await withRoleIds(` ${booster} ,\n${supporter} `, async () => {
expect((await login('gate_supporter', GATED)).status).toBe(200)
})
})
test('a Discord link holding only other roles, or no Discord link at all, is refused', async () => {
await withRoleIds(`${supporter},${booster}`, async () => {
const other = await login('gate_outsider', GATED)
expect(other.status).toBe(400)
expect(other.json.error_description).toBe(REFUSED)
const unlinked = await login('gate_unlinked', GATED)
expect(unlinked.status).toBe(400)
expect(unlinked.json.error_description).toBe(REFUSED)
})
})
test('other builds, and grants posting no ver, are never gated', async () => {
await withRoleIds(undefined, async () => {
expect((await login('gate_outsider')).status).toBe(200)
expect((await login('gate_outsider', GAME_VERSION)).status).toBe(200)
expect((await login('gate_outsider', '20250718.01')).status).toBe(200)
expect((await login('gate_unlinked', '20230414')).status).toBe(200)
})
})
test('every point release of the gated build is gated', async () => {
// The gate is on the BUILD (the bare date): the client's `.NN` is a same-day
// rebuild, so `.14`, `.2` and the date alone are all the rollout build.
await withRoleIds(undefined, async () => {
for (const version of ['20260323', '20260323.2', '20260323.14']) {
const res = await login('gate_outsider', version)
expect(res.status, version).toBe(400)
expect(res.json.error_description, version).toBe(REFUSED)
}
})
})
test('the credential is checked first, so the gate cannot probe for accounts', async () => {
await withRoleIds(undefined, async () => {
const res = await postToken(
`grant_type=password&username=gate_outsider&password=wrong&ver=${GATED}`
)
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('invalid account_id or password')
})
})
test('create_account on the build is refused before anything is minted', async () => {
await withRoleIds(`${supporter}`, async () => {
const before = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
n: number
}>()
const res = await postToken(
`grant_type=create_account&platform_id=steam-gated&password=hunter2&ver=${GATED}`
)
expect(res.status).toBe(400)
expect(res.json).toEqual({ error: 'invalid_grant', error_description: REFUSED })
const after = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
n: number
}>()
expect(after?.n).toBe(before?.n)
})
})
// The two lists behind the gates are vars (`SUPPORTED_GAME_VERSIONS`,
// `ROLLOUT_GAME_VERSIONS`), so an operator can end the rollout or drop a build without
// a deploy of new code. Same env-override discipline as withRoleIds.
describe('the lists come from vars', () => {
const UNSUPPORTED = 'this game version is not supported by this server; update your game'
async function withVars<T>(
vars: { SUPPORTED_GAME_VERSIONS?: string; ROLLOUT_GAME_VERSIONS?: string },
run: () => Promise<T>
): Promise<T> {
const originals = {
SUPPORTED_GAME_VERSIONS: env.SUPPORTED_GAME_VERSIONS,
ROLLOUT_GAME_VERSIONS: env.ROLLOUT_GAME_VERSIONS,
}
const apply = (values: typeof originals) => {
for (const key of ['SUPPORTED_GAME_VERSIONS', 'ROLLOUT_GAME_VERSIONS'] as const) {
if (values[key] === undefined) delete env[key]
else env[key] = values[key]
}
}
apply({ ...originals, ...vars })
try {
return await run()
} finally {
apply(originals)
}
}
test('a build dropped from SUPPORTED_GAME_VERSIONS is refused a token, staff included', async () => {
await withVars({ SUPPORTED_GAME_VERSIONS: '20230414,20260323' }, async () => {
await withRoleIds(undefined, async () => {
// The 2025 build, every point release of it: this is the forced upgrade.
for (const version of ['20250718', '20250718.01', '20250718.2']) {
const res = await login('gate_outsider', version)
expect(res.status, version).toBe(400)
expect(res.json, version).toEqual({
error: 'invalid_grant',
error_description: UNSUPPORTED,
})
}
// Staff get no exception here — the build is not served, full stop.
expect((await login('gate_developer', '20250718.01')).status).toBe(400)
// The builds that ARE listed, and a grant posting no `ver`, are untouched;
// the rollout gate still applies to the one under rollout.
expect((await login('gate_outsider', GAME_VERSION)).status).toBe(200)
expect((await login('gate_outsider')).status).toBe(200)
expect((await login('gate_developer', GATED)).status).toBe(200)
expect((await login('gate_outsider', GATED)).json.error_description).toBe(REFUSED)
})
})
})
test('create_account on an unsupported build is refused before anything is minted', async () => {
await withVars({ SUPPORTED_GAME_VERSIONS: '20230414' }, async () => {
const before = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
n: number
}>()
const res = await postToken(
'grant_type=create_account&platform_id=steam-unsupported&password=hunter2&ver=20250718.01'
)
expect(res.status).toBe(400)
expect(res.json).toEqual({ error: 'invalid_grant', error_description: UNSUPPORTED })
const after = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
n: number
}>()
expect(after?.n).toBe(before?.n)
})
})
test('ROLLOUT_GAME_VERSIONS=off ends the rollout: the build opens to everyone', async () => {
await withVars({ ROLLOUT_GAME_VERSIONS: 'off' }, async () => {
await withRoleIds(undefined, async () => {
const res = await login('gate_outsider', GATED)
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string)['rn.ver']).toBe(GATED)
expect((await login('gate_unlinked', GATED)).status).toBe(200)
// ...and accounts can be created on it, since nothing needs a Discord link.
const created = await postToken(
`grant_type=create_account&platform_id=steam-rollout-over&password=hunter2&ver=${GATED}`
)
expect(created.status).toBe(200)
})
})
})
test('ROLLOUT_GAME_VERSIONS names which builds are gated', async () => {
// Move the gate onto the beta build: it is now supporters-only and the 2026
// build is open. Both stay served.
await withVars({ ROLLOUT_GAME_VERSIONS: '20250718' }, async () => {
await withRoleIds(supporter, async () => {
const outsider = await login('gate_outsider', '20250718.01')
expect(outsider.status).toBe(400)
expect(outsider.json.error_description).toBe(REFUSED)
expect((await login('gate_supporter', '20250718.01')).status).toBe(200)
expect((await login('gate_outsider', GATED)).status).toBe(200)
})
})
})
test('a build under rollout is served even when SUPPORTED_GAME_VERSIONS omits it', async () => {
await withVars({ SUPPORTED_GAME_VERSIONS: '20230414' }, async () => {
await withRoleIds(supporter, async () => {
// Rolling out, so the ROLLOUT gate answers, not the support one.
expect((await login('gate_supporter', GATED)).status).toBe(200)
expect((await login('gate_outsider', GATED)).json.error_description).toBe(REFUSED)
})
})
// In neither list, nobody reaches it — a rollout pulled back entirely.
await withVars(
{ SUPPORTED_GAME_VERSIONS: '20230414', ROLLOUT_GAME_VERSIONS: 'off' },
async () => {
await withRoleIds(supporter, async () => {
for (const username of ['gate_supporter', 'gate_developer']) {
const res = await login(username, GATED)
expect(res.status, username).toBe(400)
expect(res.json.error_description, username).toBe(UNSUPPORTED)
}
})
}
)
})
test('entries are read as builds, so a copied point release still matches', async () => {
await withVars({ SUPPORTED_GAME_VERSIONS: ' 20230414, 20250718.01 ' }, async () => {
expect((await login('gate_outsider', '20250718.2')).status).toBe(200)
expect((await login('gate_outsider', '20250718')).status).toBe(200)
})
// Blank is unset: the default list applies, including the alpha builds.
await withVars({ SUPPORTED_GAME_VERSIONS: ' ' }, async () => {
expect((await login('gate_outsider', '20231207')).status).toBe(200)
})
})
})
})
test('the signup caps come from vars, and 0 disables an arm', async () => {
// The cap an operator actually runs is the `MAX_ACCOUNTS_PER_IP` var; the constant in
// auth.app.ts is only the fallback. `env` is shared by every test in this file, so the
@@ -1056,6 +1629,24 @@ describe('auth worker routes', () => {
} finally {
env.MAX_ACCOUNTS_PER_IP = original
}
// The device arm reads its own var the same way.
const originalDevice = env.MAX_ACCOUNTS_PER_DEVICE_ID
try {
env.MAX_ACCOUNTS_PER_DEVICE_ID = 1
const device = '&device_id=dev-var-install&device_class=2'
const first = await postToken(`grant_type=create_account&platform_id=steam-dvar0${device}`)
expect(first.status).toBe(200)
const capped = await postToken(`grant_type=create_account&platform_id=steam-dvar1${device}`)
expect(capped.status).toBe(400)
expect(capped.json.error_description).toMatch(/device/)
env.MAX_ACCOUNTS_PER_DEVICE_ID = 0
const uncapped = await postToken(`grant_type=create_account&platform_id=steam-dvar2${device}`)
expect(uncapped.status).toBe(200)
} finally {
env.MAX_ACCOUNTS_PER_DEVICE_ID = originalDevice
}
})
test('POST /connect/token does not cap logins, only signups', async () => {
@@ -1421,6 +2012,22 @@ describe('auth worker routes', () => {
expect(await res.text()).toBe('')
})
test('GET /role/screenshare/:id matches the token feature gate for existing accounts', async () => {
for (const id of [42, 77]) {
const res = await exports.default.fetch(`${ORIGIN}/role/screenshare/${id}`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(true)
}
})
test('GET /role/screenshare/:id does not grant a role to unknown accounts', async () => {
for (const id of ['999999', 'nonsense']) {
const res = await exports.default.fetch(`${ORIGIN}/role/screenshare/${id}`)
expect(res.status).toBe(404)
expect(await res.text()).toBe('')
}
})
test('GET /role/developer/:id returns a bare false for an un-flagged account', async () => {
const res = await exports.default.fetch(`${ORIGIN}/role/developer/42`)
expect(res.status).toBe(200)
@@ -1533,6 +2140,76 @@ describe('auth worker routes', () => {
expect(res.status).toBe(404)
})
test('Studio device login is approved on the website and polled on auth', async () => {
const client = `client_id=${STUDIO_CLIENT_ID}&client_secret=${STUDIO_CLIENT_SECRET}`
const bad = await exports.default.fetch(`${ORIGIN}/connect/deviceauthorization`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'client_id=recroom.studio&client_secret=nope',
})
expect(bad.status).toBe(400)
const started = await exports.default.fetch(`${ORIGIN}/connect/deviceauthorization`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: client,
})
expect(started.status).toBe(200)
const codes = (await started.json()) as {
device_code: string
user_code: string
verification_uri: string
verification_uri_complete: string
expires_in: number
interval: number
}
// The editor opens this in a browser. It is the website, not the auth API.
expect(codes.verification_uri).toBe('https://www.rec.example.com/device')
expect(codes.verification_uri_complete).toBe(
`https://www.rec.example.com/device?user_code=${codes.user_code}`
)
expect(codes.expires_in).toBeGreaterThan(0)
expect(codes.interval).toBeGreaterThan(0)
const pending = await postToken(
`${client}&grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:device_code')}&device_code=${codes.device_code}`
)
expect(pending.status).toBe(400)
expect(pending.json.error).toBe('authorization_pending')
const session = await postToken(
`grant_type=password&username=Player42&password=${LOGIN_PASSWORD}`
)
expect(session.status).toBe(200)
const approved = await exports.default.fetch(`${ORIGIN}/connect/device/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${session.json.access_token as string}`,
},
body: `user_code=${codes.user_code}`,
})
expect(approved.status).toBe(200)
const signedIn = await postToken(
`${client}&grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:device_code')}&device_code=${codes.device_code}`
)
expect(signedIn.status).toBe(200)
expect(typeof signedIn.json.access_token).toBe('string')
expect(signedIn.json.expires_in).toBe(TOKEN_TTL_SECONDS)
// Studio treats expires_in as seconds, same as the game client.
const devicePayload = decodePayload(signedIn.json.access_token as string)
expect(devicePayload.sub).toBe('42')
// Player42 has no hasStudio flag, so a Studio login is still Limited.
expect(devicePayload.role).not.toContain('betastudio')
const again = await postToken(
`${client}&grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:device_code')}&device_code=${codes.device_code}`
)
expect(again.status).toBe(400)
expect(again.json.error).toBe('invalid_grant')
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
@@ -1562,9 +2239,13 @@ describe('auth worker routes', () => {
'GET /role/developer/{id}',
'GET /role/moderator',
'GET /role/moderator/{id}',
'GET /role/screenshare/{id}',
'POST /account/me/changepassword',
'POST /cachedlogin/forplatformid/{platform}/{id}',
'POST /cachedlogin/forplatformids',
'POST /connect/device/approve',
'POST /connect/device/deny',
'POST /connect/deviceauthorization',
'POST /connect/token',
])
@@ -1955,3 +2636,145 @@ describe('staff login audit', () => {
expect(await staffLogins(9605)).toHaveLength(1)
})
})
// The website's "Sign in with Discord". `www` proves the Discord user (it holds the client
// secret) and posts an identity ASSERTION signed with the shared JWT_SECRET; this grant
// resolves it through the link table like a cached_login, or makes the account when there
// is none. Everything here mints the assertion the way www does, with the test key.
describe('identity_assertion grant', () => {
const SIGNING_KEY = 'test-signing-key'
const assertionFor = (platformId: string, platform = PlatformType.Discord) =>
generateIdentityAssertion({ platform, platformId }, SIGNING_KEY)
const redeem = async (assertion: string, ip?: string) =>
postToken(`grant_type=identity_assertion&assertion=${encodeURIComponent(assertion)}`, ip)
test('an assertion round-trips, and nothing else passes as one', async () => {
const assertion = await assertionFor('700000000000000001')
await expect(validateIdentityAssertion(assertion, SIGNING_KEY)).resolves.toEqual({
platform: PlatformType.Discord,
platformId: '700000000000000001',
})
// A different key, and an access token signed with the RIGHT key: both refused. The
// second is the one that matters — the same secret signs sessions, so the audience is
// what keeps a stolen token from being replayed as proof of a platform identity.
await expect(validateIdentityAssertion(assertion, 'another-key')).resolves.toBeNull()
const session = await generateToken('42', '', 4, SIGNING_KEY)
await expect(validateIdentityAssertion(session, SIGNING_KEY)).resolves.toBeNull()
// And the other direction: an assertion carries no `sub`, so it is no session.
const res = await exports.default.fetch(`${ORIGIN}/connect/device/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Bearer ${assertion}`,
},
body: 'user_code=ABCDEFGH',
})
expect(res.status).toBe(401)
})
test('signs into the one account the identity is linked to', async () => {
await seedAccount(9400, 'DiscordLinked')
// Linked the way the benefits claim links: with roles recorded.
await linkPlatformIdentity(env.DB, 9400, PlatformType.Discord, '700000000000009400', [])
const res = await redeem(await assertionFor('700000000000009400'))
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('9400')
expect(res.json).not.toHaveProperty('created')
expect(typeof res.json.refresh_token).toBe('string')
})
test('refuses a missing, forged or replayed-session assertion', async () => {
expect((await postToken('grant_type=identity_assertion')).status).toBe(400)
const forged = await generateIdentityAssertion(
{ platform: PlatformType.Discord, platformId: '700000000000009400' },
'not-the-key'
)
const res = await redeem(forged)
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('invalid or expired identity assertion')
// An access token for the linked account, presented as the assertion.
const session = await generateToken('9400', '', 4, SIGNING_KEY)
expect((await redeem(session)).status).toBe(400)
})
test('creates and links an account when the identity opens none, once', async () => {
const discordId = '700000000000009401'
const first = await redeem(await assertionFor(discordId))
expect(first.status).toBe(200)
expect(first.json.created).toBe(true)
const accountId = Number(decodePayload(first.json.access_token as string).sub)
expect(accountId).toBeGreaterThan(0)
// Linked on the Discord platform, and ONLY there: the blob carries no platform
// identity (the snowflake stays off the DTO and the token), like a password account.
const links = await getLinksForAccount(env.DB, accountId)
expect(links.map((l) => [l.platform, l.platformId])).toEqual([
[PlatformType.Discord, discordId],
])
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
.bind(accountId)
.first<{ data: string }>()
const blob = JSON.parse(row!.data) as Record<string, unknown>
expect(blob.platformId).toBeUndefined()
expect(blob.passwordHash).toBeUndefined()
// Placed in Orientation like any signup.
const presence = await env.DB.prepare('SELECT 1 AS ok FROM presence WHERE account_id = ?1')
.bind(accountId)
.first()
expect(presence).not.toBeNull()
// The same Discord again is a sign-IN to that account, not a second account.
const again = await redeem(await assertionFor(discordId))
expect(again.status).toBe(200)
expect(again.json).not.toHaveProperty('created')
expect(decodePayload(again.json.access_token as string).sub).toBe(String(accountId))
await expect(
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, discordId)
).resolves.toBe(1)
})
// The stretch of the feature: a Discord-made account has no password, and a password is
// what signs it into the game (a sideloaded Quest has no Meta nonce to offer). The
// website's password form posts with an EMPTY old password the first time.
test('a Discord-made account can set a password and then sign in with it', async () => {
const created = await redeem(await assertionFor('700000000000009402'))
const accountId = decodePayload(created.json.access_token as string).sub as string
expect((await postToken(`account_id=${accountId}&password=hunter22`)).status).toBe(400)
const set = await changePassword(
'oldPassword=&newPassword=hunter22',
created.json.access_token as string
)
expect(set.status).toBe(200)
const login = await postToken(`grant_type=password&account_id=${accountId}&password=hunter22`)
expect(login.status).toBe(200)
expect(decodePayload(login.json.access_token as string).sub).toBe(accountId)
})
test('refuses an identity that opens several accounts rather than guessing', async () => {
await seedAccount(9403, 'TwoLinksA')
await seedAccount(9404, 'TwoLinksB')
await linkPlatformIdentity(env.DB, 9403, PlatformType.Discord, '700000000000009403', [])
await linkPlatformIdentity(env.DB, 9404, PlatformType.Discord, '700000000000009403', [])
const res = await redeem(await assertionFor('700000000000009403'))
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('platform identity is linked to more than one account')
})
// The signup caps reach this path as they reach create_account — www forwards the
// browser's real address over the service binding, so the per-IP cap counts browsers.
test('the per-IP signup cap applies to accounts made this way', async () => {
const ip = '198.51.100.77'
for (let i = 0; i < 3; i++) {
const res = await redeem(await assertionFor(`70000000000000950${i}`), ip)
expect(res.status).toBe(200)
expect(res.json.created).toBe(true)
}
const fourth = await redeem(await assertionFor('700000000000009509'), ip)
expect(fourth.status).toBe(400)
expect(fourth.json.error_description).toBe('too many accounts created from this network')
// Signing IN from that network is untouched: the cap is on creation.
const existing = await redeem(await assertionFor('700000000000009500'), ip)
expect(existing.status).toBe(200)
})
})
+4
View File
@@ -8,6 +8,10 @@ export default defineConfig({
miniflare: {
bindings: {
ENVIRONMENT: 'VITEST',
// Password (no-platform) signup is OFF by default, and most create_account tests
// post no `platform` — they'd all test the closed door. The switch itself is
// covered by a test that flips it off and back.
PASSWORD_SIGNUP: 'on',
},
},
}),
+5 -1
View File
@@ -57,6 +57,10 @@
// constants in src/auth.app.ts.
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown" // overridden during deployment
"SENTRY_RELEASE": "unknown", // overridden during deployment
// Overridden at deploy time, same as `ns`. The device-login response builds
// the browser URL from these (`https://www.<DOMAIN>/device`).
"DOMAIN": "rec.example.com",
"SUBDOMAINS": "{}"
}
}
+1
View File
@@ -16,6 +16,7 @@
},
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"hono": "4.13.5",
"workers-tagged-logger": "1.0.1"
},
+11
View File
@@ -2,6 +2,7 @@ import { Hono } from 'hono'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import type { App } from './context'
@@ -22,5 +23,15 @@ const app = new Hono<App>()
.get('/', async (c) => {
return c.text('hello, world!')
})
.get('/iam/me/channels/:channel', async (c) => {
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
if (accountId === null) return c.body(null, 401)
const channel = c.req.param('channel')
if (!['None', 'Announcements', 'JustInTimeTutorials'].includes(channel)) {
return c.body(null, 400)
}
// GetChannelMessages decodes a bare List<InAppMessageDTO>. No campaigns are configured.
return c.json([])
})
export default app
+38 -2
View File
@@ -1,8 +1,44 @@
import { SELF } from 'cloudflare:test'
import { expect, it } from 'vitest'
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { sign } from 'hono/jwt'
import { beforeAll, expect, it } from 'vitest'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
beforeAll(async () => {
await adminSecretsStore(env.JWT_SECRET).create('cards-test-secret')
})
async function bearer() {
const token = await sign(
{ sub: '205', exp: Math.floor(Date.now() / 1000) + 60 },
'cards-test-secret',
'HS256'
)
return { Authorization: `Bearer ${token}` }
}
it('response with hello world', async () => {
const res = await SELF.fetch('https://example.com')
expect(res.status).toBe(200)
expect(await res.text()).toMatchInlineSnapshot(`"hello, world!"`)
})
it('in-app message channels return a bare list and require authentication', async () => {
const url = 'https://example.com/iam/me/channels/Announcements'
expect((await SELF.fetch(url)).status).toBe(401)
for (const channel of ['Announcements', 'JustInTimeTutorials', 'None']) {
const response = await SELF.fetch(`https://example.com/iam/me/channels/${channel}`, {
headers: await bearer(),
})
expect(response.status).toBe(200)
expect(await response.json()).toEqual([])
}
expect(
(await SELF.fetch('https://example.com/iam/me/channels/Unknown', { headers: await bearer() }))
.status
).toBe(400)
})
+8 -1
View File
@@ -17,11 +17,18 @@ its path.
Objects are keyed by prefix — `sigs/` (anti-cheat signatures), `room/` (saved room
scenes, and room images by their bare `ImageName`), `invention/` (invention data), `data/`
(generic client uploads), `avatar/` (custom avatar item assetbundles) — and served as `application/octet-stream`; the worker never interprets what it hands back.
`/unityasset/{filename}` is the exception: a Rec Room Studio room bundle is stored under
`studio/<roomId>/` (the game's own `unity/<roomId>/` layout, under another prefix), its key
recorded as the filename of a `unity_asset` row (the rooms worker owns that table; the
studio worker writes the rows), and the route serves only a filename that table records.
`HEAD` on that path is 200 when a bundle is stored under the name.
Reads are unauthenticated: a caller needs the exact key, which only comes from an
authenticated call to another worker.
This worker only reads. Uploads go through `storage`, which writes the same bucket, and
images are served by `img`.
images are served by `img`. Studio room bundles are uploaded by the `studio` worker into
the same bucket. The worker binds the shared D1 database to resolve those filenames and
does not migrate it.
## API documentation
+158 -2
View File
@@ -3,8 +3,10 @@ import { describeRoute, openAPIRouteHandler, resolver } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
applyNoExecuteHeaders,
withCleanSpec,
withDefaultCors,
withNoExecute,
withNotFound,
withOnError,
writeContentRange,
@@ -167,6 +169,13 @@ const app = new Hono<App>()
// unchanged by who may read a response they already had to name exactly.
.use('*', withDefaultCors())
// The blob routes force `application/octet-stream` over whatever type an upload was
// stored with, which is what keeps a stored HTML body from rendering; `nosniff` makes
// that type binding on the browser, and the sandboxing CSP covers the config files,
// which go out under their own types. The hand-rolled HEAD below is outside this chain
// and stamps the same headers itself.
.use('*', withNoExecute())
.onError(withOnError())
.notFound(withNotFound())
@@ -353,6 +362,131 @@ const app = new Hono<App>()
(c) => serveAsset(c, `avatar/${c.req.param('asset')}`)
)
// SDK source downloads use an opaque upload capability (no bearer on this
// request). Only creator-authenticated save history exposes these filenames.
.get('/avatarunityasset/:filename{.+}', (c) => {
const name = c.req.param('filename')
if (!/^\d{4}-\d{2}-\d{2}\/[0-9a-f-]{36}$/.test(name)) return c.notFound()
return serveAsset(c, `avatarunityasset/${name}`)
})
// Editable Unity source manifests. Baked bundles are served separately.
.get(
'/roomunityasset/:filename{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Download a Studio authoring manifest',
parameters: [keyParam('filename', 'Uploaded JSON source manifest', true)],
responses: assetResponses('The source manifest'),
}),
async (c) => {
const name = c.req.param('filename')
if (name.includes('..')) return c.body(null, 400)
return serveAsset(c, `roomunityasset/${name}`)
}
)
// Rec Room Studio room bundles. The filename is the one a room save or
// `GET …/unityasset` advertises — the R2 key itself (`studio/<roomId>/…`), as
// `unity_asset.filename` records it. Only a recorded filename is served, so this
// checks the table before streaming the key. Reads stay unauthenticated, same as
// `/avatar/` and `/room/`. HEAD is the editor's "already uploaded?" check: 200
// exists, 404 does not.
.on(
'HEAD',
'/unityasset/:filename{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Check that a Rec Room Studio room bundle exists',
description: [
'200 when `filename` names a stored Studio bundle, 404 otherwise. The',
'Studio editor treats this HEAD as “already uploaded?” before it downloads the bytes',
'with GET. No body.',
].join(' '),
parameters: [keyParam('filename', 'The bundle filename from the room save.', true)],
responses: {
200: { description: 'The bundle exists (no body)' },
400: { description: 'The filename contains `..` (no body)' },
404: { description: 'No single stored bundle has this filename' },
},
}),
(c) => studioUnityAssetHead(c.env, c.req.param('filename'))
)
.get(
'/unityasset/:filename{.+}',
describeRoute({
tags: ['Assets'],
summary: 'Serve a Rec Room Studio room bundle',
description: [
'Streams the Studio asset bundle stored for `filename`. The name comes from a',
'room save’s `UnitySubAssets` (or from `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset`',
'on the rooms worker) and is the R2 key the studio upload stored the bytes under.',
'Only a filename recorded in `unity_asset` is served: anything else is a 404, as is',
'a missing table. The worker does not interpret the bytes.',
].join(' '),
parameters: [
keyParam('filename', 'The bundle filename from the room save.', true),
...CONDITIONAL_HEADERS,
],
responses: assetResponses('The asset bundle'),
}),
(c) => serveUnityAsset(c)
)
/**
* The R2 key for a Studio bundle filename — the filename itself, once `unity_asset` says
* a build is stored under it. Null when no build is, or the table has not been migrated
* yet (the rooms worker owns it).
*/
async function unityAssetR2Key(env: StudioAssetEnv, filename: string): Promise<string | null> {
try {
// Inlined rather than imported so this worker does not take a dependency on the
// domain package.
const row = await env.DB.prepare(
`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`
)
.bind(filename)
.first<{ stored: number }>()
if (!row) return null
if (/^(quest\/)?studio-avatar\//.test(filename)) return `avatar/${filename}`
// Room builds and consumable exports use the game's /room namespace.
// Retain older installations that stored the same filename at the bucket root.
if (filename.startsWith('studio/') && (await env.CDN_ASSETS.head(`room/${filename}`)))
return `room/${filename}`
return filename
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
if (message.toLowerCase().includes('no such table')) return null
throw err
}
}
/** GET `/unityasset/:filename` — the bytes, with the same range handling as other blobs. */
async function serveUnityAsset(c: Context<App>) {
const name = c.req.param('filename') ?? ''
if (name.includes('..')) return c.body(null, 400)
const key = await unityAssetR2Key(c.env, name)
return serveAsset(c, key ?? `unityasset/${name}`)
}
/** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */
async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> {
if (name.includes('..')) return new Response(null, { status: 400 })
const key = await unityAssetR2Key(env, name)
const head = await env.CDN_ASSETS.head(key ?? `unityasset/${name}`)
if (!head) return new Response(null, { status: 404 })
const headers = new Headers()
head.writeHttpMetadata(headers)
headers.set('etag', head.httpEtag)
headers.set('accept-ranges', 'bytes')
headers.set('cache-control', CACHE_CONTROL)
headers.set('access-control-allow-origin', '*')
applyNoExecuteHeaders(headers)
const response = new Response(null, { status: 200, headers, encodeBody: 'manual' })
response.headers.set('content-length', String(head.size))
return response
}
// The generated spec. Documentation only — no request is validated against it (see
// openapi.ts). `hide: true` keeps this route out of its own output.
app.get(
@@ -376,7 +510,9 @@ app.get(
'served as',
'`application/octet-stream`; the worker never interprets what it hands back. Reads',
'are unauthenticated — a caller needs the exact key, which only comes from an',
'authenticated call to another worker.',
'authenticated call to another worker. `/unityasset/{filename}` is a Studio room',
'bundle: the filename is the R2 key, streamed once `unity_asset` records it.',
'`HEAD` on that path is 200 when a bundle is stored under the name.',
'',
'This worker only READS. Uploads go through the `storage` worker, which writes the',
'same bucket, and images are served by `img` rather than from here.',
@@ -396,4 +532,24 @@ app.get(
)
)
export default app
/**
* What the Studio HEAD needs from the environment. Narrower than `Env` on purpose: the
* `mono` worker mounts this app with its own (superset) Env, which has no `ASSETS`
* binding, and the wrapper below must stay mountable there.
*/
type StudioAssetEnv = Pick<Env, 'DB' | 'CDN_ASSETS'>
export default {
fetch(
request: Request,
env: StudioAssetEnv,
executionCtx: ExecutionContext
): Response | Promise<Response> {
const url = new URL(request.url)
if (request.method === 'HEAD' && url.pathname.startsWith('/unityasset/')) {
const filename = decodeURIComponent(url.pathname.slice('/unityasset/'.length))
return studioUnityAssetHead(env, filename)
}
return app.fetch(request, env, executionCtx)
},
}
+5
View File
@@ -6,6 +6,11 @@ export type Env = SharedHonoEnv & {
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
// signed by `auth` verify here.
JWT_SECRET: SecretsStoreSecret
// Shared `recflare` database, read-only here. Studio bundle downloads check that
// `unity_asset` records the requested filename (which is the R2 key) before
// streaming it. The rooms worker owns that table's migration; this binding has no
// migrations_dir.
DB: D1Database
// R2 bucket holding CDN binaries: signature blobs under `sigs/<name>` and
// room build data under `room/<name>`.
CDN_ASSETS: R2Bucket
+145
View File
@@ -12,6 +12,32 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com'
test('serves opaque avatar source manifests and SDK avatar bundle GET/HEAD/ranges', async () => {
const source = '2026-10-18/11111111-1111-1111-1111-111111111111'
await env.CDN_ASSETS.put('avatarunityasset/' + source, '{"Version":3}')
expect(await (await exports.default.fetch(ORIGIN + '/avatarunityasset/' + source)).text()).toBe(
'{"Version":3}'
)
await env.DB.prepare(
'CREATE TABLE IF NOT EXISTS unity_asset(unity_asset_id TEXT NOT NULL,target INTEGER NOT NULL,version INTEGER NOT NULL,kind TEXT NOT NULL,filename TEXT NOT NULL,hash TEXT NOT NULL,PRIMARY KEY(unity_asset_id,target,version,kind))'
).run()
const name = 'quest/studio-avatar/test/item/lease.material.assetbundle'
await env.DB.prepare(
'INSERT INTO unity_asset(unity_asset_id,target,version,kind,filename,hash) VALUES (?,2,6,?,?,?)'
)
.bind('avatar-fixture', 'material', name, 'hash')
.run()
await env.CDN_ASSETS.put('avatar/' + name, 'UnityFS\0avatar')
expect(
(await exports.default.fetch(ORIGIN + '/unityasset/' + name, { method: 'HEAD' })).status
).toBe(200)
const response = await exports.default.fetch(ORIGIN + '/unityasset/' + name, {
headers: { Range: 'bytes=0-7' },
})
expect(response.status).toBe(206)
expect(await response.text()).toBe('UnityFS\0')
})
describe('cdn endpoints', () => {
test('GET / reports service status', async () => {
const res = await exports.default.fetch(`${ORIGIN}/`)
@@ -19,6 +45,38 @@ describe('cdn endpoints', () => {
expect(await res.json()).toEqual({ service: 'cdn', status: 'ok' })
})
// A stored blob carries whatever type it was uploaded with; the routes force
// octet-stream, and these headers make that binding and sandbox anything that is served
// under its own type (the configs). The hand-rolled HEAD sits outside Hono and must
// carry them too.
test('every response carries headers that stop a stored document executing', async () => {
await env.CDN_ASSETS.put('data/a-stored-page', '<script>alert(1)</script>', {
httpMetadata: { contentType: 'text/html' },
})
for (const path of [
'/',
'/config/LoadingScreenTipData',
'/config/RRPlusConfig_v3',
'/data/a-stored-page',
]) {
const res = await exports.default.fetch(`${ORIGIN}${path}`)
expect(res.status, path).toBe(200)
expect(res.headers.get('x-content-type-options'), path).toBe('nosniff')
expect(res.headers.get('content-security-policy'), path).toBe("default-src 'none'; sandbox")
}
const blob = await exports.default.fetch(`${ORIGIN}/data/a-stored-page`)
expect(blob.headers.get('content-type')).toBe('application/octet-stream')
await env.CDN_ASSETS.put('unityasset/plain.assetbundle', new Uint8Array([1, 2, 3]))
const head = await exports.default.fetch(`${ORIGIN}/unityasset/plain.assetbundle`, {
method: 'HEAD',
})
expect(head.status).toBe(200)
expect(head.headers.get('content-length')).toBe('3')
expect(head.headers.get('x-content-type-options')).toBe('nosniff')
expect(head.headers.get('content-security-policy')).toBe("default-src 'none'; sandbox")
})
test('GET /config/LoadingScreenTipData returns the tip array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/config/LoadingScreenTipData`)
expect(res.status).toBe(200)
@@ -286,6 +344,66 @@ describe('cdn endpoints', () => {
expect(res.status).toBe(404)
})
test('GET and HEAD /unityasset/:filename stream a stored studio bundle', async () => {
await env.DB.prepare('DROP TABLE IF EXISTS unity_asset').run()
const absent = await exports.default.fetch(`${ORIGIN}/unityasset/not-stored.assetbundle`)
expect(absent.status).toBe(404)
// The rooms worker's table (UNITY_ASSET_SCHEMA_DDL in @repo/domain), inlined like
// the route's own lookup is.
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS unity_asset (
unity_asset_id TEXT NOT NULL,
target INTEGER NOT NULL,
version INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT 'main',
filename TEXT NOT NULL,
hash TEXT NOT NULL,
PRIMARY KEY (unity_asset_id, target, version, kind)
)`
).run()
// The filename IS the R2 key.
const filename = 'studio/20930/abc111.windows.main.assetbundle'
await env.DB.prepare(
`INSERT INTO unity_asset (unity_asset_id, target, version, kind, filename, hash)
VALUES ('asset-a', 0, 1, 'main', ?1, 'n2SnR+G5fxMfq7a0Rylsm28CAeefs8U1bmx36JtqgGo=')`
)
.bind(filename)
.run()
await env.CDN_ASSETS.put(filename, new Uint8Array([1, 2, 3, 4]))
// A key in the bucket that no row records is not served.
await env.CDN_ASSETS.put('studio/20930/unrecorded.assetbundle', new Uint8Array([9]))
const res = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toBe('application/octet-stream')
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4]))
const head = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, { method: 'HEAD' })
expect(head.status).toBe(200)
expect(head.headers.get('content-length')).toBe('4')
expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0))
// Current room/consumable builds live in the game's room namespace.
await env.CDN_ASSETS.put('room/' + filename, new Uint8Array([5, 6, 7, 8, 9]))
const current = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, {
headers: { Range: 'bytes=1-2' },
})
expect(current.status).toBe(206)
expect(new Uint8Array(await current.arrayBuffer())).toEqual(new Uint8Array([6, 7]))
const currentHead = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, {
method: 'HEAD',
})
expect(currentHead.status).toBe(200)
expect(currentHead.headers.get('content-length')).toBe('5')
expect((await exports.default.fetch(`${ORIGIN}/unityasset/other.assetbundle`)).status).toBe(404)
expect(
(await exports.default.fetch(`${ORIGIN}/unityasset/studio/20930/unrecorded.assetbundle`))
.status
).toBe(404)
expect((await exports.default.fetch(`${ORIGIN}/unityasset/a..b`)).status).toBe(400)
})
test('GET /openapi.json documents every route', async () => {
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
expect(res.status).toBe(200)
@@ -314,7 +432,10 @@ describe('cdn endpoints', () => {
'GET /data/{id}',
'GET /invention/{dataBlob}',
'GET /room/{dataBlob}',
'GET /roomunityasset/{filename}',
'GET /sigs/{sigName}',
'GET /unityasset/{filename}',
'HEAD /unityasset/{filename}',
])
// Every operation carries a summary — a path present but undescribed is not
@@ -327,3 +448,27 @@ describe('cdn endpoints', () => {
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
})
})
test('Studio authoring source manifests and source files remain distinct from baked bundles', async () => {
await env.CDN_ASSETS.put(
'roomunityasset/studio-source-fixture.json',
'{"Version":3,"Files":[]}',
{ httpMetadata: { contentType: 'application/json' } }
)
const source = await exports.default.fetch(`${ORIGIN}/roomunityasset/studio-source-fixture.json`)
expect(source.status).toBe(200)
expect(await source.json()).toEqual({ Version: 3, Files: [] })
await env.CDN_ASSETS.put('unityasset/studio-source-fixture.txt', 'source data')
const file = await exports.default.fetch(`${ORIGIN}/unityasset/studio-source-fixture.txt`)
expect(await file.text()).toBe('source data')
expect(
(
await exports.default.fetch(`${ORIGIN}/unityasset/studio-source-fixture.txt`, {
method: 'HEAD',
})
).status
).toBe(200)
expect((await exports.default.fetch(`${ORIGIN}/roomunityasset/missing-source.json`)).status).toBe(
404
)
})
+11
View File
@@ -32,6 +32,17 @@
"directory": "./static",
"run_worker_first": true
},
// Shared `recflare` DB, read so `/unityasset/:filename` can resolve a Studio
// bundle's public filename to the R2 key the studio worker stored. Studio owns
// the migration (`apps/studio/migrations`); this binding deliberately has no
// migrations_dir, so deploying cdn does not try to create those tables.
"d1_databases": [
{
"binding": "DB",
"database_name": "recflare",
"database_id": "local"
}
],
// CDN binaries (signature blobs + room build data) are stored as R2 objects
// and streamed back by key. Keys are prefixed `sigs/` and `room/`.
"r2_buckets": [
+47 -26
View File
@@ -2,9 +2,9 @@ import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { withDatabase } from '@repo/domain'
import { mergePlayerSettings as mergeStoredPlayerSettings, readPlayerSettings } from '@repo/domain'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { validateAndGetAccountId, validateAndGetBuild } from '@repo/jwt'
import { censorSwears } from '../../api/src/sanitize'
import { NotificationType } from '../../notify/src/notification-types'
@@ -20,12 +20,13 @@ import {
CreatePartyChatResponse,
CreateThreadRequest,
CreateThreadResponse,
FavoriteChatResponse,
FavoriteThreadRequest,
form,
json,
messageCountParam,
NOT_A_MEMBER_RESPONSE,
PartyChatThread,
PartyChatReadResponse,
PartyInviteSettings,
RenameThreadRequest,
SendMessageRequest,
@@ -202,10 +203,7 @@ async function getPlayerSettings(
env: Env,
accountId: number
): Promise<Record<string, string> | null> {
return env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
`player:${accountId}`,
'json'
).catch(() => null)
return readPlayerSettings(env.RECFLARE_PLAYER_SETTINGS, accountId).catch(() => null)
}
/**
@@ -252,15 +250,15 @@ async function writeChatPrivacy(
* Merge keys into the player's settings map, the way the `playersettings` worker's own PUT
* does. Never a whole-map write: the bag holds every setting the player has (OOBE state,
* tutorial mask, …) and storing one key on its own would wipe the rest. Values are strings,
* which is what that worker stores and what its GET serves back.
* which is what that worker stores and what its GET serves back. Nothing is written when
* the patch changes no value — the client re-posts its privacy settings freely.
*/
async function mergePlayerSettings(
env: Env,
accountId: number,
patch: Record<string, string>
): Promise<void> {
const merged: Record<string, string> = { ...(await getPlayerSettings(env, accountId)), ...patch }
await env.RECFLARE_PLAYER_SETTINGS.put(`player:${accountId}`, JSON.stringify(merged))
await mergeStoredPlayerSettings(env.RECFLARE_PLAYER_SETTINGS, accountId, patch)
}
/**
@@ -575,7 +573,7 @@ async function formField(c: Context<App>, name: string): Promise<string | undefi
/**
* A concise `describeRoute` spec for one of the thread-scoped actions that answers the
* bare ChatResult integer rather than an HTTP status — rename, leave, snooze, favorite,
* bare ChatResult integer rather than an HTTP status — rename, leave, snooze,
* add-member and the read-pointer moves. They share the auth gate, the `:id` path param,
* and the "3 when the caller isn't on the thread" behaviour.
*/
@@ -894,7 +892,10 @@ const app = new Hono<App>()
},
],
responses: {
200: json(PartyChatThread, 'The caller’s party, or `{}` when they have none'),
200: json(
PartyChatReadResponse,
'2026: a list of party threads; older builds: a thread or {}'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -903,19 +904,22 @@ const app = new Hono<App>()
if (id === null) return c.body(null, 401)
// Path 1: already in a party. One query, no settings read.
const build = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
const listResponse = build !== null && build >= '20260323'
const noParty = () => (listResponse ? c.json([]) : c.json({}))
let thread = await getPartyThreadForPlayer(c.env.DB, id)
// Path 2: not in one — the key is the only thing that can name a party to join.
if (thread === null) {
const chatThreadId = await readLatestPartyChatId(c.env, id)
if (chatThreadId === null) return c.json({})
if (chatThreadId === null) return noParty()
// Checked BEFORE the join: a gone thread, one of some other type, or a party
// too old to still be taking people isn't something to put anybody on —
// whoever wrote the key.
const meta = await getThreadMeta(c.env.DB, chatThreadId)
if (meta === null || meta.chatThreadType !== ChatThreadType.Party) return c.json({})
if (!isPartyJoinable(meta.createdAt)) return c.json({})
if (meta === null || meta.chatThreadType !== ChatThreadType.Party) return noParty()
if (!isPartyJoinable(meta.createdAt)) return noParty()
await addThreadMember(c.env.DB, chatThreadId, id)
// The same announcement the add-member route makes: the party learns someone
@@ -928,7 +932,8 @@ const app = new Hono<App>()
const messages = await getThreadMessages(c.env.DB, thread.chatThreadId, {
limit: DEFAULT_THREAD_MESSAGE_COUNT,
})
return c.json(toPartyChatThread(thread, messages))
const party = toPartyChatThread(thread, messages)
return listResponse ? c.json([party]) : c.json(party)
}
)
@@ -1357,28 +1362,44 @@ const app = new Hono<App>()
// Favorite or unfavorite a thread (`favorite=True`), for the caller alone — like
// snoozing, it's a per-member flag that pins the thread in their own inbox.
//
// Answers the client's `FavoriteChatResponse` — a bare `{ ChatResult, IsFavorited }`
// object, not the bare ChatResult integer the neighbouring thread actions answer.
.on(
['PUT', 'POST'],
'/thread/:id{[0-9]+}/favorite',
chatResultRoute(
'Favorite or unfavorite a thread',
[
describeRoute({
tags: ['Chat'],
summary: 'Favorite or unfavorite a thread',
description: [
'Like snoozing, a per-member flag that pins the thread in the caller’s own inbox and',
'leaves everyone else’s untouched.',
'leaves everyone else’s untouched. Answers the client’s `FavoriteChatResponse`, a bare',
'`{ ChatResult, IsFavorited }` object — not the bare ChatResult integer the other thread',
'actions answer.',
].join(' '),
{ requestBody: form(FavoriteThreadRequest, 'The favorite flag') }
),
security: AUTHED,
parameters: [THREAD_ID_PARAM],
requestBody: form(FavoriteThreadRequest, 'The favorite flag'),
responses: {
200: json(
FavoriteChatResponse,
'ChatResult 0 and the new flag on success; ChatResult 3 when the caller isn’t on the thread'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
return c.json({ ChatResult: CHAT_MEMBERSHIP_NOT_FOUND, IsFavorited: false })
}
await setThreadFavorited(c.env.DB, chatThreadId, id, await formBool(c, 'favorite'))
return c.json(CHAT_SUCCESS)
const isFavorited = await formBool(c, 'favorite')
await setThreadFavorited(c.env.DB, chatThreadId, id, isFavorited)
return c.json({ ChatResult: CHAT_SUCCESS, IsFavorited: isFavorited })
}
)
@@ -1558,4 +1579,4 @@ app.get(
)
)
export default withDatabase<Env>(app)
export default app
+19
View File
@@ -274,6 +274,13 @@ export const PartyChatThread = z.object({
ClubId: z.int().nullable().describe('Always null here — this worker serves no club threads'),
})
/** 2026 reads a thread list; older clients read a thread or an empty object. */
export const PartyChatReadResponse = z.union([
PartyChatThread,
z.array(PartyChatThread),
z.object({}).strict(),
])
/**
* `POST /thread/party` — the client's CreatePartyChat. A bare two-key wrapper, PascalCase
* like the thread inside it, with no `{ success, error, value }` envelope around it.
@@ -381,6 +388,18 @@ export const FavoriteThreadRequest = z.object({
.describe('`True`/`False` as the client spells it (`1`/`yes` also count as true)'),
})
/**
* `PUT|POST /thread/:id/favorite` response — the client's `FavoriteChatResponse`, a bare
* two-key object with no envelope. Unlike the other thread actions it is NOT a bare
* ChatResult integer: the client parses this object either way.
*/
export const FavoriteChatResponse = z.object({
ChatResult,
IsFavorited: z
.boolean()
.describe('The caller’s favorite flag after the call (false when they aren’t on the thread)'),
})
// ---- Shared parameters -----------------------------------------------------
/** The numeric `:id` path segment naming a thread (constrained to digits by the route). */
+39 -6
View File
@@ -50,10 +50,10 @@ function b64url(input: ArrayBuffer | string): string {
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
async function bearer(sub: number): Promise<Record<string, string>> {
async function bearer(sub: number, version?: string): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify({ sub: String(sub), exp: now + 3600 })
JSON.stringify({ sub: String(sub), exp: now + 3600, 'rn.ver': version })
)}`
const key = await crypto.subtle.importKey(
'raw',
@@ -74,6 +74,18 @@ beforeAll(async () => {
})
describe('chat endpoints', () => {
it('2026 party reads return a list, including when the caller has no party', async () => {
const empty = await SELF.fetch(`${ORIGIN}/thread/party`, {
headers: await bearer(42601, '20260323.14'),
})
expect(empty.status).toBe(200)
expect(await empty.json()).toEqual([])
const threadId = await createThread(env.DB, [42601], null, undefined, ChatThreadType.Party)
const present = await SELF.fetch(`${ORIGIN}/thread/party`, {
headers: await bearer(42601, '20260323.14'),
})
expect(await present.json()).toMatchObject([{ ChatThreadId: threadId }])
})
it('GET / reports service status', async () => {
const res = await SELF.fetch(`${ORIGIN}/`)
expect(res.status).toBe(200)
@@ -956,6 +968,17 @@ describe('PUT /thread/chatPrivacySetting', () => {
})
})
// Re-posting the stored value writes nothing to KV — writes are the cost, and the client
// posts these freely. The raw value is seeded with whitespace JSON.stringify never
// produces; surviving the PUT untouched means no write happened.
it('does not write KV when the value is already stored', async () => {
const padded = '{ "Recroom.OOBE": "77", "directMessagePrivacySetting": "Favorites" }'
await env.RECFLARE_PLAYER_SETTINGS.put('player:887010', padded)
const res = await put(887010, { directMessagePrivacySetting: 'Favorites' })
expect(await res.json()).toMatchObject({ directMessagePrivacySetting: 1 })
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:887010', 'text')).toBe(padded)
})
it('accepts the enum by ordinal too, and is case-insensitive about the name', async () => {
expect(await (await put(887005, { directMessagePrivacySetting: '2' })).json()).toMatchObject({
directMessagePrivacySetting: 2,
@@ -2141,13 +2164,17 @@ describe('PUT /thread/:id/favorite', () => {
const res = await favorite(caller, chatThreadId, 'favorite=True')
expect(res.status).toBe(200)
expect(await res.json()).toBe(0)
// The client's FavoriteChatResponse: a bare 2-key object, no envelope.
expect(await res.json()).toEqual({ ChatResult: 0, IsFavorited: true })
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(true)
// Per-member, like snoozing: the other player's inbox is untouched.
expect((await getThreadForPlayer(env.DB, chatThreadId, other))?.isFavorited).toBe(false)
await favorite(caller, chatThreadId, 'favorite=False')
expect(await (await favorite(caller, chatThreadId, 'favorite=False')).json()).toEqual({
ChatResult: 0,
IsFavorited: false,
})
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(false)
})
@@ -2155,13 +2182,19 @@ describe('PUT /thread/:id/favorite', () => {
const caller = 893003
const chatThreadId = await createThread(env.DB, [caller, 893004], null, caller)
expect(await (await favorite(caller, chatThreadId, 'favorite=True', 'POST')).json()).toBe(0)
expect(await (await favorite(caller, chatThreadId, 'favorite=True', 'POST')).json()).toEqual({
ChatResult: 0,
IsFavorited: true,
})
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(true)
})
it('refuses a thread the caller is not on', async () => {
const chatThreadId = await createThread(env.DB, [893005, 893006], null, 893005)
expect(await (await favorite(893007, chatThreadId, 'favorite=True')).json()).toBe(3)
expect(await (await favorite(893007, chatThreadId, 'favorite=True')).json()).toEqual({
ChatResult: 3,
IsFavorited: false,
})
})
it('401s without a token', async () => {
+86 -3
View File
@@ -12,6 +12,7 @@ import {
createClub,
createClubAnnouncement,
deleteClub,
deleteClubAnnouncement,
getClub,
getClubAnnouncements,
getClubDetails,
@@ -21,6 +22,7 @@ import {
getHomeClub,
getMembership,
getOrCreateSubscriptionClub,
getPublicClubsByMember,
getSubscriptionClub,
getSubscriptionMemberships,
glyphLength,
@@ -36,7 +38,6 @@ import {
setMemberType,
subscribeToClub,
updateClub,
withDatabase,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
@@ -56,6 +57,7 @@ import {
ClubMembersEnvelope,
ClubSearchResponse,
CreateClubRequest,
DeleteAnnouncementResponse,
EmptyObject,
ErrorEnvelope,
form,
@@ -77,7 +79,7 @@ import {
} from './openapi'
import type { Context } from 'hono'
import type { App, Env } from './context'
import type { App } from './context'
/**
* Clubs Worker. Hosts the club endpoints the game client calls on the `clubs` host:
@@ -643,6 +645,58 @@ const app = new Hono<App>()
}
)
// Delete one of a club's announcements. The club's owner (creator) only. Answers
// `{ Success, Error, error_id }` — PascalCase, unlike the rest of this worker.
.delete(
'/announcements/club/:clubId{[0-9]+}/:announcementId{[0-9]+}',
describeRoute({
tags: ['Announcements'],
summary: 'Delete a club announcement',
description: [
'The club’s owner (creator) only. Answers `{ Success, Error, error_id }` —',
'PascalCase, not the `{ error, success, value }` envelope the rest of this worker uses.',
].join(' '),
security: AUTHED,
parameters: [
CLUB_ID_PARAM,
{
name: 'announcementId',
in: 'path',
required: true,
description: 'The announcement’s id',
schema: { type: 'string' },
},
],
responses: {
200: json(DeleteAnnouncementResponse, 'Deleted'),
401: UNAUTHORIZED_RESPONSE,
403: json(DeleteAnnouncementResponse, 'Not the club’s owner'),
404: json(DeleteAnnouncementResponse, 'No such club or announcement'),
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const clubId = Number.parseInt(c.req.param('clubId'), 10)
const announcementId = Number.parseInt(c.req.param('announcementId'), 10)
const club = await getClub(c.env.DB, clubId)
if (club === null) {
return c.json({ Success: false, Error: 'Club not found.', error_id: null }, 404)
}
const membership = await getMembership(c.env.DB, clubId, id)
if (membership < ClubMembershipType.Creator) {
return c.json({ Success: false, Error: 'Insufficient permissions.', error_id: null }, 403)
}
if (!(await deleteClubAnnouncement(c.env.DB, clubId, announcementId))) {
return c.json({ Success: false, Error: 'Announcement not found.', error_id: null }, 404)
}
return c.json({ Success: true, Error: null, error_id: null })
}
)
// The clubs the player is a member of (GetMyMembershipClubs). Reads the caller's
// memberships from `club_member`. A caller with no valid token has no clubs, so
// this answers an empty list rather than 401ing — the client shows the "my clubs"
@@ -686,6 +740,35 @@ const app = new Hono<App>()
}
)
// The clubs shown on another player's profile: the PUBLIC clubs the account is a
// member of. Public — a private club is left out for everyone, the account's own
// owner included (they read theirs from /club/mine/member). An unknown account
// simply has no clubs.
.get(
'/account/:accountId{[0-9]+}/clubs',
describeRoute({
tags: ['Clubs'],
summary: 'The public clubs an account is a member of',
description: [
'The account’s memberships from `club_member`, oldest club first, PUBLIC clubs only',
'(private clubs, subscription clubs and pending/denied/banned rows excluded). Public',
'(no auth). An unknown account has `[]`.',
].join(' '),
parameters: [
{
name: 'accountId',
in: 'path',
required: true,
description: 'Account id (digits only)',
schema: { type: 'string' },
},
],
responses: { 200: json(ClubDto.array(), 'The account’s public clubs') },
}),
async (c) =>
c.json(await getPublicClubsByMember(c.env.DB, Number.parseInt(c.req.param('accountId'), 10)))
)
// Club search / browse. Public, non-subscription clubs; `category` filters to that
// category, `query` matches the name or description, `sort` picks the order (1 =
// newest, 2 = by name, default = most members first), and `count` caps the page
@@ -1707,4 +1790,4 @@ app.get(
)
)
export default withDatabase<Env>(app)
export default app
+11
View File
@@ -236,6 +236,17 @@ export const AnnouncementIdEnvelope = z.object({
value: z.int().describe('The new announcement’s id'),
})
/**
* The reply to deleting an announcement (`DELETE /announcements/club/:clubId/:announcementId`).
* PascalCase with a snake_case `error_id` — not the `{ error, success, value }` envelope
* the rest of this worker answers.
*/
export const DeleteAnnouncementResponse = z.object({
Success: z.boolean(),
Error: z.string().nullable().describe('Null on success; the message shown otherwise'),
error_id: z.null(),
})
// ---- Other response shapes -------------------------------------------------
/** `GET /club/search` — a page of clubs plus the full match count. */
+113
View File
@@ -369,6 +369,64 @@ describe('clubs endpoints', () => {
expect(await res.json()).toEqual([])
})
test('GET /account/:id/clubs lists only the PUBLIC clubs the account is a member of', async () => {
const club = (ClubId: number, Visibility: number, ClubType = 0) => ({
ClubId,
Name: `Club ${ClubId}`,
Description: '',
Category: 'Social',
Visibility,
Joinability: 0,
AllowJuniors: true,
MainImageName: '',
ClubType,
ClubhouseRoomId: null,
CreatorAccountId: 4500,
IsRRO: false,
MinLevel: 0,
State: 0,
MemberCount: 2,
CreatedAt: `2026-07-0${ClubId - 9100}T00:00:00Z`,
})
// 9101 public, 9102 private, 9103 public but only a pending request, 9104 a
// public subscription club, 9105 public.
const clubs = [club(9101, 1), club(9102, 0), club(9103, 1), club(9104, 1, 1), club(9105, 1)]
const insertClub = env.DB.prepare('INSERT INTO club (data) VALUES (?1)')
const insertMember = env.DB.prepare(
'INSERT INTO club_member (club_id, account_id, membership_type, created_at) VALUES (?1, 4501, ?2, ?3)'
)
await env.DB.batch([
...clubs.map((c) => insertClub.bind(JSON.stringify(c))),
...clubs.map((c) =>
insertMember.bind(c.ClubId, c.ClubId === 9103 ? 1 : 10, '2026-07-10T00:00:00Z')
),
])
const pending = await env.DB.prepare(
'SELECT membership_type FROM club_member WHERE club_id = 9103 AND account_id = 4501'
).first<{ membership_type: number }>()
expect(pending?.membership_type).toBeLessThan(10)
// No auth needed; the private, pending and subscription clubs are left out.
const res = await exports.default.fetch(`${ORIGIN}/account/4501/clubs`)
expect(res.status).toBe(200)
const listed = (await res.json()) as Array<{ ClubId: number; Visibility: number }>
expect(listed.map((c) => c.ClubId)).toEqual([9101, 9105])
expect(listed[0]).toMatchObject({ Name: 'Club 9101', Visibility: 1, MemberCount: 2 })
// The member themself gets the same public-only list here.
const own = await exports.default.fetch(`${ORIGIN}/account/4501/clubs`, {
headers: await bearer('4501'),
})
expect(((await own.json()) as Array<{ ClubId: number }>).map((c) => c.ClubId)).toEqual([
9101, 9105,
])
// An account with no memberships has no clubs; a non-numeric id doesn't match.
const none = await exports.default.fetch(`${ORIGIN}/account/4599/clubs`)
expect(await none.json()).toEqual([])
expect((await exports.default.fetch(`${ORIGIN}/account/abc/clubs`)).status).toBe(404)
})
test('the my-clubs lists exclude subscription clubs (ClubType 1)', async () => {
// A subscription club the player both created and is a member of. It's reached
// through /subscription/*, so it must not show up among their clubs.
@@ -1209,6 +1267,59 @@ describe('clubs endpoints', () => {
expect(missing.status).toBe(404)
})
test('DELETE /announcements/club/:id/:announcementId deletes one, owner only', async () => {
const create = await exports.default.fetch(`${ORIGIN}/club/create`, {
method: 'POST',
headers: { ...(await bearer('9450')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'name=Retractions',
})
const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId
const postOne = async (title: string): Promise<number> => {
const res = await exports.default.fetch(`${ORIGIN}/announcements/club/${clubId}`, {
method: 'POST',
headers: { ...(await bearer('9450')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: `title=${title}`,
})
return ((await res.json()) as { value: number }).value
}
const keepId = await postOne('Keep')
const dropId = await postOne('Drop')
const del = async (path: string, sub: string | null = '9450'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/announcements/club/${path}`, {
method: 'DELETE',
headers: sub === null ? {} : await bearer(sub),
})
const ids = async (): Promise<number[]> => {
const res = await exports.default.fetch(`${ORIGIN}/announcements/club/${clubId}`)
const body = (await res.json()) as {
value: { Announcements: Array<{ AnnouncementId: number }> }
}
return body.value.Announcements.map((a) => a.AnnouncementId)
}
// Signed out is a 401; a non-member is refused; neither touches the board.
expect((await del(`${clubId}/${dropId}`, null)).status).toBe(401)
const refused = await del(`${clubId}/${dropId}`, '9451')
expect(refused.status).toBe(403)
expect(await refused.json()).toEqual({
Success: false,
Error: 'Insufficient permissions.',
error_id: null,
})
expect(await ids()).toEqual([dropId, keepId])
// The owner deletes it; the other stays.
const ok = await del(`${clubId}/${dropId}`)
expect(ok.status).toBe(200)
expect(await ok.json()).toEqual({ Success: true, Error: null, error_id: null })
expect(await ids()).toEqual([keepId])
// Gone now, so a repeat 404s; so does an unknown club, or an id from another club.
expect((await del(`${clubId}/${dropId}`)).status).toBe(404)
expect((await del(`99999/${keepId}`)).status).toBe(404)
expect(await ids()).toEqual([keepId])
})
test('PUT /club/:id/minlevel sets the join level, co-owner only', async () => {
type Details = { error: string; success: boolean; value: { Club: { MinLevel: number } } }
const create = await exports.default.fetch(`${ORIGIN}/club/create`, {
@@ -1642,10 +1753,12 @@ describe('clubs endpoints', () => {
)
)
expect([...documented].sort()).toEqual([
'DELETE /announcements/club/{clubId}/{announcementId}',
'DELETE /club/home/me',
'DELETE /club/{clubId}',
'DELETE /club/{clubId}/additionalimage/{index}',
'DELETE /club/{clubId}/clubhouse',
'GET /account/{accountId}/clubs',
'GET /announcements/club/{clubId}',
'GET /announcements/v2/mine/unread',
'GET /announcements/v2/subscription/mine/unread',
+12
View File
@@ -72,6 +72,18 @@ const app = new Hono<App>()
(c) => c.json(false)
)
.get(
'/purchaseRestriction/isplayerrestricted',
describeRoute({
tags: ['Purchase'],
summary: 'Player purchase restriction status',
description:
'No external payment restrictions are configured. The 2026 client expects a bare boolean.',
responses: { 200: json(BareBoolean, 'No purchase restriction') },
}),
(c) => c.json(false)
)
// Begin a purchase. The client asks for a transaction handle before it takes the
// player to the platform store; nothing is charged or recorded here, so the id is a
// fixed placeholder and the posted body is ignored.
@@ -18,6 +18,12 @@ describe('commerce endpoints', () => {
expect(await res.json()).toBe(false)
})
it('2026 purchase restriction response is a bare boolean', async () => {
const res = await SELF.fetch(`${ORIGIN}/purchaseRestriction/isplayerrestricted`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(false)
})
it('POST /purchase/v1/initiatepurchase returns a transaction id', async () => {
const res = await SELF.fetch(`${ORIGIN}/purchase/v1/initiatepurchase`, {
method: 'POST',
@@ -88,6 +94,7 @@ describe('commerce endpoints', () => {
'GET /api/catalog/v1/all',
'GET /purchase/v1/cleanuppending',
'GET /purchase/v1/hasspentmoney',
'GET /purchaseRestriction/isplayerrestricted',
'GET /purchasecampaign/allcurrent/v2',
'GET /reminder/currentTokenBundles/v2',
'POST /purchase/v1/cleanuppending',
@@ -48,12 +48,13 @@ const app = new Hono<App>()
return c.json([])
})
// Sampling configuration, asked for once per session (`?sessionId=<guid>`). An empty
// object carries no per-event overrides, which the client reads as "sample everything
// at the built-in default rates". Since the events are discarded anyway, the rate it
// picks makes no difference here.
// Sampling configuration, asked for once per session (`?sessionId=<guid>`). A BARE ARRAY
// of `{ EventType, ShouldBeSampled }` entries (the client decodes a `List<EventDTO>`),
// e.g. `[{ "EventType": "player_join", "ShouldBeSampled": true }]` — not an object: the
// `{}` this once served made the client's decoder throw. Empty says "sample nothing",
// which is the right answer since the events are discarded anyway.
.get('/sampling', async (c) => {
return c.json({})
return c.json([])
})
export default app
@@ -49,7 +49,7 @@ it('acknowledges an unreadable body on both event paths', async () => {
it('serves an empty sampling configuration', async () => {
const res = await SELF.fetch(`${ORIGIN}/sampling?sessionId=e9a899ce-ce46-447d-bb22-11e82ed68f8d`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({})
expect(await res.json()).toEqual([])
})
// The client always sends `sessionId`, but nothing here reads it, so a missing one must
@@ -57,5 +57,5 @@ it('serves an empty sampling configuration', async () => {
it('serves the sampling configuration without a sessionId', async () => {
const res = await SELF.fetch(`${ORIGIN}/sampling`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({})
expect(await res.json()).toEqual([])
})
+56 -30
View File
@@ -25,7 +25,11 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
| 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/itemWishlists/v1/wishlist/me` | ✓ | The caller's item wishlist |
| GET | `/api/itemWishlists/v1/wishlist/:accountId` | ✓ | Another player's item wishlist |
| POST | `/api/itemWishlists/v1/wishlist/add` | ✓ | Wish for an item (form, enveloped) |
| POST | `/api/itemWishlists/v1/wishlist/remove` | ✓ | Un-wish an item (a POST, not DELETE) |
| POST | `/api/itemWishlists/v1/isonwishlist/bulk` | ✓ | Per-player "has it wished for" booleans |
| 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 |
@@ -39,6 +43,7 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
| 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 |
| POST | `/api/consumables/v1/updateActive` | ✓ | Mark a consumable active (stub, empty) |
| 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 |
@@ -47,10 +52,14 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
| 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 → 5 XP + gift box |
| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) |
| POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 10 XP + gift box |
| GET | `/api/roomkeys/v1/mine` | ✓ | The player's held room keys |
| GET | `/api/roomkeys/v1/room` | | The keys a room has listed |
| POST | `/api/roomkeys/v1/create` | ✓ | List a key for a room (owner/co-owner) |
| PUT | `/api/roomkeys/v1/updateAll` | ✓ | Edit one key (owner/co-owner) |
| GET | `/api/storefronts/v1/buyRoomKey` | ✓ | Buy a room key; pays the room owner |
| POST | `/api/storefronts/v1/PurchaseRoomKeyWithCurrency` | ✓ | Buy a room key with its room currency |
| POST | `/api/roomCurrencies/v2/purchase` | ✓ | Buy room currency with tokens (offer) |
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | ~ | Gold year for `developer`s, else `{}` |
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
@@ -457,17 +466,19 @@ 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, gift context)
holding the last claim and a count. One claim per type per activity 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.
(`REWARD_COOLDOWN_MS`) — except `FirstActivityOfDay`, which is one per PLAYER per rolling
24h (`DAILY_REWARD_COOLDOWN_MS`): it is always claimed on the `''` context, whichever
activity asked, so its row's `granted_at` is simply the player's last one.
- **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 part of the key** — the "first activity of
the day" is per activity, so a player who moves from Soccer to Paintball is owed another
reward while a second Soccer match inside the hour is not.
- **`giftContext` (the activity, e.g. `Soccer`) is part of the key** for the hourly types,
so a player who moves from Soccer to Paintball is owed another reward while a second Soccer
match inside the hour is not. The prize is still picked from the `giftContext` that asked,
`FirstActivityOfDay` included.
- **A contextless ask keys on `''`, not NULL.** SQLite allows — and does not dedupe — NULLs
in a non-INTEGER primary key, so a NULL context would insert a fresh row on every ask
instead of hitting the conflict, and the cooldown would never apply. Migration
@@ -475,29 +486,35 @@ suggests; per-type windows would be a map keyed by type.
primary key) and lands the pre-existing rows on that same `''` bucket, so cooldowns from
before it keep counting.
**What a claim pays: 5 XP, in a gift box.** The XP (`GAME_REWARD_XP`) is banked in
**What a claim pays: 10 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.
- **Deliberately smaller than a level.** The first level costs 10 XP, so a single action
can't be a level-up — it takes two rewards to reach level 2, and the early levels are paced
by the hourly cooldown rather than cleared in one match.
- **A NEW activity pays 250 tokens on top** (`NEW_ACTIVITY_BONUS_TOKENS`): the first claim
ever in a `giftContext`, whichever reward type asked for it — `isNewActivity` counts the
player's rows in that context right after the claim and is true when the claim's own row
is the only one. Counting after the write is what stops two asks landing together
(`FirstActivityOfDay` and `PostGameActivity` for the same match) from both paying: a race
can skip the bonus, never double it. The tokens are credited to the RecCenterTokens
balance (`StorefrontBalanceUpdate` with the resulting total) and arrive in a **second box**
of their own — `GiftContext` 51 (`GameRewardsTokens`), `Currency` 250, no XP — after the
reward's, so the player sees what they were for. The contextless `''` bucket is not an
activity and never pays it.
- **One flat amount for every reward type.** Pricing `FirstActivityOfDay` differently from
`PostGameActivity` is a map keyed by type.
- **Exactly an early level.** Each of the first three levels costs 10 XP, so a fresh
player's first rewards each level them up; from level 4 (20 XP a level) it takes two or
more, and the daily XP cap paces it from there.
- **`PostGameActivity`/`PostQuestActivity` pay 250 tokens, up to 1,250 a day**
(`GAME_REWARD_TOKENS`, `GAME_REWARD_DAILY_TOKEN_CAP`) — per player, whatever the
`giftContext`. `FirstActivityOfDay` pays 250 more OUTSIDE that cap — its own once-a-day
claim already limits it, so it has no allowance row. The running total
is one row per player in `reward_status` under the reserved type `$tokens` (context `''`):
`grant_count` is the TOKENS paid in the window and `granted_at` when the window opened.
`claimAllowance` adds to it in one atomic upsert, refusing a grant that would cross the cap
(all or nothing, so the cap is a multiple of the amount), and resets it in place once the
window is 24h old — a rolling window, so no time zone's midnight decides it. The tokens are
credited to the RecCenterTokens balance (`StorefrontBalanceUpdate` with the resulting total)
and arrive in a **second box** of their own — `GiftContext` 51 (`GameRewardsTokens`),
`Currency` 250, no XP — after the reward's, so the player sees what they were for.
- **XP is capped too: 150 a day** (`GAME_REWARD_DAILY_XP_CAP`), fifteen claims' worth, on the
same machinery — the player's `$xp` row, `grant_count` the XP paid in the window. Every
type's XP draws on it but `FirstActivityOfDay`'s, a bonus on top like its tokens. Past the
cap a claim still counts against its cooldown and still pays its activity's item (in a box
with `Xp` 0) and its tokens; a claim with nothing left to carry gets no box at all.
- **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.
@@ -537,7 +554,7 @@ costs are easy to edit one at a time and hard to eyeball as a curve.
| 50 | 5-Star Clothing (rarity 50) — the only one |
**One reward per level crossed** — a grant spanning several levels pays each of them. In
practice a 5 XP game reward crosses at most one, so the second reward a fresh player claims
practice a 10 XP game reward crosses at most one, so the first reward a fresh player claims
hands over two boxes: the XP reward itself and the 2-Star Clothing for reaching level 2. Each
arrives as a gift box announced like any other (`Level 2!`).
@@ -600,6 +617,15 @@ role that role's RecCenterTokens in a gift box, every time it fires. The map is
for the box. An offline player finds the box on their next `GET /api/avatar/v2/gifts`.
- A grant that fails partway is logged with the account, role and amount and is not retried —
a retry that also failed partway is how a balance gets credited twice.
- **The first link is paid on demand.** `www`'s benefits claim calls `grantDiscordRoleGift`
— one account, the roles Discord just served, the same map and box — the first time an
account links its Discord, so a new supporter gets one box now rather than after the next
run. `www` decides "first" from the link table; this module keeps no ledger either way, and
the cron pays that account again on its next run like everyone else.
- **A whole run can be paid by hand.** `www`'s staff panel ("Bonus reward!", developer-only,
`POST /api/staff/discord-roles/supporter-gift`) calls `grantDiscordRoleGifts` with its own
bindings — the same map, audience and box as the cron — and answers with the run's summary.
No ledger here either: it pays everyone again on top of the schedule, which is the point.
One D1 read for the links, then a handful of writes and two hub calls per box, sequentially.
A cron invocation has a fixed subrequest budget (50 free, 1000 paid); a community whose
@@ -629,10 +655,10 @@ Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no cod
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 5 XP; there is no daily XP cap beyond the hourly cooldown (the
reference caps activity XP per day in `daily_xp_ledgers`).
- Several routes (equipment, room consumables/currencies) are empty-list stubs pending
their own stores.
- Game rewards pay a flat 10 XP, capped at 150 a day (the reference caps activity XP per day
in `daily_xp_ledgers`).
- The level-reward table and the served config's `GiftRarity` disagree in places (see the
level section); we grant from the table and leave the config as captured, so a client that
previews an upcoming reward would preview the config's answer, not ours.
@@ -0,0 +1,16 @@
-- Who HOLDS a room key — one row per (key, player).
--
-- Written by `POST /api/roomkeys/v1/awardbulk`, which awards the CALLER the keys its body
-- names. A key is held or it isn't — there is no quantity — so the pair is the primary key
-- and awarding a key a player already holds changes nothing (`awarded_at` keeps the first
-- award). `room_key_id` is `room_key.room_key_id`. Owned by the `econ` worker; generated
-- from src/room-key-db.ts (ROOM_KEY_SCHEMA_DDL) — keep in sync.
CREATE TABLE IF NOT EXISTS room_key_player (
room_key_id INTEGER NOT NULL,
account_id INTEGER NOT NULL,
awarded_at TEXT NOT NULL,
PRIMARY KEY (room_key_id, account_id)
);
-- "This player's keys" — the primary key only serves lookups that lead with the key.
CREATE INDEX IF NOT EXISTS idx_room_key_player_account ON room_key_player (account_id);
@@ -0,0 +1,20 @@
-- An invention's `NumDownloads` is the number of players who have it: the rows in
-- `inventory_invention` that name it. `grantInvention` now re-derives it on every grant
-- (packages/domain/src/inventory-invention-db.ts); every purchase before that left it at
-- the 0 a save mints, so this backfills the inventions already bought.
--
-- The `invention` table is the `api` worker's (same database). `ModifiedAt` is left alone:
-- being bought is not an edit of the invention.
-- Backs that per-invention count; the primary key leads with account_id.
-- Generated from INVENTORY_INVENTION_SCHEMA_DDL — keep in sync.
CREATE INDEX IF NOT EXISTS idx_inventory_invention_invention
ON inventory_invention (invention_id);
UPDATE invention
SET data = json_set(
data,
'$.NumDownloads',
(SELECT COUNT(*) FROM inventory_invention WHERE invention_id = invention.id)
)
WHERE id IN (SELECT invention_id FROM inventory_invention);
+34
View File
@@ -0,0 +1,34 @@
-- Item wishlists, owned by the `econ` worker. One row per item a player has wished for —
-- what the store's heart toggle writes (`POST /api/itemWishlists/v1/wishlist/add|remove`,
-- form-encoded `purchasableItemId=1534&customAvatarItemId=`) and the wishlist page reads
-- (`GET /api/itemWishlists/v1/wishlist/me|{accountId}`, a bare array of entries).
--
-- `wishlist_item_id` is a GUID: the client's `WishlistItemId` is a `Guid`, not an int, so the
-- row's key has to be one. `player_id` is the account the list belongs to.
--
-- A row names ONE item in one of two ways — a catalog item by `purchasable_item_id` (the
-- storefront's `PurchasableItemId`) or a player-made/first-party custom avatar item by
-- `custom_avatar_item_id` (its GUID, stored lowercase) — so exactly one of the two is set.
-- On the wire the client's `PurchasableItemId` is a plain int, so a custom-item row is served
-- as `PurchasableItemId: 0` with the GUID beside it; the column is still NULL here so the two
-- kinds of row can't be confused.
--
-- The two partial unique indexes make a wish boolean: a second `add` of the same item finds
-- the existing row rather than making a twin. `idx_wishlist_player` is the read's access
-- pattern (one player's whole list).
--
-- Kept in sync with WISHLIST_SCHEMA_DDL in src/wishlist-db.ts.
CREATE TABLE IF NOT EXISTS wishlist (
wishlist_item_id TEXT PRIMARY KEY,
player_id INTEGER NOT NULL,
purchasable_item_id INTEGER,
custom_avatar_item_id TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_wishlist_player ON wishlist (player_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_wishlist_player_purchasable
ON wishlist (player_id, purchasable_item_id) WHERE purchasable_item_id IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_wishlist_player_custom
ON wishlist (player_id, custom_avatar_item_id) WHERE custom_avatar_item_id IS NOT NULL;
@@ -0,0 +1,15 @@
-- The served store listing behind a catalog row, verbatim as JSON: the `StoreItems[]` entry
-- of `static/storefronts/sf3-2025.json` whose `PurchasableItemId` is the row's `catalog_id`.
--
-- `POST /api/storefronts/v1/purchasableitems` answers with whole listings — prices, item set,
-- `AvatarItemInfo` and the rest — which the catalog's own columns do not carry. Keeping the
-- listing on the row lets that lookup be one indexed query instead of fetching and parsing the
-- 4 MB storefront file per request.
--
-- Filled by `runx catalog load`, like every other column. NULL on a row the served store does
-- not list (the same rows whose `catalog_id` is NULL), and until the first load after this
-- migration.
--
-- Its own migration because 0015 and 0016 are already applied.
ALTER TABLE catalog ADD COLUMN listing TEXT;
@@ -0,0 +1,13 @@
-- What a player holds of a room consumable now remembers its last write: `concurrency_code`
-- (the request's `NewConcurrencyCode`) and `modified_at`. Owned by the `econ` worker; generated
-- from src/room-consumable-db.ts (ROOM_INVENTORY_SCHEMA_DDL, which mirrors the table as it
-- stands AFTER this migration) — keep in sync.
--
-- Both are served on the client's `RoomConsumableInventoryItem` (`GET
-- …/roomConsumable/room/{roomId}/me`, and the purchase's `InventoryItem`) as `ConcurrencyCode`
-- and `ModifiedAt`. The code is stored to be echoed, not checked — no optimistic concurrency is
-- implemented. Nullable: rows written before this have neither, and are served a null code and
-- the listing's own `ModifiedAt`.
ALTER TABLE room_inventory ADD COLUMN concurrency_code TEXT;
ALTER TABLE room_inventory ADD COLUMN modified_at TEXT;
+3 -1
View File
@@ -16,10 +16,12 @@ export const SCHEMA_DDL: string[] = [
avatar TEXT,
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL,
has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL
has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL,
is_influencer INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInfluencer')) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON account (account_id)`,
`CREATE INDEX IF NOT EXISTS idx_account_has_plus ON account (has_plus) WHERE has_plus = 1`,
`CREATE INDEX IF NOT EXISTS idx_account_is_influencer ON account (is_influencer) WHERE is_influencer = 1`,
]
/** The stored avatar payload — opaque JSON the client sets and reads back. */
+135
View File
@@ -1,6 +1,8 @@
import { BalancePlatform } from '../../notify/src/notification-payloads'
import { CurrencyType } from './currency'
import type { GiftContent } from '@repo/domain'
// The currency vocabulary and the Plus reload SQL live in the import-free `currency.ts` so
// the Node CLI in @repo/tools can share them; re-exported here so this stays the module
// everything else reads balances from.
@@ -199,3 +201,136 @@ export async function spendCurrency(
.run()
return meta.changes > 0
}
/** One token box to hand over: whose, how many, and the stored box. See {@link payTokenGifts}. */
export interface TokenGift {
accountId: number
/** Signed: positive credits, negative debits (guarded — see below), zero only boxes. */
amount: number
/** The box as stored in `received_gift`, serialized as-is. */
content: GiftContent
}
/** One box handed over by {@link payTokenGifts}: the resulting balance and the box's id. */
export interface PaidTokenGift extends TokenGift {
balance: number
giftId: number
}
/**
* How many accounts one round of {@link payTokenGifts} writes at once. Every statement
* binds three values per account, and D1 allows 100 bound parameters a statement.
*/
export const TOKEN_GIFT_CHUNK = 30
/**
* Hand RecCenterTokens in a gift box to MANY accounts at once — the staff drops, the room
* gift and the Discord supporter cron, which all pay a crowd. Paid one account at a time
* (seed, credit, re-read, box: five D1 round trips each) a full server was ~1,250 queries
* in series, past D1's 1,000-queries-an-invocation cap somewhere around 200 players, and
* died partway with the first players paid and the rest not. This pays
* {@link TOKEN_GIFT_CHUNK} accounts a round in two batches of multi-row statements.
*
* Per account it is exactly the one-player grant: the signup grant seeded first (a
* never-touched balance must not start from the gift alone), the balance moved, then a box
* stored. A credit is an upsert; a debit is `amount >= ?` guarded in the UPDATE itself, so
* nobody is overdrawn, and an account that can't cover it is SKIPPED — no box — rather than
* failing the rest. `paid` keeps the order of `gifts`; `skipped` is the rest. A duplicate
* account among `gifts` is paid once, the first entry's amount: a multi-row upsert may not
* name one key twice.
*
* Sends NOTHING: the caller builds the balance frame (the RESULTING total, `balance`, into
* the -2 bucket) and the box's announcement from the rows returned, and should push them in
* one hub call. Nothing here is transactional across rounds: a round that throws leaves the
* earlier ones paid, which the caller reports.
*/
export async function payTokenGifts(
db: D1Database,
gifts: readonly TokenGift[],
startingTokens: number
): Promise<{ paid: PaidTokenGift[]; skipped: number[] }> {
const grants = startingBalances(startingTokens)
const paid: PaidTokenGift[] = []
const skipped: number[] = []
const seen = new Set<number>()
const unique = gifts.filter((g) => !seen.has(g.accountId) && seen.add(g.accountId))
for (let i = 0; i < unique.length; i += TOKEN_GIFT_CHUNK) {
const chunk = unique.slice(i, i + TOKEN_GIFT_CHUNK)
const credits = chunk.filter((g) => g.amount > 0)
const debits = chunk.filter((g) => g.amount < 0)
const holds = chunk.filter((g) => g.amount === 0)
// Seed the signup grant, then move the money: one batch, one transaction. A debit's
// guard is per row, so a chunk of debits is one UPDATE per distinct amount.
const statements: D1PreparedStatement[] = [
db
.prepare(
`INSERT OR IGNORE INTO balance (account_id, currency_type, amount) VALUES ${chunk
.flatMap(() => grants.map(() => '(?, ?, ?)'))
.join(', ')}`
)
.bind(
...chunk.flatMap((g) => grants.flatMap((b) => [g.accountId, b.currencyType, b.amount]))
),
]
if (credits.length > 0) {
statements.push(
db
.prepare(
`INSERT INTO balance (account_id, currency_type, amount) VALUES ${credits
.map(() => '(?, ?, ?)')
.join(', ')}
ON CONFLICT (account_id, currency_type) DO UPDATE SET amount = amount + excluded.amount
RETURNING account_id, amount`
)
.bind(...credits.flatMap((g) => [g.accountId, CurrencyType.RecCenterTokens, g.amount]))
)
}
for (const amount of new Set(debits.map((g) => g.amount))) {
const ids = debits.filter((g) => g.amount === amount).map((g) => g.accountId)
statements.push(
db
.prepare(
`UPDATE balance SET amount = amount - ?1
WHERE currency_type = ?2 AND amount >= ?1
AND account_id IN (${ids.map(() => '?').join(', ')})
RETURNING account_id, amount`
)
.bind(-amount, CurrencyType.RecCenterTokens, ...ids)
)
}
if (holds.length > 0) {
statements.push(
db
.prepare(
`SELECT account_id, amount FROM balance
WHERE currency_type = ?1 AND account_id IN (${holds.map(() => '?').join(', ')})`
)
.bind(CurrencyType.RecCenterTokens, ...holds.map((g) => g.accountId))
)
}
const [, ...moved] = await db.batch<{ account_id: number; amount: number }>(statements)
// RETURNING comes back in no promised order; key it, then walk the chunk in its order.
const balances = new Map(moved.flatMap((r) => r.results).map((r) => [r.account_id, r.amount]))
const debited = chunk.filter((g) => balances.has(g.accountId))
for (const g of chunk) if (!balances.has(g.accountId)) skipped.push(g.accountId)
if (debited.length === 0) continue
const createdAt = new Date().toISOString()
const { results: boxes } = await db
.prepare(
`INSERT INTO received_gift (account_id, data, created_at) VALUES ${debited
.map(() => '(?, ?, ?)')
.join(', ')} RETURNING id, account_id`
)
.bind(...debited.flatMap((g) => [g.accountId, JSON.stringify(g.content), createdAt]))
.all<{ id: number; account_id: number }>()
const giftIds = new Map(boxes.map((r) => [r.account_id, r.id]))
for (const g of debited) {
paid.push({ ...g, balance: balances.get(g.accountId)!, giftId: giftIds.get(g.accountId)! })
}
}
return { paid, skipped }
}
+48 -13
View File
@@ -1,6 +1,6 @@
import { CatalogKind } from './catalog-load'
import type { CatalogKindValue } from './catalog-load'
import type { CatalogKindValue, StoreListing } from './catalog-load'
/**
* The item CATALOG on the shared `recflare` D1 database — every avatar item, consumable and
@@ -72,9 +72,9 @@ export {
*
* `catalog_id` is the small NUMERIC handle the site uses where a key would be unwieldy. For a
* row the store lists it is the listing's own `PurchasableItemId` — the number `sf3-2025.json`
* sells it under — and for a skin the store never listed it is handed out by the loader from
* `CATALOG_ID_BASE`, well above any real one. It is unique where set, and the migration that
* adds it is 0016, since 0015 was already applied.
* sells it under, including the skins we added to the store (numbered there from
* `UNLISTED_SKIN_ID_BASE`, 1 000 000). A row the file does not list has none and is NULL. It is
* unique where set, and the migration that adds it is 0016, since 0015 was already applied.
*
* Nullability is load-bearing rather than incidental: `tooltip` is genuinely NULL on some rows
* of BOTH kinds and `""` on others, and the client's DTOs serve the distinction through, so the
@@ -96,7 +96,8 @@ export const CATALOG_SCHEMA_DDL: string[] = [
tag_list TEXT,
created_at TEXT,
prefab_name TEXT,
unlocked_level INTEGER
unlocked_level INTEGER,
listing TEXT
)`,
// The search index. Folded, because a name search is case-insensitive and SQLite's LIKE is
// only case-insensitive for ASCII — which these names are not all of.
@@ -120,10 +121,9 @@ export interface CatalogRow {
* the store's `PurchasableItemId`, so the number resolves a row here and a listing in
* `sf3-2025.json` alike.
*
* Still not an identity: an unlisted skin's number is a load-order surrogate, and a store
* could renumber. Never store it, never treat it as what an item IS — `item_key` is that,
* and it is what the inventory holds. NULL only between a row existing and a load numbering
* it.
* Still not an identity: a store could renumber. Never store it, never treat it as what an
* item IS — `item_key` is that. NULL for a row `sf3-2025.json` does not list (a merge keeps
* such rows); every reader filters on `catalog_id IS NOT NULL`.
*/
catalog_id: number | null
kind: string
@@ -139,6 +139,12 @@ export interface CatalogRow {
created_at: string | null
prefab_name: string | null
unlocked_level: number | null
/**
* The served store listing behind the row — its `sf3-2025.json` `StoreItems[]` entry,
* verbatim, as JSON (migration 0028). NULL where `catalog_id` is: a row the store does not
* list has no listing. Read it through {@link getStoreListings}.
*/
listing: string | null
}
/**
@@ -259,8 +265,8 @@ export async function getCatalogItem(db: D1Database, itemKey: string): Promise<C
/**
* One catalog row by its numeric handle — a store `PurchasableItemId` for a listed item.
*
* Fine for a request that looked the number up moments ago; never for anything stored, since
* an unlisted skin's number is reassigned by every load.
* The number the client looks items up by, since it is the one sf3-2025.json serves. Not for
* anything stored: `item_key` is the identity, and a fresher store dump could renumber.
*/
export async function getCatalogItemById(
db: D1Database,
@@ -272,6 +278,35 @@ export async function getCatalogItemById(
.first<CatalogRow>()
}
/**
* Store listings by `PurchasableItemId` (the `catalog_id`), as the served store lists them —
* what `POST /api/storefronts/v1/purchasableitems` answers. In the order asked; an unknown id,
* a repeat, or a row without a listing is skipped rather than left as a hole, so the result may
* be SHORTER than the input and must not be read positionally.
*
* Every kind resolves — avatar items, consumables, skins and custom avatar items alike — and the
* listing is served exactly as the store lists it. Only listings that load as no row at all
* (loot boxes, token bundles, consumable packs; see `ListingKind`) are unknown here.
*/
export async function getStoreListings(db: D1Database, ids: number[]): Promise<StoreListing[]> {
const unique = [...new Set(ids)]
if (unique.length === 0) return []
// One bound JSON array rather than a placeholder per id: D1 caps a statement at 100 bound
// variables, and nothing stops the client asking for more.
const { results } = await db
.prepare(
`SELECT catalog_id, listing FROM catalog
WHERE catalog_id IN (SELECT value FROM json_each(?1)) AND listing IS NOT NULL`
)
.bind(JSON.stringify(unique))
.all<{ catalog_id: number; listing: string }>()
const byId = new Map(results.map((r) => [r.catalog_id, r.listing]))
return unique.flatMap((id) => {
const listing = byId.get(id)
return listing === undefined ? [] : [JSON.parse(listing) as StoreListing]
})
}
/**
* Resolve many keys at once, in the order asked; unknown keys are skipped rather than left as
* holes, so the result may be SHORTER than the input and must not be read positionally. One
@@ -351,8 +386,8 @@ export async function getAvatarItemsByTag(
tag: string
): Promise<CatalogAvatarItem[]> {
const { results } = await db
.prepare('SELECT * FROM catalog WHERE tag_list = ?1 ORDER BY friendly_name')
.bind(tag)
.prepare('SELECT * FROM catalog WHERE kind = ?1 AND tag_list = ?2 ORDER BY friendly_name')
.bind(CatalogKind.AvatarItem, tag)
.all<CatalogRow>()
return results.map(toCatalogAvatarItem)
}
+50 -6
View File
@@ -29,6 +29,13 @@ export const CatalogKind = {
Skin: 'skin',
/** A consumable: food, a potion, a KO icon. Its `item_key` is the `ConsumableItemDesc`. */
Consumable: 'consumable',
/**
* A first-party custom avatar item the store lists. Its `item_key` is the
* `CustomAvatarItemId`. The ITEM lives in `api`'s `custom_avatar_item` table (`just cai-load`)
* and is resolved there; the row here exists for its LISTING, so a `PurchasableItemId` lookup
* answers for every item the store sells. Only the shared columns are filled.
*/
CustomAvatarItem: 'custom_avatar_item',
} as const
export type CatalogKindValue = (typeof CatalogKind)[keyof typeof CatalogKind]
@@ -114,15 +121,13 @@ export interface SkinCapture {
/**
* What a store listing SELLS — the catalog kind it loads as, or why it loads as nothing.
*
* `custom_avatar_item` listings are first-party UGC: they live in the `custom_avatar_item`
* table (`just cai-load`), not here. `query` is a loot box and `currency` a token bundle —
* `query` is a loot box and `currency` a token bundle —
* neither is an item. `unknown` carries no id at all (two expo items the dump prices at
* nothing). `consumable_pack` is assigned by the load rather than by {@link listingKind}: a
* consumable the store sells singly AND as a pack ("Disco Dance Break", "… 3-Pack") is one
* consumable listed twice, and the catalog is of things, not of listings.
*/
export type ListingKind =
CatalogKindValue | 'custom_avatar_item' | 'query' | 'currency' | 'unknown' | 'consumable_pack'
export type ListingKind = CatalogKindValue | 'query' | 'currency' | 'unknown' | 'consumable_pack'
/** Classify one listing by which of its item ids is set. */
export function listingKind(listing: StoreListing): ListingKind {
@@ -131,7 +136,7 @@ export function listingKind(listing: StoreListing): ListingKind {
if (drop.ConsumableItemDesc !== '') return CatalogKind.Consumable
if (drop.EquipmentModificationGuid !== '') return CatalogKind.Skin
if (drop.CustomAvatarItemId !== null && drop.CustomAvatarItemId !== '') {
return 'custom_avatar_item'
return CatalogKind.CustomAvatarItem
}
if (drop.IsQuery) return 'query'
if (drop.Currency > 0) return 'currency'
@@ -186,7 +191,7 @@ export const UNLISTED_SKIN_RARITY = 50
* What a GOLD skin costs — any skin whose name carries `(Gold)`, thirteen of them, all from the
* capture. A prestige price, and the same for a subscriber: no discount, no sale.
*/
export const GOLD_SKIN_PRICE = 1_000_000
export const GOLD_SKIN_PRICE = 5_000_000
/** Whether a listing is a gold skin — a skin whose name says `(Gold)`. */
export const isGoldSkin = (listing: StoreListing): boolean =>
@@ -270,6 +275,7 @@ export const CATALOG_INSERT_COLUMNS = [
'created_at',
'prefab_name',
'unlocked_level',
'listing',
] as const
/** A value bound into a load's INSERT. `undefined` is a key the capture omitted. */
@@ -332,6 +338,7 @@ function avatarItemRow(listing: StoreListing): CatalogLoadRow {
null,
null,
null,
JSON.stringify(listing),
],
}
}
@@ -359,6 +366,7 @@ function consumableRow(listing: StoreListing): CatalogLoadRow {
null,
null,
null,
JSON.stringify(listing),
],
}
}
@@ -386,6 +394,39 @@ function listedSkinRow(listing: StoreListing): CatalogLoadRow {
null,
drop.EquipmentPrefabName,
null,
JSON.stringify(listing),
],
}
}
/**
* A listed first-party custom avatar item: the shared columns, keyed by its
* `CustomAvatarItemId`. The record itself is `api`'s; this row carries the listing.
*/
function customAvatarItemRow(listing: StoreListing): CatalogLoadRow {
const drop = listing.GiftDrop
const guid = drop.CustomAvatarItemId as string
return {
key: guid,
id: listing.PurchasableItemId,
label: `${drop.FriendlyName} (custom avatar item)`,
values: [
guid,
listing.PurchasableItemId,
CatalogKind.CustomAvatarItem,
drop.FriendlyName,
drop.Tooltip,
drop.Rarity,
ALL_PLATFORMS,
drop.ThumbnailImageName,
null,
null,
null,
drop.TagList,
null,
null,
null,
JSON.stringify(listing),
],
}
}
@@ -445,6 +486,9 @@ export function buildCatalogLoad(listings: StoreListing[]): CatalogLoad {
case CatalogKind.Skin:
push(listedSkinRow(listing))
break
case CatalogKind.CustomAvatarItem:
push(customAvatarItemRow(listing))
break
default:
skipped[kind] = (skipped[kind] ?? 0) + 1
}
+187 -63
View File
@@ -4,8 +4,9 @@
* An operator maps Discord ROLE ids to a token amount — `DISCORD_ROLE_TOKENS`,
* `<roleId>=<tokens>,<roleId>=<tokens>` — and every time the cron fires, every account
* holding one of those roles is handed that many RecCenterTokens in a gift box. ONE box per
* account per run: an account holding several mapped roles is paid the HIGHEST amount among
* them, not the sum, so the map reads as tiers however Discord stacks the roles.
* account per run: an account holding several mapped roles — across every Discord link it
* has — is paid the HIGHEST amount among them, not the sum, so the map reads as tiers
* however Discord stacks the roles.
*
* HOW OFTEN is the cron schedule's business, not this module's: `triggers.crons` in
* wrangler.jsonc says weekly, and an operator who wants a daily gift changes that line. There
@@ -20,6 +21,12 @@
* never claimed on the website has no Discord link, and so holds no roles here, however
* many they hold in the guild.
*
* The same box is ALSO handed once, on demand, the first time an account links its Discord
* (`www`'s benefits claim calls `grantDiscordRoleGift` with the roles it just read), so a
* supporter who claims the day after a run isn't waiting a whole schedule for their first
* gift. That is the one grant here with a "first time" to it, and `www` decides it — this
* module keeps no ledger either way.
*
* Each grant is the same three moves econ's other server-handed tokens make (the game
* reward's tickets, the new-activity bonus): seed the signup grant, credit the balance,
* then a box that displays the amount — announced with `StorefrontBalanceUpdate` (the
@@ -36,14 +43,21 @@ import { PlatformType } from '@repo/domain/src/enums'
import { getLinksForPlatform } from '../../auth/src/platform-db'
import { NotificationType } from '../../notify/src/notification-types'
import { ALL_PLATFORMS, creditCurrency, CurrencyType, ensureStartingBalances } from './balance-db'
import {
ALL_PLATFORMS,
creditCurrency,
CurrencyType,
ensureStartingBalances,
payTokenGifts,
TOKEN_GIFT_CHUNK,
} from './balance-db'
import type { GiftContent } from '@repo/domain'
import type { PlatformLink } from '../../auth/src/platform-db'
import type {
BalanceResponsePayload,
GiftPackagePayload,
} from '../../notify/src/notification-payloads'
import type { PlayerNotification } from '../../notify/src/notifications-hub'
import type { Env } from './context'
/** The system "Coach" account — who a box the server hands over is from. */
@@ -55,6 +69,13 @@ const HUB_INSTANCE = 'global'
/** The message on the box. */
export const DISCORD_ROLE_GIFT_MESSAGE = 'Thanks for supporting the server!'
/**
* What a grant needs off the bindings: the database, the hub, and the role map. Named as a
* Pick so `www` — whose `Env` declares the same three — can hand its own bindings to
* `grantDiscordRoleGift` without pretending to be econ.
*/
export type RoleGiftEnv = Pick<Env, 'DB' | 'RECFLARE_NOTIFICATIONS_HUB' | 'DISCORD_ROLE_TOKENS'>
/** One role's gift, as configured. */
export interface RoleTokens {
/** The Discord role id — a snowflake, kept as a string (one exceeds 2^53). */
@@ -95,13 +116,32 @@ export function parseRoleTokens(raw: string | undefined): {
}
/**
* The gift a link earns: of the mapped roles it holds, the one paying the most — or null
* when it holds none. Ties go to whichever is mapped first; they pay the same either way.
* The role map off the bindings, with every rejected entry logged — once per run for the
* cron, once per claim for the on-demand grant, which is seldom enough to be a reminder
* rather than noise. Empty when the gift is off.
*/
export function bestRoleGift(link: PlatformLink, roles: readonly RoleTokens[]): RoleTokens | null {
function configuredRoles(env: RoleGiftEnv): RoleTokens[] {
const { roles, rejected } = parseRoleTokens(env.DISCORD_ROLE_TOKENS)
for (const entry of rejected) {
console.error(
`discord role gift: ignoring "${entry}" in DISCORD_ROLE_TOKENS — expected <roleId>=<tokens>`
)
}
return roles
}
/**
* The gift a set of held roles earns: of the mapped roles among them, the one paying the
* most — or null when none is mapped. Ties go to whichever is mapped first; they pay the
* same either way.
*/
export function bestRoleGift(
held: readonly string[],
roles: readonly RoleTokens[]
): RoleTokens | null {
let best: RoleTokens | null = null
for (const role of roles) {
if (!link.roles.includes(role.roleId)) continue
if (!held.includes(role.roleId)) continue
if (best === null || role.tokens > best.tokens) best = role
}
return best
@@ -147,22 +187,31 @@ export interface RoleGiftSummary {
granted: number
/** Tokens handed out this run, across every box. */
tokens: number
/** Grants that failed partway — logged with the account and role to sort out by hand. */
/** Accounts whose grant failed — logged with the account and role to sort out by hand. */
failed: number
}
/**
* One run of the gift: every Discord link holding a mapped role is paid its best role's
* tokens. Sequential: each grant is a handful of D1 writes and two hub calls, and a cron
* invocation has a fixed subrequest budget (50 free, 1000 paid) — a community whose
* supporters outgrow that is the point to batch it, not before.
* One run of the gift: every account whose Discord links hold a mapped role is paid its
* best role's tokens. Batched, as the staff drops are: `payTokenGifts` banks the boxes a
* few dozen accounts a round, and the frames go out in ONE hub call — grant by grant this
* was five D1 round trips and two hub calls an account, in series, against a cron
* invocation's fixed subrequest budget (50 free, 1,000 paid).
*
* `startingTokens` is the signup grant to seed before the credit — `creditCurrency` upserts
* the balance row, so a never-touched balance would otherwise start from this gift instead
* of the grant plus this gift.
* A round that throws fails every account in it: they are logged, with their roles, and
* counted in `failed` rather than retried — a retry that ALSO failed partway is how a
* balance gets credited twice — and the later rounds still run.
*
* `startingTokens` is the signup grant to seed before the credit — the credit upserts the
* balance row, so a never-touched balance would otherwise start from this gift instead of
* the grant plus this gift.
*
* Takes the {@link RoleGiftEnv} pick, not econ's whole `Env`: `www`'s staff panel runs this
* same function on a button (`POST /api/staff/discord-roles/supporter-gift`) with its own
* bindings, and the three it reads are declared on both workers.
*/
export async function grantDiscordRoleGifts(
env: Env,
env: RoleGiftEnv,
startingTokens: number
): Promise<RoleGiftSummary> {
const summary: RoleGiftSummary = {
@@ -174,12 +223,7 @@ export async function grantDiscordRoleGifts(
failed: 0,
}
const { roles, rejected } = parseRoleTokens(env.DISCORD_ROLE_TOKENS)
for (const entry of rejected) {
console.error(
`discord role gift: ignoring "${entry}" in DISCORD_ROLE_TOKENS — expected <roleId>=<tokens>`
)
}
const roles = configuredRoles(env)
if (roles.length === 0) {
console.log('discord role gift: off (no roles in DISCORD_ROLE_TOKENS)')
return { ...summary, skipped: true }
@@ -189,19 +233,53 @@ export async function grantDiscordRoleGifts(
const links = await getLinksForPlatform(env.DB, PlatformType.Discord)
summary.links = links.length
// One box per ACCOUNT: an account with two Discord links is paid the best role across
// both, once. Kept in link order (oldest first) so the frames go out in a stable order.
const best = new Map<number, RoleTokens>()
for (const link of links) {
const role = bestRoleGift(link, roles)
const role = bestRoleGift(link.roles, roles)
if (role === null) continue
const held = best.get(link.accountId)
if (held === undefined || role.tokens > held.tokens) best.set(link.accountId, role)
}
const frames: PlayerNotification[] = []
const due = [...best]
for (let i = 0; i < due.length; i += TOKEN_GIFT_CHUNK) {
const round = due.slice(i, i + TOKEN_GIFT_CHUNK)
try {
await grantOne(env, link, role, startingTokens)
summary.granted++
summary.tokens += role.tokens
const { paid } = await payTokenGifts(
env.DB,
round.map(([accountId, role]) => ({
accountId,
amount: role.tokens,
content: roleGiftContent(role.tokens),
})),
startingTokens
)
for (const p of paid) {
summary.granted++
summary.tokens += p.amount
frames.push(...giftFrames(p.accountId, p.balance, p.giftId, p.content))
}
} catch (err) {
// Logged with everything needed to check what did land rather than retried: a
// retry that ALSO failed partway is how a balance gets credited twice.
summary.failed++
summary.failed += round.length
console.error(
`discord role gift: account ${link.accountId}'s grant for role ${role.roleId} (${role.tokens} tokens) failed: ${err instanceof Error ? err.message : String(err)}`
`discord role gift: a round of ${round.length} grant(s) failed (${round
.map(
([accountId, role]) => `account ${accountId} role ${role.roleId} ${role.tokens} tokens`
)
.join('; ')}): ${err instanceof Error ? err.message : String(err)}`
)
}
}
if (frames.length > 0) {
try {
await env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayers(frames)
} catch (err) {
console.error(
`discord role gift: could not notify ${summary.granted} account(s) of their box: ${err instanceof Error ? err.message : String(err)}`
)
}
}
@@ -212,58 +290,104 @@ export async function grantDiscordRoleGifts(
return summary
}
/**
* The on-demand grant: pay ONE account the gift its roles earn right now, exactly as a cron
* run would — same map, same best-role pick, same box. Returns the role paid, or null when
* none of `held` is mapped (which includes the map being off), in which case nothing is
* written. Throws as `grantOne` does, so the caller decides what a half-landed grant means.
*
* `www`'s benefits claim calls this with the roles Discord just served, the first time an
* account links its Discord; whether it IS the first time is the caller's call, since this
* keeps no ledger. It reads the roles it is handed rather than the stored link so the
* caller can pay before, after or without writing the row.
*/
export async function grantDiscordRoleGift(
env: RoleGiftEnv,
accountId: number,
held: readonly string[],
startingTokens: number
): Promise<RoleTokens | null> {
const role = bestRoleGift(held, configuredRoles(env))
if (role === null) return null
await grantOne(env, accountId, role, startingTokens)
return role
}
/**
* Pay one account its role's gift: balance, box, then the two frames. The frames are
* best-effort — the tokens are credited and the box stored before either is sent, and an
* offline player meets both on their next login.
*/
async function grantOne(
env: Env,
link: PlatformLink,
env: RoleGiftEnv,
accountId: number,
role: RoleTokens,
startingTokens: number
): Promise<void> {
await ensureStartingBalances(env.DB, link.accountId, startingTokens)
await ensureStartingBalances(env.DB, accountId, startingTokens)
const balance = await creditCurrency(
env.DB,
link.accountId,
accountId,
CurrencyType.RecCenterTokens,
role.tokens,
startingTokens
)
const content = roleGiftContent(role.tokens)
const gift = await createGift(env.DB, link.accountId, content)
const gift = await createGift(env.DB, accountId, content)
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
try {
// The balance first, so the box's announcement lands on a total that includes it.
// `Balance` is the RESULTING total, into the one -2 bucket: the frame SETS it.
await hub.notifyPlayer(link.accountId, NotificationType.StorefrontBalanceUpdate, {
Balance: balance,
CurrencyType: CurrencyType.RecCenterTokens,
Platform: ALL_PLATFORMS,
} satisfies BalanceResponsePayload)
await hub.notifyPlayer(link.accountId, NotificationType.GiftPackageReceivedImmediate, {
Id: gift.id,
FromPlayerId: COACH_ACCOUNT_ID,
ConsumableItemDesc: content.ConsumableItemDesc,
AvatarItemType: content.AvatarItemType,
AvatarItemDesc: content.AvatarItemDesc,
EquipmentPrefabName: content.EquipmentPrefabName,
EquipmentModificationGuid: content.EquipmentModificationGuid,
CurrencyType: content.CurrencyType,
Currency: content.Currency,
Xp: content.Xp,
GiftContext: content.GiftContext ?? 0,
GiftRarity: content.GiftRarity,
Message: content.Message,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: ALL_PLATFORMS,
} satisfies GiftPackagePayload)
await env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayers(
giftFrames(accountId, balance, gift.id, content)
)
} catch (err) {
console.error(
`discord role gift: could not notify account ${link.accountId} of box ${gift.id}: ${err instanceof Error ? err.message : String(err)}`
`discord role gift: could not notify account ${accountId} of box ${gift.id}: ${err instanceof Error ? err.message : String(err)}`
)
}
}
/**
* The two frames announcing a stored box, in order: the balance first — the RESULTING
* total, into the one -2 bucket; the frame SETS it — so the box's announcement lands on a
* total that already includes it.
*/
function giftFrames(
accountId: number,
balance: number,
giftId: number,
content: GiftContent
): PlayerNotification[] {
return [
{
playerId: accountId,
notificationType: NotificationType.StorefrontBalanceUpdate,
data: {
Balance: balance,
CurrencyType: CurrencyType.RecCenterTokens,
Platform: ALL_PLATFORMS,
} satisfies BalanceResponsePayload,
},
{
playerId: accountId,
notificationType: NotificationType.GiftPackageReceivedImmediate,
data: {
Id: giftId,
FromPlayerId: COACH_ACCOUNT_ID,
ConsumableItemDesc: content.ConsumableItemDesc,
AvatarItemType: content.AvatarItemType,
AvatarItemDesc: content.AvatarItemDesc,
EquipmentPrefabName: content.EquipmentPrefabName,
EquipmentModificationGuid: content.EquipmentModificationGuid,
CurrencyType: content.CurrencyType,
Currency: content.Currency,
Xp: content.Xp,
GiftContext: content.GiftContext ?? 0,
GiftRarity: content.GiftRarity,
Message: content.Message,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: ALL_PLATFORMS,
} satisfies GiftPackagePayload,
},
]
}
+1906 -176
View File
@@ -1,6 +1,7 @@
import { Hono } from 'hono'
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import { z } from 'zod'
import {
addXp,
@@ -15,12 +16,13 @@ import {
grantInvention,
levelReward,
levelsReached,
listInfluencerIds,
ownsInvention,
setOutfit,
withDatabase,
updateAccount,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetPlus, validateAndGetVersion } from '@repo/jwt'
import { validateAndGetAccountId, validateAndGetBuild, validateAndGetPlus } from '@repo/jwt'
import {
getCustomAvatarItems,
@@ -40,6 +42,7 @@ import { censorSwears } from '../../api/src/sanitize'
import { BalanceAddType } from '../../notify/src/notification-payloads'
import { NotificationType } from '../../notify/src/notification-types'
import adCarouselItems from '../static/ad-carousel-items.json'
import consumableDefinitions from '../static/consumables.json'
import defaultAvatarItems from '../static/default-avatar-items.json'
import defaultAvatar from '../static/default-avatar.json'
import defaultBaseAvatarItems from '../static/default-base-avatar-items.json'
@@ -56,7 +59,7 @@ import {
isSpendable,
spendCurrency,
} from './balance-db'
import { getCatalogItem, toCatalogAvatarItem } from './catalog-db'
import { getCatalogItem, getStoreListings, toCatalogAvatarItem } from './catalog-db'
// `LEGACY_CLIENT_BUILD` is shared with the storefront generator rather than restated: it picks
// which store FILE a caller is served here, and is what the generator's help names.
import { CatalogKind, LEGACY_CLIENT_BUILD } from './catalog-load'
@@ -84,6 +87,8 @@ import {
AwardRoomConsumablesRequest,
AwardRoomCurrencyRequest,
AwardRoomCurrencyResultList,
AwardRoomKeyResultList,
AwardRoomKeysRequest,
BalanceEntry,
BulkPurchaseRequest,
BulkPurchaseResponse,
@@ -92,6 +97,7 @@ import {
BuyInventionV3Response,
BuyItemRequest,
BuyItemResponse,
BuyRoomKeyResponse,
ChallengeProgressRequest,
ChallengeProgressResponse,
ChecklistCompleteResponse,
@@ -104,12 +110,15 @@ import {
CreateRoomCurrencyRequest,
CreateRoomKeyRequest,
CustomAvatarItemsResponse,
DeletePurchaseOfferRequest,
EquipmentUpdateRequest,
ErrorResponse,
form,
GameRewardRequest,
HeldRoomKeyDto,
InfluencerIdsResponse,
InfluencerTierResponse,
IsOnWishlistBulkRequest,
IsOnWishlistBulkResponse,
ItemPurchaseInfoList,
ItemPurchaseInfosRequest,
json,
@@ -120,9 +129,22 @@ import {
MakerAiFreeTrialEligibilityResponse,
OpaqueJsonBody,
OPTIONAL_AUTHED,
OwnsRoomKeysRequest,
OwnsRoomKeysResponse,
PurchasableItemsRequest,
PurchaseRoomConsumableRequest,
PurchaseRoomConsumableResponse,
PurchaseRoomCurrencyEnvelope,
PurchaseRoomCurrencyRequest,
PurchaseRoomKeyWithCurrencyRequest,
PurchaseRoomKeyWithCurrencyResponse,
QueryConsumablesRequest,
ReferralProgressResponse,
RemoveInfluencerBody,
RoomConsumableDto,
RoomConsumableEnvelope,
RoomConsumableInventoryItemDto,
RoomCurrencyBalance,
RoomCurrencyDto,
RoomCurrencyEnvelope,
RoomCurrencyPurchaseOfferDto,
@@ -131,35 +153,69 @@ import {
RoomEconConfig,
RoomKeyDto,
RoomKeyEnvelope,
RoomPurchasableList,
RRPlusSignUpBonus,
SaveOutfitRequest,
SaveOutfitV4Response,
SubscriptionResponse,
SupportedInfluencerResponse,
SupportInfluencerBody,
SupportInfluencerResponse,
UgcPurchasableBulkRequest,
UgcPurchasableItemList,
UNAUTHORIZED_RESPONSE,
UpdateActiveConsumableRequest,
UpdateObjectiveRequest,
UpdateObjectiveResponse,
UpdateRoomCurrencyRequest,
UpdateRoomKeyRequest,
UpsertRoomConsumableRequest,
WishlistEnvelope,
WishlistItemList,
WishlistItemRequest,
} from './openapi'
import { claimReward, isNewActivity } from './reward-db'
import { claimAllowance, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db'
import {
awardRoomConsumable,
getRoomConsumable,
getRoomConsumables,
getRoomInventory,
toInventoryItem,
upsertRoomConsumable,
} from './room-consumable-db'
import {
awardRoomCurrency,
createPurchaseOffer,
createRoomCurrency,
deletePurchaseOffer,
findPurchaseOffer,
getPurchaseOffers,
getRoomBalance,
getRoomCurrencies,
getRoomCurrency,
spendRoomCurrency,
updateRoomCurrency,
} from './room-currency-db'
import { createRoomKey, getRoomKeys } from './room-key-db'
import {
awardRoomKey,
claimRoomKey,
createRoomKey,
getHeldRoomKeys,
getRoomKey,
getRoomKeys,
getRoomOwnerId,
ownsRoomKeys,
releaseRoomKey,
updateRoomKey,
} from './room-key-db'
import { getRoomPurchasables } from './room-purchasables-db'
import {
addWishlistItem,
getWishlist,
isOnWishlists,
parseWishlistTarget,
removeWishlistItem,
} from './wishlist-db'
import type { Context } from 'hono'
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
@@ -167,6 +223,7 @@ import type { CustomAvatarItem } from '../../api/src/custom-avatar-items-db'
import type { SavedInvention } from '../../api/src/inventions-db'
import type {
BalanceResponsePayload,
ConsumableMappingPayload,
LocalRoomKeyPayload,
PurchaseBalanceModificationPayload,
RoomCurrencyPayload,
@@ -182,9 +239,10 @@ import type { ConsumeResult } from './consumables-db'
import type { App, Env } from './context'
import type { Equipment } from './equipment-db'
import type { AvatarItem } from './inventory-db'
import type { RoomConsumable } from './room-consumable-db'
import type { RoomConsumable, RoomConsumableInventoryItem } from './room-consumable-db'
import type { RoomCurrency, RoomCurrencyPurchaseOffer } from './room-currency-db'
import type { RoomKey } from './room-key-db'
import type { RoomKey, RoomKeyHolding } from './room-key-db'
import type { WishlistItem } from './wishlist-db'
// Invention storage (owned by the `api` worker, on this same `recflare` database).
// Imported directly rather than copied: these are plain D1 helpers with no bindings of
@@ -198,7 +256,7 @@ import type { RoomKey } from './room-key-db'
* inventory (avatar items, equipment, bought inventions), consumables, saved outfits,
* avatars, gift boxes, weekly-challenge progress and game-reward eligibility are D1-backed;
* storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS
* binding. Some routes are still empty-list stubs (room keys, wishlist, …).
* binding. Some routes are still empty-list stubs (equipment, room consumables, …).
*
* Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
*/
@@ -214,16 +272,17 @@ async function authedId(c: Context<App>): Promise<number | null> {
/**
* The client build this request's token was minted for (`rn.ver`), as a comparable NUMBER —
* the leading `YYYYMMDD` of e.g. `20250718.01`, whose `.01` is a same-day rebuild and not a
* version to order by. `null` when there is no valid token, when it carries no `rn.ver` (an
* older token, issued before the claim did), or when the claim isn't a build at all.
* version to order by (`validateAndGetBuild` drops it). `null` when there is no valid
* token, when it carries no `rn.ver` (an older token, issued before the claim did), or
* when the claim isn't a build at all.
*
* Unverified — a client can claim any build — which is fine for what it gates here: a build
* lying about itself only changes which storefront its own player is shown.
*/
async function authedBuild(c: Context<App>): Promise<number | null> {
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
const version = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
if (version === null) return null
const build = Number.parseInt(version.split('.')[0] ?? '', 10)
const build = Number.parseInt(version, 10)
return Number.isInteger(build) ? build : null
}
@@ -232,6 +291,39 @@ function unauthorized(c: Context<App>) {
return c.body(null, 401)
}
/**
* The `Error` a wishlist write answers when its body names no item. Shown to the player
* verbatim behind the client's “Wishlist error: ” prefix, so it is a sentence, not a code.
*/
const WISHLIST_NO_ITEM = 'No item was specified.'
/**
* The `{ Value, Success, Error, error_id }` envelope the wishlist writes answer in — the
* same shape the room-currency writes use. A null `item` with no `error` is still a success
* (removing something that wasn't listed); an `error` makes it a refusal.
*/
function wishlistEnvelope(c: Context<App>, item: WishlistItem | null, error?: string) {
return c.json({
Value: item,
Success: error === undefined,
Error: error ?? null,
error_id: null,
})
}
/**
* The item a wishlist write's form body names — `purchasableItemId` or `customAvatarItemId`,
* as the client spells them, with the PascalCase spellings accepted for a hand-written
* request. Null when the body names nothing usable (see `parseWishlistTarget`).
*/
async function wishlistTargetFromBody(c: Context<App>) {
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
return parseWishlistTarget(
body.purchasableItemId ?? body.PurchasableItemId,
body.customAvatarItemId ?? body.CustomAvatarItemId
)
}
/**
* A boolean the client may send either as a JSON `true` or as .NET's `bool.ToString()`
* output — `"True"`/`"False"`, capitalized. `Boolean(value)` is a trap here: the string
@@ -277,6 +369,7 @@ const HUB_INSTANCE = 'global'
const CREATE_CURRENCY_FAILED = 'Failed to create currency'
const UPDATE_CURRENCY_FAILED = 'Failed to update currency'
const CREATE_OFFER_FAILED = 'Failed to create purchase offer'
const DELETE_OFFER_FAILED = 'Failed to delete purchase offer'
const SAVE_CONSUMABLE_FAILED = 'Failed to save consumable'
/**
* `Status` on the room-key create's `{ Status, RoomKey }` reply. 0 is the observed success
@@ -292,6 +385,14 @@ const ROOM_KEY_STATUS_FAILED = 1
*/
const MAX_KEYS_PER_ROOM = 10
/**
* The most a room key may cost, by what it is priced in: tokens (a null `PurchaseCurrencyId`)
* are real money to the player paying, so they are capped low; a room's own currency is the
* room's to inflate.
*/
const MAX_KEY_PRICE_TOKENS = 1000
const MAX_KEY_PRICE_ROOM_CURRENCY = 1_000_000_000
/**
* The envelope the create endpoint answers in: `{ Value, Success, Error, error_id }`.
*
@@ -336,8 +437,8 @@ function roomConsumableEnvelope(c: Context<App>, value: RoomConsumable | null, e
}
/**
* The create-offer envelope — the same `{ Value, Success, Error, error_id }` shape again,
* carrying one purchase offer.
* The create- and delete-offer envelope — the same `{ Value, Success, Error, error_id }`
* shape again, carrying one purchase offer.
*/
function purchaseOfferEnvelope(
c: Context<App>,
@@ -471,6 +572,24 @@ async function pushRoomKeyCreated(
)
}
/**
* Whether a consumable is a first-party (Rec Room Original) item, as its definition in
* `static/consumables.json` says — the value every consumable instance we serve must carry.
*
* The 2026 client keys an instance on this flag and checks it against the definition it holds:
* a mismatch throws (`Key IsRRO=False does not match Definition IsRRO=True!`) and the frame is
* lost, so the purchase never shows. Every definition in the file says true; a desc with none
* is still a built-in store item, so it defaults to true too.
*/
const RRO_BY_DESC = new Map(
(consumableDefinitions as Array<{ ConsumableItemDesc: string; IsRRO?: boolean }>).map((d) => [
d.ConsumableItemDesc,
d.IsRRO !== false,
])
)
const isRroConsumable = (consumableItemDesc: string): boolean =>
RRO_BY_DESC.get(consumableItemDesc) ?? true
/**
* Push a ConsumableMappingRemoved notification to a player after they consume a
* consumable, mirroring the reference's
@@ -496,7 +615,9 @@ async function pushConsumableRemoved(
IsActive: false,
ActiveDurationMinutes: 0,
IsTransferable: false,
}
ActivatedByRoomie: false,
IsRRO: isRroConsumable(consumed.consumableItemDesc),
} satisfies ConsumableMappingPayload
)
} catch (err) {
logger.error('failed to push ConsumableMappingRemoved notification', {
@@ -518,21 +639,49 @@ async function pushConsumableAdded(
c: Context<App>,
accountId: number,
gift: StoredGift
): Promise<void> {
await pushConsumableMappingAdded(c, accountId, {
mappingId: gift.ConsumableMappingId ?? 0,
consumableItemDesc: gift.ConsumableItemDesc,
count: gift.ConsumableCount,
preExistingCount: gift.ConsumablePreExistingCount ?? 0,
})
}
/**
* The `ConsumableMappingAdded` frame itself — one granted consumable instance (its row id,
* how many it holds, and the player's count before it). Sent when a box carrying it is
* opened ({@link pushConsumableAdded}) or, when there IS no box, at grant time: a purchase
* under `BypassGiftPackages` is never opened, and without this the consumable didn't show
* until the client refetched its inventory. Never both for one grant — a second frame for
* the same row id could be counted twice. Best-effort like the removed push.
*/
async function pushConsumableMappingAdded(
c: Context<App>,
accountId: number,
granted: {
mappingId: number
consumableItemDesc: string
count: number
preExistingCount: number
}
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
accountId,
NotificationType.ConsumableMappingAdded,
{
Id: gift.ConsumableMappingId ?? 0,
ConsumableItemDesc: gift.ConsumableItemDesc,
Id: granted.mappingId,
ConsumableItemDesc: granted.consumableItemDesc,
CreatedAt: new Date().toISOString(),
Count: gift.ConsumableCount,
InitialCount: gift.ConsumablePreExistingCount ?? 0,
Count: granted.count,
InitialCount: granted.preExistingCount,
IsActive: false,
ActiveDurationMinutes: 0,
IsTransferable: false,
}
ActivatedByRoomie: false,
IsRRO: isRroConsumable(granted.consumableItemDesc),
} satisfies ConsumableMappingPayload
)
} catch (err) {
logger.error('failed to push ConsumableMappingAdded notification', {
@@ -649,11 +798,17 @@ async function pushBalancePurchase(
}
/**
* The influencer partner tier every account has here — the "not an influencer" one. It is
* the whole body of both `/api/influencerpartnerprogram/influencer` and `…/myinfluencer`,
* served as a bare number rather than wrapped in anything.
* The influencer an account supports in the partner program, as its reads serve it — the
* `supportedInfluencerId` on the account, or 0 for nobody (and for an account that does not
* exist, which supports nobody too). The whole body of `/api/influencerpartnerprogram/influencer`,
* `…/myinfluencer`, served as a bare number rather than wrapped in anything; the `…/support`
* and `…/remove` writes answer a status envelope instead. The stored id is served as-is: it was an influencer when picked, and a later
* revoke of their flag drops them from `…/influencers` but not from their supporters.
*/
const NOT_AN_INFLUENCER = 0
async function supportedInfluencer(db: D1Database, accountId: number): Promise<number> {
const account = await getAccount(db, accountId)
return account?.supportedInfluencerId ?? 0
}
/**
* Whether the caller holds a Rec Room Plus subscription — the ONE definition, shared by
@@ -1735,7 +1890,19 @@ async function grantGiftDrop(
consumableCount
)
}
if (options.skipGiftBox === true) return { id: 0, drop: giftDrop }
if (options.skipGiftBox === true) {
// No box means nothing will ever be opened to announce the consumable, so announce it
// now — otherwise a bag bought with `BypassGiftPackages` doesn't show it until a refetch.
if (isConsumable) {
await pushConsumableMappingAdded(c, accountId, {
mappingId: consumableMappingId,
consumableItemDesc: giftDrop.ConsumableItemDesc,
count: consumableCount,
preExistingCount: consumablePreExisting,
})
}
return { id: 0, drop: giftDrop }
}
const { id } = await createGift(
db,
accountId,
@@ -2167,15 +2334,24 @@ function isBulkLine(resolved: BulkPurchaseLine | BulkLineFailure): resolved is B
}
/**
* XP paid for a claimed game reward. One flat amount for every reward type, matching the
* one flat cooldown they share — "First Game of the Day" and "Activity completed!" are the
* same size of pat on the back until there's reason to price them apart.
* XP paid for a claimed game reward. One flat amount for every reward type — "First Game of
* the Day" and "Activity completed!" are the same size of pat on the back until there's
* reason to price them apart.
*
* Deliberately smaller than the 10 XP the first level costs: a single action shouldn't be a
* level-up, let alone two of them. At 5 it takes two rewards to reach level 2, and the early
* levels are paced by the hourly cooldown rather than cleared in one match.
* Exactly what each of the first three levels costs, so a fresh player's first rewards each
* level them up; from level 4 (20 XP a level) it takes two or more. The daily
* {@link GAME_REWARD_DAILY_XP_CAP} paces it from there.
*/
const GAME_REWARD_XP = 5
const GAME_REWARD_XP = 10
/**
* The most game-reward XP a player earns in one rolling 24h window: fifteen claims' worth,
* whatever the type or activity — `FirstActivityOfDay` aside, whose XP is a bonus on top, like
* its tokens. The running total is the player's `$xp` row in `reward_status`
* (`claimAllowance`). A multiple of {@link GAME_REWARD_XP} — a claim that would cross it pays
* no XP.
*/
const GAME_REWARD_DAILY_XP_CAP = 150
/**
* `GiftContext.GameRewards` — what the box says it came from, so the client files it under
@@ -2188,29 +2364,46 @@ const GIFT_CONTEXT_GAME_REWARDS = 50
const DEFAULT_GAME_REWARD_MESSAGE = 'Reward earned!'
/**
* Tokens paid the FIRST time a player completes an activity — a `giftContext` no row of
* theirs in `reward_status` has seen before, whatever the reward type. A one-off per
* activity on top of the hourly XP: the reward for trying something new, sized as a
* visible nudge (a cheap catalog item) rather than an income, since the catalog of
* activities is finite and each pays it exactly once.
* Tokens paid with each claimed {@link TOKEN_REWARD_TYPES} reward, until the player has had
* {@link GAME_REWARD_DAILY_TOKEN_CAP} in the current 24h window — whatever the activity.
* The running total is the player's `$tokens` row in `reward_status` (`claimAllowance`).
*/
const NEW_ACTIVITY_BONUS_TOKENS = 250
const GAME_REWARD_TOKENS = 250
/**
* The most game-reward tokens a player earns in one rolling 24h window: five claims' worth.
* A multiple of {@link GAME_REWARD_TOKENS} — a claim that would cross it pays nothing.
*/
const GAME_REWARD_DAILY_TOKEN_CAP = 1250
/**
* The reward types whose tokens draw on the daily allowance: the client's ask after a match
* and after a quest. {@link FIRST_ACTIVITY_OF_DAY} pays its own on top, outside the cap.
*/
const TOKEN_REWARD_TYPES = new Set(['PostGameActivity', 'PostQuestActivity'])
/**
* The once-a-day bonus. Its claim is per PLAYER — keyed on the empty context whatever
* activity asked — on a 24h cooldown, and it pays {@link GAME_REWARD_TOKENS} outside the
* daily cap: the claim itself is what limits it, so it needs no allowance row.
*/
const FIRST_ACTIVITY_OF_DAY = 'FirstActivityOfDay'
/**
* `GiftContext.GameRewardsTokens` — the tokens variant of {@link GIFT_CONTEXT_GAME_REWARDS},
* on the box the new-activity bonus arrives in.
* on the box the token payout arrives in.
*/
const GIFT_CONTEXT_GAME_REWARDS_TOKENS = 51
/** The message on the new-activity bonus box. */
const NEW_ACTIVITY_BONUS_MESSAGE = 'New activity bonus!'
/** The message on the token payout's box. */
const GAME_REWARD_TOKENS_MESSAGE = 'Activity bonus!'
/**
* The gift-drop the new-activity bonus hands over: tokens in a box, no item, no XP. Like
* The gift-drop a claimed game reward's token payout hands over: tokens in a box, no item, no XP. Like
* {@link toGameRewardDrop}, `grantGiftDrop` creates only the box from it — the tokens are
* credited to the balance separately, and the copy here is what the box displays.
*/
function toNewActivityBonusDrop(): StoreGiftDrop {
function toGameRewardTokensDrop(): StoreGiftDrop {
return {
FriendlyName: '',
Tooltip: '',
@@ -2221,7 +2414,7 @@ function toNewActivityBonusDrop(): StoreGiftDrop {
EquipmentModificationGuid: '',
Rarity: 0,
Context: GIFT_CONTEXT_GAME_REWARDS_TOKENS,
Currency: NEW_ACTIVITY_BONUS_TOKENS,
Currency: GAME_REWARD_TOKENS,
CurrencyType: CurrencyType.RecCenterTokens,
Xp: 0,
}
@@ -2233,7 +2426,7 @@ function toNewActivityBonusDrop(): StoreGiftDrop {
* nothing into the inventory and only creates the box. The XP is banked in `progression`;
* the copy here is what the box and its notification display.
*/
function toGameRewardDrop(): StoreGiftDrop {
function toGameRewardDrop(xp: number): StoreGiftDrop {
return {
FriendlyName: '',
Tooltip: '',
@@ -2246,7 +2439,7 @@ function toGameRewardDrop(): StoreGiftDrop {
Context: GIFT_CONTEXT_GAME_REWARDS,
Currency: 0,
CurrencyType: 0,
Xp: GAME_REWARD_XP,
Xp: xp,
}
}
@@ -2311,10 +2504,14 @@ async function pickQuestReward(
* row, so the item IS granted — unlike {@link toGameRewardDrop}'s empty box. The catalog
* row for the item, when it resolves, supplies what the table doesn't carry (name, tooltip,
* `AvatarItemType`), so the inventory entry reads like a bought one rather than blank.
* The XP is the flat game-reward amount, not the row's (always 0): the reward is the item,
* and the XP is the same pat on the back every claim gets.
* The XP is the game reward's (`xp`), not the row's (always 0): the reward is the item, and
* the XP is the same pat on the back every claim gets while the daily cap has room.
*/
function toQuestRewardDrop(reward: QuestReward, catalog: CatalogRow | null): StoreGiftDrop {
function toQuestRewardDrop(
reward: QuestReward,
catalog: CatalogRow | null,
xp: number
): StoreGiftDrop {
return {
FriendlyName: catalog?.friendly_name ?? '',
Tooltip: catalog?.tooltip ?? '',
@@ -2327,7 +2524,7 @@ function toQuestRewardDrop(reward: QuestReward, catalog: CatalogRow | null): Sto
Context: reward.Context,
Currency: reward.Currency,
CurrencyType: reward.CurrencyType,
Xp: GAME_REWARD_XP,
Xp: xp,
}
}
@@ -2645,9 +2842,8 @@ interface SettledInventionPurchase {
*
* Ownership is recorded in `inventory_invention`; the creator is not sold their own
* invention (they own it already, via CreatorPlayerId) and a re-buy is a 409 rather
* than a second row. The invention’s `NumDownloads` counter is deliberately NOT
* bumped: that column lives on the `invention` table the `api` worker owns, and this
* worker only reads it.
* than a second row. The grant also resyncs the invention’s `NumDownloads` — the count
* of players who have it — and the invention returned carries the new number.
*/
async function settleInventionPurchase(
c: Context<App>,
@@ -2693,7 +2889,7 @@ async function settleInventionPurchase(
// around them, so order them by what a failure costs. A buyer who paid and got the
// invention but left the creator unpaid is recoverable; a buyer charged for nothing
// is not.
await grantInvention(c.env.DB, id, inventionId)
const downloads = await grantInvention(c.env.DB, id, inventionId)
if (price > 0) {
// Seed the creator's signup grant BEFORE crediting them: `creditCurrency` upserts
@@ -2730,7 +2926,7 @@ async function settleInventionPurchase(
if (price > 0) {
await pushBalancePurchase(c, id, CurrencyType.RecCenterTokens, -price, balance)
}
return { invention, balance }
return { invention: { ...invention, NumDownloads: downloads }, balance }
}
// strict: false so trailing-slash routes (e.g. `/gifts/consume/`, which the client
@@ -3084,20 +3280,41 @@ const app = new Hono<App>({ strict: false })
}
)
// The caller's item wishlist. [Authorize]; empty — nothing stores wishlists yet.
// The caller's item wishlist. [Authorize]. A bare array of entries, newest wish first;
// `[]` for a player who has wished for nothing. The client appends a KEYLESS query string
// (`/me?True`, `/me?False` — a raw `bool.ToString()` after the `?`, no parameter name) that
// no server can bind by name, so it is ignored: both spellings serve the same list. The
// client caches this for five seconds and drops the cache when an add succeeds.
.get(
'/api/itemWishlists/v1/wishlist/me',
listRoute('The player’s item wishlist', 'Empty for now', true),
describeRoute({
tags: ['Econ'],
summary: 'The player’s item wishlist',
description: [
'The caller’s wishlist as a bare array of entries, newest first — `[]` when empty.',
'Each entry names ONE item: a storefront item by `PurchasableItemId`, or a custom',
'avatar item by `CustomAvatarItemId` (with `PurchasableItemId` 0, the client’s field',
'being a plain int).',
'',
'The client appends `?True` or `?False` with no parameter name; it cannot be read',
'and is ignored — both serve the same list.',
].join('\n'),
security: AUTHED,
responses: {
200: json(WishlistItemList, 'The caller’s wishlist, newest first'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([])
return c.json(await getWishlist(c.env.DB, id))
}
)
// Another player's item wishlist, by account id — what the client reads to show what
// somebody else is hoping for (and to mark items in the store as already wished for).
// Empty like `/me`: nothing stores wishlists, so there is nothing to show for anyone.
// somebody else is hoping for. Public to any signed-in player: a wishlist exists to be
// read by others. An unknown account has simply wished for nothing.
//
// Registered AFTER `/me` so that path stays its own route rather than being read as an
// account id — the pattern here is digits-only, so it could not swallow `me`, but the
@@ -3108,10 +3325,11 @@ const app = new Hono<App>({ strict: false })
tags: ['Econ'],
summary: 'Another player’s item wishlist',
description: [
'The wishlist of the account named in the path, as a bare array. Empty for now —',
'nothing on this server stores wishlists, so every player’s is empty, and an empty',
'list is what the client renders as “nothing wished for” where a 404 would read as a',
'failed load.',
'The wishlist of the account named in the path, as a bare array of entries, newest',
'first. Readable by any signed-in player — a wishlist is for others to see. An',
'unknown account answers `[]`, which the client renders as “nothing wished for”',
'where a 404 would read as a failed load. The keyless `?True`/`?False` the client',
'appends is ignored, as on `/me`.',
].join(' '),
security: AUTHED,
parameters: [
@@ -3124,14 +3342,136 @@ const app = new Hono<App>({ strict: false })
},
],
responses: {
200: json(JsonArray, 'That player’s wishlist — empty for now'),
200: json(WishlistItemList, 'That player’s wishlist, newest first'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([])
const accountId = Number.parseInt(c.req.param('accountId'), 10)
if (!Number.isInteger(accountId)) return c.json([])
return c.json(await getWishlist(c.env.DB, accountId))
}
)
// Wish for an item. [Authorize]. Form-encoded: the client posts BOTH `purchasableItemId`
// and `customAvatarItemId` every time with the unused one empty (its two nullables are
// boxed without a HasValue guard), so either may be empty or absent; exactly one has to
// name an item. Answers the `{ Value, Success, Error, error_id }` envelope with the entry.
// An item already on the list answers its EXISTING entry: the toggle is driven by the
// client's idea of the current state, and re-adding changes nothing.
//
// The row has to persist: on success the client drops its cached list and re-reads it,
// so a 200 that stored nothing shows the heart un-filling again at once.
.post(
'/api/itemWishlists/v1/wishlist/add',
describeRoute({
tags: ['Econ'],
summary: 'Wish for an item',
description: [
'Adds an item to the caller’s wishlist. Form-encoded; the client posts both',
'`purchasableItemId` and `customAvatarItemId` with the unused one EMPTY',
'(`purchasableItemId=1534&customAvatarItemId=`), and either may also be absent —',
'exactly one has to name an item.',
'',
'Answers `{ Value, Success, Error, error_id }` with the entry as `Value`. An item',
'already on the list answers its existing entry rather than a twin or a refusal.',
'A body naming no item is `Success: false` with a player-readable `Error` — the',
'client shows it verbatim behind “Wishlist error: ”. A bare entry or a 204 fails the',
'client the same way `Success: false` does.',
].join('\n'),
security: AUTHED,
requestBody: form(WishlistItemRequest, 'The item to wish for'),
responses: {
200: json(WishlistEnvelope, 'The entry, or a refusal in the same shape'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await wishlistTargetFromBody(c)
if (target === null) return wishlistEnvelope(c, null, WISHLIST_NO_ITEM)
return wishlistEnvelope(c, await addWishlistItem(c.env.DB, id, target))
}
)
// Take an item off the caller's wishlist. [Authorize]. A POST, not a DELETE, with the same
// form body as `add`. The client parses the same envelope and discards `Value`, so the
// removed entry is served anyway; an item that wasn't on the list is still a success —
// the list ends up as asked — with a null `Value`.
.post(
'/api/itemWishlists/v1/wishlist/remove',
describeRoute({
tags: ['Econ'],
summary: 'Un-wish an item',
description: [
'Removes an item from the caller’s wishlist. A POST (not a DELETE) with the same',
'form body as `add`. Answers the same envelope with the REMOVED entry as `Value`;',
'the client discards it but parses the shape, so a bare body or 204 fails it. An',
'item that wasn’t on the list is still `Success: true`, with a null `Value`. A body',
'naming no item is `Success: false`.',
].join('\n'),
security: AUTHED,
requestBody: form(WishlistItemRequest, 'The item to un-wish'),
responses: {
200: json(WishlistEnvelope, 'The removed entry, or a refusal in the same shape'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const target = await wishlistTargetFromBody(c)
if (target === null) return wishlistEnvelope(c, null, WISHLIST_NO_ITEM)
return wishlistEnvelope(c, await removeWishlistItem(c.env.DB, id, target))
}
)
// Whether each of several players has an item wished for. [Authorize]. Form-encoded:
// `accountIds` repeated once per player plus the item named as on add/remove. A BARE
// positional array of booleans back, one per id in the order posted — a short array would
// leave the tail reading as unset, so every id is answered, unknown players as false.
.post(
'/api/itemWishlists/v1/isonwishlist/bulk',
describeRoute({
tags: ['Econ'],
summary: 'Whether players have an item wished for',
description: [
'For each `accountIds` entry, whether that player’s wishlist has the item named by',
'`purchasableItemId` or `customAvatarItemId`. Form-encoded, `accountIds` repeated',
'once per id (a comma-separated single value is accepted too). The answer is a BARE',
'array of booleans, one per id in the order posted. A body naming no item answers',
'false for everyone.',
].join(' '),
security: AUTHED,
requestBody: form(IsOnWishlistBulkRequest, 'The players to ask about, and the item'),
responses: {
200: json(IsOnWishlistBulkResponse, 'One boolean per `accountIds` entry, in order'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req
.parseBody({ all: true })
.catch(() => ({}) as Record<string, string | string[] | File | File[]>)
// `all: true` keeps the repeated field a list; a single value arrives as a string.
const accountIds = [body.accountIds, body.AccountIds]
.flat()
.filter((v): v is string => typeof v === 'string')
.flatMap((v) => v.split(','))
.map((v) => Number.parseInt(v.trim(), 10))
.filter((n) => Number.isInteger(n))
const first = (v: unknown): unknown => (Array.isArray(v) ? v[0] : v)
const target = parseWishlistTarget(
first(body.purchasableItemId ?? body.PurchasableItemId),
first(body.customAvatarItemId ?? body.CustomAvatarItemId)
)
if (target === null) return c.json(accountIds.map(() => false))
return c.json(await isOnWishlists(c.env.DB, accountIds, target))
}
)
@@ -3516,7 +3856,15 @@ const app = new Hono<App>({ strict: false })
continue
}
const owned = await awardRoomConsumable(c.env.DB, accountId, consumableId, quantity)
const code = (request as { ConcurrencyCodes?: { NewConcurrencyCode?: unknown } })
.ConcurrencyCodes?.NewConcurrencyCode
const owned = await awardRoomConsumable(
c.env.DB,
accountId,
consumableId,
quantity,
typeof code === 'string' ? code : null
)
results.push({
ConsumableId: consumableId,
Success: true,
@@ -3524,7 +3872,7 @@ const app = new Hono<App>({ strict: false })
Response: {
PlayerId: accountId,
ConsumableId: consumableId,
Quantity: owned,
Quantity: owned.quantity,
AwardedAt: new Date().toISOString(),
},
})
@@ -3534,6 +3882,202 @@ const app = new Hono<App>({ strict: false })
}
)
// Buy some of a room's consumables, for the caller or as a gift. One route for both kinds of
// price: a listing in TOKENS pays the room's owner, as a room key does; a listing in a ROOM
// CURRENCY pays nobody — that money is the room's own invention. What is bought lands in
// `room_inventory`, the same holding `awardBulk` adds to.
.put(
// Two paths, one sale: the client posts a token listing to `/purchase/tokens` and a
// room-currency one to `/purchase/currency`, with the same body. The body's
// `ExpectedPriceAndCurrency` is what is checked against the listing, not the path.
'/api/roomconsumables/v1/roomconsumable/:id/purchase/:via{currency|tokens}',
describeRoute({
tags: ['Storefront'],
summary: 'Buy a room consumable',
description: [
'Buys `Quantity` of the consumable named in the path, at its listed price EACH.',
'The client posts a listing priced in tokens to `…/purchase/tokens` and one priced',
'in a room currency to `…/purchase/currency`; both take the same body and answer',
'the same way, and it is the body’s `ExpectedPriceAndCurrency` that must match.',
'',
'`ExpectedPriceAndCurrency` must be the listing’s own: `Price` its unit price and',
'`CurrencyId` its `PurchaseCurrencyId` — null for a listing priced in tokens.',
'`Quantity` must be at least 1 and, when the listing sets one, at most its',
'`MaximumCountPerPurchase`. `ConcurrencyCodes` is accepted and IGNORED.',
'',
'- In TOKENS: the total leaves the buyer and is paid to the room’s OWNER, who gets a',
' StorefrontBalanceUpdate; the buyer gets a StorefrontBalancePurchase. Both carry',
' a resulting total.',
'- In a ROOM CURRENCY: the total leaves the buyer’s holding of it (`room_balance`)',
' and NOBODY is paid.',
'',
'The room’s creator and co-owners pay nothing: the client asks them `Price` 0 (the',
'listed price is accepted too).',
'',
'`GiftRecipientAccountId` sends what is bought to that player; the buyer still pays.',
'Null (or the buyer’s own id) keeps it. Either way the items are added to the',
'recipient’s `room_inventory`.',
'',
'Answers the client’s purchase result, observed from the live client:',
'`BalanceUpdateResult` (an UpdateResponse — 0 OK), `CurrencyBalanceResponse` (what',
'the BUYER now holds of what they paid in: `{ AccountId, CurrencyId, Balance,',
'ModifiedAt }`, `CurrencyId` null and `Balance` their token total for a token',
'listing) and `OperationResult` — `{ Status, InventoryItem }`, the item being what',
'the RECIPIENT now owns — the same `RoomConsumableInventoryItem` that',
'`…/roomConsumable/room/{roomId}/me` lists: `Count` the resulting total,',
'`ConcurrencyCode` the request’s `NewConcurrencyCode` (stored, not checked),',
'`Consumable` the listing plus `IsGiftable`/`Visibility`.',
'',
'A refusal is a 200 in the same shape — `Status` 1, a null `InventoryItem`, the',
'balance as it stands — with the reason in `BalanceUpdateResult`: 4 no such',
'consumable or room, 7 a bad `Quantity`, 8 a bad gift recipient, 6 a price or',
'currency that is not the listing’s, 2 not enough to pay. Nothing is charged or',
'given. The refusal form is an ASSUMPTION — only success has been observed.',
].join('\n'),
security: AUTHED,
parameters: [
{
name: 'id',
in: 'path',
required: true,
description: 'The consumable to buy — a `room_consumable` id',
schema: { type: 'string' },
},
{
name: 'via',
in: 'path',
required: true,
description: '`tokens` for a listing priced in tokens, `currency` for a room currency',
schema: { type: 'string', enum: ['tokens', 'currency'] },
},
],
requestBody: jsonBody(PurchaseRoomConsumableRequest, 'What to buy, and what the client saw'),
responses: {
200: json(PurchaseRoomConsumableResponse, 'What the recipient now owns, or a refusal'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
const currencyType = CurrencyType.RecCenterTokens
const body = await c.req
.json<{
ConcurrencyCodes?: { NewConcurrencyCode?: unknown } | null
ExpectedPriceAndCurrency?: { Price?: unknown; CurrencyId?: unknown } | null
Quantity?: unknown
GiftRecipientAccountId?: unknown
}>()
.catch(() => null)
const request = body !== null && typeof body === 'object' ? body : {}
const expected = request.ExpectedPriceAndCurrency
const expectedCurrency =
typeof expected?.CurrencyId === 'string' && expected.CurrencyId !== ''
? expected.CurrencyId
: null
const consumable = await getRoomConsumable(c.env.DB, c.req.param('id'))
// The balance the answer reports on: what the listing is priced in once it is known,
// what the request named on a refusal that never got that far. Null is tokens.
const balanceCurrency = consumable ? consumable.PurchaseCurrencyId : expectedCurrency
// Every key is always sent: the client reads an omitted `Status` or
// `BalanceUpdateResult` as its enum's 0 member, which is success.
const answer = async (code: number, item: RoomConsumableInventoryItem | null) =>
c.json({
BalanceUpdateResult: code,
CurrencyBalanceResponse: {
AccountId: id,
CurrencyId: balanceCurrency,
Balance:
balanceCurrency === null
? await getBalance(c.env.DB, id, currencyType, startingTokens)
: await getRoomBalance(c.env.DB, balanceCurrency, id),
ModifiedAt: new Date().toISOString(),
},
OperationResult: {
Status: item === null ? ROOM_KEY_STATUS_FAILED : ROOM_KEY_STATUS_OK,
InventoryItem: item,
},
})
if (!consumable) return answer(UpdateResponse.NoItemAvailable, null)
const quantity = request.Quantity
if (
typeof quantity !== 'number' ||
!Number.isInteger(quantity) ||
quantity < 1 ||
(consumable.MaximumCountPerPurchase > 0 && quantity > consumable.MaximumCountPerPurchase)
) {
return answer(UpdateResponse.RequestedAmountNotAllowed, null)
}
const gift = request.GiftRecipientAccountId
if (
gift !== null &&
gift !== undefined &&
!(Number.isInteger(gift) && (gift as number) > 0)
) {
return answer(UpdateResponse.PlayerNotEligible, null)
}
const recipientId = (gift as number | null | undefined) ?? id
// Paid in the listing's own currency only — never whichever the request would rather.
if (expectedCurrency?.toLowerCase() !== consumable.PurchaseCurrencyId?.toLowerCase()) {
return answer(UpdateResponse.RequestedPriceDoesNotMatch, null)
}
const canManage = await canManageRoomById(c.env.DB, consumable.RoomId, id)
if (canManage === null) return answer(UpdateResponse.NoItemAvailable, null)
const expectedPrice = expected?.Price
if (expectedPrice !== consumable.Price && !(canManage && expectedPrice === 0)) {
return answer(UpdateResponse.RequestedPriceDoesNotMatch, null)
}
const total = canManage ? 0 : consumable.Price * quantity
if (total > 0 && consumable.PurchaseCurrencyId !== null) {
if (!(await spendRoomCurrency(c.env.DB, consumable.PurchaseCurrencyId, id, total))) {
return answer(UpdateResponse.NotEnoughCredit, null)
}
} else if (total > 0) {
const ownerId = await getRoomOwnerId(c.env.DB, consumable.RoomId)
if (ownerId === null) return answer(UpdateResponse.NoItemAvailable, null)
if (!(await spendCurrency(c.env.DB, id, currencyType, total, startingTokens))) {
return answer(UpdateResponse.NotEnoughCredit, null)
}
// Seed the owner's signup grant BEFORE crediting them, as the key sale does.
await ensureStartingBalances(c.env.DB, ownerId, startingTokens)
const ownerBalance = await creditCurrency(
c.env.DB,
ownerId,
currencyType,
total,
startingTokens
)
await pushBalanceUpdate(c, ownerId, currencyType, ownerBalance)
await pushBalancePurchase(
c,
id,
currencyType,
-total,
await getBalance(c.env.DB, id, currencyType, startingTokens)
)
}
const newCode = request.ConcurrencyCodes?.NewConcurrencyCode
const owned = await awardRoomConsumable(
c.env.DB,
recipientId,
consumable.RoomConsumableId,
quantity,
typeof newCode === 'string' ? newCode : null
)
return answer(UpdateResponse.OK, toInventoryItem(recipientId, owned, consumable))
}
)
// Create a consumable, or replace one when the body names an existing id. Auth-gated (401)
// and gated to the room's creator or a co-owner (403) — a shop is the room's to stock.
.put(
@@ -3633,10 +4177,48 @@ const app = new Hono<App>({ strict: false })
return roomConsumableEnvelope(c, consumable)
}
)
// What the CALLER owns of one room's consumables. NOT the catalogue the same path without
// `/me` serves: a different element type (`RoomConsumableInventoryItem`), so a separate handler.
.get(
'/api/roomconsumables/v1/roomConsumable/room/:roomId/me',
listRoute('The caller’s room consumables', 'Empty stub'),
(c) => c.json([])
describeRoute({
tags: ['Econ'],
summary: 'The caller’s room consumables',
description: [
'A bare array of what the caller owns of the room’s consumables, in the shop’s order —',
'the client’s `RoomConsumableInventoryItem`, observed from the live client:',
'`{ RoomConsumableId, AccountId, Count, ConcurrencyCode, ModifiedAt, Consumable }`.',
'',
'`Count` is what they hold now; a consumable they hold none of (never had, or spent',
'down to zero) is absent. `ConcurrencyCode` is the last `NewConcurrencyCode` written',
'(stored, not checked). `Consumable` is the listing plus `IsGiftable` (false) and',
'`Visibility` (0), with `ImageName` as "" rather than null — NOT the catalogue',
'element `…/room/{roomId}` serves.',
'',
'An unknown room, or one the caller owns nothing of, is an empty list.',
].join('\n'),
security: AUTHED,
parameters: [
{
name: 'roomId',
in: 'path',
required: true,
description: 'The room whose consumables to list',
schema: { type: 'integer' },
},
],
responses: {
200: json(RoomConsumableInventoryItemDto.array(), 'What the caller owns, shop order'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
if (!Number.isInteger(roomId)) return c.json([])
return c.json(await getRoomInventory(c.env.DB, roomId, id))
}
)
// The custom currencies a room has minted. Public, like the room itself: a room's
// currencies are shown to every player who walks into it, so the list is not a secret
@@ -3989,6 +4571,64 @@ const app = new Hono<App>({ strict: false })
}
)
// Take a purchase offer down. Auth-gated (401) and gated to the creator or a co-owner of
// the room that minted the currency the offer sells (403). The body names ONLY the offer,
// so the currency — and through it the room — is found by walking the shops for the id;
// nothing about the caller's whereabouts is consulted.
.post(
'/api/roomcurrencies/v1/deletePurchaseOffer',
describeRoute({
tags: ['Econ'],
summary: 'Remove a purchase offer from a room currency',
description: [
'Removes the offer named by `PurchaseOfferId` (its `CurrencyPurchaseOfferId`) from',
'whichever currency’s shop lists it. Form-encoded (`PurchaseOfferId=…`). The body',
'names nothing else: the currency, and the room that gates the delete, are found',
'from the offer.',
'',
'Gated to the creator or a co-owner of the room that minted the currency. A valid',
'token from anyone else is a 403. Answers the same',
'`{ Value, Success, Error, error_id }` envelope the create answers in, with the',
'offer as it stood when removed in `Value`, or a 200 carrying `Success: false` when',
'no shop lists that id. What the client does with `Value` here is an assumption —',
'only the create’s reply has been observed.',
].join('\n'),
security: AUTHED,
requestBody: form(DeletePurchaseOfferRequest, 'The offer to remove'),
responses: {
200: json(
RoomCurrencyPurchaseOfferEnvelope,
'The offer as removed, or a rejection with `Success: false`'
),
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the creator or a co-owner of the minting room (empty body)' },
},
}),
async (c) => {
const accountId = await authedId(c)
if (accountId === null) return unauthorized(c)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const purchaseOfferId = (
typeof body.PurchaseOfferId === 'string' ? body.PurchaseOfferId : ''
).trim()
if (purchaseOfferId === '') return purchaseOfferEnvelope(c, null, DELETE_OFFER_FAILED)
// Found BEFORE it is removed, because the find is what says which room gates the
// delete: an unknown id is a refusal, and a known one is somebody's to refuse.
const found = await findPurchaseOffer(c.env.DB, purchaseOfferId)
if (!found) return purchaseOfferEnvelope(c, null, DELETE_OFFER_FAILED)
const canManage = await canManageRoomById(c.env.DB, found.RoomId, accountId)
if (canManage === null) return purchaseOfferEnvelope(c, null, DELETE_OFFER_FAILED)
if (!canManage) return c.body(null, 403)
const removed = await deletePurchaseOffer(c.env.DB, purchaseOfferId)
if (!removed) return purchaseOfferEnvelope(c, null, DELETE_OFFER_FAILED)
return purchaseOfferEnvelope(c, removed.Offer)
}
)
// Award room currency to players, several awards per call. Auth-gated (401); everything
// else is reported PER ENTRY, including whether the caller may award that currency at all.
.post(
@@ -4258,6 +4898,198 @@ const app = new Hono<App>({ strict: false })
c.json([])
)
// What one player holds of one room currency. Any signed-in caller may read any player's,
// as the query names the player rather than taking the caller.
.get(
'/api/roomcurrencies/v1/getBalance',
describeRoute({
tags: ['Econ'],
summary: 'A player’s balance of one room currency',
description: [
'What the player named by `accountId` holds of the room currency `currencyId`',
'(`room_balance`). A player who has never earned any — and a currency that does not',
'exist — is a `Balance` of 0, not a 404.',
'',
'`ModifiedAt` is the time of the response: `room_balance` keeps no modification time.',
'A missing `currencyId` or a non-integer `accountId` is a 400.',
].join('\n'),
security: AUTHED,
parameters: [
{
name: 'currencyId',
in: 'query',
required: true,
description: 'The room currency',
schema: { type: 'string' },
},
{
name: 'accountId',
in: 'query',
required: true,
description: 'The player whose balance to read',
schema: { type: 'integer' },
},
],
responses: {
200: json(RoomCurrencyBalance, 'The player’s balance'),
400: { description: 'A missing `currencyId` or a non-integer `accountId`' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
if ((await authedId(c)) === null) return unauthorized(c)
const currencyId = c.req.query('currencyId')?.trim() ?? ''
const rawAccountId = c.req.query('accountId')?.trim() ?? ''
if (currencyId === '' || !/^\d+$/.test(rawAccountId)) return c.body(null, 400)
const accountId = Number(rawAccountId)
return c.json({
AccountId: accountId,
CurrencyId: currencyId,
Balance: await getRoomBalance(c.env.DB, currencyId, accountId),
ModifiedAt: new Date().toISOString(),
})
}
)
// Buy room currency with tokens, through one of the currency's purchase offers. The body
// names only the OFFER — which currency it sells is whichever one lists it — and the
// amount and price the client rendered, both of which must still match the offer.
.post(
'/api/roomCurrencies/v2/purchase',
describeRoute({
tags: ['Econ'],
summary: 'Buy room currency with tokens',
description: [
'Buys from a room currency’s shop. Form-encoded',
'(`PurchaseOfferId=ffab4157-…&RequestedAmount=444&RequestedPrice=555`): the caller',
'pays the offer’s `Price` in RecCenterTokens and is given its `CurrencyAmount` of the',
'currency the offer belongs to (`room_balance`).',
'',
'`PurchaseOfferId` is a `CurrencyPurchaseOfferId` out of a currency’s purchase offers,',
'and is all that says which currency is bought. `RequestedAmount` and `RequestedPrice`',
'must EQUAL the offer’s `CurrencyAmount` and `Price` — an offer edited since the client',
'drew it is refused rather than sold at numbers the player did not see.',
'',
'The tokens are paid to the room’s OWNER (its `CreatorAccountId`), as a room-key sale',
'is. The room’s owner and co-owners (the `canManageRoom` set) take from their own shop',
'for free: the client shows them the offer at no cost and posts `RequestedPrice=0`, so',
'their posted price is not checked and no tokens move. A free offer moves none either.',
'',
'Answers the `{ Value, Success, Error, error_id }` envelope the room-currency writes',
'use. `Value` is BOTH balances the purchase moved, each a RESULTING total:',
'`CurrencyBalanceResponse` (`AccountId`, `CurrencyId`, `Balance`, `ModifiedAt`) is',
'what the buyer now holds of the room currency, and `TokenBalanceResponse`',
'(`Balance`, `CurrencyType`, `Platform`) their tokens. `Platform` is -2, the one',
'bucket every balance surface here names — any other value shows the buyer a phantom',
'second token balance. A refusal (no such offer, a mismatch, not enough tokens) is a',
'200 with `Success: false` and a null `Value` — that form is an assumption — and',
'nothing moved.',
'',
'When tokens moved, both players get a socket push carrying their resulting token',
'total — the buyer a StorefrontBalancePurchase, the owner a StorefrontBalanceUpdate.',
].join('\n'),
security: AUTHED,
requestBody: form(PurchaseRoomCurrencyRequest, 'The offer, and the numbers the client saw'),
responses: {
200: json(PurchaseRoomCurrencyEnvelope, 'The buyer’s new holding, or a refusal'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '')
const int = (v: unknown): number => (/^-?\d+$/.test(str(v)) ? Number(str(v)) : Number.NaN)
const refuse = (error: string) =>
c.json({ Value: null, Success: false, Error: error, error_id: null })
const offerId = str(body.PurchaseOfferId)
const found = offerId === '' ? null : await findPurchaseOffer(c.env.DB, offerId)
if (found === null) return refuse('No such purchase offer')
const { Offer: offer, RoomId: roomId } = found
// An offer that hands over nothing, or costs less than nothing, is not for sale.
if (!Number.isInteger(offer.CurrencyAmount) || offer.CurrencyAmount <= 0) {
return refuse('Purchase offer is not available')
}
if (!Number.isInteger(offer.Price) || offer.Price < 0) {
return refuse('Purchase offer is not available')
}
if (int(body.RequestedAmount) !== offer.CurrencyAmount) {
return refuse('Requested amount does not match')
}
const ownerId = await getRoomOwnerId(c.env.DB, roomId)
if (ownerId === null) return refuse('Purchase offer is not available')
// The room's owner and co-owners take from their own shop for free: the client
// shows THEM the offer at no cost and posts `RequestedPrice=0`, so holding a
// contributor to the offer's price refused every purchase they made. Nobody is
// charged — the owner paying themselves would be a debit and a credit of the same
// tokens, and a co-owner is building the shop, not buying from it — so the posted
// price is not checked for them at all; everyone else must have seen the real one.
const canManage = (await canManageRoomById(c.env.DB, roomId, id)) === true
if (!canManage && int(body.RequestedPrice) !== offer.Price) {
return refuse('Requested price does not match')
}
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
const tokens = CurrencyType.RecCenterTokens
const charged = offer.Price > 0 && !canManage
// Charged FIRST, and atomically: a buyer who cannot pay is handed nothing.
if (charged && !(await spendCurrency(c.env.DB, id, tokens, offer.Price, startingTokens))) {
return refuse('Not enough tokens')
}
const { Balance: currencyBalance } = await awardRoomCurrency(
c.env.DB,
offer.CurrencyId,
id,
offer.CurrencyAmount
)
if (charged) {
// Seed the owner's signup grant BEFORE crediting them — `creditCurrency` upserts
// the row, which would otherwise cost a never-seen owner their starting tokens.
await ensureStartingBalances(c.env.DB, ownerId, startingTokens)
const ownerBalance = await creditCurrency(
c.env.DB,
ownerId,
tokens,
offer.Price,
startingTokens
)
// Both frames carry a RESULTING total — see the frame rule above pushBalanceUpdate.
await pushBalanceUpdate(c, ownerId, tokens, ownerBalance)
}
const tokenBalance = await getBalance(c.env.DB, id, tokens, startingTokens)
if (charged) await pushBalancePurchase(c, id, tokens, -offer.Price, tokenBalance)
// Both balances are RESULTING totals. `Platform` names the one account-wide bucket,
// like every other token balance this worker reports.
return c.json({
Value: {
CurrencyBalanceResponse: {
AccountId: id,
CurrencyId: offer.CurrencyId,
Balance: currencyBalance,
ModifiedAt: new Date().toISOString(),
},
TokenBalanceResponse: {
Balance: tokenBalance,
CurrencyType: tokens,
Platform: ALL_PLATFORMS,
},
},
Success: true,
Error: null,
error_id: null,
})
}
)
// The room-economy surface the client asks for on entering a room: the room's own
// inventory/offers/gift-drop shops and the caller's slice of them. Nothing here is
// stored yet, so every one is an empty list — the client reads that as "this room
@@ -4287,8 +5119,30 @@ const app = new Hono<App>({ strict: false })
)
.get(
'/econ/roomOffer/room/:roomId/purchaseCounts',
listRoute('Per-offer purchase counts for a room', 'Empty stub'),
(c) => c.json([])
describeRoute({
summary: 'Per-offer purchase counts for a room',
responses: {
200: json(
z.union([
JsonArray,
z.object({
Value: JsonArray,
Success: z.boolean(),
Error: z.string().nullable(),
error_id: z.string().nullable(),
}),
]),
'2026: RecNet result envelope; older builds: a list'
),
},
}),
async (c) => {
if (((await authedBuild(c)) ?? 0) >= 20260323) {
// The 2026 client decodes RecNetResultListObject<RoomOfferPurchaseCountDTO>.
return c.json({ Value: [], Success: true, Error: null, error_id: null })
}
return c.json([])
}
)
.get(
'/econ/roomGiftDropShops/room/:roomId',
@@ -4326,14 +5180,109 @@ const app = new Hono<App>({ strict: false })
}
)
// The UGC items a room sells (the creator-made things on sale inside it). Same empty
// stub as the room-economy routes above and asked for on the same room load: nothing
// stores room UGC purchasables yet, and an empty list reads as "this room sells
// nothing" where a 404 stalls the load.
// The paintball battle pass's season config. A stub: nothing stores a season yet, so
// every caller is served the one example season below, verbatim.
.get(
'/api/ugcPurchasables/v1/items/room/:roomId',
listRoute('A room’s UGC purchasables', 'Empty stub so the client doesn’t 404'),
(c) => c.json([])
'/paintball-battle-pass/config',
describeRoute({
tags: ['Econ'],
summary: 'The paintball battle pass config (stub)',
description: 'A fixed example season — no battle pass is stored yet.',
responses: { 200: json(JsonObject, 'The battle pass config') },
}),
(c) =>
c.json({
BattlePassId: 1,
Name: 'Season 1',
ValidRoomIds: [112],
StartTime: '2026-10-01T00:00:00Z',
EndTime: '2026-12-01T00:00:00Z',
Levels: [{ XpRequired: 100, RewardGiftDropIds: [1] }],
EventXP: { '1': 10, '2': 25, '3': 50, '4': 100 },
BoostConfig: {
TemporaryBoostDurationInMinutes: 60,
TemporaryBoostXpMultiplierPercentage: 50,
PermanentBoostXpMultiplierPercentage: 25,
SocialBoostXpMultiplierPercentage: 10,
MaxBoostXpMultiplierPercentage: 100,
},
DailyParticipationBonusLimit: 5,
MinimumParticipationTimeInSeconds: 120,
PurchasableSkus: [{ Type: 1, TokenPrice: 1000, BonusLevels: 0, PermanentBoost: false }],
PermanentBoostTokenPrice: 500,
LimitedTimeReward: { CompletionDeadline: '2026-11-01T00:00:00Z', GiftDropIds: [9] },
})
)
// The timed currency grant the client polls. A stub: nothing accrues or is granted yet,
// so every caller is served the one example grant below, verbatim. Takes no body.
.post(
'/api/timedCurrencyGrants/update',
describeRoute({
tags: ['Econ'],
summary: 'The timed currency grant (stub)',
description: 'A fixed example grant — no timed grant is stored or paid out yet.',
responses: { 200: json(JsonObject, 'The timed currency grant') },
}),
(c) =>
c.json({
TimedCurrencyGrantId: 1,
Name: 'Daily Tokens',
StartTime: '2026-10-01T00:00:00Z',
EndTime: '2026-12-01T00:00:00Z',
CurrencyType: 0,
OfflineRatePerHour: 1.0,
OnlineRatePerHour: 5.0,
ActiveExperiment: null,
})
)
// The client reporting how far the player has moved, towards a distance-moved reward
// track. A stub: the form body (`distanceMeters=12.5`) is not read, nothing is tracked,
// and the answer is an empty 200.
.post(
'/api/distanceMovedRewardTracks/distance',
describeRoute({
tags: ['Econ'],
summary: 'Report distance moved (stub)',
description:
'Takes a form-encoded `distanceMeters` and stores nothing — no reward track exists yet.',
responses: { 200: { description: 'Empty body' } },
}),
(c) => c.body(null, 200)
)
// Everything a room sells, in one list: its keys, its consumables and the purchase offers
// of its currencies, each as the client's 10-key `UgcPurchasableItem` with `ItemType`
// saying which (0 key, 1 consumable, 3 currency pack). Asked for on every room load. A
// room that sells nothing is `[]` — the client's decoder throws on `{}` or an empty body.
.get(
'/api/ugcPurchasables/v1/items/room/:roomId{[0-9]+}',
describeRoute({
tags: ['Rooms'],
summary: 'A room’s purchasables',
description:
'The room’s shop as one flat list: its room keys (`ItemType` 0), room consumables ' +
'(1) and the purchase offers of each of its currencies (3), assembled from the ' +
'`room_key`, `room_consumable` and `room_currency` tables. `ItemId` is a bare GUID ' +
'— a key’s `ReplicationId`, a consumable’s `RoomConsumableId`, an offer’s ' +
'`CurrencyPurchaseOfferId` — with the kind beside it as `ItemType`, not the ' +
'`{ itemType, itemId }` struct the bulk lookup takes. `PurchaseCurrencyId` is the ' +
'room currency a line is charged in, or null for tokens (every currency pack). A room ' +
'that sells nothing is an empty array.',
parameters: [
{
name: 'roomId',
in: 'path',
required: true,
description: 'The room whose shop to list',
schema: { type: 'string', pattern: '^[0-9]+$' },
},
],
responses: { 200: json(RoomPurchasableList, 'The room’s shop') },
}),
async (c) =>
c.json(await getRoomPurchasables(c.env.DB, Number.parseInt(c.req.param('roomId'), 10)))
)
// Bulk lookup of UGC purchasables by `{ itemType, itemId }`. Only custom avatar items
@@ -4423,6 +5372,89 @@ const app = new Hono<App>({ strict: false })
}
)
// Store listings by `PurchasableItemId` — `{ PurchasableItemIds: [2594], GiftDropIds: null }`.
// Answers a bare array of the listings exactly as the served 2025 store (`sf3-2025.json`)
// lists them, read from the `catalog` table's `listing` column rather than the 4 MB file
// (a row's `catalog_id` IS its `PurchasableItemId`). Every item kind resolves; unknown ids
// are dropped, so the answer is in request order but not positional. `GiftDropIds` has only
// been seen null and is ignored. No auth: a listing is what the public store shows anyone.
.post(
'/api/storefronts/v1/purchasableitems',
describeRoute({
tags: ['Storefront'],
summary: 'Store listings by PurchasableItemId',
description: [
'Resolves `PurchasableItemIds[]` against the `catalog` table and answers each listing',
'verbatim as the served 2025 store (`sf3-2025.json`) lists it — `GiftDrop`, `Prices`,',
'`SubscriberPrices` and the rest. Avatar items, consumables, skins and custom avatar',
'items all resolve. Unknown ids (and repeats) are dropped, so the response follows',
'request order but is never a positional match. `GiftDropIds` is ignored.',
].join(' '),
requestBody: jsonBody(PurchasableItemsRequest, 'The listings to resolve'),
responses: {
200: json(JsonArray, 'The resolved listings (unknown ids omitted)'),
400: json(ErrorResponse, 'Malformed body'),
},
}),
async (c) => {
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (!body || typeof body !== 'object')
return c.json({ error: 'A JSON body is required' }, 400)
const raw = body.PurchasableItemIds
if (raw !== undefined && raw !== null && !Array.isArray(raw)) {
return c.json({ error: 'PurchasableItemIds must be an array' }, 400)
}
const ids = ((raw as unknown[] | null | undefined) ?? []).filter((id): id is number =>
Number.isInteger(id)
)
return c.json(await getStoreListings(c.env.DB, ids))
}
)
// The consumable DEFINITIONS the client knows how to use (`?supportedVersion=2`), as
// opposed to the ones a player owns. Served verbatim from `static/consumables.json`, the
// live game's list; `supportedVersion` is accepted and unused. A row's `ConfigurationData`
// is JSON escaped into a STRING (`"{\"MeshConfigurationData\":null,…}"`), or null.
.get(
'/api/consumables/v1/all',
describeRoute({
tags: ['Consumables'],
summary: 'All consumable definitions',
description:
'Every consumable definition, verbatim from static/consumables.json. supportedVersion is ignored.',
responses: { 200: json(JsonArray, 'The consumable definitions') },
}),
(c) => c.json(consumableDefinitions)
)
// STUB. The client's lookup of consumable definitions by descriptor
// (`?configurationVersion=2`), form-encoded with one repeated key:
// `descriptors=xfeDIDboPES-AS90CCn2xQ&descriptors=test_bad`. The answer is an array of
// definitions — `{ ConsumableItemDesc, InitialCount, CreatedAt, ThumbnailImageName,
// FriendlyName, RoomieCategory, ConsumableToolName, ConfigurationData }`, with
// `ConfigurationData` JSON escaped into a string as on `/all` — holding only the
// descriptors that resolve. Nothing here can resolve one yet, so every query answers [].
.post(
'/api/consumables/v1/query/bulk',
describeRoute({
tags: ['Consumables'],
summary: 'Look up consumable definitions by descriptor (stub)',
description: [
'STUB. Takes a form body repeating `descriptors` once per consumable descriptor and',
'answers the definitions that resolve. There is no lookup behind it yet, so the',
'answer is always []. configurationVersion is ignored.',
].join(' '),
requestBody: form(QueryConsumablesRequest, 'The descriptors to look up'),
responses: { 200: json(JsonArray, 'The matching definitions (always empty for now)') },
}),
async (c) => {
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
const descriptors = body['descriptors']
logger.info('consumable query/bulk (stub, nothing resolved)', { descriptors })
return c.json([])
}
)
// Unlocked consumables. [Authorize]. The consumables the player has bought (from
// `buyItem`, stored in the `consumable` table), grouped by item into the client's
// unlocked-consumable DTO. A player who has bought none gets an empty list.
@@ -4445,7 +5477,17 @@ const app = new Hono<App>({ strict: false })
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getConsumables(c.env.DB, id))
const consumables = await getConsumables(c.env.DB, id)
// The 2026 client includes this flag in the instance key and verifies it against the
// definition, so it comes from the definition — see `isRroConsumable`.
return c.json(
((await authedBuild(c)) ?? 0) >= 20260323
? consumables.map((item) => ({
...item,
IsRRO: isRroConsumable(item.ConsumableItemDesc),
}))
: consumables
)
}
)
@@ -4487,6 +5529,54 @@ const app = new Hono<App>({ strict: false })
}
)
// STUB. The client posts `{ Id, IsActive, ActivatedByRoomie }` when a consumable starts
// or stops being used — `Id` is the consumable row id, as on `/consume`. Nothing is
// stored: the body is read only to log it, and the client ignores the response entirely,
// so none is sent.
//
// What this most likely means: the consumable is IN USE, and marking it so is what keeps
// a player from reusing one before it is over (a second Confetti Cannon while the first
// is still firing), and `ActivatedByRoomie` says a roommate set it off rather than its
// owner. That is a guess from the field names, not observed behaviour, so no row flips
// and `/consume` is unchanged. When the client's reading of it is known, this is where
// an `is_active` flag on the consumable row (and a refusal on `/consume`) would go.
.post(
'/api/consumables/v1/updateActive',
describeRoute({
tags: ['Consumables'],
summary: 'Mark a consumable active or inactive (stub)',
description: [
'STUB. Accepts `{ Id, IsActive, ActivatedByRoomie }` — the client posting that one',
'of its consumables has started or stopped being used — and stores nothing. The',
'client ignores the response entirely, so this answers an empty 200.',
'',
'Believed to mark the consumable as IN USE so it cannot be reused before it is over,',
'with `ActivatedByRoomie` saying a roommate set it off; that is a guess from the field',
'names, and nothing acts on it yet. `/consume` is unaffected.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(UpdateActiveConsumableRequest, 'The consumable and its new state'),
responses: {
200: { description: 'Empty body — the client ignores the response entirely' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req
.json<Record<string, unknown>>()
.catch(() => ({}) as Record<string, unknown>)
logger.info('consumable updateActive (stub, not stored)', {
accountId: id,
consumableId: body.Id,
isActive: body.IsActive,
activatedByRoomie: body.ActivatedByRoomie,
})
return c.body(null, 200)
}
)
// Currency balance. [Authorize]. The trailing int is a CurrencyType — the client
// fetches `/balance/2` (RecCenterTokens) on load. Backed by the `balance` table; a
// player who has never been granted gets their starting balance on this first read.
@@ -5378,9 +6468,11 @@ const app = new Hono<App>({ strict: false })
// posting the type and the message to show for it (`rewardType=FirstActivityOfDay&
// Message=First Game of the Day`, or `rewardType=PostGameActivity&Message=Activity
// completed!&giftContext=Soccer`) — so whether a reward is actually OWED is decided
// here, from `reward_status`: one claim per type per activity per hour, atomically.
// here, from `reward_status`: one claim per type per activity per hour, atomically —
// except FirstActivityOfDay, one per player per 24h.
//
// A claim pays GAME_REWARD_XP into `progression` and hands over a gift box carrying that
// A claim pays GAME_REWARD_XP into `progression` (up to GAME_REWARD_DAILY_XP_CAP per player
// per rolling 24h, FirstActivityOfDay's on top) and hands over a gift box carrying that
// XP, announced with the same GiftPackageReceivedImmediate frame the weekly gift uses —
// the client posted the message to show, so the box wears it. An on-cooldown ask changes
// nothing and pays nothing.
@@ -5390,19 +6482,20 @@ const app = new Hono<App>({ strict: false })
// reference answers its own (different, selection-based) flow with a success envelope,
// not a list of rewards.
//
// `giftContext` (the activity, e.g. `Soccer`) is part of the cooldown key: the first
// activity of the day is per ACTIVITY, so a player who moves from Soccer to Paintball is
// owed another reward while a second Soccer match inside the hour is not. An ask that
// sends no context keys on `''`.
// `giftContext` (the activity, e.g. `Soccer`) is part of the cooldown key, so a player who
// moves from Soccer to Paintball is owed another reward while a second Soccer match inside
// the hour is not. An ask that sends no context keys on `''` — as FirstActivityOfDay always
// does, being the player's first activity of the day, not each activity's.
//
// It also picks the PRIZE: a context that is a key of `static/quest-rewards.json`
// (`Dodgeball`, `Quest_Goblin_S`, …) draws one of that activity's rewards — an avatar item
// granted into the inventory, or Laser Tag's ticket payout — and the box carries it, with
// the activity's own `GiftContext`. A context the table doesn't know gets the XP-only box.
//
// And the FIRST claim ever in a context — a new activity for this player, whichever type
// asked — pays NEW_ACTIVITY_BONUS_TOKENS on top, credited to the balance and delivered in
// a second box of its own so the player sees what the tokens were for.
// A `PostGameActivity`/`PostQuestActivity` claim also pays GAME_REWARD_TOKENS, up to
// GAME_REWARD_DAILY_TOKEN_CAP per player per rolling 24h across every activity, and a
// FirstActivityOfDay claim pays them on top of the cap — credited to the balance and
// delivered in a second box of its own so the player sees what the tokens were for.
.post(
'/api/gamerewards/v1/request',
describeRoute({
@@ -5414,9 +6507,11 @@ const app = new Hono<App>({ strict: false })
'owed another reward while the same one is not; an ask with no `giftContext` keys on',
'the empty context. A `giftContext` that names an activity in `quest-rewards.json`',
'(`Dodgeball`, `Quest_Goblin_S`, …) draws one of that activity’s rewards and grants it;',
'any other claim pays XP only. The first claim ever in a `giftContext` — a new activity',
'for the player — also pays a one-off 250 tokens, in a box of its own. The reward rides',
'in a gift box, so a claim and a rejected (on-cooldown) ask both answer `[]`.',
'any other claim pays XP only. A `PostGameActivity` or `PostQuestActivity` claim also',
'pays 250 tokens, in a box of its own, up to 1250 per player per rolling 24 hours',
'whatever the activity. `FirstActivityOfDay` is once per player per 24 hours, whatever',
'the `giftContext`, and pays 250 tokens outside that cap. The reward rides in a gift',
'box, so a claim and a rejected (on-cooldown) ask both answer `[]`.',
].join(' '),
security: AUTHED,
requestBody: form(GameRewardRequest, 'The reward type and its display message'),
@@ -5433,29 +6528,44 @@ const app = new Hono<App>({ strict: false })
// No type, nothing to gate: don't write a row keyed on an empty string.
if (rewardType === '') return c.json([])
const giftContext = typeof body.giftContext === 'string' ? body.giftContext : ''
const claimed = await claimReward(c.env.DB, id, rewardType, giftContext)
// FirstActivityOfDay is the player's, not the activity's: claimed on the empty context
// and a day's cooldown. Its prize is still picked from the activity that asked.
const daily = rewardType === FIRST_ACTIVITY_OF_DAY
const claimed = daily
? await claimReward(c.env.DB, id, rewardType, '', new Date(), DAILY_REWARD_COOLDOWN_MS)
: await claimReward(c.env.DB, id, rewardType, giftContext)
// On cooldown: nothing was claimed, so nothing is paid and nothing is announced.
if (claimed === null) return c.json([])
const message =
typeof body.Message === 'string' && body.Message !== ''
? body.Message
: DEFAULT_GAME_REWARD_MESSAGE
// The XP draws on the player's daily allowance — FirstActivityOfDay's is a bonus on top,
// its once-a-day claim being limit enough. Past the cap the claim pays no XP.
const xpToday = daily
? null
: await claimAllowance(c.env.DB, id, '$xp', GAME_REWARD_XP, GAME_REWARD_DAILY_XP_CAP)
const xp = daily || xpToday !== null ? GAME_REWARD_XP : 0
// Bank the XP first: it is the reward, and the box is the wrapper the client shows.
// A failure here must not leave a box promising XP that was never credited.
const { progression, levelsGained } = await addXp(c.env.DB, id, GAME_REWARD_XP)
const gained = xp > 0 ? await addXp(c.env.DB, id, xp) : null
// An activity the reward table knows pays one of ITS rewards the player lacks — the
// item rides in the box and is granted with it. Anything else gets the plain XP box.
const questReward = await pickQuestReward(c.env.DB, id, giftContext)
const itemKey = questReward?.AvatarItemDesc || questReward?.EquipmentModificationGuid
const drop =
questReward === null
? toGameRewardDrop()
: toQuestRewardDrop(questReward, itemKey ? await getCatalogItem(c.env.DB, itemKey) : null)
? toGameRewardDrop(xp)
: toQuestRewardDrop(
questReward,
itemKey ? await getCatalogItem(c.env.DB, itemKey) : null,
xp
)
// A currency reward (Laser Tag's tickets) is credited here: `grantGiftDrop` grants
// items, not balances. Seed the signup grant first — `creditCurrency` upserts the
// row, and a never-touched RecCenterTokens balance would otherwise lose it.
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
if (drop.Currency > 0 && drop.CurrencyType !== CurrencyType.Invalid) {
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
await ensureStartingBalances(c.env.DB, id, startingTokens)
const balance = await creditCurrency(
c.env.DB,
@@ -5466,53 +6576,95 @@ const app = new Hono<App>({ strict: false })
)
await pushBalanceUpdate(c, id, drop.CurrencyType, balance)
}
const granted = await grantGiftDrop(c, id, drop, message)
await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID)
// A new activity pays its token bonus in a second box — after the reward's own, so
// the player sees the reward they asked for first and the bonus as the extra it is.
// Same order as the currency reward above: balance first, then the box that shows it.
let bonusGiftId: number | null = null
if (await isNewActivity(c.env.DB, id, giftContext, claimed)) {
const bonus = toNewActivityBonusDrop()
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
await ensureStartingBalances(c.env.DB, id, startingTokens)
const balance = await creditCurrency(
// A box with nothing in it — the XP capped and no item or currency to carry — is not
// handed over at all.
let rewardGiftId: number | null = null
if (questReward !== null || xp > 0) {
const granted = await grantGiftDrop(c, id, drop, message)
await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID)
rewardGiftId = granted.id
}
// The tokens ride in a second box — after the reward's own, so the player sees the
// reward they asked for first — while the player's daily allowance has room. Same
// order as the currency reward above: balance first, then the box that shows it.
let tokensGiftId: number | null = null
let tokensToday: number | null = null
if (TOKEN_REWARD_TYPES.has(rewardType)) {
tokensToday = await claimAllowance(
c.env.DB,
id,
bonus.CurrencyType,
bonus.Currency,
'$tokens',
GAME_REWARD_TOKENS,
GAME_REWARD_DAILY_TOKEN_CAP
)
}
// FirstActivityOfDay's claim already held it to once a day: it pays without the cap.
if (daily || tokensToday !== null) {
const tokensDrop = toGameRewardTokensDrop()
await ensureStartingBalances(c.env.DB, id, startingTokens)
const tokenBalance = await creditCurrency(
c.env.DB,
id,
tokensDrop.CurrencyType,
tokensDrop.Currency,
startingTokens
)
await pushBalanceUpdate(c, id, bonus.CurrencyType, balance)
const bonusGift = await grantGiftDrop(c, id, bonus, NEW_ACTIVITY_BONUS_MESSAGE)
await pushGiftReceived(c, id, bonusGift, NEW_ACTIVITY_BONUS_MESSAGE, COACH_ACCOUNT_ID)
bonusGiftId = bonusGift.id
await pushBalanceUpdate(c, id, tokensDrop.CurrencyType, tokenBalance)
const tokensGift = await grantGiftDrop(c, id, tokensDrop, GAME_REWARD_TOKENS_MESSAGE)
await pushGiftReceived(c, id, tokensGift, GAME_REWARD_TOKENS_MESSAGE, COACH_ACCOUNT_ID)
tokensGiftId = tokensGift.id
}
if (gained !== null) {
// Every grant moves the bar, whether or not it crossed a level.
await pushProgressionUpdate(c, id, gained.progression)
// …and every level crossed is worth a box of its own tier.
await grantLevelUpGifts(c, id, gained)
}
// Every grant moves the bar, whether or not it crossed a level.
await pushProgressionUpdate(c, id, progression)
// …and every level crossed is worth a box of its own tier.
await grantLevelUpGifts(c, id, { progression, levelsGained })
logger.info('game reward claimed', {
accountId: id,
rewardType,
giftContext,
grantCount: claimed,
message,
xp: GAME_REWARD_XP,
level: progression.Level,
levelsGained,
levelXp: progression.XP,
giftId: granted.id,
newActivityBonus: bonusGiftId === null ? 0 : NEW_ACTIVITY_BONUS_TOKENS,
bonusGiftId,
xp,
xpToday,
level: gained?.progression.Level,
levelsGained: gained?.levelsGained,
levelXp: gained?.progression.XP,
giftId: rewardGiftId,
tokens: tokensGiftId === null ? 0 : GAME_REWARD_TOKENS,
tokensToday,
tokensGiftId,
})
return c.json([])
}
)
// The player's room keys. Returns "[]": nothing sells a key yet, so nobody holds one.
.get('/api/roomkeys/v1/mine', listRoute('The player’s room keys', 'Empty for now'), (c) =>
c.json([])
// The caller's room keys: every key they hold (`room_key_player`), across all rooms.
.get(
'/api/roomkeys/v1/mine',
describeRoute({
tags: ['Econ'],
summary: 'The player’s room keys',
description: [
'Every key the caller holds — bought, awarded, or taken free as a room manager —',
'across every room, oldest key first. No parameters.',
'',
'Each entry is the room key plus `IsGiftable` (false) and `Visibility` (0), with',
'`ImageName` as "" rather than null when the key has no art — the shape observed',
'from the live client, which differs from `/api/roomkeys/v1/room`’s.',
].join('\n'),
security: AUTHED,
responses: {
200: json(HeldRoomKeyDto.array(), 'The caller’s keys, oldest first'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getHeldRoomKeys(c.env.DB, id))
}
)
// The keys a room has listed. Public, like the room's currencies: a key is offered to
// whoever walks up to the door it opens. A room with none, an unknown room and a missing
@@ -5562,12 +6714,13 @@ const app = new Hono<App>({ strict: false })
'',
'`Name` and `Description` are masked by the same word list as every other string a',
'player types. A room may list at most 10 keys — the `RoomKeyConfig.MaxKeysPerRoom`',
'the `api` config tells the client — and the eleventh is refused.',
'the `api` config tells the client — and the eleventh is refused. A new key is priced',
'in tokens (the body names no currency), so a `Price` over 1000 is refused too.',
'',
'Answers `{ Status, RoomKey }` — `Status` 0 and the created key, as the live client',
'reads it — not the `{ Value, Success, Error, error_id }` envelope the room-currency',
'writes use. A recoverable refusal (an unusable `RoomId`, an empty `Name`, an unknown',
'room, a full room) is a 200 carrying a non-zero `Status` and a null `RoomKey`; only',
'room, a full room, a `Price` over the limit) is a 200 carrying a non-zero `Status` and a null `RoomKey`; only',
'the auth gates answer with an HTTP status of their own.',
'',
'Pushes `LocalRoomKeyCreated` (120), carrying the same object, to everyone in the',
@@ -5596,6 +6749,10 @@ const app = new Hono<App>({ strict: false })
if (!Number.isInteger(roomId)) return roomKeyEnvelope(c, null)
const name = str(body.Name).trim()
if (name === '') return roomKeyEnvelope(c, null)
// A key cannot cost less than nothing — nor, being priced in tokens until an edit
// says otherwise, more than the token cap.
const price = Math.max(0, int(body.Price, 0))
if (price > MAX_KEY_PRICE_TOKENS) return roomKeyEnvelope(c, null)
const canManage = await canManageRoomById(c.env.DB, roomId, accountId)
if (canManage === null) return roomKeyEnvelope(c, null)
@@ -5610,8 +6767,7 @@ const app = new Hono<App>({ strict: false })
Type: str(body.Type).trim() || 'Key',
Name: censorSwears(name),
Description: censorSwears(str(body.Description)),
// A key cannot cost less than nothing.
Price: Math.max(0, int(body.Price, 0)),
Price: price,
})
await pushRoomKeyCreated(c, key, accountId)
@@ -5619,6 +6775,466 @@ const app = new Hono<App>({ strict: false })
}
)
// Buy a room key with tokens. A GET with query params — that is how the client sends it.
// The key's price leaves the buyer and is paid to the room's owner; holding the key is a
// `room_key_player` row. Every refusal is a 200 in the same shape, told apart by the
// entry's `UpdateResponse` — only a missing token answers with a status of its own.
.get(
'/api/storefronts/v1/buyRoomKey',
describeRoute({
tags: ['Storefront'],
summary: 'Buy a room key',
description: [
'Looks the key up by `RoomKeyId`, confirms the client’s `RequestedPrice` matches its',
'stored `Price`, debits the buyer that price in RecCenterTokens, pays it to the',
'room’s OWNER (its `CreatorAccountId`), and records the buyer as holding the key',
'(`room_key_player`). A free key moves no tokens, and nor does a key bought by the',
'room’s creator or a CO-OWNER: they get their own room’s keys free, and the client',
'asks them `RequestedPrice=0` (the stored price is accepted too). A GET because that',
'is how the client sends it.',
'',
'Answers the client’s `RoomKeyPurchaseResponseDTO`: `RoomKeyResponse` is the',
'`{ Status, RoomKey }` the create answers in, and `BalanceUpdateResponse` carries one',
'entry (`UpdateResponse`, `Data` = the key) and the buyer’s RESULTING `Balance`.',
'`Platform` is -2, the one bucket every balance surface here names — any other value',
'shows the buyer a phantom second balance.',
'',
'A refusal is a 200 in the same shape — `Status` 1, a null `RoomKey`, the balance',
'untouched — with the reason in the entry’s `UpdateResponse`: 6 the price does not',
'match, 3 already held, 2 not enough tokens, 4 no such key (or no such room, or a key',
'priced in a ROOM CURRENCY, which this token route does not sell).',
'',
'When tokens moved, both players get a socket push carrying their resulting total —',
'the buyer a StorefrontBalancePurchase, the owner a StorefrontBalanceUpdate.',
].join('\n'),
security: AUTHED,
parameters: [
{
name: 'RoomKeyId',
in: 'query',
required: true,
description: 'The key to buy — a `room_key` id',
schema: { type: 'integer' },
},
{
name: 'RequestedPrice',
in: 'query',
required: false,
description: 'The price the client rendered; must equal the key’s. Defaults to 0',
schema: { type: 'integer' },
},
],
responses: {
200: json(BuyRoomKeyResponse, 'The purchase result, or a refusal in the same shape'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
const currencyType = CurrencyType.RecCenterTokens
const roomKeyId = Number.parseInt(c.req.query('RoomKeyId') ?? '', 10)
// Absent/non-numeric reads as 0, which only matches a free key — a priced one then
// fails the confirmation below rather than selling for nothing.
const requestedPrice = Number.parseInt(c.req.query('RequestedPrice') ?? '0', 10) || 0
const key = Number.isInteger(roomKeyId) ? await getRoomKey(c.env.DB, roomKeyId) : null
// Every key is always sent: the client reads an omitted one as its enum's 0 member,
// which is success for `Status`/`UpdateResponse` and SteamPurchased for `Platform`.
const answer = async (code: number) =>
c.json({
RoomKeyResponse: {
Status: code === UpdateResponse.OK ? ROOM_KEY_STATUS_OK : ROOM_KEY_STATUS_FAILED,
RoomKey: code === UpdateResponse.OK ? key : null,
},
BalanceUpdateResponse: {
BalanceUpdates: [{ UpdateResponse: code, Data: key }],
Balance: await getBalance(c.env.DB, id, currencyType, startingTokens),
CurrencyType: currencyType,
Platform: ALL_PLATFORMS,
},
})
// A key priced in a room currency is not for sale in tokens.
if (key === null || key.PurchaseCurrencyId !== null) {
return answer(UpdateResponse.NoItemAvailable)
}
const ownerId = await getRoomOwnerId(c.env.DB, key.RoomId)
if (ownerId === null) return answer(UpdateResponse.NoItemAvailable)
// The room's creator and co-owners get their own room's keys free — the client asks
// them `RequestedPrice=0` — and are never charged, whatever price they name.
const canManage = (await canManageRoomById(c.env.DB, key.RoomId, id)) === true
if (requestedPrice !== key.Price && !(canManage && requestedPrice === 0)) {
return answer(UpdateResponse.RequestedPriceDoesNotMatch)
}
// Claim the key BEFORE charging: the insert is atomic, so of two concurrent buys only
// one gets past here and only one is charged. A buyer who cannot pay gives it back.
if (!(await claimRoomKey(c.env.DB, id, key.RoomKeyId))) {
return answer(UpdateResponse.AlreadyOwned)
}
if (key.Price > 0 && !canManage) {
if (!(await spendCurrency(c.env.DB, id, currencyType, key.Price, startingTokens))) {
await releaseRoomKey(c.env.DB, id, key.RoomKeyId)
return answer(UpdateResponse.NotEnoughCredit)
}
// Seed the owner's signup grant BEFORE crediting them — `creditCurrency` upserts
// the row, which would otherwise cost a never-seen owner their starting tokens.
await ensureStartingBalances(c.env.DB, ownerId, startingTokens)
const ownerBalance = await creditCurrency(
c.env.DB,
ownerId,
currencyType,
key.Price,
startingTokens
)
// Both frames carry a RESULTING total — see the frame rule above pushBalanceUpdate.
await pushBalanceUpdate(c, ownerId, currencyType, ownerBalance)
await pushBalancePurchase(
c,
id,
currencyType,
-key.Price,
await getBalance(c.env.DB, id, currencyType, startingTokens)
)
}
return answer(UpdateResponse.OK)
}
)
// Buy a room key with the room currency it is priced in — buyRoomKey's mirror image, and
// almost nothing shared on the wire: a POST with a form body, a bare `{ Balance,
// RoomKeyResponse }`, and a `Balance` that is an OBJECT. Nobody is paid: room currency is
// the room's own play money, so the price simply leaves the buyer.
.post(
'/api/storefronts/v1/PurchaseRoomKeyWithCurrency',
describeRoute({
tags: ['Storefront'],
summary: 'Buy a room key with room currency',
description: [
'Buys a key priced in a room currency. Form-encoded',
'(`RoomKeyId=43&RequestedPrice=1000&RequestedPurchaseCurrencyId=b9f41a7c-…`): the',
'key’s `Price` is subtracted from what the caller holds of that currency',
'(`room_balance`) and they are recorded as holding the key (`room_key_player`).',
'NOBODY IS PAID — unlike the token sale, the price does not reach the room’s owner.',
'',
'`RequestedPurchaseCurrencyId` must be the key’s own `PurchaseCurrencyId` and',
'`RequestedPrice` its `Price` — except that the room’s creator or a CO-OWNER gets',
'their own room’s keys free: the client asks them `RequestedPrice=0` (the stored',
'price is accepted too) and nothing is subtracted.',
'A key priced in TOKENS (a null `PurchaseCurrencyId`)',
'is not sold here — that is `GET /api/storefronts/v1/buyRoomKey`.',
'',
'Answers the client’s `RoomKeyPurchaseWithCurrencyResponseDTO`, a bare object with no',
'envelope: `Balance` is an OBJECT — `{ AccountId, CurrencyId, Balance, ModifiedAt }`,',
'the buyer’s RESULTING holding — and `RoomKeyResponse` the `{ Status, RoomKey }` the',
'create answers in. Not buyRoomKey’s shape; there is no `Platform` here.',
'',
'A refusal (no such key, a currency or price that is not the key’s, a key already',
'held, too little of the currency) is a 200 in the same shape: `Status` 1, a null',
'`RoomKey`, and the holding as it stands. That form is an assumption — only success',
'has been observed.',
].join('\n'),
security: AUTHED,
requestBody: form(PurchaseRoomKeyWithCurrencyRequest, 'The key, and what the client saw'),
responses: {
200: json(
PurchaseRoomKeyWithCurrencyResponse,
'The purchase result, or a refusal in the same shape'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '')
const int = (v: unknown): number => (/^-?\d+$/.test(str(v)) ? Number(str(v)) : Number.NaN)
const requestedCurrencyId = str(body.RequestedPurchaseCurrencyId)
const roomKeyId = int(body.RoomKeyId)
const key = Number.isInteger(roomKeyId) ? await getRoomKey(c.env.DB, roomKeyId) : null
// The currency the answer reports on: the key's own once it is known to be the one
// asked for, the requested one on a refusal that never got that far.
const currencyId = key?.PurchaseCurrencyId ?? requestedCurrencyId
// `Status` is always sent: the client reads an omitted one as 0, which is success.
const answer = async (sold: boolean) =>
c.json({
Balance: {
AccountId: id,
CurrencyId: currencyId,
Balance: await getRoomBalance(c.env.DB, currencyId, id),
ModifiedAt: new Date().toISOString(),
},
RoomKeyResponse: {
Status: sold ? ROOM_KEY_STATUS_OK : ROOM_KEY_STATUS_FAILED,
RoomKey: sold ? key : null,
},
})
// A key priced in tokens is buyRoomKey's to sell, and a key is paid for in its own
// currency only — never whichever one the request would rather spend.
if (key === null || key.PurchaseCurrencyId === null) return answer(false)
if (requestedCurrencyId.toLowerCase() !== key.PurchaseCurrencyId.toLowerCase()) {
return answer(false)
}
// The room's creator and co-owners get their own room's keys free, as with the token
// sale — the client asks them `RequestedPrice=0` — and are never charged.
const canManage = await canManageRoomById(c.env.DB, key.RoomId, id)
if (canManage === null) return answer(false)
const requestedPrice = int(body.RequestedPrice)
if (requestedPrice !== key.Price && !(canManage && requestedPrice === 0)) {
return answer(false)
}
// Claim the key BEFORE charging, as the token sale does: the insert is atomic, so of
// two concurrent buys only one is charged. A buyer who cannot pay gives it back.
if (!(await claimRoomKey(c.env.DB, id, key.RoomKeyId))) return answer(false)
if (
key.Price > 0 &&
!canManage &&
!(await spendRoomCurrency(c.env.DB, key.PurchaseCurrencyId, id, key.Price))
) {
await releaseRoomKey(c.env.DB, id, key.RoomKeyId)
return answer(false)
}
return answer(true)
}
)
// Edit a room key. "All" is the key's FIELDS, not the room's keys: the body names one
// `RoomKeyId` and carries everything editable about it. Gated like the create — the room
// comes off the KEY, never the request.
.put(
'/api/roomkeys/v1/updateAll',
describeRoute({
tags: ['Econ'],
summary: 'Update a room key',
description: [
'Edits ONE key — despite the name, "all" is its fields — form-encoded',
'(`RoomKeyId=43&Name=key1&Description=…&Price=1000&PurchaseCurrencyId=`).',
'',
'Gated to the CREATOR or a CO-OWNER of the room that lists the key; a valid token',
'from anyone else is a 403.',
'',
'`PurchaseCurrencyId` sent EMPTY prices the key in tokens (stored and served as',
'null) — that is how a key on a room currency is taken back off it; otherwise it',
'must be a `room_currency` id belonging to the key’s own room.',
'`ImageName` is the key’s art, stored as posted; sent EMPTY it clears the art',
'(served as null). That `ImageName` rides in this form is an ASSUMPTION.',
'`Price` is capped by what the key ends up priced in: 1000 in tokens, 1000000000 in',
'a room currency. A field left out of the body is left alone rather than reset, and',
'the cap is checked against the resulting pair.',
'',
'`Name` and `Description` are masked by the same word list as every other string a',
'player types. What can never change: the ids, the room, the `Type`, `CreatedAt`.',
'',
'Answers the create’s `{ Status, RoomKey }` envelope with the key as it now stands',
'— an ASSUMPTION: what the client reads from this response has not been observed.',
'A recoverable refusal (no such key, a blank `Name`, a `Price` over the cap, an',
'unknown or foreign currency) is a 200 carrying a non-zero `Status` and a null',
'`RoomKey`.',
].join('\n'),
security: AUTHED,
requestBody: form(UpdateRoomKeyRequest, 'The key and its fields'),
responses: {
200: json(RoomKeyEnvelope, 'The key as it now stands, or a refusal'),
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
},
}),
async (c) => {
const accountId = await authedId(c)
if (accountId === null) return unauthorized(c)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
const roomKeyId = Number.parseInt(str(body.RoomKeyId) ?? '', 10)
if (!Number.isInteger(roomKeyId)) return roomKeyEnvelope(c, null)
const existing = await getRoomKey(c.env.DB, roomKeyId)
if (!existing) return roomKeyEnvelope(c, null)
const canManage = await canManageRoomById(c.env.DB, existing.RoomId, accountId)
if (canManage === null) return roomKeyEnvelope(c, null)
if (!canManage) return c.body(null, 403)
// A name sent but blank is a bad edit, not an instruction to leave it alone.
const name = str(body.Name)?.trim()
if (name === '') return roomKeyEnvelope(c, null)
let price = existing.Price
if (str(body.Price) !== undefined) {
price = Number.parseInt(str(body.Price)!, 10)
if (!Number.isInteger(price) || price < 0) return roomKeyEnvelope(c, null)
}
// Present-but-empty is a VALUE here — tokens — unlike the fields above, where only
// absence means "leave alone".
let purchaseCurrencyId = existing.PurchaseCurrencyId
const postedCurrencyId = str(body.PurchaseCurrencyId)?.trim()
if (postedCurrencyId === '') {
purchaseCurrencyId = null
} else if (postedCurrencyId !== undefined && postedCurrencyId !== purchaseCurrencyId) {
// A key is sold in its own room's money, not another's.
const currency = await getRoomCurrency(c.env.DB, postedCurrencyId)
if (!currency || currency.RoomId !== existing.RoomId) return roomKeyEnvelope(c, null)
purchaseCurrencyId = currency.CurrencyId
}
const maxPrice =
purchaseCurrencyId === null ? MAX_KEY_PRICE_TOKENS : MAX_KEY_PRICE_ROOM_CURRENCY
if (price > maxPrice) return roomKeyEnvelope(c, null)
// Blank is a value here too: no art. Only absence leaves the stored name alone.
const postedImageName = str(body.ImageName)?.trim()
const imageName = postedImageName === undefined ? existing.ImageName : postedImageName || null
const description = str(body.Description)
return roomKeyEnvelope(
c,
await updateRoomKey(c.env.DB, roomKeyId, {
Name: name === undefined ? existing.Name : censorSwears(name),
Description: description === undefined ? existing.Description : censorSwears(description),
Price: price,
PurchaseCurrencyId: purchaseCurrencyId,
ImageName: imageName,
})
)
}
)
// Award the CALLER some room keys. Auth-gated (401); everything else is reported per
// entry, each in its OWN `{ Value, Success, Error, error_id }` envelope — the client
// decodes a list of single-value envelopes here. The body is a bare array of `{ RoomKeyId,
// AccountId }` — `AccountId` is accepted and unused: the token says who gets the key.
.post(
'/api/roomkeys/v1/awardbulk',
describeRoute({
tags: ['Econ'],
summary: 'Award room keys to the caller',
description: [
'Records the CALLER as holding each key the body names (`room_key_player`). The body',
'is a BARE JSON ARRAY, `[{ "RoomKeyId": 43, "AccountId": 205 }]`; `AccountId` is',
'accepted and IGNORED — the token says who is awarded.',
'',
'A key is held or it isn’t: awarding one the caller already holds succeeds and',
'changes nothing.',
'',
'The answer is an ARRAY OF ENVELOPES — one `{ Value, Success, Error, error_id }` per',
'entry, in the order posted, as the client’s decoder reads it; not a bare array of',
'awards, and not one envelope around a list. `Value` is `{ RoomKeyId, AccountId }`,',
'`AccountId` being the caller. Each entry stands alone: a `RoomKeyId` naming no listed',
'key answers `Success` false with a null `Value` (the failure form is an assumption —',
'only success has been observed) and leaves the rest to land.',
'',
'NOTE: this awards to whoever is asking, and nothing is spent for it — the key’s',
'`Price` is not charged and no room membership is checked.',
].join('\n'),
security: AUTHED,
requestBody: jsonBody(AwardRoomKeysRequest, 'The keys to give the caller'),
responses: {
200: json(AwardRoomKeyResultList, 'One result per entry, in the order posted'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const accountId = await authedId(c)
if (accountId === null) return unauthorized(c)
const body = await c.req.json<unknown>().catch(() => null)
// A body that is no list has no entries to report on.
if (!Array.isArray(body)) return c.json([])
const failed = (error: string) => ({
Value: null,
Success: false,
Error: error,
error_id: null,
})
const results = []
for (const entry of body as unknown[]) {
const roomKeyId =
typeof entry === 'object' && entry !== null
? (entry as { RoomKeyId?: unknown }).RoomKeyId
: undefined
if (typeof roomKeyId !== 'number' || !Number.isInteger(roomKeyId)) {
results.push(failed('Invalid room key'))
continue
}
const awardedAt = await awardRoomKey(c.env.DB, accountId, roomKeyId)
results.push(
awardedAt === null
? failed('No such room key')
: {
Value: { RoomKeyId: roomKeyId, AccountId: accountId },
Success: true,
Error: null,
error_id: null,
}
)
}
return c.json(results)
}
)
// Who holds which room key. Auth-gated (401). The body is a bare array of `{ AccountId,
// RoomKeyId }` pairs and — unlike the award's — `AccountId` is READ here: a room asks
// about the players standing in it, not only the caller. A bare array back, one answer
// per pair.
.post(
'/api/roomkeys/v1/owns/bulk',
describeRoute({
tags: ['Econ'],
summary: 'Whether players hold room keys',
description: [
'Answers, for each `{ AccountId, RoomKeyId }` pair posted, whether that player holds',
'that key (`room_key_player`). The body is a BARE JSON ARRAY, and so is the answer:',
'`[{ "AccountId": 205, "RoomKeyId": 43, "DoesPlayerOwnRoomKey": true }]`, one entry',
'per pair in the order posted — no envelope, unlike the award beside it.',
'',
'`AccountId` is honoured, not replaced by the caller: any signed-in player may ask',
'about any player. An unknown key or player is simply not held; an entry without two',
'integer ids is skipped.',
].join('\n'),
security: AUTHED,
requestBody: jsonBody(OwnsRoomKeysRequest, 'The (player, key) pairs to check'),
responses: {
200: json(OwnsRoomKeysResponse, 'One answer per pair, in the order posted'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const accountId = await authedId(c)
if (accountId === null) return unauthorized(c)
const body = await c.req.json<unknown>().catch(() => null)
if (!Array.isArray(body)) return c.json([])
const pairs: RoomKeyHolding[] = []
for (const entry of body as unknown[]) {
if (typeof entry !== 'object' || entry === null) continue
const { AccountId, RoomKeyId } = entry as Record<string, unknown>
if (typeof AccountId !== 'number' || !Number.isInteger(AccountId)) continue
if (typeof RoomKeyId !== 'number' || !Number.isInteger(RoomKeyId)) continue
pairs.push({ AccountId, RoomKeyId })
}
const owned = await ownsRoomKeys(c.env.DB, pairs)
return c.json(pairs.map((pair, i) => ({ ...pair, DoesPlayerOwnRoomKey: owned[i] })))
}
)
// The Rec Room Plus sign-up bonus: which bonus is running and the token price window
// the free items are drawn from. Fixed numbers, the same for every caller.
//
@@ -5687,13 +7303,41 @@ const app = new Hono<App>({ strict: false })
}
)
// The subscription seasons running right now (the RR+ seasonal reward tracks). Nothing
// here runs a season, so this is an empty-list stub — the client reads it as "no season
// in progress" and skips the seasonal UI, where a 404 stalls the RR+ page load.
// The 2026 decoder requires an object; RecNet rejects JSON null as an empty response.
.get(
'/api/subscriptionseasons/v1/seasons/current',
listRoute('Current subscription seasons', 'Empty stub — no RR+ season is running'),
(c) => c.json([])
describeRoute({
summary: 'Current subscription season',
responses: {
200: json(
z.union([
JsonArray,
z.object({
SubscriptionSeasonId: z.string().uuid(),
Name: z.string(),
ImageName: z.string(),
StartAt: z.string(),
NextSeasonStart: z.string().nullable(),
Milestones: JsonArray,
}),
]),
'2026: season object without milestones; older builds: []'
),
},
}),
async (c) =>
c.json(
((await authedBuild(c)) ?? 0) >= 20260323
? {
SubscriptionSeasonId: '00000000-0000-0000-0000-000000000000',
Name: '',
ImageName: '',
StartAt: '0001-01-01T00:00:00Z',
NextSeasonStart: null,
Milestones: [],
}
: []
)
)
// Whether the caller can start a Maker AI free trial. Always false, mirroring the
@@ -5763,22 +7407,24 @@ const app = new Hono<App>({ strict: false })
// Everyone in the influencer partner program, by account id — the list the client keeps
// so it can badge an influencer wherever they turn up, rather than asking per player.
// Membership is the account's `isInfluencer` flag (`runx admin grant-influencer`), read
// through the `is_influencer` generated column's partial index so the account table is
// never scanned.
//
// Empty: no programme runs here, so there is nobody to list. Note this is the LIST
// counterpart of the single-account check below, and the two answer very differently —
// that one 404s to say "not an influencer", this one is a 200 carrying an empty list,
// because "nobody is" is a complete answer to "who is?".
//
// `take` is accepted and ignored; there is nothing to page through.
// Paged in shape only: the client asks for `take=1000` and this server will not have
// that many, so the whole list is one page and `ContinuationToken` is always null. `take`
// is accepted and ignored. Note this is the LIST counterpart of the single-account tier
// check below, which still answers 0 for everyone.
.get(
'/api/influencerpartnerprogram/influencers',
describeRoute({
tags: ['Econ'],
summary: 'Every influencer in the partner program',
description: [
'The account ids in the influencer partner program, as `{ InfluencerIds }` — an object',
'around the list, not a bare array. Always empty here: no programme runs on this',
'server. `take` is accepted and ignored, there being nothing to page.',
'The account ids in the influencer partner program — every account flagged',
'`isInfluencer` — as `{ InfluencerIds, ContinuationToken }`: an object around the',
'list, not a bare array. Served whole: `take` is accepted and ignored, and',
'`ContinuationToken` is always null.',
].join(' '),
security: AUTHED,
parameters: [
@@ -5786,90 +7432,174 @@ const app = new Hono<App>({ strict: false })
name: 'take',
in: 'query',
required: false,
description: 'How many ids to return. Accepted and ignored.',
description: 'How many ids to return. Accepted and ignored — the list is served whole.',
schema: { type: 'integer' },
},
],
responses: {
200: json(InfluencerIdsResponse, 'The influencer ids — always empty'),
200: json(InfluencerIdsResponse, 'The influencer ids, lowest first'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ InfluencerIds: [] })
const InfluencerIds = await listInfluencerIds(c.env.DB)
return c.json({ InfluencerIds, ContinuationToken: null })
}
)
// One account's standing in the influencer partner program. NOBODY here has one: this
// server runs no such program, so the answer is the literal `0` — the "not an influencer"
// tier — for every account.
// The influencer one account SUPPORTS in the partner program, by account id — every
// player picks one (`…/support` below), and this is how anyone reads anyone's pick.
//
// A BARE NUMBER is the whole body, like `…/makerai/checkfreetrialeligibility`'s bare
// `false`, not a number wrapped in an object. This used to answer 404 with an empty body;
// the tier is what the client actually reads.
//
// `accountId` names the account being asked about. It makes no difference to the answer
// while nobody is an influencer, but it is read rather than ignored so this stays the
// question it looks like — the caller's own standing is `…/myinfluencer` below.
// `false`, not a number wrapped in an object; 0 is "supports nobody", which is also what
// an account that does not exist answers. The caller's own pick is `…/myinfluencer`.
.get(
'/api/influencerpartnerprogram/influencer',
describeRoute({
tags: ['Econ'],
summary: 'An account’s influencer partner program tier',
summary: 'The influencer an account supports',
description: [
'The partner tier of the account named by `accountId`, as a BARE NUMBER — the whole',
'body is `0`, not an object around it. Always 0: this server runs no partner program,',
'so no account is an influencer. Auth-gated; a missing or invalid token is a 401.',
'The account id of the influencer the account named by `accountId` supports, as a',
'BARE NUMBER — the whole body is the id, not an object around it; `0` when they',
'support nobody (or no such account exists). Auth-gated; a missing or invalid token',
'is a 401. A missing or non-numeric `accountId` is a 400.',
].join(' '),
security: AUTHED,
parameters: [
{
name: 'accountId',
in: 'query',
required: false,
description: 'The account being asked about. Every account answers 0.',
required: true,
description: 'The account being asked about.',
schema: { type: 'integer' },
},
],
responses: {
200: json(InfluencerTierResponse, 'The account’s tier — always 0'),
200: json(SupportedInfluencerResponse, 'The supported influencer’s id, or 0'),
400: { description: '`accountId` missing or not an integer (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(NOT_AN_INFLUENCER)
const accountId = Number.parseInt(c.req.query('accountId') ?? '', 10)
if (!Number.isInteger(accountId)) return c.body(null, 400)
return c.json(await supportedInfluencer(c.env.DB, accountId))
}
)
// The same question about the CALLER — the `my` form, which names no account because the
// token already does. Same bare `0`, for the same reason: nobody here is an influencer.
// token already does. Same bare number, same 0 for nobody.
//
// Its own route rather than an alias of the one above, because the two differ in who they
// are about; they agree today only because the answer is currently the same for everyone.
// are about.
.get(
'/api/influencerpartnerprogram/myinfluencer',
describeRoute({
tags: ['Econ'],
summary: 'The caller’s influencer partner program tier',
summary: 'The influencer the caller supports',
description: [
'The caller’s own partner tier — the `my` form of the route above, taking the account',
'from the token rather than a query parameter. A BARE NUMBER, always `0`: this server',
'runs no partner program. Auth-gated; a missing or invalid token is a 401.',
'The account id of the influencer the caller supports — the `my` form of the route',
'above, taking the account from the token rather than a query parameter. A BARE',
'NUMBER; `0` when they support nobody. Auth-gated; a missing or invalid token is a 401.',
].join(' '),
security: AUTHED,
responses: {
200: json(InfluencerTierResponse, 'The caller’s tier — always 0'),
200: json(SupportedInfluencerResponse, 'The supported influencer’s id, or 0'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(NOT_AN_INFLUENCER)
return c.json(await supportedInfluencer(c.env.DB, id))
}
)
// Pick the influencer the caller supports: `influencerAccountId=<id>`, form-encoded. ONE
// per player, stored on the account as `supportedInfluencerId`; posting again replaces
// the pick. The id has to name an account currently flagged `isInfluencer` — anything
// else, the caller's own id included, is a 400 and changes nothing. The reply is a bare
// `{ Success, Error, error_id }` status envelope — no `Value`: the client reads the pick
// back from `…/myinfluencer` rather than from this reply.
.post(
'/api/influencerpartnerprogram/support',
describeRoute({
tags: ['Econ'],
summary: 'Pick the influencer the caller supports',
description: [
'Set the ONE influencer the caller supports in the partner program, replacing any',
'earlier pick. `influencerAccountId` must name an account that is currently an',
'influencer (listed by `…/influencers`); the caller cannot support themselves. The',
'reply is a `{ Success, Error, error_id }` status envelope with no `Value`; read the',
'new pick back from `…/myinfluencer`.',
].join(' '),
security: AUTHED,
requestBody: form(SupportInfluencerBody, 'The influencer to support'),
responses: {
200: json(SupportInfluencerResponse, 'The pick was recorded'),
400: {
description:
'`influencerAccountId` missing, not an integer, the caller’s own id, or not an influencer (empty body)',
},
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const raw = body.influencerAccountId
const influencerId = Number.parseInt(typeof raw === 'string' ? raw : '', 10)
if (!Number.isInteger(influencerId) || influencerId === id) return c.body(null, 400)
const influencer = await getAccount(c.env.DB, influencerId)
if (influencer?.isInfluencer !== true) return c.body(null, 400)
await updateAccount(c.env.DB, id, { supportedInfluencerId: influencerId })
return c.json({ Success: true, Error: null, error_id: null })
}
)
// Stop supporting an influencer: `influencerAccountId=<id>`, form-encoded, the same body
// as `…/support`. The field is UNSET on the account (dropped from the JSON, not written
// as 0), so `…/myinfluencer` goes back to 0. The id has to be an integer (else 400), but
// it only has to NAME THE CURRENT PICK to do anything: naming anyone else — a stale pick
// from before a `…/support` that replaced it, or nobody at all — is a no-op that still
// answers success, like a wishlist remove of something not listed. Guarding on the match
// keeps a stale client from clearing a pick it never saw.
.post(
'/api/influencerpartnerprogram/remove',
describeRoute({
tags: ['Econ'],
summary: 'Stop supporting an influencer',
description: [
'Clear the influencer the caller supports in the partner program. `influencerAccountId`',
'names the current pick; the field is unset and `…/myinfluencer` answers 0 again.',
'An id that is not the current pick changes nothing and still answers success. The',
'reply is the same `{ Success, Error, error_id }` status envelope as `…/support`.',
].join(' '),
security: AUTHED,
requestBody: form(RemoveInfluencerBody, 'The influencer to stop supporting'),
responses: {
200: json(SupportInfluencerResponse, 'The pick was cleared (or was not this one)'),
400: { description: '`influencerAccountId` missing or not an integer (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const raw = body.influencerAccountId
const influencerId = Number.parseInt(typeof raw === 'string' ? raw : '', 10)
if (!Number.isInteger(influencerId)) return c.body(null, 400)
if ((await supportedInfluencer(c.env.DB, id)) === influencerId) {
// `undefined` wins the spread in updateAccount and JSON.stringify drops the key.
await updateAccount(c.env.DB, id, { supportedInfluencerId: undefined })
}
return c.json({ Success: true, Error: null, error_id: null })
}
)
@@ -5919,4 +7649,4 @@ export const scheduled: ExportedHandlerScheduledHandler<Env> = (_controller, env
// A Worker only runs `scheduled` when it's on the default export, so econ takes the object
// form the runtime requires (as `www` does for its role sweep and `match` for presence).
export default withDatabase<Env>({ fetch: app.fetch, scheduled })
export default { fetch: app.fetch, scheduled } satisfies ExportedHandler<Env>
+475 -15
View File
@@ -222,25 +222,55 @@ export const RRPlusSignUpBonus = z.object({
/**
* `GET /api/influencerpartnerprogram/influencers` — the ids of every influencer in the
* partner program, which the client uses to badge them wherever they appear. An object
* around the list, not a bare array.
* around the list, not a bare array. `ContinuationToken` is the paging cursor; always null
* here, the whole list fitting in one page.
*/
export const InfluencerIdsResponse = z.object({
InfluencerIds: z
.array(z.int())
.describe('Account ids in the partner program. Empty — no programme runs here'),
.describe('Account ids in the partner program — every account flagged `isInfluencer`'),
ContinuationToken: z
.null()
.describe('Paging cursor for the next page. Always null — the list is served whole'),
})
/**
* `GET /api/influencerpartnerprogram/influencer` and `…/myinfluencer` — one account's
* standing in the partner program.
* `GET /api/influencerpartnerprogram/influencer` and `…/myinfluencer` — the account id of
* the influencer an account supports.
*
* A BARE NUMBER, not an object: the body is the literal `0`, which is the "not an
* influencer" tier. Nobody on this server is one, so 0 is the answer for every account, the
* caller's own included.
* A BARE NUMBER, not an object: the body is the literal id, or `0` when the account
* supports nobody. The write (`POST …/support`) does NOT answer in this shape; see
* `SupportInfluencerResponse`.
*/
export const InfluencerTierResponse = z
.literal(0)
.describe('The account’s partner tier. Always 0 — nobody here is an influencer')
export const SupportedInfluencerResponse = z
.int()
.describe('The account id of the influencer this account supports; 0 for nobody')
/** `POST /api/influencerpartnerprogram/support` — the influencer the caller picks. */
export const SupportInfluencerBody = z.object({
influencerAccountId: z
.int()
.describe('The account id of the influencer to support. Must be flagged `isInfluencer`'),
})
/** `POST /api/influencerpartnerprogram/remove` — the influencer the caller stops supporting. */
export const RemoveInfluencerBody = z.object({
influencerAccountId: z
.int()
.describe('The account id of the influencer to stop supporting — the caller’s current pick'),
})
/**
* The reply to `POST /api/influencerpartnerprogram/support` and `…/remove` — a bare
* `{ Success, Error, error_id }` status envelope with NO `Value`: the pick is read back from
* `…/myinfluencer`, not from this reply. The same mixed casing as `RoomCurrencyEnvelope`
* (PascalCase `Success`/`Error` beside a lowercase `error_id`), minus the payload.
*/
export const SupportInfluencerResponse = z.object({
Success: z.boolean().describe('Always true — every refusal is an empty 400 instead'),
Error: z.null().describe('Always null'),
error_id: z.null().describe('Always null. Present as a key, and lowercase'),
})
/**
* `GET /api/incentivizedreferrals/progress` — how far the caller has got with the
@@ -425,6 +455,68 @@ export const BuyInventionResponse = z.object({
.describe('The same envelope `POST /api/inventions/v6/save` returns'),
})
/**
* `GET /api/storefronts/v1/buyRoomKey` — the client's `RoomKeyPurchaseResponseDTO`, recovered
* from its decoder: the room-key envelope `/api/roomkeys/v1/create` answers in, beside a
* BalanceUpdateResponse whose bucket key is `Platform` (NOT the `BalanceType` the invention
* buys echo) and whose entry `Data` is the key again.
*
* Every enum here reads an OMITTED key as its 0 member — `Status` 0 is success,
* `UpdateResponse` 0 is OK, `Platform` 0 is SteamPurchased — so all three are always sent.
*/
export const BuyRoomKeyResponse = z.object({
RoomKeyResponse: z.object({
Status: z.int().describe('0 on success; 1 on a refusal'),
RoomKey: JsonObject.nullable().describe(
'The bought key (the 10-key room key); null on a refusal'
),
}),
BalanceUpdateResponse: z.object({
BalanceUpdates: z.array(
z.object({
UpdateResponse: z
.int()
.describe(
'0 OK, 2 NotEnoughCredit, 3 AlreadyOwned, 4 NoItemAvailable, ' +
'6 RequestedPriceDoesNotMatch'
),
Data: JsonObject.nullable().describe('The key the request named; null when it named none'),
})
),
Balance: z.int().describe('The buyer’s RESULTING token total, not the change'),
CurrencyType: z.int().describe('2 = RecCenterTokens'),
Platform: z.int().describe('-2 — the one account-wide bucket every balance surface names'),
}),
})
/** `POST /api/storefronts/v1/PurchaseRoomKeyWithCurrency` — form-encoded. */
export const PurchaseRoomKeyWithCurrencyRequest = z.object({
RoomKeyId: z.string().describe('The key to buy — a `room_key` id'),
RequestedPrice: z.string().describe('The price the client rendered; must equal the key’s'),
RequestedPurchaseCurrencyId: z
.string()
.describe('The room currency to pay in; must be the key’s own `PurchaseCurrencyId`'),
})
/**
* `POST /api/storefronts/v1/PurchaseRoomKeyWithCurrency` — the client's
* `RoomKeyPurchaseWithCurrencyResponseDTO`, recovered from its decoder. A BARE object, no
* envelope, and NOT buyRoomKey's shape: the outer `Balance` is an OBJECT (the 4-key
* room-currency balance) holding a numeric `Balance`, and there is no `Platform` anywhere.
*/
export const PurchaseRoomKeyWithCurrencyResponse = z.object({
Balance: z.object({
AccountId: z.int().describe('The buyer'),
CurrencyId: z.string().describe('The room currency paid in'),
Balance: z.int().describe('What the buyer holds of it afterwards — the RESULTING total'),
ModifiedAt: z.string().describe('ISO-8601 UTC'),
}),
RoomKeyResponse: z.object({
Status: z.int().describe('0 on success; 1 on a refusal. Always sent — omitted reads as 0'),
RoomKey: JsonObject.nullable().describe('The bought key; null on a refusal'),
}),
})
/**
* `POST /api/storefronts/v3/buyInvention` — the purchase result the 2025 client wants,
* which is NOT v2's despite settling the identical purchase. Two differences, both
@@ -502,6 +594,30 @@ export const UgcPurchasableItemDto = z.object({
/** What the bulk lookup answers: the resolved items, unknown ids omitted. */
export const UgcPurchasableItemList = z.array(UgcPurchasableItemDto)
/**
* `GET /api/ugcPurchasables/v1/items/room/{roomId}` — one line of a room's shop. The same
* 10 keys as the bulk lookup's item, with `ItemType` naming the kind (0 room key, 1 room
* consumable, 3 currency purchase offer) and `ImageName` nullable, as a key or consumable
* without art is served everywhere else.
*/
export const RoomPurchasableDto = UgcPurchasableItemDto.extend({
ItemType: z
.number()
.int()
.describe('0 RoomKey, 1 RoomConsumable, 3 RoomCurrencyItem (a currency pack for tokens)'),
ItemId: z
.string()
.describe('A bare GUID — the key’s ReplicationId, the consumable’s or offer’s id'),
ImageName: z.string().nullable().describe('Null for a listing with no art'),
PurchaseCurrencyId: z
.string()
.nullable()
.describe('The room currency the line is charged in; null for tokens'),
})
/** The room’s shop — a bare array, empty for a room that sells nothing. */
export const RoomPurchasableList = z.array(RoomPurchasableDto)
/**
* `POST /api/items/purchaseInfos` JSON body — the same `{ itemType, itemId }` reference
* shape the UGC bulk lookup takes, minus the room. camelCase INSIDE the reference, which is
@@ -517,6 +633,18 @@ export const ItemPurchaseInfosRequest = z.object({
),
})
/** `POST /api/storefronts/v1/purchasableitems` — store listings by id. */
export const PurchasableItemsRequest = z.object({
PurchasableItemIds: z
.array(z.number().int())
.nullable()
.describe('The listings to resolve, by `PurchasableItemId`'),
GiftDropIds: z
.array(z.number().int())
.nullable()
.describe('Sent by the client (observed null); accepted and ignored'),
})
/** One `Prices[]` entry: what the item costs in one currency, and any sale on top. */
export const ItemPriceDto = z.object({
CurrencyType: z.number().int().describe('2 = RecCenterTokens — what UGC items are priced in'),
@@ -645,12 +773,19 @@ export const RoomKeyDto = z.object({
PurchaseCurrencyId: z
.string()
.nullable()
.describe('A `room_currency` id, or null — the create body names none'),
.describe('A `room_currency` id, or null for tokens — which a new key is priced in'),
CreatedAt: z.string().describe('ISO-8601 UTC'),
ImageName: z.string().nullable().describe('Null — a key cannot carry art yet'),
ImageName: z.string().nullable().describe('The key’s art; null until an edit sets one'),
Type: z.int().describe('The key type’s ordinal: 0 `Key`, the only one seen'),
})
/** A held key as `GET /api/roomkeys/v1/mine` serves it — the room key plus two fields. */
export const HeldRoomKeyDto = RoomKeyDto.extend({
ImageName: z.string().describe('The key’s art; "" when it has none (NOT null, unlike the list)'),
IsGiftable: z.boolean().describe('Always false — gifting keys is not modelled'),
Visibility: z.int().describe('Always 0 — not modelled'),
})
/**
* The envelope `POST /api/roomkeys/v1/create` answers in: `{ Status, RoomKey }`, observed from
* the live client — NOT the `{ Value, Success, Error, error_id }` the room-currency and
@@ -672,9 +807,167 @@ export const CreateRoomKeyRequest = z.object({
RoomId: z.string().describe('The room the key opens'),
Name: z.string().describe('Shown to players; profanity-masked like every typed string'),
Description: z.string().optional().describe('Defaults to empty'),
Price: z.string().optional().describe('What the key costs; defaults to 0'),
Price: z
.string()
.optional()
.describe('What the key costs, in tokens; defaults to 0. At most 1000'),
})
/**
* `PUT /api/roomkeys/v1/updateAll` — form-encoded. "All" is the FIELDS, not the keys: the body
* names one key and carries every editable field of it
* (`RoomKeyId=43&Name=key1&Description=…&Price=1000&PurchaseCurrencyId=`). For the two
* fields that can be emptied — `PurchaseCurrencyId` and `ImageName` — a BLANK value is an
* instruction to clear it; only a field absent from the body is left alone.
*/
export const UpdateRoomKeyRequest = z.object({
RoomKeyId: z.string().describe('The key to edit'),
Name: z.string().optional().describe('Left alone when absent; profanity-masked'),
Description: z.string().optional().describe('Left alone when absent; profanity-masked'),
Price: z
.string()
.optional()
.describe('Left alone when absent. At most 1000 in tokens, 1000000000 in a room currency'),
PurchaseCurrencyId: z
.string()
.optional()
.describe(
'EMPTY means tokens; otherwise a `room_currency` id of the key’s own room. Left alone when absent'
),
ImageName: z
.string()
.optional()
.describe('The key’s art. EMPTY clears it (served as null). Left alone when absent'),
})
/**
* `POST /api/roomkeys/v1/awardbulk` — a BARE JSON ARRAY of the keys to award, as the client
* posts it. `AccountId` is accepted and IGNORED: the token says who is awarded.
*/
export const AwardRoomKeysRequest = z.array(
z.object({
RoomKeyId: z.int().describe('The key to award — a `room_key` id'),
AccountId: z
.int()
.optional()
.describe('Accepted and IGNORED — the caller is who is awarded the key'),
})
)
/**
* `POST /api/roomkeys/v1/awardbulk` answers an ARRAY OF ENVELOPES — every element its own
* `{ Value, Success, Error, error_id }` around one award (recovered from the client's
* decoder). Not a bare array of awards, and not one envelope wrapping a list. Only the
* success form has been observed; a failed entry's null `Value` is an assumption.
*/
export const AwardRoomKeyResultList = z.array(
z.object({
Value: z
.object({
RoomKeyId: z.int(),
AccountId: z.int().describe('Who now holds the key — the caller'),
})
.nullable()
.describe('The award, or null on a failure'),
Success: z.boolean(),
Error: z.string().nullable().describe('Null on success; the failure message otherwise'),
error_id: z.null().describe('Always null. Present as a key, and lowercase'),
})
)
/**
* `POST /api/roomkeys/v1/owns/bulk` — a BARE JSON ARRAY of (player, key) pairs to check.
* Unlike the award's, this `AccountId` is READ: it names whose keys are being asked about.
*/
export const OwnsRoomKeysRequest = z.array(
z.object({
AccountId: z.int().describe('The player being asked about'),
RoomKeyId: z.int().describe('The key — a `room_key` id'),
})
)
/** `POST /api/roomkeys/v1/owns/bulk` — a bare array, one answer per pair in the order asked. */
export const OwnsRoomKeysResponse = z.array(
z.object({
AccountId: z.int(),
RoomKeyId: z.int(),
DoesPlayerOwnRoomKey: z.boolean(),
})
)
/**
* One wishlist entry — the client's five-key DTO, in its order. An element of the GET's bare
* array and the `Value` of the add/remove envelope.
*
* `PurchasableItemId` is a plain int on the client, so an entry for a custom avatar item
* carries 0 there and the item's GUID in `CustomAvatarItemId`; a storefront-item entry has
* the id and a null GUID. Exactly one of the two names the item.
*/
export const WishlistItemDto = z.object({
WishlistItemId: z.string().describe('GUID — the client’s `Guid`, so never an int'),
AccountId: z.int().describe('Whose list the entry is on'),
PurchasableItemId: z
.int()
.describe('The storefront item wished for, or 0 when the entry names a custom avatar item'),
CustomAvatarItemId: z
.string()
.nullable()
.describe('The custom avatar item wished for, or null when the entry names a storefront item'),
CreatedAt: z.string().describe('ISO-8601 UTC'),
})
/** `GET /api/itemWishlists/v1/wishlist/me|{accountId}` — a bare array, newest wish first. */
export const WishlistItemList = z.array(WishlistItemDto)
/**
* The envelope `wishlist/add` and `wishlist/remove` answer in — the same `{ Value, Success,
* Error, error_id }` the room-currency writes use. The client reads `add`’s `Value` and
* discards `remove`’s, but parses both as this shape: a bare entry or a 204 fails the same
* way `Success: false` does. `Error` is shown to the player verbatim behind “Wishlist error: ”.
*/
export const WishlistEnvelope = z.object({
Value: WishlistItemDto.nullable().describe(
'The entry added or removed; null on a refusal, or on removing an item that wasn’t listed'
),
Success: z.boolean(),
Error: z.string().nullable().describe('Null on success; the failure message otherwise'),
error_id: z.null().describe('Always null. Present as a key, and lowercase'),
})
/**
* `wishlist/add` and `wishlist/remove` — form-encoded. The client posts BOTH fields every
* time with the unused one empty (`purchasableItemId=1534&customAvatarItemId=`); either may
* also be omitted. Exactly one has to name an item.
*/
export const WishlistItemRequest = z.object({
purchasableItemId: z
.string()
.optional()
.describe('The storefront item, as a positive integer; empty or absent for a custom item'),
customAvatarItemId: z
.string()
.optional()
.describe('The custom avatar item’s GUID; empty or absent for a storefront item'),
})
/**
* `POST /api/itemWishlists/v1/isonwishlist/bulk` — form-encoded. `accountIds` is REPEATED,
* once per account (`accountIds=205&accountIds=207`); a comma-separated single value is
* accepted too. The item is named as on add/remove.
*/
export const IsOnWishlistBulkRequest = WishlistItemRequest.extend({
accountIds: z
.array(z.string())
.describe('The players to ask about, repeated once per id — answered in this order'),
})
/**
* `isonwishlist/bulk`’s answer: a bare POSITIONAL array of booleans, one per `accountIds`
* entry in the order posted. A short array leaves the tail reading as “not wished for”, so
* every id gets an element — an unknown player’s is false.
*/
export const IsOnWishlistBulkResponse = z.array(z.boolean())
/**
* One purchase offer on a room currency — a way to BUY that currency, priced in another
* ("5 SuperTokens for 500 Rec Center Tokens"). The client's own model, member for member and
@@ -786,6 +1079,86 @@ export const AwardRoomConsumableResult = z.object({
export const AwardRoomConsumableResultList = z.array(AwardRoomConsumableResult)
/**
* The client's `RoomConsumableInventoryItem` — what a player owns of one consumable. The
* element of `GET …/roomConsumable/room/{roomId}/me` and the purchase's `InventoryItem`.
*/
export const RoomConsumableInventoryItemDto = z.object({
RoomConsumableId: z.string(),
AccountId: z.int().describe('Who owns them'),
Count: z.int().describe('The RESULTING total owned, never the change'),
ConcurrencyCode: z
.string()
.nullable()
.describe('The last `NewConcurrencyCode` written — stored, not checked'),
ModifiedAt: z.string().describe('ISO-8601 UTC — the last write'),
Consumable: RoomConsumableDto.extend({
ImageName: z.string().describe('"" when the listing has no picture (NOT null)'),
IsGiftable: z.boolean().describe('Always false — not modelled'),
Visibility: z.int().describe('Always 0 — not modelled'),
}).describe('The listing — the catalogue element plus two fields'),
})
/**
* `PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/{tokens|currency}` — the client's
* `RoomConsumablePurchaseRequest`, four keys in declaration order (from its formatter). One
* route for both kinds of price: `ExpectedPriceAndCurrency.CurrencyId` null is TOKENS.
*/
export const PurchaseRoomConsumableRequest = z.object({
ConcurrencyCodes: z
.object({
CurrentConcurrencyCode: z.string().nullable().optional(),
NewConcurrencyCode: z.string().nullable().optional(),
})
.optional()
.describe('Accepted and IGNORED — no optimistic concurrency is implemented yet'),
ExpectedPriceAndCurrency: z
.object({
Price: z.int().describe('The UNIT price the client rendered; must equal the listing’s'),
CurrencyId: z
.string()
.nullable()
.describe('The listing’s room currency, or null for a listing priced in tokens'),
})
.describe('What the client showed — refused unless it is the listing’s own'),
Quantity: z.int().describe('How many to buy; at least 1, at most `MaximumCountPerPurchase`'),
GiftRecipientAccountId: z
.int()
.nullable()
.optional()
.describe('Who receives them; null for the buyer. The buyer pays either way'),
})
/**
* The consumable purchase's answer, observed from the live client. The refusal form (`Status`
* 1, a null `InventoryItem`) is an assumption — only success has been observed.
*/
export const PurchaseRoomConsumableResponse = z.object({
BalanceUpdateResult: z
.int()
.describe(
'An UpdateResponse: 0 OK, 2 NotEnoughCredit, 4 NoItemAvailable, ' +
'6 RequestedPriceDoesNotMatch, 7 RequestedAmountNotAllowed, 8 PlayerNotEligible'
),
CurrencyBalanceResponse: z
.object({
AccountId: z.int().describe('The BUYER — who paid, even for a gift'),
CurrencyId: z
.string()
.nullable()
.describe('The room currency paid in; null for a listing priced in tokens'),
Balance: z.int().describe('What the buyer holds of it afterwards — the RESULTING total'),
ModifiedAt: z.string().describe('ISO-8601 UTC'),
})
.describe('What the buyer now holds of what they paid in'),
OperationResult: z.object({
Status: z.int().describe('0 on success; 1 on a refusal. Always sent — omitted reads as 0'),
InventoryItem: RoomConsumableInventoryItemDto.nullable().describe(
'What the recipient now owns of the consumable; null on a refusal'
),
}),
})
/**
* The envelope the consumable write answers in — the same
* `{ Value, Success, Error, error_id }` the room-currency writes use.
@@ -827,9 +1200,17 @@ export const CreatePurchaseOfferRequest = z.object({
Order: z.string().optional().describe('Where it sits in the shop; defaults to 0'),
})
/** `POST /api/roomcurrencies/v1/deletePurchaseOffer` — form-encoded. */
export const DeletePurchaseOfferRequest = z.object({
PurchaseOfferId: z
.string()
.describe('The offer to take down, by its `CurrencyPurchaseOfferId` (any letter case)'),
})
/**
* The envelope the create-offer endpoint answers in — the same
* `{ Value, Success, Error, error_id }` the currency writes use, with the offer in `Value`.
* The envelope the create- and delete-offer endpoints answer in — the same
* `{ Value, Success, Error, error_id }` the currency writes use, with the offer (as created,
* or as it stood when removed) in `Value`.
*/
export const RoomCurrencyPurchaseOfferEnvelope = z.object({
Value: RoomCurrencyPurchaseOfferDto.nullable(),
@@ -889,6 +1270,61 @@ export const AwardRoomCurrencyResult = z.object({
export const AwardRoomCurrencyResultList = z.array(AwardRoomCurrencyResult)
/**
* `POST /api/roomCurrencies/v2/purchase` — form-encoded
* (`PurchaseOfferId=ffab4157-…&RequestedAmount=444&RequestedPrice=555`). The offer alone says
* which currency is being bought; the two numbers are what the client rendered, and must match
* the offer.
*/
export const PurchaseRoomCurrencyRequest = z.object({
PurchaseOfferId: z.string().describe('The offer — a `CurrencyPurchaseOfferId`'),
RequestedAmount: z.string().describe('How much room currency; must equal the offer’s'),
RequestedPrice: z
.string()
.describe(
'What it costs in tokens; must equal the offer’s, except for the room’s owner and co-owners, who are shown 0 and pay nothing'
),
})
/**
* What a player holds of one room currency, as `GET /api/roomcurrencies/v1/getBalance` answers
* it. `ModifiedAt` is the time of the response: `room_balance` keeps no modification time.
*/
export const RoomCurrencyBalance = z.object({
AccountId: z.int().describe('The player the balance belongs to'),
CurrencyId: z.string().describe('The room currency'),
Balance: z.int().describe('What the player holds of it; 0 when they have never earned any'),
ModifiedAt: z.string().describe('ISO-8601 UTC; the time of the response'),
})
/**
* What `POST /api/roomCurrencies/v2/purchase` answers: both balances the purchase moved,
* inside the `{ Value, Success, Error, error_id }` envelope the room-currency writes use.
* Each `Balance` is a RESULTING total. Only the success form has been observed; a refusal's
* null `Value` is an assumption.
*/
export const PurchaseRoomCurrencyEnvelope = z.object({
Value: z
.object({
CurrencyBalanceResponse: z.object({
AccountId: z.int().describe('The buyer'),
CurrencyId: z.string().describe('The room currency bought'),
Balance: z.int().describe('What the buyer now holds of it'),
ModifiedAt: z.string().describe('ISO-8601 UTC'),
}),
TokenBalanceResponse: z.object({
Balance: z.int().describe('The buyer’s resulting token total'),
CurrencyType: z.int().describe('2 = RecCenterTokens'),
Platform: z.int().describe('-2 — the one account-wide bucket every balance surface names'),
}),
})
.nullable()
.describe('Null on a refusal'),
Success: z.boolean(),
Error: z.string().nullable().describe('Null on success; the refusal otherwise'),
error_id: z.null().describe('Always null. Present as a key, and lowercase'),
})
/**
* `POST /api/roomcurrencies/v1/updateCurrency` — form-encoded. Names the currency by id
* rather than by room: the room it belongs to is whichever one minted it, and an edit cannot
@@ -973,6 +1409,30 @@ export const ConsumeConsumableRequest = z.object({
DeltaCount: z.int().optional().describe('How many to spend; defaults to 1'),
})
/**
* `POST /api/consumables/v1/query/bulk` form body — `descriptors` repeated once per
* consumable to look up.
*/
export const QueryConsumablesRequest = z.object({
descriptors: z
.union([z.string(), z.array(z.string())])
.optional()
.describe('A ConsumableItemDesc; the key repeats for each one asked about'),
})
/**
* `POST /api/consumables/v1/updateActive` JSON body — the client marking one of its
* consumables active or not. Accepted and NOT acted on yet; see the route.
*/
export const UpdateActiveConsumableRequest = z.object({
Id: z.int().describe('The consumable row id'),
IsActive: z.boolean().describe('Whether the consumable is now in use'),
ActivatedByRoomie: z
.boolean()
.optional()
.describe('Whether a roommate, rather than the owner, activated it'),
})
/** `POST /api/avatar/v2/gifts/consume` form body (posted with a trailing slash). */
export const ConsumeGiftRequest = z.object({
Id: z.string().describe('The gift-box id to open'),
+56 -25
View File
@@ -30,13 +30,29 @@ export const REWARD_STATUS_SCHEMA_DDL: string[] = [
]
/**
* How long a player must wait between rewards of the same type in the same activity. One
* hour flat, for every type — despite what a name like `FirstActivityOfDay` suggests.
* Per-type windows would be a map keyed by reward type; there's one window until a reward
* type needs its own.
* How long a player must wait between rewards of the same type in the same activity: one
* hour, for every type but `FirstActivityOfDay`, which waits {@link DAILY_REWARD_COOLDOWN_MS}.
*/
export const REWARD_COOLDOWN_MS = 60 * 60 * 1000
/** `FirstActivityOfDay`'s cooldown: a rolling 24h since the player's last one. */
export const DAILY_REWARD_COOLDOWN_MS = 24 * 60 * 60 * 1000
/**
* The game reward's per-PLAYER daily allowances, each kept in `reward_status` as one row under
* its reserved type and the empty context: `grant_count` is the AMOUNT paid in the current
* window (tokens, or XP — not a count of claims) and `granted_at` is when that window opened.
* The `$` keeps them apart from the client's own type names (`FirstActivityOfDay`,
* `PostGameActivity`), which a client-sent type could otherwise forge.
*/
export type Allowance = '$tokens' | '$xp'
/**
* How long an allowance window lasts: a rolling 24h from the first grant in it, rather than a
* calendar day — so no time zone's midnight decides when a player's allowance comes back.
*/
export const ALLOWANCE_WINDOW_MS = 24 * 60 * 60 * 1000
/**
* Claim a reward if the player is due one, returning how many of that type they have now
* claimed in that context — or `null` when the cooldown hasn't elapsed and nothing was
@@ -61,9 +77,10 @@ export async function claimReward(
accountId: number,
rewardType: string,
giftContext = '',
now: Date = new Date()
now: Date = new Date(),
cooldownMs: number = REWARD_COOLDOWN_MS
): Promise<number | null> {
const cutoff = new Date(now.getTime() - REWARD_COOLDOWN_MS).toISOString()
const cutoff = new Date(now.getTime() - cooldownMs).toISOString()
const row = await db
.prepare(
`INSERT INTO reward_status (account_id, reward_type, gift_context, granted_at, grant_count)
@@ -80,28 +97,42 @@ export async function claimReward(
}
/**
* Whether the claim just written by {@link claimReward} is the player's FIRST in
* `giftContext` — across every reward type, so an activity is "new" once, not once per
* type it happens to be asked under. Call it right after the claim with the count it
* returned: it is true when that claim INSERTED its row (`grantCount` 1; a repeat past the
* cooldown is 2+) and the player's rows in that context are exactly that one.
* Add `amount` to the player's daily `allowance` if it fits under `cap`, returning the
* window's new total — or `null` when it doesn't fit and nothing was granted.
*
* Counting AFTER the claim is what keeps two asks landing together (say
* `FirstActivityOfDay` and `PostGameActivity` for the same match) from both reading "new":
* D1 serializes the inserts, so at most the first insert's count is 1 and the second's is
* 2 — a race can skip the bonus, never pay it twice. The contextless `''` bucket is not an
* activity and is never new.
* A window older than {@link ALLOWANCE_WINDOW_MS} is reset in place (the row's key allows
* one per player): `granted_at` moves to now and the total restarts at `amount`. Otherwise the
* amount is added to the running total, and `granted_at` is left alone so the window keeps its
* start. All grant-or-nothing: a claim that would cross the cap pays nothing rather than the
* remainder, so `cap` should be a multiple of `amount`.
*
* One statement, for the same reason as {@link claimReward}: asks landing together can't both
* read a total under the cap and both pay. SQLite evaluates every `SET` against the row as it
* was, so both `CASE`s see the old `granted_at`.
*/
export async function isNewActivity(
export async function claimAllowance(
db: D1Database,
accountId: number,
giftContext: string,
grantCount: number
): Promise<boolean> {
if (giftContext === '' || grantCount !== 1) return false
allowance: Allowance,
amount: number,
cap: number,
now: Date = new Date()
): Promise<number | null> {
if (amount > cap) return null
const cutoff = new Date(now.getTime() - ALLOWANCE_WINDOW_MS).toISOString()
const row = await db
.prepare('SELECT COUNT(*) AS n FROM reward_status WHERE account_id = ?1 AND gift_context = ?2')
.bind(accountId, giftContext)
.first<{ n: number }>()
return row?.n === 1
.prepare(
`INSERT INTO reward_status (account_id, reward_type, gift_context, granted_at, grant_count)
VALUES (?1, ?2, '', ?3, ?4)
ON CONFLICT (account_id, reward_type, gift_context) DO UPDATE SET
granted_at = CASE WHEN reward_status.granted_at <= ?5
THEN excluded.granted_at ELSE reward_status.granted_at END,
grant_count = CASE WHEN reward_status.granted_at <= ?5
THEN ?4 ELSE reward_status.grant_count + ?4 END
WHERE reward_status.granted_at <= ?5 OR reward_status.grant_count + ?4 <= ?6
RETURNING grant_count`
)
.bind(accountId, allowance, now.toISOString(), amount, cutoff, cap)
.first<{ grant_count: number }>()
return row?.grant_count ?? null
}
+122 -30
View File
@@ -11,14 +11,16 @@
* names an existing `RoomConsumableId`
* - `GET /api/roomconsumables/v1/roomConsumable/room/{roomId}` lists a room's
* - `POST /api/roomconsumables/v1/roomConsumable/awardBulk` gives one to a player
* - `PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/tokens` and `…/purchase/currency`
* sell one, in tokens or the listing's room currency
*
* `PurchaseCurrencyId` is a `room_currency`, so a room prices its goods in its own money. It
* is nullable — `Guid?` in the client's model — for a listing that names no currency, and
* nothing here resolves or validates it yet: a consumable priced in a currency that has since
* been deleted still lists, at a price nobody can pay.
*
* Nothing BUYS one yet. There is no purchase endpoint, so `MaximumCountPerPurchase` is stored
* and served and read by nothing.
* A null `PurchaseCurrencyId` is priced in TOKENS, which the purchase pays to the room's
* owner; `MaximumCountPerPurchase` caps a purchase's `Quantity` (0 means no cap).
*
* This worker (`econ`) owns the tables and their migrations — see apps/econ/migrations/,
* 0021 (the listings) and 0022 (what players own of them).
@@ -72,7 +74,7 @@ export interface RoomConsumable {
PurchaseCurrencyId: string | null
/** ISO-8601 UTC. */
ModifiedAt: string
/** How many may be bought at once. Stored and served; nothing buys a consumable yet. */
/** How many may be bought at once; 0 is no cap. */
MaximumCountPerPurchase: number
}
@@ -201,7 +203,7 @@ export async function getRoomConsumables(
/**
* What a player owns of a room's consumables — the shop's other side, filled by
* `POST /api/roomconsumables/v1/roomConsumable/awardBulk`.
* `POST /api/roomconsumables/v1/roomConsumable/awardBulk` and by the purchase.
*
* Keyed by CONSUMABLE, not by room: `room_consumable` already says which room a listing
* belongs to, and repeating it here would be a second place for that to be wrong. A missing
@@ -212,12 +214,18 @@ export async function getRoomConsumables(
* `consumable_id` is not a foreign key, but the award route checks the listing exists before
* writing: an inventory row naming nothing would be a thing a player owns that cannot be
* described.
*
* `concurrency_code` is the last `NewConcurrencyCode` a write sent, served back as the item's
* `ConcurrencyCode`; nothing checks it — no optimistic concurrency is implemented. It and
* `modified_at` came in migration 0029, so a row older than that has both null.
*/
export const ROOM_INVENTORY_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS room_inventory (
player_id INTEGER NOT NULL,
consumable_id TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 0,
concurrency_code TEXT,
modified_at TEXT,
PRIMARY KEY (player_id, consumable_id)
)`,
// "What does this player own?" is the read the client makes on entering a room, so the
@@ -225,15 +233,67 @@ export const ROOM_INVENTORY_SCHEMA_DDL: string[] = [
`CREATE INDEX IF NOT EXISTS idx_room_inventory_player ON room_inventory (player_id)`,
]
/** What a player owns of one consumable. */
export interface RoomInventoryItem {
PlayerId: number
ConsumableId: string
Quantity: number
/**
* The listing as it rides inside a {@link RoomConsumableInventoryItem}: the catalogue's
* {@link RoomConsumable} plus `IsGiftable` and `Visibility`, and `ImageName` as '' rather
* than null — observed from the live client, and NOT what the room's catalogue
* (`GET …/roomConsumable/room/{roomId}`) serves. Neither extra field is modelled here, so
* both are their false/0.
*/
export interface InventoryConsumable extends Omit<RoomConsumable, 'ImageName'> {
ImageName: string
IsGiftable: boolean
Visibility: number
}
/**
* Give a player some of a consumable, returning what they own afterwards.
* What a player owns of one consumable, as the client reads it — `RoomConsumableInventoryItem`,
* observed from the live client. The element of `GET …/roomConsumable/room/{roomId}/me` and
* the `InventoryItem` a purchase answers with.
*/
export interface RoomConsumableInventoryItem {
RoomConsumableId: string
AccountId: number
/** The RESULTING total owned, never the change. */
Count: number
/** The last `NewConcurrencyCode` written; null on a row older than migration 0029. */
ConcurrencyCode: string | null
/** ISO-8601 UTC — the last write; the listing's own time on a row older than 0029. */
ModifiedAt: string
Consumable: InventoryConsumable
}
/** What a player holds of one consumable — the row, before it is joined to its listing. */
export interface RoomInventoryHolding {
quantity: number
concurrencyCode: string | null
modifiedAt: string | null
}
/** Project a holding and its listing onto the client's inventory item. */
export function toInventoryItem(
accountId: number,
holding: RoomInventoryHolding,
consumable: RoomConsumable
): RoomConsumableInventoryItem {
return {
RoomConsumableId: consumable.RoomConsumableId,
AccountId: accountId,
Count: holding.quantity,
ConcurrencyCode: holding.concurrencyCode,
ModifiedAt: holding.modifiedAt ?? consumable.ModifiedAt,
Consumable: {
...consumable,
ImageName: consumable.ImageName ?? '',
IsGiftable: false,
Visibility: 0,
},
}
}
/**
* Give a player some of a consumable, returning what they hold afterwards. `concurrencyCode`
* is the request's `NewConcurrencyCode`, stored to be served back; null leaves the stored one.
*
* Floored at zero in the statement itself, so a negative `quantity` deducts without a
* deduction and a concurrent award racing it below zero. Returns the RESULTING total, never
@@ -249,48 +309,80 @@ export async function awardRoomConsumable(
db: D1Database,
playerId: number,
consumableId: string,
quantity: number
): Promise<number> {
quantity: number,
concurrencyCode: string | null = null
): Promise<RoomInventoryHolding> {
const read = db.prepare(
'SELECT quantity FROM room_inventory WHERE player_id = ?1 AND consumable_id = ?2'
`SELECT quantity, concurrency_code, modified_at FROM room_inventory
WHERE player_id = ?1 AND consumable_id = ?2`
)
const [, , after] = await db.batch<{ quantity: number }>([
const [, , after] = await db.batch<{
quantity: number
concurrency_code: string | null
modified_at: string | null
}>([
read.bind(playerId, consumableId),
db
.prepare(
`INSERT INTO room_inventory (player_id, consumable_id, quantity)
VALUES (?1, ?2, MAX(0, ?3))
ON CONFLICT (player_id, consumable_id)
DO UPDATE SET quantity = MAX(0, room_inventory.quantity + ?3)`
`INSERT INTO room_inventory (player_id, consumable_id, quantity, concurrency_code, modified_at)
VALUES (?1, ?2, MAX(0, ?3), ?4, ?5)
ON CONFLICT (player_id, consumable_id) DO UPDATE SET
quantity = MAX(0, room_inventory.quantity + ?3),
concurrency_code = COALESCE(?4, room_inventory.concurrency_code),
modified_at = ?5`
)
.bind(playerId, consumableId, quantity),
.bind(playerId, consumableId, quantity, concurrencyCode, new Date().toISOString()),
read.bind(playerId, consumableId),
])
// No row means a quantity of zero — nothing is inserted until a player is first given one.
return after.results[0]?.quantity ?? 0
const row = after.results[0]
return {
quantity: row?.quantity ?? 0,
concurrencyCode: row?.concurrency_code ?? null,
modifiedAt: row?.modified_at ?? null,
}
}
/** Everything a player owns of one room's consumables. A listing they own none of is absent. */
/**
* Everything a player owns of one room's consumables, in the shop's order. A listing they own
* none of is absent — including one they held and spent down to zero.
*/
export async function getRoomInventory(
db: D1Database,
roomId: number,
playerId: number
): Promise<RoomInventoryItem[]> {
): Promise<RoomConsumableInventoryItem[]> {
const { results } = await db
.prepare(
`SELECT i.player_id AS playerId, i.consumable_id AS consumableId, i.quantity AS quantity
`SELECT ${SELECT_COLUMNS.split(', ')
.map((col) => `c.${col}`)
.join(', ')},
i.quantity AS quantity, i.concurrency_code AS concurrency_code,
i.modified_at AS inventory_modified_at
FROM room_inventory AS i
JOIN room_consumable AS c ON c.room_consumable_id = i.consumable_id
WHERE c.room_id = ?1 AND i.player_id = ?2
WHERE c.room_id = ?1 AND i.player_id = ?2 AND i.quantity > 0
ORDER BY c.modified_at, c.room_consumable_id`
)
.bind(roomId, playerId)
.all<{ playerId: number; consumableId: string; quantity: number }>()
.all<
RoomConsumableRow & {
quantity: number
concurrency_code: string | null
inventory_modified_at: string | null
}
>()
return results.map((r) => ({
PlayerId: r.playerId,
ConsumableId: r.consumableId,
Quantity: r.quantity,
}))
return results.map((row) =>
toInventoryItem(
playerId,
{
quantity: row.quantity,
concurrencyCode: row.concurrency_code,
modifiedAt: row.inventory_modified_at,
},
toRoomConsumable(row)
)
)
}

Some files were not shown because too many files have changed in this diff Show More