116 Commits

Author SHA1 Message Date
Nexi 7add875223 [api] implement invention cheers (#55)
Co-authored-by: Nexi (CWN) <communityshieldofficial@gmail.com>
2026-09-09 17:27:55 -04:00
Devin Zuczek 548089a3ec [notify] fix intermittant drop in notifications 2026-09-09 17:26:14 -04:00
Devin Zuczek f681af34fa [chat] parties WIP 2026-09-09 17:26:14 -04:00
Devin Zuczek cdb17f5284 [api] fix missing playerevents endpoint 2026-09-09 17:26:14 -04:00
Devin Zuczek db01d2bedd [discovery] add cache since this never changes 2026-09-09 17:26:14 -04:00
Devin Zuczek 629a702403 [accounts] emoji 2026-09-09 17:26:14 -04:00
Devin Zuczek 261ff21e0d [accounts] profanity filter on user/display name 2026-09-09 17:26:14 -04:00
Nexi 438475e326 [api] fix(security): bound API-owned uploads (#56)
Co-authored-by: Nexi (CWN) <communityshieldofficial@gmail.com>
2026-09-09 17:24:02 -04:00
Devin Zuczek 87a1cd6b55 [econ] update giftdrop type 2026-09-09 00:24:31 -04:00
Devin Zuczek 9a8fc1cab5 [rooms] add showcase stub 2026-09-08 23:52:56 -04:00
Nexi 222547ee13 [api] audit fixes: account tests, dependencies and security hardening (#54)
* test(accounts): cover three username changes

* chore(deps): update vulnerable runtime dependencies

* fix(security): bound uploads and validate token subjects strictly

---------

Co-authored-by: Nexi (CWN) <communityshieldofficial@gmail.com>
2026-09-08 23:47:58 -04:00
Devin Zuczek 9696c56317 [playersettings] fix delete setting 2026-09-08 23:27:04 -04:00
Devin Zuczek a2946fb9f4 [api] fix saved outfits not appearing 2026-09-08 23:07:27 -04:00
Devin Zuczek acf70a083d [commerce] just cache all of commerce b/c we dont use it 2026-09-07 16:18:09 -04:00
Devin Zuczek e0f802cee5 [api] improve moderationblock 2026-09-03 11:40:45 -04:00
Devin Zuczek 3d7ea3cf27 [api] show ban details 2026-09-03 11:05:16 -04:00
Devin Zuczek 4bc5f51b9c [rooms] remove friendlyname 2026-09-03 11:04:54 -04:00
Devin Zuczek fef6754aad [api] add non-working v3/votekick 2026-09-02 14:54:14 -04:00
Devin Zuczek 30b441aa65 [app] room owner insta-kicks 2026-09-02 14:01:19 -04:00
Devin Zuczek d441181b03 [invention] add invention v3 purchase 2026-09-02 11:22:03 -04:00
Devin Zuczek 20196361c8 [match] consume invite on join 2026-09-02 11:21:31 -04:00
Devin Zuczek bc12bb07d4 [api] disable statsig in 2025 2026-09-02 10:41:59 -04:00
Devin Zuczek 010c83d3ff [api] add statsig dump 2026-09-02 10:41:13 -04:00
Devin Zuczek 36f8b01f83 [accounts] allow 3 username changes 2026-09-02 10:40:59 -04:00
Devin Zuczek 6adb3ab741 [rooms] fix #50 room leaderboards 2026-09-02 00:45:11 -04:00
Devin Zuczek 9c63f077b8 [match] fix play-and-invite endpoint 2026-09-01 14:47:49 -04:00
Devin Zuczek 2af5d5089d [match] support a cluster of tachyon servers 2026-09-01 10:55:47 -04:00
Devin Zuczek cae0ebd4ae [docs] update some documentation about versions 2026-09-01 09:56:55 -04:00
Devin Zuczek ab623c376b [rooms] add recrally default 2026-09-01 01:20:58 -04:00
Devin Zuczek 1e26082f2e [match] the other v2 invite endpoint 2026-09-01 01:17:37 -04:00
Devin Zuczek 7d20f96414 [www] add single person notify for fun 2026-08-31 23:43:51 -04:00
Devin Zuczek 99417e052e [www] update year 2026-08-31 23:43:51 -04:00
Devin Zuczek 8260c5abcd [plus] discord role verifier to grant RR plus 2026-08-31 23:43:51 -04:00
Devin Zuczek 740e9efa09 [api] delete invention 2026-08-31 23:43:51 -04:00
Devin Zuczek 5928e07509 [api] turn off statsig 2026-08-31 23:43:19 -04:00
Devin Zuczek 45a07c6ba5 [img] add RecRally 2026-08-31 11:10:24 -04:00
Devin Zuczek 3be2066526 [econ] better quest rewards (for now until chests figured out) 2026-08-30 02:46:08 -04:00
Devin Zuczek 8e452f23eb [match] gate 2025 rooms 2026-08-30 01:37:43 -04:00
Devin Zuczek 02c965c2ee Merge remote-tracking branch 'origin/main' 2026-08-30 00:49:13 -04:00
Devin Zuczek e07c52ced7 [match] add basic stat table for fun 2026-08-30 00:44:50 -04:00
Devin Zuczek 5aea5a4cf9 [api] 2025 inventions 2026-08-29 14:40:57 -04:00
Devin Zuczek a620cadde0 [tests] fix a few failing tests 2026-08-29 13:15:37 -04:00
Devin Zuczek 750413f240 [econ] remove consumables from sf3, fix weekly 2026-08-28 14:03:56 -04:00
Devin Zuczek 77acb008b1 [www] update w/redirect 2026-08-28 10:36:54 -04:00
Devin Zuczek 1c586e9b20 [www] update web 2026-08-28 10:09:46 -04:00
Devin Zuczek 351fc594ac [api] disable roomie for now 2026-08-28 10:09:34 -04:00
Devin Zuczek c13f62bb91 [store] wip 2026-08-28 01:56:14 -04:00
Devin Zuczek f8ca01dd4d [packages] tools for catalog building 2026-08-27 22:43:41 -04:00
Devin Zuczek a169cf3e6e [econ] implement catalog 2026-08-27 22:43:22 -04:00
Devin Zuczek 5e138a0646 [api] add CAI reporting, CircuitChipList path 2026-08-27 22:42:35 -04:00
Devin Zuczek 2d2e2f44f3 [api] turn off datacollection 2026-08-27 20:45:16 -04:00
Devin Zuczek dcedf11c3d [api] turn off datacollection 2026-08-27 20:43:42 -04:00
Devin Zuczek 299f369925 [accounts] turn off IAP 2026-08-27 17:18:42 -04:00
Devin Zuczek 0f36db2a7f [api] invention reports 2026-08-26 21:59:26 -04:00
Devin Zuczek 062ed452a5 [match] fix 2025 game invites 2026-08-26 17:37:09 -04:00
Devin Zuczek e2ced3e797 [leaderboard] fix ranks 2026-08-26 12:12:44 -04:00
Devin Zuczek 35350ffa19 [econ] cap message len just in case 2026-08-26 11:24:30 -04:00
Devin Zuczek 19e1d48807 [econ] gift profanity just in case 2026-08-26 11:21:30 -04:00
Devin Zuczek d31e05d41a [econ] gift purchase 2026-08-26 11:14:59 -04:00
Devin Zuczek dbfc116808 [api] update hot avatar custom endpoint 2026-08-26 10:53:56 -04:00
Devin Zuczek b848e34b71 [commerce] stub endpoint 2026-08-26 10:28:48 -04:00
Devin Zuczek 4ca0e42cef [econ] fix rrplus discount 2026-08-26 01:22:55 -04:00
devin a12ff24068 Leaderboards (#43)
* leaderboards

* [leaderboard] add basic leaderboards - will probably have to clean up later but data is collected now
2026-08-26 01:04:27 -04:00
Devin Zuczek 7ef949bfcf [api,econ,img] shirts fix #35 2026-08-26 00:38:02 -04:00
Devin Zuczek 199c34d1cb [api] #29 fix player cheers 2026-08-25 22:22:13 -04:00
Devin Zuczek 4ff9609a8f [api] wip reputation 2026-08-25 21:37:07 -04:00
Devin Zuczek 4bacdb0312 [econ] friendly name for weekly 2026-08-25 17:40:44 -04:00
Devin Zuczek da62d7138d [rooms] add support for beta/limitsv2 2026-08-25 17:20:17 -04:00
Devin Zuczek 751e1f28c2 [leaderboard] stub 2 endpoints 2026-08-25 16:43:00 -04:00
Devin Zuczek c5421539c3 [econ] add the POST equipment update endpoint 2026-08-25 16:36:20 -04:00
Devin Zuczek 7275176734 [econ] auto challenges 2026-08-25 16:18:32 -04:00
Devin Zuczek 7c3f2a36cd [econ] fix challenges not persisting 2026-08-25 15:46:02 -04:00
Devin Zuczek f6d92ec1e1 [econ] debugging challenges 2026-08-25 14:44:08 -04:00
Devin Zuczek 5cb2cbf967 [rooms,match] add a couple missing endpoints 2026-08-25 14:02:39 -04:00
Devin Zuczek 599730379c [events] fix Rec Rooms failure to actually adhere to API versioning 2026-08-25 13:13:46 -04:00
Devin Zuczek 1d8399f64a [events] fix event delete 2026-08-25 12:42:37 -04:00
Devin Zuczek 6a9a1a95b5 [openapi] define missing routes 2026-08-25 12:42:24 -04:00
Devin Zuczek 3b5e47c39d [tests] fix drift 2026-08-25 11:42:29 -04:00
Devin Zuczek f9ffbcd374 [match] tachyon support endpoint 2026-08-24 23:58:01 -04:00
Devin Zuczek bddd59ec1a [chat] little fix to chat endpoint to not send entire thread 2026-08-24 21:58:24 -04:00
Devin Zuczek 39ac005adc [api] add outfits endpoint 2026-08-24 21:22:36 -04:00
Devin Zuczek db9f2d292b [rooms] fix friendy name and room update 2026-08-24 20:43:21 -04:00
Devin Zuczek 5382bf96e3 [match] add tachyon server configuration 2026-08-24 20:15:07 -04:00
Devin Zuczek b8d5103d57 [platformnotifications] fix endpoint 2026-08-24 18:51:09 -04:00
Devin Zuczek 55ac92670f [lists] more lists testing 2026-08-24 18:17:17 -04:00
Devin Zuczek b355b6cea9 [api] another events endpoint 2026-08-24 18:17:02 -04:00
Devin Zuczek 0f281db32e [econ] influencer endpoint 2026-08-24 18:16:50 -04:00
Devin Zuczek f1e960f950 [rooms] banned endpoint, event index 2026-08-24 18:16:27 -04:00
devin 6e8b91d503 [api] fix default avatars in 2025 (#40) 2026-08-24 13:42:36 -04:00
Devin Zuczek be24e62f1f [match] better invites 2026-08-22 12:23:57 -04:00
Devin Zuczek 1a2643240b [api] msg delete 2026-08-22 12:23:31 -04:00
Devin Zuczek 8c5de75eed [rooms] improve performance of rooms query 2026-08-21 15:48:12 -04:00
Devin Zuczek 6fd8d6074c [leaderboard] update openapi 2026-08-21 15:45:55 -04:00
Devin Zuczek dcaff859d0 [leaderboard] add stub 2026-08-21 15:16:00 -04:00
Devin Zuczek 0699e4409e [auth] made up nonce for oculus 2026-08-21 15:16:00 -04:00
Devin Zuczek 655c7ee4d6 [roomcomments] implement basic room comments 2026-08-21 15:16:00 -04:00
Devin Zuczek 062c07cc33 [link] stub 2026-08-21 15:16:00 -04:00
Devin Zuczek 3e59ebeaa8 [moderation] stub voice endpoint 2026-08-21 15:16:00 -04:00
Devin Zuczek 879d8e95ab [lists] fix a few lists 2026-08-21 15:16:00 -04:00
Devin Zuczek e47ce58db3 [ai] fix stub endpoint 2026-08-21 15:16:00 -04:00
Devin Zuczek 038e3b3c50 [rooms] fix my rooms 2026-08-21 15:16:00 -04:00
Devin Zuczek 663fdaf857 [lists] more lists 2026-08-21 15:16:00 -04:00
Devin Zuczek cf1f09a22e [lists] fixup lists a bit 2026-08-21 15:16:00 -04:00
Devin Zuczek b60e422580 [rooms] fix bulk loading issue 2026-08-21 15:16:00 -04:00
Devin Zuczek 44be2e071d [rooms] improve tag query performance 2026-08-21 15:16:00 -04:00
Devin Zuczek 5db02db172 [discovery][lists] semi working lists and discovery 2026-08-21 15:16:00 -04:00
Devin Zuczek f678f87d00 [chat] censor 2026-08-21 15:16:00 -04:00
Devin Zuczek e7eba28023 [auth] fix potential issue with refresh tokens, however the JWT being 86400 probably fixes this too 2026-08-21 15:16:00 -04:00
Devin Zuczek c1850e9fbc [api] some gameconfig changes 2026-08-21 15:16:00 -04:00
Devin Zuczek f2f3c7badc [events] prevent events longer than 24h 2026-08-21 15:16:00 -04:00
Devin Zuczek 6add3437cf [pn] stub endpoint 2026-08-21 15:16:00 -04:00
Devin Zuczek 6916ed2557 [api] 2025 gameconfig swap, implement sanitize 2026-08-21 15:16:00 -04:00
Devin Zuczek ec808d3cd8 [events] fixup to some event endpoints 2026-08-21 15:16:00 -04:00
Devin Zuczek bf4389f480 [chat] updates to privacy and filters 2026-08-21 15:16:00 -04:00
Devin Zuczek 244b3bca70 [match] do not allow crossplay between 2023 and 2025 2026-08-21 15:16:00 -04:00
devin 178d3b5b0e support for 202507 endpoints (#37)
* [auth][api] accept the 20250424.01 client

* [2025] unstable

* 20250718.0

* correct one this time

* stubs

* more stubs

* more stubs

* [lists] add worker

* [ai] route stubs

* [api] player photo setting

* [econ] add roomEconConfig route

* [infra] update worker generators

* [worker] add cards/moderation/platformnotification workers

* [lists] updates to some endpoints

* [clubs] stub out announcement endpoint, for now

* [econ] stub out season endpoints for now

* [chat] apps/chat stub out party endpoint not sure the shape yet

* [api] stub out statsig and lockeditems

* [doc] new services

* [lists] stub the bulk endpoint

* [datacollection] add placeholder service until we can kill it

* [api] set gifting to lvl5

* update lock

* [cdn] enable cache

* [match] matchmake v2

* [lists] stub some lists

* [ai] stubs

* [rooms] new subroom save endpoint

* [econ] add bulk purchase endpoint

* [discovery] update featured creator to 1 for fun

* [api] add photo settings flag

* [chat] fixup chat permissions (sorta)

* [auth] restrictions endpoint

* [rooms] contributed endpoint

* [api] fix outfit endpoint

* [discovery] attempt to fix store

* [chat] privacy endpoints

* [api] cheered images

* [rooms] add xp endpoint (disbaled)

* [rooms] add xp endpoint (disabled)

* update images-db for cheers

* [rooms] add autocomplete endpoint

* [cdn/img] increase cache ttl for statics

* [api] bulk route for images

* [accounts] add banner image

* [api] add misc missing endpoints

* [discovery] remove AI tab

* [platformnotifications] stub some endpoints

* [lists] add some more lists

* [rooms] additional endpoints

* [chat] stub a few privacy endpoints

* [econ] stub some endpoints

* misc db fixes

* [api] tweak shape for images v6

* [rooms] dont show trending RROs
2026-08-21 15:15:55 -04:00
240 changed files with 378153 additions and 49779 deletions
+325 -99
View File
@@ -1,21 +1,42 @@
---
name: weekly-challenge-config
description: Read and author the `Config` rule tree in apps/econ/static/weekly-challenge.json — node types, scene-id predicates, and the shared-scene traps
description: Read and author the `Config` rule tree a weekly challenge carries — the full node-type enum, event types, event variables, named scene constants, the shared-scene traps, and where the generator in apps/econ/src/challenge-rotation.ts emits them from
---
# The weekly-challenge `Config` rule tree
Reference for reading and writing the `Config` field of a challenge in
`apps/econ/static/weekly-challenge.json` (served by `GET /api/challenge/v2/getCurrent`).
Reference for reading and writing the `Config` field of a weekly challenge (served by
`GET /api/challenge/v2/getCurrent`).
**Rotations are generated, so there are two places a tree comes from.** Normally
`apps/econ/src/challenge-rotation.ts` emits it: a week is five (room, kind) pairs drawn from
`CHALLENGE_ROOMS` with the week's seed, and the tree is built by `configFor` from one of the
three idioms below. Adding variety means adding a room or a kind there, not hand-writing a
tree. The other place is `apps/econ/static/weekly-challenge.json`: a non-empty `Challenges`
array in that file PINS the week to a hand-authored rotation and skips generation, which is
how a one-off debug or event week gets served. Both end up as the same `Config` string on
the wire, and everything below applies to both.
**The server never evaluates these rules.** The client reads the tree, watches its own
gameplay, and posts the tree back to `/api/challenge/v2/updateProgress` with its verdict.
So the tree is a _specification handed to the client_, and a malformed one fails silently —
the challenge just never completes. Nothing server-side will tell you.
Everything here was read off one captured live rotation, not a spec. Meanings marked
_(inferred)_ are read from how values line up with the strings the client renders; the rest
are pinned by the data.
## Provenance
Two independent sources, and it matters which one a fact came from:
- **`CoffeeMan240/RecRoom.ChallengeLib`** — https://github.com/CoffeeMan240/RecRoom.ChallengeLib,
a .NET builder library reverse-engineered from the 20200306 client with data types from
20210813. It names every node type, field and enum below. Facts from it are marked **(lib)**
and are *names*, not observations: the library is a reimplementation, so a name can be right
about intent and still diverge from what the 20230414 client this server targets actually
reads. See "Where the lib and the live data disagree".
- **One captured live rotation** — the shipped `ChallengeMapId: 17`. Facts from it are marked
**(captured)** and are pinned by data actually served to a real client.
Where both agree the fact is solid. Where only the lib has it, treat the name as a strong
hypothesis and test in-game before shipping a rotation that depends on it.
## `Config` is an escaped JSON string
@@ -31,70 +52,183 @@ Author the tree as an object and stringify it into the field — don't hand-esca
bun -e 'const t={ct:0,ipc:false,wc:[{ct:6,vs:[2]}]}; console.log(JSON.stringify(JSON.stringify(t)))'
```
To read one back:
To read this week's back (the generated rotation, or the pinned file if one is in place):
```sh
bun -e 'const c=require("./apps/econ/static/weekly-challenge.json");
for (const x of c.Challenges) console.log(x.ChallengeId, x.Description, "\n ", JSON.parse(x.Config))'
bun -e 'const {buildRotation}=await import("./apps/econ/src/challenge-rotation.ts");
for (const x of buildRotation(new Date()).Challenges)
console.log(x.ChallengeId, x.Description, "\n ", JSON.parse(x.Config))'
```
## Node types
Pass a date to look at any other week — the rotation is a pure function of which week it is.
Each node carries a numeric type in `ct`. Two composite kinds appear:
## Node types (`ct`) — the full enum
- **Match** (`ct: 0`) — `wc` is a list of predicates that must _all_ hold for one game
result (AND).
- **Counter** (`ct: 1`) — `ctc` holds the child node to count, `t` is the target count.
**(lib)** `ChallengeTypes`. Every node carries one. Bold rows are the ones the captured
rotation actually uses.
Which slot a node uses (`wc` vs `ctc`) tells you what its children are; a node never has
both. `ipc` is `false` on every composite node in the reference data — purpose unknown, but
the client echoes it back, so keep emitting it.
| `ct` | Name | Kind | Extra fields |
| ---- | ---- | ---- | ------------ |
| **`0`** | `Challenge` | Composite — the plain AND node | `wc`, `rc` |
| **`1`** | `ChallengeCountChallenge` | Composite — count to a target | `ctc`, `t`, + `wc`/`rc` |
| **`2`** | `TimedBufferChallenge` | Composite — count within a rolling window | `ccc`, `t`, `i`, `pm`, `n`, `pb`, `m`, + `wc`/`rc` |
| `3` | `DynamicFloatArithmeticChallenge` | Leaf — compare two float resolvers | `op`, `rA`, `rB` |
| `4` | `DynamicIntArithmeticChallenge` | Leaf — compare two int resolvers | `op`, `rA`, `rB` |
| `5` | `RequiredToolChallenge` | Leaf — **removed** mid-2020; see note | `vs` |
| **`6`** | `RequiredEventTypeChallenge` | Leaf — which gameplay event | `vs` (ChallengeEventTypes) |
| **`7`** | `RequiredRoomSceneLocationChallenge` | Leaf — scene allow-list | `vs` (`[{"l": guid}]`) |
| `8` | `RequiredEnemyTypeChallenge` | Leaf — which enemy | `vs` (EnemyTypes) |
| **`9`** | `BoolVarEqualsChallenge` | Leaf — a bool session var equals | `v`, `vs` |
| `10` | — | **unnamed in the enum**; do not use | — |
| `11` | `DiscGolfFinishUnderParChallenge` | Leaf — no fields at all | — |
| `12` | `RequiredGameModeActivityChallenge` | Leaf — legacy game mode | `vs` (LegacyGameModeType) |
| `13` | `CompleteGameWithoutChallenge` | Macro over `ct:2` | as `ct:2` |
| `14` | `RequiredGestureChallenge` | Leaf — a gesture var (`v: "hg"`) | `v`, `vs` (PlayerGesture) |
| `15` | `HitstreakChallenge` | Macro over `ct:1` | as `ct:1` |
| `16` | `HitstreakCountChallenge` | Macro over `ct:2` | as `ct:2` |
## Predicate leaves
`ct: 5` is obsolete — the lib marks it `LEGACYRequiredToolChallenge`, removed from the game
around mid-late 2020. Post-2020 clients express "used tool X" as a `ct:0` with a
`PickedUpTool` event predicate plus a `ct:4` comparing the `t_t` var. Its value enum
(`SpawnableToolTypes`) **re-rolls every game build**, so any tool-typed challenge is
build-specific.
Leaves carry `vs`, a list of accepted values matched as OR.
The macros (`13`, `15`, `16`) serialize as their base type plus preset children — they are
authoring conveniences, not distinct client behavior. But they emit their own `ct`, so the
client must know the id: don't invent macro ids.
| `ct` | Shape | Meaning |
| ---- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `6` | `{"ct":6,"vs":[2]}` | _(inferred)_ The kind of event being matched — a finished game/session. Present in **every** leaf group and always `[2]`; nothing observed varying it, so treat it as required boilerplate. |
| `7` | `{"ct":7,"vs":[{"l":"<guid>"}]}` | Scene allow-list: each `l` is a subroom's `UnitySceneId` (see `apps/rooms`). Matches if the game happened in any of them. |
| `9` | `{"ct":9,"vs":[true],"v":"won"}` | A named session variable (`v`) equals one of `vs` — here, the player won. |
## Fields
## The two idioms
| Field | Meaning |
| ----- | ------- |
| `ct` | Node type, above. **(lib + captured)** |
| `ipc` | `IgnorePreviousCompletions` **(lib)** — the captured data sets it `false` on every composite. Keep emitting it. |
| `wc` | `WithConditions` — predicates that must **all** hold (AND). **(lib + captured)** |
| `rc` | `ResetConditions` — matching any of these **resets progress to zero**. This is what makes streaks. **(lib)** |
| `ctc` | `ChallengesToCount``ct:1`'s children; each match increments toward `t`. **(lib + captured)** |
| `ccc` | `ChallengesToCount` for `ct:2` — same idea, different slot name. **(lib)** |
| `t` | Target count. **(lib + captured)** |
| `vs` | Accepted values, matched as OR. **(lib + captured)** |
| `v` | Var key for `ct:9`/`ct:14`. **(lib + captured)** |
| `in` | `Inclusive` — omitted when false. **(lib)**; the captured `won` predicate omits it. |
| `ex` | `ExcludesIncludesNull` — omitted when false. **(lib)** |
| `i` | `ct:2` window length in seconds, as a **2-dp string** (`"-1.00"`). `-1` = no window. **(lib)** |
| `pm` | `ct:2` progress mode: `0` Complete, `1` Count. **(lib)** |
| `n` | `ct:2` notification counts — milestones the client announces en route to `t`. **(lib)** |
| `pb` | `ct:2` `PersistBuffer` — carry the buffer across games. **(lib)** |
| `m` | `ct:2` count method, omitted when `0`. **(lib)** |
| `op` | `ct:3`/`ct:4` comparison: `0` GT, `1` LT, `2` EQ, `3` GTE, `4` LTE. **(lib)** |
| `rA`, `rB` | `ct:3`/`ct:4` operands, each a num resolver. **(lib)** |
| `cc`, `c` | **Client-side progress — never author these.** See below. **(captured)** |
Every challenge in the captured rotation is one of these.
A num resolver (`rA`/`rB`) is `{"t":0,"c":<const>}` for a constant or `{"t":1,"vk":"<var>"}`
for a session variable. Note `t` means *resolver type* here, not target.
```jsonc
// "Complete ^TheRiseOfJumbotron quest" — one winning session in one scene
{ "ct": 0, "ipc": false, "wc": [
{ "ct": 6, "vs": [2] },
{ "ct": 9, "vs": [true], "v": "won" },
{ "ct": 7, "vs": [{ "l": "acc06e66-…" }] } // TheRiseofJumbotron / Home
]}
`m` values **(lib)**: `0` Count, `1` UniqueToolCount, `2` UniqueAttackerCount,
`3` UniqueDefenderCount, `4` GroupedToolMaxCount, `5` UniqueGameCount.
// "Complete 5 Charades games" — count matching sessions to a target
{ "ct": 1, "ipc": false, "t": 5, "ctc": [
{ "ct": 0, "ipc": false, "wc": [
{ "ct": 6, "vs": [2] },
{ "ct": 7, "vs": [{ "l": "a673712c-…" }, { "l": "4078dfed-…" }] } // 3DCharades + Legacy3DCharades
]}
]}
```
## Event types — `ct: 6` `vs` values
The quest challenges have **no `t`** (one qualifying session is the whole goal) and the
counted ones have **no `won` predicate** (finishing counts, winning is irrelevant). A "one
map only" challenge is the counted shape with a single-entry scene list.
**(lib)** `ChallengeEventTypes`. The captured rotation only ever used `2`, which the old
version of this doc guessed was opaque boilerplate. It is not — it is `GameEnd`:
## Scene ids, not room ids
| id | Name | | id | Name |
| -- | ---- | - | -- | ---- |
| `0` | `None` | | `7` | `Score` |
| `1` | `GameStart` | | `8` | `ShieldBlock` |
| **`2`** | **`GameEnd`** | | `9` | `ActivityLoad` |
| `3` | `LocalPlayerEliminated` | | `10` | `FlagCaptured` |
| `4` | `ElminatedOtherPlayer` _(sic)_ | | `11` | `FlagReturned` |
| `5` | `EliminatedAI` | | `12` | `Gesture` |
| `6` | `PickedUpTool` | | `13` | `PlayerJoined` |
`ct: 7` matches `UnitySceneId`, so one guid can name several rooms — a screens room, its VR
twin, and the standalone base room all share a scene. The captured "Complete 10 games in
^Paintball" lists six guids, which are the subrooms of _both_ `Paintball` and `PaintballVR`,
each of which is also a standalone base room (`River`, `Clearcut`, …). One list covers every
way in.
`{"ct":6,"vs":[2]}` appears in every captured leaf group because every captured challenge
counts *finished games*, not because the field is fixed. Counting anything else — hits,
scores, revives — means changing this value.
Resolve a guid against `SubRooms[].UnitySceneId` in `apps/rooms/static/ImportRooms.json`
(same data as `apps/rooms/migrations/0002_import_rooms.sql`). Run from the repo root:
## Session variables (`v`, `vk`)
**(lib)** The vars an event publishes, keyed by the event that carries them.
| Key | Type | On | Meaning |
| --- | ---- | -- | ------- |
| `gid` | string | any game event | Game id |
| `gameMode` | int | any game event | `LegacyGameModeType` |
| `numTeammates` | int | any game event | Size of your team |
| `t_score` | float | any game event | Your team's score |
| `jip` | bool | GameStart | Joined in progress |
| `isSpectator` | bool | GameStart | Spectating |
| `te` | float | GameEnd | Time elapsed |
| **`won`** | bool | GameEnd | Did the player win (used in quests) |
| `ev_score` | float | Score | Current score |
| `e_vid` | int | enemy events | Enemy photon view id |
| `e_t` | int | enemy events | `EnemyTypes` |
| `dp_vid` / `dp_pid` | int | player events | Defender photon view id / RecNet id |
| `ap_vid` / `ap_pid` | int | player events | Attacker photon view id / RecNet id |
| `bodyPart` | int | ElminatedOtherPlayer | `BodyPart`: `-1` None, `0` Head, `1` Torso, `2` LeftHand, `3` RightHand, `4` Mouth |
| `t_vid` / `t_t` | int | tool events | Tool photon view id / `SpawnableToolTypes` |
| `strokeCount` / `par` | int | DiscGolf Score | Strokes taken / hole par |
So the captured `{"ct":9,"vs":[true],"v":"won"}` is `GameEnded.Won` — a quest win.
## Named scene constants — `ct: 7` `vs` values
**(lib)** `RoomSceneLocations`, cross-checked against `apps/rooms/static/ImportRooms.json`.
`ct:7` matches `UnitySceneId`, so **one guid can name several rooms**. Bolded rooms share
their scene — a challenge naming that guid completes in every room listed.
The lib names every scene present in our room data (36 of its 40 resolve; the other four are
marked below), so this table is a complete index in both directions.
| Constant | Scene id | Rooms in `ImportRooms.json` |
| -------- | -------- | --------------------------- |
| `DORM_ROOM` | `76d98498-60a1-430c-ab76-b54a29b7a163` | DormRoom/Home |
| `REC_CENTER` | `cbad71af-0831-44d8-b8ef-69edafa841f6` | RecCenter/Home |
| `LEGACY_CHARADES` | `4078dfed-24bb-4db7-863f-578ba48d726b` | Legacy3DCharades/Home |
| `LAKE` | `f6f7256c-e438-4299-b99e-d20bef8cf7e0` | **DiscGolfLake/Home**, **Lake/Home** |
| `PROPULSION` | `d9378c9f-80bc-46fb-ad1e-1bed8a674f55` | **DiscGolfPropulsion/Home**, **PropulsionTestRange/Home** |
| `DODGEBALL` | `3d474b26-26f7-45e9-9a36-9b02847d5e6f` | **Dodgeball/Home**, **Gym/Home**, **DodgeballVR/Home** |
| `THE_LOUNGE` | `a067557f-ca32-43e6-b6e5-daaec60b4f5a` | Lounge/Home |
| `PADDLEBALL` | `d89f74fa-d51e-477a-a425-025a891dd499` | Paddleball/Home |
| `RIVER` | `e122fe98-e7db-49e8-a1b1-105424b6e1f0` | **Paintball/River**, **PaintballVR/River**, **River/Home** |
| `HOMESTEAD` | `a785267d-c579-42ea-be43-fec1992d1ca7` | **Paintball/Homestead**, **PaintballVR/Homestead**, **Homestead/Home** |
| `QUARRY` | `ff4c6427-7079-4f59-b22a-69b089420827` | **Paintball/Quarry**, **PaintballVR/Quarry**, **Quarry/Home** |
| `CLEAR_CUT` | `380d18b5-de9c-49f3-80f7-f4a95c1de161` | **Paintball/Clearcut**, **PaintballVR/Clearcut**, **Clearcut/Home** |
| `SPILLWAY` | `58763055-2dfb-4814-80b8-16fac5c85709` | **Paintball/Spillway**, **PaintballVR/Spillway**, **Spillway/Home** |
| `QUEST_FOR_THE_GOLDEN_TROPHY` | `91e16e35-f48f-4700-ab8a-a1b79e50e51b` | GoldenTrophy/Home |
| `ORIENTATION` | `c79709d8-a31b-48aa-9eb8-cc31ba9505e8` | Orientation/Home |
| `THE_RISE_OF_JUMBOTRON` | `acc06e66-c2d0-4361-b0cd-46246a4c455c` | TheRiseofJumbotron/Home |
| `CURSE_OF_THE_CRIMSON_CAULDRON` | `949fa41f-4347-45c0-b7ac-489129174045` | CrimsonCauldron/Home |
| `THE_ISLE_OF_LOST_SKULLS` | `7e01cfe0-820a-406f-b1b3-0a5bf575235c` | IsleOfLostSkulls/Home |
| `SOCCER` | `6d5eea4b-f069-4ed0-9916-0e2f07df0d03` | **Soccer/Home**, **Stadium/Home** |
| `PERFORMANCE_HALL` | `9932f88f-3929-43a0-a012-a40b5128e346` | PerformanceHall/Home |
| `PSVR_ROOM_CALIBRATION` | `f5fbd9c9-e853-4036-9d48-5f68e861af04` | _not in ImportRooms.json_ |
| `PARK` | `0a864c86-5a71-4e18-8041-8124e4dc9d98` | Park/Home |
| `WAREHOUSE` | `239e676c-f12f-489f-bf3a-d4c383d692c3` | **LaserTag/Hangar**, **Hangar/Home** |
| `CYBERJUNK_CITY` | `9d6456ce-6264-48b4-808d-2d96b3d91038` | **LaserTag/CyberJunkCity**, **LaserTagCyberJunk/Home**, **CyberJunkCity/Home** |
| `MAKER_ROOM` | `a75f7547-79eb-47c6-8986-6767abcb4f92` | MakerRoom/Home |
| `FRONTIER_SOLOS` | `b010171f-4875-4e89-baba-61e878cd41e1` | RecRoyaleSolos/Home |
| `FRONTIER_SQUADS` | `253fa009-6e65-4c90-91a1-7137a56a267f` | RecRoyaleSquads/Home |
| `CRESCENDO_OF_THE_BLOOD_MOON` | `49cb8993-a956-43e2-86f4-1318f279b22a` | Crescendo/Home |
| `BOWLING_ALLEY` | `ae929543-9a07-41d5-8ee9-dbbee8c36800` | **Bowling/Home**, **BowlingAlley/Home** |
| `ANIMATION_RECORDING_STUDIO` | `a95c349c-0f96-4c2d-a4c8-4969ffa8ea44` | _not in ImportRooms.json_ |
| `STUNTRUNNER` | `b7281665-a715-4051-826b-8e08e69c6172` | StuntRunner/StuntRunner |
| `STUNTRUNNER_THE_MAIN_EVENT` | `3a636bd2-f896-424c-9225-c184522c0d87` | StuntRunner/TheMainEvent |
| `STUNTRUNNER_BASE_ROOM` | `882e9b96-7115-4b03-86f6-c0c9d8e22e00` | StuntRunnerBaseRoom/Home |
| `REGISTRATION` | `cf61556d-68fd-4288-9ae5-7a512621e569` | Registration/Home |
| `AR_ROOM` | `bf268f5f-b55b-41af-8628-32fa4b5d70b6` | ARRoom/Home |
| `DRIVEIN` | `65ddbb48-5a01-4e3e-972d-e5c7419e2bc3` | **Paintball/Drive-in**, **PaintballVR/Drive-in**, **DriveIn/Home** |
| `CHARADES_THE_INK_SPACE` | `a673712c-877f-4749-b69a-4a4c6310d545` | 3DCharades/InkSpaceHome |
| `THE_INK_SPACE_BASE_ROOM` | `1fa06e3c-c307-4c11-a91b-1fabcddb8a96` | TheInkSpace/Home |
| `FRONTIER_UGC` | `a16bfd31-ffb9-46ac-a199-362c163130c0` | _not in ImportRooms.json_ |
The lib also defines `INVALID`, which serializes as `Guid.Empty` and is not a real scene.
Two shared scenes are genuine surprises rather than a deliberate screens/VR/base-room trio:
**`Soccer/Home` and `Stadium/Home` are the same scene**, and **`Dodgeball` shares its scene
with the plain `Gym`**. Decide whether the extra rooms are acceptable before shipping.
Regenerate the room column from the repo root:
```sh
cat > /tmp/scene.ts <<'EOF'
@@ -111,73 +245,165 @@ bun run /tmp/scene.ts 380d18b5-de9c-49f3-80f7-f4a95c1de161
# → 380d18b5-… Paintball/Clearcut, PaintballVR/Clearcut, Clearcut/Home
```
With no arguments it dumps every scene, which is how you go the other way — from a room name
to the guid to put in `vs`.
With no arguments it dumps every scene, which is how you go the other way.
### Shared scenes to watch for
## The idioms
These guids resolve to more than one room, so a challenge naming one also completes in the
others. Most are a deliberate screens/VR/base-room trio, but two are genuine surprises:
**`Soccer/Home` and `Stadium/Home` are the same scene**, so a soccer challenge also completes
in the Stadium, and `Dodgeball` shares its scene with the plain `Gym`.
### One-shot quest **(captured)**
| Scene id | Rooms |
| ----------- | ------------------------------------------------------------------ |
| `6d5eea4b…` | Soccer/Home, **Stadium/Home** |
| `3d474b26…` | Dodgeball/Home, **Gym/Home**, DodgeballVR/Home |
| `ae929543…` | Bowling/Home, BowlingAlley/Home |
| `f6f7256c…` | DiscGolfLake/Home, Lake/Home |
| `d9378c9f…` | DiscGolfPropulsion/Home, PropulsionTestRange/Home |
| `239e676c…` | LaserTag/Hangar, Hangar/Home |
| `9d6456ce…` | LaserTag/CyberJunkCity, LaserTagCyberJunk/Home, CyberJunkCity/Home |
| `e122fe98…` | Paintball/River, PaintballVR/River, River/Home |
| `a785267d…` | Paintball/Homestead, PaintballVR/Homestead, Homestead/Home |
| `ff4c6427…` | Paintball/Quarry, PaintballVR/Quarry, Quarry/Home |
| `380d18b5…` | Paintball/Clearcut, PaintballVR/Clearcut, Clearcut/Home |
| `58763055…` | Paintball/Spillway, PaintballVR/Spillway, Spillway/Home |
| `65ddbb48…` | Paintball/Drive-in, PaintballVR/Drive-in, DriveIn/Home |
No `t` — one qualifying session is the whole goal.
Regenerate this list with the script above and no arguments.
```jsonc
// "Complete ^TheRiseOfJumbotron quest"
{ "ct": 0, "ipc": false, "wc": [
{ "ct": 6, "vs": [2] }, // GameEnd
{ "ct": 9, "vs": [true], "v": "won" }, // …and won
{ "ct": 7, "vs": [{ "l": "acc06e66-…" }] } // THE_RISE_OF_JUMBOTRON
]}
```
### Counted sessions **(captured)**
No `won` predicate — finishing counts, winning is irrelevant. A "one map only" challenge is
this with a single-entry scene list.
```jsonc
// "Complete 5 Charades games"
{ "ct": 1, "ipc": false, "ctc": [
{ "ct": 0, "ipc": false, "wc": [
{ "ct": 6, "vs": [2] },
{ "ct": 7, "vs": [{ "l": "a673712c-…" }, { "l": "4078dfed-…" }] } // both Charades scenes
]}
], "t": 5 }
```
### Streaks and buffers **(lib)**
`rc` resets the count, which is how "N in a row without dying" is expressed. Wrapping that
in a `ct:2` counts how many streaks you land:
```jsonc
// "Get 20 three-kill streaks in the Golden Trophy quest", announced at 5/10/15
{ "ct": 2, "ipc": false,
"wc": [{ "ct": 7, "ipc": false, "vs": [{ "l": "91e16e35-…" }] }], // GOLDEN_TROPHY
"ccc": [{ "ct": 1, "ipc": false, "t": 3,
"ctc": [{ "ct": 6, "ipc": false, "vs": [5] }], // EliminatedAI
"rc": [{ "ct": 6, "ipc": false, "vs": [3] }] }],// …reset on your own death
"i": "-1.00", // no time window
"t": 20, "pm": 1, // count mode, target 20
"n": [5, 10, 15], // milestone notifications
"pb": true } // buffer survives across games
```
`pm: 1` (Count) is what makes `t` a tally; `pm: 0` (Complete) treats the buffer as a
one-shot. `i: "-1.00"` disables the time window — a positive value makes it "N within
X seconds".
## Progress fields (`cc`, `c`) — client-side only
On `updateProgress` the client posts the same tree back with its own progress written into
it: **`cc`** on the counter node is the current count (`…,"t":5,"cc":1`), and **`c`**
(`"c":true`) marks a node it now considers satisfied.
it: **`cc`** on a counter is the current count (`…,"t":5,"cc":1`), and **`c`** (`"c":true`)
marks a node it now considers satisfied.
Neither belongs in `weekly-challenge.json` — they are progress, not definition. The server
echoes the posted `Config` back untouched and never persists it (`challenge_status` stores
only the completion flag; see `apps/econ/src/challenge-db.ts`), so the running count lives
only in the client. Don't add `cc`/`c` to an authored tree, and don't try to read progress
out of one.
Neither belongs in an authored tree — they are progress, not definition. The server
stores the posted tree per player (`challenge_status.config`; see
`apps/econ/src/challenge-db.ts`) and `getCurrent` serves it back in place of the authored
tree, which is how a half-finished challenge survives a session — but it still evaluates
none of it: the counting is the client's. Don't author `cc`/`c`, and don't try to read
progress out of the tree you author.
## Authoring a new challenge
This is also the cheapest way to decode an unfamiliar tree: serve it, play the activity, and
watch which node grows a `cc`.
1. Pick the idiom: one-shot (`ct: 0` root, add the `won` predicate if winning is required)
or counted (`ct: 1` root with `t`).
2. Resolve the scenes with the script above, and check the shared-scene table — decide
## Where the lib and the live data disagree
The library is a 2020/2021 reimplementation, not the 20230414 client. Known divergences,
all worth checking before trusting a lib-only field:
- **`BoolVarEqualsChallenge` and `RequiredGestureChallenge` serialize `ct: 0` in the lib.**
`RequiredObjectChallenge` declares `ChallengeType` as a getter-only auto-property with no
initializer and the `VarEquals` subclasses never override it, so it defaults to `0`. The
captured rotation proves the real value is `9` for the `won` predicate. Don't take a
lib-generated `ct` for those two at face value.
- **`in` is emitted where the captured data omits it.** `BoolVarEqualsChallenge`'s
constructor forces `Inclusive = true`, so the lib would write
`{"ct":9,"in":true,"vs":[true],"v":"won"}` where the live rotation sent no `in`.
- **`ipc` is written twice when true** — `ChallengeBase.Serialize` adds it, then
`Challenge.Serialize` adds it again, which throws on the duplicate dictionary key. The lib
only works with `IgnorePreviousCompletions = false`, which is all the captured data uses.
- **`ChallengeCountChallengeBuilder.ResetCondition` adds the node to itself** — its parameter
shadows the `challenge` field. Set `ResetConditions` directly instead.
- **`SpawnableToolTypes` re-rolls per build**, so `ct:5` and any `t_t` comparison is pinned
to one client version.
## Adding to the generator
This is the usual way a new challenge ships: the week picks from `CHALLENGE_ROOMS` in
`apps/econ/src/challenge-rotation.ts`, so a room added there starts appearing in rotations
on its own.
1. **A new room** — add an entry with its `UnitySceneId`(s) from
`apps/rooms/static/ImportRooms.json`. A scene no room on this server hosts can never be
completed and nothing will tell you. Check the shared-scene table above and record the
extra rooms in `shares`. Set `kinds` conservatively: `win` reads the `won` variable, so
only rooms where winning is a real outcome; `ai` is quests. **Append, never insert**
`ChallengeId` is the candidate's index, so inserting renumbers every challenge after it.
2. **A new kind** — add it to `ChallengeKind` and give it a branch in all three of
`configFor` (the tree), `copyFor` (the strings) and `nameFor` (the slug). The compiler
will point at the two you forget. Build the tree from an idiom below; the copy is
generated from the same inputs so it can't drift out of step with the tree.
3. Keep the target constants (`GAMES_TARGET`, `AI_TARGET`) as the single source for both the
tree and the copy.
## Authoring a pinned rotation
For a one-off week: put challenges in `apps/econ/static/weekly-challenge.json` and the file
takes over completely — generation is skipped, and its `Gift`, window and `ChallengeMapId`
are served as written.
1. Pick the idiom: one-shot (`ct:0` root, add the `won` predicate if winning is required),
counted (`ct:1` root with `t`), or buffered/streak (`ct:2` root, `rc` on the child).
2. Resolve the scenes from the table above, and check the bolded shared rooms — decide
whether the extra rooms it lets in are acceptable.
3. Build the tree as an object, stringify it twice into `Config`.
4. Give the entry a `ChallengeId` unique **within the rotation** (they aren't sequential),
3. Pick the right event type for `ct:6``2` (GameEnd) only if you really are counting
finished games.
4. Build the tree as an object, stringify it twice into `Config`.
5. Give the entry a `ChallengeId` unique **within the rotation** (they aren't sequential),
and write the real goal in `Description``Name` is an internal slug that is not
authoritative (captured id `63` is named `Complete3SpillwayGames` but its `Config` and
description are Clearcut).
5. Leave `Complete: false`; `getCurrent` stamps it per caller.
6. Bump `ChallengeMapId` if this is a new rotation — ids only need to be unique within one,
and a new map id is what resets stored completions.
7. Keep `ServerTime` inside `StartAt``EndAt`, or the client renders the rotation as expired.
description are Clearcut). **Keep `Description`/`Tooltip` in step with `Config`:** the
client renders the strings and evaluates the tree independently, so a mismatch ships a
challenge that advances somewhere the text never mentions.
6. Leave `Complete: false`; `getCurrent` stamps it per caller.
7. Set a `ChallengeMapId` that no recent week has used — a new map id is what resets stored
completions, and generated weeks are `1000 + weekIndex`, so stay well clear of that range.
8. Keep `ServerTime` inside `StartAt``EndAt`, or the client renders the rotation as expired.
A pinned file is static, so its clock has to be frozen there; a generated week doesn't,
because its window is really the current one.
Sanity check the file parses and every tree parses:
Sanity check that every tree in this week's rotation parses, pinned or generated:
```sh
bun -e 'const c=require("./apps/econ/static/weekly-challenge.json");
c.Challenges.forEach(x => JSON.parse(x.Config)); console.log("ok", c.Challenges.length)'
bun -e 'const {buildRotation}=await import("./apps/econ/src/challenge-rotation.ts");
const c=buildRotation(new Date());
c.Challenges.forEach(x => JSON.parse(x.Config)); console.log("ok", c.ChallengeMapId, c.Challenges.length)'
```
Then `bun turbo -F econ test``src/test/integration/api.test.ts` imports the file and
asserts `getCurrent` against it.
Then `bun vitest run apps/econ``src/test/integration/api.test.ts` builds the same rotation
and asserts `getCurrent` against it, and walks two years of generated weeks checking every
tree. Note the gift threshold follows the rotation size (`CHALLENGES_REQUIRED_FOR_GIFT`
clamps to what you publish), so a pinned rotation of three or fewer asks for all of them.
## Credits
The node-type, event-type, field, variable and scene-constant tables above are derived from
**[CoffeeMan240/RecRoom.ChallengeLib](https://github.com/CoffeeMan240/RecRoom.ChallengeLib)**,
a .NET challenge-builder library that reverse-engineered this format from the 20200306 client
(data types from 20210813). Without it the `ct` values were opaque integers. Thanks to
CoffeeMan240 for publishing it.
## Related
- `apps/econ/README.md` — the rest of the weekly-challenge file (top level, `Gift`, progress)
- `.agents/daily-objectives/SKILL.md` — the other objective system, on `GET api/config/v2`
- `.agents/skills/daily-objectives-config/SKILL.md` — the other objective system, on
`GET api/config/v2`. Different grammar entirely: a flat `{type, score}` enum, not a tree.
+54
View File
@@ -87,6 +87,17 @@ RECFLARE_DOMAIN=rec.example.com
# RECFLARE_MAX_ROOMS_PER_ACCOUNT=10
# RECFLARE_MAX_CLUBS_PER_ACCOUNT=10
# Largest binary the storage worker accepts in one upload, in bytes. The default is
# 64 MiB. The endpoint rejects larger files with HTTP 413 before writing to R2. This
# value must be a positive integer; zero or an invalid value restores the default.
# RECFLARE_MAX_UPLOAD_BYTES=67108864
# Largest file the API worker accepts through either saved-image upload or either
# custom-avatar-item file field, in bytes. The default is 64 MiB PER FILE. Larger
# parsed files receive HTTP 413 before arrayBuffer() or an R2 write. This complements
# RECFLARE_MAX_UPLOAD_BYTES, which protects the separate storage worker.
# RECFLARE_MAX_API_UPLOAD_BYTES=67108864
# Rooms to switch out at matchmake time (`match`), as comma-separated <fromRoomId>=<to>
# pairs, where <to> is a room id or room name. This is how a stock RRO room is replaced
# with your own: 2=MyHub sends everyone who matchmakes into the Rec Center (room 2) to the
@@ -108,6 +119,22 @@ RECFLARE_DOMAIN=rec.example.com
# RECFLARE_PHOTON_VOICE_APP_ID=
# RECFLARE_PHOTON_CHAT_APP_ID=
# The pool of Tachyon servers sessions are spread across (`match`, GET
# /player/connection-info): a COMMA-SEPARATED list of `host:port` entries, one of which
# the client is handed as `voiceConnectionInfo`. EMPTY unless you set it — no separate
# voice server. Not a secret (the client receives the address in the clear), like the
# Photon ids above. One entry is the ordinary single-server case:
# RECFLARE_TACHYON_HOST_PORT=127.0.0.1:7777
#
# List several and each room instance is assigned one for its lifetime, so everybody in
# a session lands on the same server while different sessions spread across the pool.
# The id the client displays (`voiceServerId`) is generated from an entry's POSITION —
# `tachyon-1`, `tachyon-2`, … — so listing one address twice models two server slots on
# one box, and inserting an entry renames every server after it. The five below are a
# mock pool (RFC 5737 documentation addresses, which answer nothing): tachyon-1/-2 share
# a host, as do tachyon-4/-5.
# RECFLARE_TACHYON_HOST_PORT=198.51.100.10:7777,198.51.100.10:7778,198.51.100.11:7777,203.0.113.20:7777,203.0.113.20:7778
# 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
# client cannot connect to, so there is no "unset" state for it to have. It is
@@ -134,3 +161,30 @@ RECFLARE_DOMAIN=rec.example.com
# Setting them both is what opens web signup; with either missing it stays closed. See
# DEPLOYING.md. Accounts are still created by the game either way, and both `auth` account
# caps above apply regardless.
# The website's benefits claim (www `/claim`): a player proves a role in your Discord and
# gets Rec Room Plus. FOUR settings, and ALL FOUR are required — with any missing, the claim
# stays closed, `/api/config` reports `benefitsEnabled: false`, and the page and its nav link
# never appear. That is the usual reason "I set the secrets and nothing shows up".
#
# The two CREDENTIALS live in the Secrets Store, like the Turnstile pair above:
#
# wrangler secrets-store secret create <store-id> --name DISCORD_CLIENT_ID \
# --scopes workers --remote
# wrangler secrets-store secret create <store-id> --name DISCORD_CLIENT_SECRET \
# --scopes workers --remote
#
# The two IDS are plain vars, and go HERE — they are not secrets, and setting the secrets
# alone is not enough. Both are Discord snowflakes: all digits, no letters, copied with
# Developer Mode on (right-click the server or role -> Copy ID). Ids, not names.
#
# ROLE_IDS is a list and ANY one of them qualifies, so several tiers can share the benefit.
# SEPARATE THEM WITH COMMAS AND NO SPACES: these knobs are passed to wrangler as `--var`
# flags that are word-split, so a value containing a space silently breaks the deploy. (The
# worker itself also accepts whitespace, which is fine in wrangler.jsonc but not here.)
# 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.
+45
View File
@@ -44,6 +44,20 @@
- Changesets manage versions and changelogs
</architecture>
<game-clients>
Supported client builds live in `SUPPORTED_GAME_VERSIONS`
(`packages/domain/src/presence-db.ts`); `GAME_VERSION` is the default the stack targets.
- `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.
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.
</game-clients>
<code-style>
- Use tabs for indentation, spaces for alignment
- Type imports use `import type`
@@ -151,6 +165,37 @@ inconsistency here without checking the client first.
served under TWO names, `result` (what the client reads) and `errorCode` (what this
server has always sent); they are the same number and must never disagree, which is why
everything answers through `matchmakeResult` rather than building the envelope by hand.
- What PLAYS a cheer on the cheered player's client is a `MessageReceived` frame carrying a
Message of type 50 `PlayerCheer` (51 `PlayerCheerAnonymous`, `FromPlayerId` 0, when the
body says `Anonymous`) with `Data` = the category as a string — the same frame every
reference server (meownet-api, DorkNet, E12354) sends. `ReputationUpdate` alone refreshes
the counters and shows nothing: the cheer "worked" server-side and nobody saw it. The
`ReputationUpdate` frames the cheer (`api`: `POST /api/PlayerCheer/v1/create`) sends are
the RECORD, trimmed — `IsCheerful` (a profile flag, always true) and `SelectedCheer` (the
cheer pinned via `POST /api/PlayerCheer/v1/SetSelectedCheer`, stored on `reputation`) come
off the row exactly as the DTO serves them. This server once overrode both per frame to
"play" the cheer; no reference does, and it played nothing.
- A cheer is a thing that happens in FRONT of people, so the `ReputationUpdate` naming the
cheered player goes to everyone in the room instance, not just the two players. The
cheered player gets it durably (their counters moved); the rest of the room gets it
ephemerally. The audience comes from the giver's live `presence` row, NOT the body's
`RoomId`, which is accepted and unused. Neither `RoomId` nor `Anonymous` is stored.
- The cheer's reply is `{ Success, Message }` — PascalCase, with `Message` NULL on success.
That is NOT the lowercase `{ success, error: "" }` envelope the reports and warnings use;
the two live side by side in the same worker and must not be unified.
- A Message's `Data` (every `MessageReceived` frame) is a STRING on the wire, so a payload
with structure to it goes in ESCAPED — `"Data": "{\"PlayerId\":\"205\"}"`, never a nested
object. An object there does not degrade: the client's decoder rejects it outright
(`expected:'String Begin Token', actual:'{'`) and loses the whole notification, not just
the field. Bites the vote-to-kick message (`api`: `POST /api/PlayerReporting/v3/voteToKick`,
whose `Data` carries `{ PlayerId, Response, GameSessionId }``PlayerId` a string inside
it, as the reference relays it) and, the same way, a chat message's `Contents`.
- Leaderboard `Rank` (`leaderboard`: `GetRanks`, `GetNearbyScores`, `GetPlayerRank`) is
0-BASED — the client adds one before it draws, so a `Rank` of 1 shows in game as second
place and the top of a board must be 0. Its own slice says the same: it asks for the first
ten rows as `RankStart` 0, `RankEnd` 9, both inclusive, so reading them as 1-based also
serves nine rows starting at the runner-up. The unranked sentinel stays a big number
(99999) precisely because 0 is now a real rank, first place.
- 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
+11 -10
View File
@@ -193,16 +193,17 @@ the same `.env` you already created. `.env.example` carries each one commented o
its built-in default: copy the lines you want to change into your `.env`, uncomment them,
edit the value, then re-deploy the worker that reads them.
| `.env` variable | Read by | Default | What it does |
| --------------------------------------- | ------- | ------- | -------------------------------------------------------------- |
| `RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID` | `auth` | `3` | Accounts one Steam-verified identity may create. `0` disables. |
| `RECFLARE_MAX_ACCOUNTS_PER_IP` | `auth` | `3` | Accounts one signup IP may create. `0` disables. |
| `RECFLARE_STARTING_TOKENS` | `econ` | `10000` | RecCenterTokens a new player is granted. |
| `RECFLARE_ROOM_REDIRECTS` | `match` | unset | Rooms to switch out on matchmake, e.g. `2=MyHub`. |
| `RECFLARE_PHOTON_REALTIME_APP_ID` | `match` | empty | Your Photon Realtime app id. No app ships with recflare. |
| `RECFLARE_PHOTON_VOICE_APP_ID` | `match` | empty | Your Photon Voice app id. |
| `RECFLARE_PHOTON_CHAT_APP_ID` | `match` | empty | Your Photon Chat app id. |
| `RECFLARE_PHOTON_REGION` | `match` | `us` | Photon region every session is pinned to. |
| `.env` variable | Read by | Default | What it does |
| --------------------------------------- | --------- | ---------- | -------------------------------------------------------------- |
| `RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID` | `auth` | `3` | Accounts one Steam-verified identity may create. `0` disables. |
| `RECFLARE_MAX_ACCOUNTS_PER_IP` | `auth` | `3` | Accounts one signup IP may create. `0` disables. |
| `RECFLARE_STARTING_TOKENS` | `econ` | `10000` | RecCenterTokens a new player is granted. |
| `RECFLARE_MAX_UPLOAD_BYTES` | `storage` | `67108864` | Maximum binary upload size; larger files receive HTTP 413. |
| `RECFLARE_ROOM_REDIRECTS` | `match` | unset | Rooms to switch out on matchmake, e.g. `2=MyHub`. |
| `RECFLARE_PHOTON_REALTIME_APP_ID` | `match` | empty | Your Photon Realtime app id. No app ships with recflare. |
| `RECFLARE_PHOTON_VOICE_APP_ID` | `match` | empty | Your Photon Voice app id. |
| `RECFLARE_PHOTON_CHAT_APP_ID` | `match` | empty | Your Photon Chat app id. |
| `RECFLARE_PHOTON_REGION` | `match` | `us` | Photon region every session is pinned to. |
Then deploy just the worker that reads it:
+34 -16
View File
@@ -2,7 +2,7 @@
<img width="1063" height="409" alt="image" src="https://github.com/user-attachments/assets/521d5b11-fb93-4900-9158-71d51d2343ae" />
![example branch parameter](https://github.com/djdevin/recflare/actions/workflows/test.yml/badge.svg?branch=main)
![badge](https://github.com/djdevin/recflare/actions/workflows/test.yml/badge.svg?branch=main)
RecFlare is a scalable implementation of RecNet — the Rec Room backend — built on
Cloudflare Workers. It implements the network services the Rec Room client talks
@@ -36,15 +36,24 @@ resulting in bugs getting fixed faster. I hope.
## Game client
See [RecFlare Client](https://github.com/djdevin/recflare-client)
See [RecFlare Client](https://github.com/djdevin/recflare-client) for the official RecFlare build.
RecFlare is compatible with the
[RecNet Plugin](https://github.com/djdevin/recnet-plugin) and the build of Rec
Room with manifest `7859140924515540835` (around 2023). Other client or game versions may expect
different endpoints and response shapes and are not supported.
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 |
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.
Generally speaking any client that effectively rewrites the nameserver with the
right mods can be used with this server.
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.
## Services
@@ -58,9 +67,11 @@ Want to run it yourself? See [DEPLOYING.md](DEPLOYING.md)
### What year is this for?
This works with 2023 clients. It has been tested with manifest `7859140924515540835`. Other clients may not work.
Most 2023 and 2025 clients. A few older builds work at
alpha quality — see the table in the "Game client" section above. Other clients may not
work.
See the "Client" section above for instructions on how to modify a client to connect to this server.
See the "Game client" section above for instructions on how to modify a client to connect to this server.
### Can I run this locally on my PC?
@@ -70,11 +81,13 @@ See "Run the development microservices" above. It may be possible later as Wrang
### Can I use this to make my own server?
Yes, that's the point. Just set your custom domain and deploy it.
Yes, that's the point. See [DEPLOYING.md](DEPLOYING.md)
### Is there an admin panel?
Not yet. But there could be.
Yes, the server comes bundled with a simple web panel with more functionality being added.
There are also [CLI tools](CLI.md) you can use for admin tasks like granting roles.
### Can I copy this project and modify it?
@@ -89,15 +102,20 @@ keeps those in sync: `pnpm` workspaces share dependencies, `@repo/` packages
share code, Turborepo runs build/test/lint with a single cached task graph, and
cross-service changes land in one atomic commit.
This makes it easier to deploy the whole stack at once or a smaller selection
of microservices to avoid downtime events.
## Credits
I started this after the official servers shut down, so I could only see the
I started this soon after the official servers shut down when I saw there were
only monolithic servers usually running on one server. I could only see the
request shapes coming from the game client. I used many different projects as
resources to get response shapes, logic examples, enums, etc. They all had
missing pieces. Again, another reason to come together on one project and
stop gatekeeping.
Unfortunately, they were all leaked code except for
[CannedNet](https://github.com/CannedNet/CannedNet), [DorkNet](https://github.com/DorkSquadRR/DorkNet), and jordanparki7's postman
collection of RecNet APIs which is gone for some reason. So I will not list the
leaks publicly.
- [CannedNet](https://github.com/CannedNet/CannedNet)
- [DorkNet](https://github.com/DorkSquadRR/DorkNet)
- jordanparki7's postman collection of RecNet APIs which is gone for some reason.
- Leaked C# projects I won't list (for response shapes)
- Claude and my [wire shapes skill](https://github.com/recflare/skills/blob/main/.claude/skills/wire-shapes/SKILL.md)
+3 -3
View File
@@ -40,15 +40,15 @@ apex/`ns` host and isn't listed within it. Each implemented worker has its own
| GameLogs | `gamelogs` | — | Not yet implemented |
| Geo | `geo` | — | Not yet implemented |
| Images | `img` | `img` | Image storage & signed delivery (R2) |
| Leaderboard | `leaderboard` | — | Not yet implemented |
| Link | `link` | | Not yet implemented |
| 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` | — | Not yet implemented |
| RoomComments | `roomcomments` | `roomcomments` | Notes pinned in a rooms 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 |
+3 -2
View File
@@ -15,12 +15,13 @@
"test": "run-vitest"
},
"dependencies": {
"@2toad/profanity": "3.3.0",
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -32,6 +33,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+70 -6
View File
@@ -32,6 +32,7 @@ import {
CreateAccountResult,
DisplayNameRequest,
EmailRequest,
EmojiRequest,
form,
HealthResponse,
IdentityFlagsRequest,
@@ -45,7 +46,9 @@ import {
SuccessResponse,
UsernameRequest,
UsernameResult,
WhitelistedEmojis,
} from './openapi'
import { resolveWhitelistedEmoji, WHITELISTED_EMOJIS } from './whitelisted-emojis'
import type { Context } from 'hono'
import type { Account } from '@repo/domain'
@@ -76,7 +79,7 @@ function unauthorized(c: Context<App>) {
}
/** Username changes a fresh account starts with (until one has been consumed). */
const DEFAULT_USERNAME_CHANGES = 1
const DEFAULT_USERNAME_CHANGES = 3
/**
* Username-change result envelope: `{ success, error, value }`, always HTTP 200.
@@ -109,8 +112,8 @@ function toAccountDto(account: Account) {
username: account.username,
displayName: account.displayName,
profileImage: account.profileImage,
// Nothing writes these yet, and rows stored before they existed have neither
// key — always emit them as "" rather than letting them go missing.
// Rows stored before these fields existed have neither key — always emit them as
// "" rather than letting them go missing.
bannerImage: account.bannerImage ?? '',
displayEmoji: account.displayEmoji ?? '',
isJunior: account.isJunior,
@@ -210,6 +213,24 @@ const app = new Hono<App>()
(c) => c.json({ service: 'accounts', status: 'ok' })
)
// ---- Emoji config --------------------------------------------------------
// The picker the client fills its displayEmoji grid from. A BARE array — no
// `{ success, error, value }` envelope and no wrapper object; the client parses the
// response body itself as the list.
.get(
'/emojiConfig/whitelistedEmojis',
describeRoute({
tags: ['Config'],
summary: 'Emoji a player may use as their displayEmoji',
description: [
'A bare JSON array of emoji, in the order the client draws them. Static — not',
'auth-gated, and identical for every player.',
].join(' '),
responses: { 200: json(WhitelistedEmojis, 'The whitelisted emoji, in picker order') },
}),
(c) => c.json(WHITELISTED_EMOJIS)
)
// ---- Self account --------------------------------------------------------
.get(
'/account/me',
@@ -403,7 +424,7 @@ const app = new Hono<App>()
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json({ accountId: id, disallowInAppPurchases: false })
return c.json({ accountId: id, disallowInAppPurchases: true })
}
)
@@ -448,7 +469,9 @@ const app = new Hono<App>()
security: AUTHED,
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Empty, over 15 characters, or non-alphanumeric (empty body)' },
400: {
description: 'Empty, over 15 characters, non-alphanumeric, or profane (empty body)',
},
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -475,7 +498,8 @@ const app = new Hono<App>()
tags: ['Profile'],
summary: 'Change username',
description: [
'Letters and digits only, at most 50 characters. Rejects a name taken by another',
'Letters and digits only, at most 50 characters, and free of profanity (the same',
'word list as `api`s `POST /api/sanitize/v1/isPure`). Rejects a name taken by another',
'account and requires a remaining change; on success the name is persisted and',
'the counter decremented. Always HTTP 200 — failures carry a message in `error`',
'(see the UsernameResult envelope).',
@@ -662,6 +686,46 @@ const app = new Hono<App>()
}
)
// The emoji shown beside the player's display name. The body is a single field —
// `displayEmoji=%F0%9F%A4%AA` — and the value is checked against the same list
// `GET /emojiConfig/whitelistedEmojis` serves, then stored in that list's CANONICAL
// form: `displayEmoji` is compared as a plain string, and the client highlights the
// current pick by matching it against the picker list it fetched, so a stored value
// that differs only by a variation selector highlights nothing.
//
// Broadcast like every other public-DTO mutation here — the emoji rides along in the
// AccountUpdate payload, so it redraws beside the name without a refetch.
.put(
'/account/me/emoji',
describeRoute({
tags: ['Profile'],
summary: 'Set display emoji',
description: [
'Persists the emoji shown beside the display name and broadcasts it in the',
'AccountUpdate payload. The value must be one the whitelist serves; an empty value',
'clears the pick.',
].join(' '),
security: AUTHED,
requestBody: form(EmojiRequest, 'A whitelisted emoji, or "" to clear'),
responses: {
200: json(SuccessResponse, 'Updated'),
400: { description: 'Not a whitelisted emoji (empty body)' },
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const submitted = (await formField(c, 'displayEmoji')).trim()
// An empty value clears the pick; anything else has to be on the list.
const displayEmoji = submitted === '' ? '' : resolveWhitelistedEmoji(submitted)
if (displayEmoji === null) return c.body(null, 400)
const account = await updateAccount(c.env.DB, id, { displayEmoji })
await pushAccountUpdate(c, account)
return c.json({ success: true })
}
)
// The profile banner — the wide image behind the header on a player's profile. Same
// shape as the avatar below: the body names an image the player has already uploaded
// (the client posts one of their own photos, `sharecamera/<date>/<uuid>.jpg`), so this
+47 -5
View File
@@ -9,6 +9,11 @@ import {
nameRejection,
} from '@repo/domain'
// The profanity filter behind `api`'s `POST /api/sanitize/v1/isPure`, imported rather
// than copied so a name is held to the very same word list every other player-typed
// string is.
import { nameContainsSwears } from '../../api/src/sanitize'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
@@ -70,7 +75,7 @@ export const AccountDto = z.object({
bannerImage: z.string().describe('Profile banner key — always "" (nothing sets it yet)'),
displayEmoji: z
.string()
.describe('Emoji beside the display name — always "" (nothing sets it yet)'),
.describe('Emoji beside the display name, set by PUT /account/me/emoji; "" when unset'),
isJunior: z.boolean(),
platforms: z.int().describe('PlatformType bitmask of linked platforms'),
personalPronouns: z.int().describe('Pronoun flags bitmask'),
@@ -134,6 +139,15 @@ export const ParentalControl = z.object({ accountId: z.int(), disallowInAppPurch
*/
export const PrivacySettings = z.object({ accountId: z.int(), isRecentHistoryVisible: z.boolean() })
/**
* `GET /emojiConfig/whitelistedEmojis` response — a BARE array of emoji, no envelope
* and no object around it (see `WHITELISTED_EMOJIS`).
*/
export const WhitelistedEmojis = z
.string()
.array()
.describe('The emoji a player may set as their displayEmoji, in picker order')
/** Root health check. */
export const HealthResponse = z.object({ service: z.literal('accounts'), status: z.literal('ok') })
@@ -158,26 +172,44 @@ export const CreateAccountRequest = z.object({
* writes the player-facing sentence and there's no reason to write it twice.
*/
/** Zod check that defers to the shared name rule, message and all. */
/**
* Zod check that defers to the shared name rule, message and all, and then refuses a name
* with a swear in it — the same filter, and the same word list, as `api`'s
* `POST /api/sanitize/v1/isPure`.
*
* Shape first, profanity second: a name that already broke the charset rule gets the one
* 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) =>
z
.string()
.trim()
.superRefine((value, ctx) => {
const rejection = nameRejection(value, label, max)
if (rejection !== null) ctx.addIssue({ code: 'custom', message: rejection })
if (rejection !== null) {
ctx.addIssue({ code: 'custom', message: rejection })
} else if (nameContainsSwears(value)) {
// Deliberately vague about WHICH word: naming it back to the player prints the
// swear in the UI, and the player knows what they typed.
ctx.addIssue({ code: 'custom', message: `Your ${label} can't contain that word.` })
}
})
export const DisplayNameRequest = z.object({
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
.min(1)
.describe('Trimmed; letters and digits only, max 15. Empty or invalid is rejected (400)'),
.describe(
'Trimmed; letters and digits only, max 15, no profanity. Empty or invalid is rejected (400)'
),
})
export const UsernameRequest = z.object({
username: nameCheck('username', MAX_USERNAME_LENGTH)
.min(1, 'You must enter a username.')
.describe('Trimmed; letters and digits only, max 50. Must be unique and changes must remain'),
.describe(
'Trimmed; letters and digits only, max 50, no profanity. Must be unique and changes must remain'
),
})
export const EmailRequest = z.object({
@@ -207,6 +239,16 @@ export const BioRequest = z.object({
bio: z.string().refine(isValidBio).describe('Free text, max 255; empty is allowed'),
})
/**
* `PUT /account/me/emoji` form body. The value must be one of the emoji served by
* `GET /emojiConfig/whitelistedEmojis`; an empty value clears the current pick. Checked
* in the handler rather than here, because the check also CANONICALIZES the value
* (see `resolveWhitelistedEmoji`) and a schema can only accept or reject it.
*/
export const EmojiRequest = z.object({
displayEmoji: z.string().describe('A whitelisted emoji, or "" to clear'),
})
export const ProfileImageRequest = z.object({
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
})
+196 -9
View File
@@ -6,6 +6,8 @@ import '../../accounts.app'
import { SCHEMA_DDL } from '@repo/domain'
import { WHITELISTED_EMOJIS } from '../../whitelisted-emojis'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
@@ -149,6 +151,15 @@ describe('auth-gated endpoints', () => {
expect(res.status).toBe(401)
})
test('GET /account/me rejects a signed token with a non-canonical account subject', async () => {
for (const sub of ['42junk', '42.5', '042', '-42', '9007199254740992']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me`, {
headers: await bearer(sub),
})
expect(res.status, sub).toBe(401)
}
})
test('GET /account/me returns the self account with a valid token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer() })
expect(res.status).toBe(200)
@@ -160,11 +171,11 @@ describe('auth-gated endpoints', () => {
username: 'Player42',
personalPronouns: 0,
identityFlags: 0,
availableUsernameChanges: 1,
availableUsernameChanges: 3,
// An unset email is "", not null — the client reads it as a string, and the
// hub frame this DTO also rides drops null values outright.
email: '',
// Nothing sets these yet, but the key has to be present — the client reads
// Unset on a fresh account, but the key has to be present — the client reads
// both off the account DTO.
bannerImage: '',
displayEmoji: '',
@@ -181,7 +192,7 @@ describe('auth-gated endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/parentalcontrol/me`, {
headers: await bearer(),
})
expect(await res.json()).toEqual({ accountId: 42, disallowInAppPurchases: false })
expect(await res.json()).toEqual({ accountId: 42, disallowInAppPurchases: true })
})
test('GET /accountprivacysettings/:id echoes the id with the privacy flags', async () => {
@@ -214,6 +225,32 @@ describe('auth-gated endpoints', () => {
expect(((await me.json()) as { displayName: string }).displayName).toBe('laskdjfasdlfkj')
})
test('PUT /account/me/displayname 400s on a name with a swear in it', async () => {
const headers = {
...(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
// player typed instead of one.
for (const displayName of ['fuck', 'ShitLord', 'Fucker123']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName }),
headers,
})
expect(res.status).toBe(400)
}
// And the words that merely contain one still get through — refusing these is worse
// than missing a swear, because the player can't see why.
for (const displayName of ['Scunthorpe', 'ClassicCar', 'Cumberland']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
...form({ displayName }),
headers,
})
expect(res.status).toBe(200)
}
})
test('PUT /account/me/username 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username: 'whoever' }),
@@ -235,7 +272,31 @@ describe('auth-gated endpoints', () => {
expect(body.value).toBe('')
})
test('PUT /account/me/username changes the name, decrements the counter, then blocks', async () => {
test('PUT /account/me/username refuses a swear without spending a change', async () => {
const headers = {
...(await bearer('894')),
'Content-Type': 'application/x-www-form-urlencoded',
}
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username: 'ShitLord' }),
headers,
})
expect(res.status).toBe(200)
const body = (await res.json()) as { success: boolean; error: string; value: string }
expect(body.success).toBe(false)
// Vague on purpose — the message must not print the swear back at the player.
expect(body.error).toMatch(/can't contain that word/i)
expect(body.value).toBe('')
// The schema runs before the handler, so a refused name costs none of the account's
// rationed changes.
const me = (await (
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('894') })
).json()) as { username: string; availableUsernameChanges: number }
expect(me.availableUsernameChanges).toBe(3)
})
test('PUT /account/me/username allows three changes, decrements the counter, then blocks', async () => {
const headers = {
...(await bearer('892')),
'Content-Type': 'application/x-www-form-urlencoded',
@@ -260,11 +321,27 @@ describe('auth-gated endpoints', () => {
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('892') })
).json()) as { username: string; availableUsernameChanges: number }
expect(me.username).toBe('coachx')
expect(me.availableUsernameChanges).toBe(0)
expect(me.availableUsernameChanges).toBe(2)
// A second change is blocked — no changes remaining (still HTTP 200).
// The second and third changes consume the rest of the account's allowance.
for (const username of ['coachy', 'coachz']) {
const changed = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username }),
headers,
})
expect(changed.status).toBe(200)
expect(((await changed.json()) as { success: boolean }).success).toBe(true)
}
const exhausted = (await (
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('892') })
).json()) as { username: string; availableUsernameChanges: number }
expect(exhausted.username).toBe('coachz')
expect(exhausted.availableUsernameChanges).toBe(0)
// A fourth change is blocked — no changes remaining (still HTTP 200).
const blocked = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
...form({ username: 'coachy' }),
...form({ username: 'coachq' }),
headers,
})
expect(blocked.status).toBe(200)
@@ -328,6 +405,91 @@ describe('auth-gated endpoints', () => {
expect(sent.map((n) => (n.data as { bannerImage?: string }).bannerImage)).toContain(key)
})
test('PUT /account/me/emoji persists the emoji and pushes the profile update', async () => {
type Sent = { playerId: number; notificationType: string | number; data: unknown }
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await hub().fetch('http://do/', { method: 'DELETE' })
// Exactly the body the client sends: one urlencoded field (`%F0%9F%A4%AA`).
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
method: 'PUT',
headers: { ...(await bearer('779')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'displayEmoji=%F0%9F%A4%AA',
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true })
// Served back by both the self and public reads — displayEmoji is in the public DTO.
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('779') })
expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('\u{1F92A}')
const pub = await exports.default.fetch(`${ORIGIN}/account/779`)
expect(((await pub.json()) as { displayEmoji: string }).displayEmoji).toBe('\u{1F92A}')
// And it rides the profile-update notification, so it redraws beside the name.
const sent = (await (await hub().fetch('http://do/all')).json()) as Sent[]
expect(sent.length).toBeGreaterThan(0)
expect(sent.map((n) => (n.data as { displayEmoji?: string }).displayEmoji)).toContain(
'\u{1F92A}'
)
})
// The pick is stored in the whitelist's CANONICAL form. A client that posts the emoji
// without its U+FE0F variation selector means the same pick, but storing what arrived
// would leave a string the picker list no longer matches, so the current pick would
// stop highlighting.
test('PUT /account/me/emoji canonicalizes a pick sent without its variation selector', async () => {
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '\u{2764}' }),
headers: { ...(await bearer('780')), 'Content-Type': 'application/x-www-form-urlencoded' },
})
expect(res.status).toBe(200)
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('780') })
const stored = ((await me.json()) as { displayEmoji: string }).displayEmoji
expect(stored).toBe('\u{2764}\u{FE0F}')
expect(WHITELISTED_EMOJIS).toContain(stored)
})
// An empty value clears the pick rather than 400ing — that's how the picker's "none"
// gets back to no emoji at all.
test('PUT /account/me/emoji clears the pick on an empty value', async () => {
const set = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '\u{1F389}' }),
headers: { ...(await bearer('781')), 'Content-Type': 'application/x-www-form-urlencoded' },
})
expect(set.status).toBe(200)
const cleared = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '' }),
headers: { ...(await bearer('781')), 'Content-Type': 'application/x-www-form-urlencoded' },
})
expect(cleared.status).toBe(200)
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('781') })
expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('')
})
// displayEmoji renders beside the display name, so an unchecked field would be a
// free-text label on every profile. Off-list values are refused, not stored.
test('PUT /account/me/emoji 401s without a token, 400s on an off-list value', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji: '\u{1F92A}' }),
})
expect(anon.status).toBe(401)
for (const displayEmoji of ['not an emoji', '\u{1F92A}\u{1F92A}', '\u{1F595}\u{1F3FB}']) {
const res = await exports.default.fetch(`${ORIGIN}/account/me/emoji`, {
...form({ displayEmoji }),
headers: { ...(await bearer('782')), 'Content-Type': 'application/x-www-form-urlencoded' },
})
expect(res.status).toBe(400)
}
// Nothing was stored by the refusals.
const me = await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('782') })
expect(((await me.json()) as { displayEmoji: string }).displayEmoji).toBe('')
})
test('PUT /account/me/bannerimage 401s without a token, 400s without an imageName', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/account/me/bannerimage`, {
...form({ imageName: 'x.jpg' }),
@@ -488,6 +650,7 @@ describe('auth-gated endpoints', () => {
'GET /account/{id}',
'GET /account/{id}/bio',
'GET /accountprivacysettings/{id}',
'GET /emojiConfig/whitelistedEmojis',
'GET /parentalcontrol/me',
'POST /account/create',
'POST /account/me/email',
@@ -495,6 +658,7 @@ describe('auth-gated endpoints', () => {
'PUT /account/me/bannerimage',
'PUT /account/me/bio',
'PUT /account/me/displayname',
'PUT /account/me/emoji',
'PUT /account/me/identityflags',
'PUT /account/me/personalpronouns',
'PUT /account/me/profileimage',
@@ -565,12 +729,12 @@ describe('name, email and bio validation', () => {
expect(body.value).toBe('')
}
// The rationed change must NOT be spent by a refusal: an account starts with one,
// A rationed change must NOT be spent by a refusal: an account starts with three,
// and burning it on a typo would leave the player stuck with a name they never had.
const me = (await (
await exports.default.fetch(`${ORIGIN}/account/me`, { headers: await bearer('8801') })
).json()) as { availableUsernameChanges: number }
expect(me.availableUsernameChanges).toBe(1)
expect(me.availableUsernameChanges).toBe(3)
// 50 is the client's own cap, so a name that long has to be accepted.
const ok = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
@@ -683,3 +847,26 @@ test('POST /account/me/phone stores an E.164 number exactly as the client sends
// Verbatim — no normalising, no stripping of the +.
expect(row?.phone).toBe('+15552223333')
})
// The emoji picker. Served as a BARE array — the client parses the response body itself
// as the list, so wrapping it in `{ value: [...] }` or the success envelope every
// mutation here uses would leave the picker empty.
test('GET /emojiConfig/whitelistedEmojis serves the list as a bare array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/emojiConfig/whitelistedEmojis`)
expect(res.status).toBe(200)
const body = (await res.json()) as string[]
expect(Array.isArray(body)).toBe(true)
expect(body).toEqual(WHITELISTED_EMOJIS)
// Order is the picker's order, and the first entry anchors it.
expect(body[0]).toBe('😀')
// Every entry is a non-empty string and appears once — a duplicate draws twice in
// the grid, and the list is compared against `displayEmoji` as an exact string.
expect(body.every((e) => typeof e === 'string' && e.length > 0)).toBe(true)
expect(new Set(body).size).toBe(body.length)
})
// Not auth-gated: the client asks for the picker before it has a token in hand.
test('GET /emojiConfig/whitelistedEmojis needs no bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/emojiConfig/whitelistedEmojis`)
expect(res.status).toBe(200)
})
+312
View File
@@ -0,0 +1,312 @@
/**
* The emoji a player may pick as their `displayEmoji` — served verbatim as a bare JSON
* array by `GET /emojiConfig/whitelistedEmojis`, with no envelope around it.
*
* ORDER IS THE PICKER'S ORDER: the client renders the array as it arrives, so this is
* grouped by theme (smileys, hearts, gestures, animals, food, weather, tech, music,
* sport, objects, vehicles, symbols). Sorting it would reshuffle the grid; append a new
* entry to the end of the group it belongs to instead.
*
* Entries are UNIQUE — a repeat draws the same emoji twice in the grid — and are stored
* as the exact code-point sequence the client sends back, variation selectors (U+FE0F)
* and zero-width joiners included: `displayEmoji` is compared as a plain string, so
* `\u{2764}` and `\u{2764}\u{FE0F}` are two different values and only the form listed
* here round-trips.
*/
export const WHITELISTED_EMOJIS: string[] = [
'😀',
'😃',
'😄',
'😁',
'😆',
'😅',
'😂',
'🤣',
'😊',
'😇',
'🙂',
'🙃',
'😉',
'😍',
'🥰',
'😘',
'😗',
'😚',
'😋',
'😛',
'😜',
'🤪',
'😝',
'🤑',
'🤗',
'🤭',
'🤫',
'🤔',
'🤨',
'😐',
'😑',
'😶',
'🙄',
'😏',
'😣',
'😥',
'😮',
'🤐',
'😯',
'😪',
'😫',
'🥱',
'😴',
'😌',
'🤓',
'😎',
'🤩',
'🥳',
'😤',
'😭',
'😢',
'🥺',
'😡',
'🤬',
'😱',
'😨',
'😰',
'😬',
'🤯',
'🥶',
'🥵',
'🤠',
'🤖',
'👽',
'👻',
'💀',
'☠️',
'👹',
'👺',
'👾',
'❤️',
'🧡',
'💛',
'💚',
'💙',
'💜',
'🖤',
'🤍',
'🤎',
'💖',
'💗',
'💓',
'💕',
'💞',
'💘',
'💝',
'💟',
'❣️',
'💔',
'👍',
'👎',
'👌',
'✌️',
'🤞',
'🤟',
'🤘',
'🤙',
'👏',
'🙌',
'👐',
'🤲',
'🙏',
'👋',
'✋',
'🤚',
'🫶',
'💪',
'🧠',
'👀',
'👁️',
'👄',
'🦾',
'🦿',
'🐶',
'🐱',
'🐭',
'🐹',
'🐰',
'🦊',
'🐻',
'🐼',
'🐨',
'🐯',
'🦁',
'🐸',
'🐵',
'🐧',
'🐦',
'🦅',
'🦆',
'🦄',
'🐴',
'🐢',
'🐙',
'🦈',
'🐬',
'🐳',
'🦋',
'🐝',
'🐞',
'🦖',
'🦕',
'🐲',
'🍎',
'🍌',
'🍇',
'🍉',
'🍓',
'🍒',
'🥝',
'🍍',
'🥑',
'🌮',
'🍕',
'🍔',
'🍟',
'🌭',
'🥪',
'🍗',
'🍿',
'🍩',
'🍪',
'🎂',
'🍫',
'🍬',
'🍭',
'🧋',
'☕',
'🥤',
'🍺',
'🥛',
'☀️',
'🌤️',
'⛅',
'🌥️',
'☁️',
'🌧️',
'⛈️',
'❄️',
'🌈',
'⭐',
'🌟',
'✨',
'⚡',
'🔥',
'💧',
'🌊',
'🌸',
'🌹',
'🍀',
'🌲',
'🎮',
'🕹️',
'💻',
'⌨️',
'🖥️',
'📱',
'🖱️',
'🎧',
'📷',
'📹',
'💿',
'💾',
'🔋',
'🔌',
'🛰️',
'🚀',
'🎵',
'🎶',
'🎼',
'🎤',
'🥁',
'🎸',
'🎹',
'🎺',
'🎷',
'🎻',
'⚽',
'🏀',
'🏈',
'⚾',
'🎾',
'🏐',
'🏓',
'🥊',
'🏆',
'🥇',
'🥈',
'🥉',
'💎',
'💰',
'💸',
'🪙',
'🎁',
'📦',
'🔑',
'🗝️',
'🛡️',
'⚔️',
'🧸',
'🎈',
'🎉',
'🎊',
'🕯️',
'💡',
'📚',
'📖',
'✏️',
'🖊️',
'🚗',
'🚕',
'🚌',
'🚓',
'🚑',
'🚒',
'🏎️',
'🚲',
'✈️',
'🚁',
'🚢',
'✔️',
'✅',
'❌',
'⭕',
'❗',
'❓',
'💯',
'♾️',
'🔔',
'🔕',
'❤️‍🔥',
]
/**
* The whitelist keyed by its FE0F-stripped form, so a lookup tolerates the one way the
* client legitimately disagrees with this list: U+FE0F is a PRESENTATION hint, and
* `\u{2764}`/`\u{2764}\u{FE0F}` are the same picked emoji even though they are different
* strings. Verified collision-free — stripping FE0F maps the 271 entries onto 271
* distinct keys — so the fold can never make two picks ambiguous.
*/
const BY_STRIPPED = new Map(
WHITELISTED_EMOJIS.map((emoji) => [emoji.replaceAll('\u{FE0F}', ''), emoji])
)
/**
* Resolve a client-submitted emoji to its CANONICAL whitelist entry, or null when it
* isn't on the list. Storing the canonical form (rather than what arrived) is what keeps
* a stored `displayEmoji` string-equal to the picker entry it came from — the client
* highlights the current pick by comparing against the list it fetched.
*
* Whitelisting matters here beyond tidiness: `displayEmoji` renders beside the display
* name, so an unchecked field is a free-text label on every player's profile.
*/
export function resolveWhitelistedEmoji(input: string): string | null {
return BY_STRIPPED.get(input.replaceAll('\u{FE0F}', '')) ?? null
}
+2 -2
View File
@@ -19,7 +19,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -31,6 +31,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+27 -23
View File
@@ -103,7 +103,7 @@ const app = new Hono<App>()
.get(
'/gameai/user/access',
describeRoute({
tags: ['Game AI'],
tags: ['Game AI', '2025'],
summary: 'May the caller use Game AI here?',
description: [
'Asked before the client offers any Game AI feature in a room. This server hosts no',
@@ -136,7 +136,7 @@ const app = new Hono<App>()
.get(
'/gameai/room/:roomId{[0-9]+}/spendsummary',
describeRoute({
tags: ['Game AI'],
tags: ['Game AI', '2025'],
summary: 'A rooms Game AI spend summary',
description: [
'What a room has spent of its Game AI budget. Refused with the same 200-plus-',
@@ -169,7 +169,7 @@ const app = new Hono<App>()
.get(
'/roomieai/user/access',
describeRoute({
tags: ['Roomie AI'],
tags: ['Roomie AI', '2025'],
summary: 'The callers Roomie AI energy budget',
description: [
'What Roomie may spend: an energy ceiling, what is left of it, and when it next',
@@ -209,7 +209,7 @@ const app = new Hono<App>()
.get(
'/roomieai/user/facts',
describeRoute({
tags: ['Roomie AI'],
tags: ['Roomie AI', '2025'],
summary: 'What Roomie knows about the caller',
description: [
'The memory Roomie is primed with: `UserContext`, a prose profile written from past',
@@ -235,28 +235,32 @@ const app = new Hono<App>()
}
)
// Whether the caller may use Maker AI at all. Always false: no model runs behind this
// worker, so the honest answer is that the feature isn't available — and false is what
// leaves the creation UI in its normal state rather than offering a tool that can't
// work. (The balances below are still served: the client reads its usage meter
// separately, and a server that bills nothing has spent nothing.)
// Whether the caller may use Maker AI at all. Granted, like the Roomie budget reads and
// unlike the Game AI checks: this is a gate, not a model call, and refusing it hides the
// feature outright. (The balances below are still zeroed: the client reads its usage
// meter separately, and a server that bills nothing has spent nothing.)
//
// The body is a BARE JSON `false` — not an envelope, unlike the Game AI refusal and the
// Roomie access check on either side of it. `econ`'s
// `/api/makerai/checkfreetrialeligibility` answers the same bare shape.
// The envelope is its own shape again — PascalCase `Success`/`Error` beside a snake_case
// `error_id`, which is neither the Game AI refusal's all-lowercase body nor the Roomie
// check's `{ success, error_id, error, value }`. Reproduced as the reference sends it;
// the mixed casing is not a typo to tidy up.
.get(
'/makerai/user/access',
describeRoute({
tags: ['Maker AI'],
tags: ['Maker AI', '2025'],
summary: 'May the caller use Maker AI?',
description: [
'Asked before the client offers Maker AI. Always `false` — no model runs behind this',
'worker. The body is a bare JSON boolean, not the `{ success, error, value }` envelope',
'the neighbouring checks answer with.',
'Asked before the client offers Maker AI. Always granted — the gate is about',
'entitlement, not capacity, and nothing here meters what Maker AI would cost.',
'',
'`roomInstanceSpecificCheck` (the client sends .NETs `False`) is accepted and ignored:',
'it asks whether the check is about the instance the player is standing in rather than',
'the account, and the answer is the same either way. The token is still validated first.',
'The envelope carries PascalCase `Success`/`Error` next to a snake_case `error_id`,',
'which matches neither neighbour on this worker. That mix is what the reference sends;',
'it is not an inconsistency to clean up.',
'',
'`roomInstanceSpecificCheck` (the client sends .NETs `True`/`False`) is accepted and',
'ignored: it asks whether the check is about the instance the player is standing in',
'rather than the account, and the answer is the same either way. The token is still',
'validated first.',
].join(' '),
security: AUTHED,
parameters: [
@@ -266,7 +270,7 @@ const app = new Hono<App>()
),
],
responses: {
200: json(MakerAiAccessResponse, 'Always `false`'),
200: json(MakerAiAccessResponse, 'Always granted'),
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -274,7 +278,7 @@ const app = new Hono<App>()
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(false)
return c.json({ Success: true, Error: null, error_id: null })
}
)
@@ -283,7 +287,7 @@ const app = new Hono<App>()
.get(
'/makerai/user/balances',
describeRoute({
tags: ['Maker AI'],
tags: ['Maker AI', '2025'],
summary: 'The callers Maker AI usage balances',
description: [
'What Maker AI has cost the caller. Live, these meter model usage in DOLLARS against',
@@ -327,7 +331,7 @@ const app = new Hono<App>()
.post(
'/realtime-session/create',
describeRoute({
tags: ['Roomie AI'],
tags: ['Roomie AI', '2025'],
summary: 'Open a realtime AI session',
description: [
'Posted when the player actually pulls out an assistant. Live, this mints a short-',
+10 -5
View File
@@ -113,12 +113,17 @@ export const GameAiSpendSummaryDenied = GameAiAccessDenied.extend({
})
/**
* `GET /makerai/user/access` — a BARE JSON boolean, not an envelope and not a `{ value }`
* wrapper. The whole body is the answer.
* `GET /makerai/user/access` — always granted, in an envelope that belongs to this endpoint
* alone: PascalCase `Success`/`Error` beside a snake_case `error_id`, and no `value` slot.
* It is neither the Game AI refusal's all-lowercase body nor the Roomie check's
* `{ success, error_id, error, value }`. Reproduced as the reference sends it — the casing
* mix is not a typo to normalise.
*/
export const MakerAiAccessResponse = z
.boolean()
.describe('Whether the caller may use Maker AI; always false — no model runs here')
export const MakerAiAccessResponse = z.object({
Success: z.boolean().describe('Whether the caller may use Maker AI. Always true'),
Error: z.null().describe('The failure message. Null — the check always passes'),
error_id: z.null().describe('The failure code. Null — the check always passes'),
})
/**
* Maker AI's dollar balances. A FLAT body — no `{ success, error, value }` envelope — and
+11 -9
View File
@@ -175,29 +175,31 @@ describe('GET /roomieai/user/facts', () => {
})
describe('GET /makerai/user/access', () => {
// Always false — nothing here runs a model. The body is the boolean itself, not an
// envelope, matching econ's `/api/makerai/checkfreetrialeligibility`.
it('refuses access with a bare false', async () => {
const res = await SELF.fetch(`${ORIGIN}/makerai/user/access?roomInstanceSpecificCheck=False`, {
// Granted, and pinned whole: the casing is mixed on purpose (PascalCase `Success`/`Error`
// beside a snake_case `error_id`) and matches neither neighbour on this worker, so a
// "consistency" edit has to fail here rather than on the client.
it('grants access', async () => {
const res = await SELF.fetch(`${ORIGIN}/makerai/user/access?roomInstanceSpecificCheck=True`, {
headers: await bearer(),
})
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('application/json')
expect(await res.text()).toBe('false')
expect(await res.json()).toEqual({ Success: true, Error: null, error_id: null })
})
it('answers the same without the query param', async () => {
// `roomInstanceSpecificCheck` is ignored, so its presence, absence and value change
// nothing.
const granted = { Success: true, Error: null, error_id: null }
const res = await SELF.fetch(`${ORIGIN}/makerai/user/access`, { headers: await bearer() })
expect(await res.text()).toBe('false')
const trueCheck = await SELF.fetch(
`${ORIGIN}/makerai/user/access?roomInstanceSpecificCheck=True`,
expect(await res.json()).toEqual(granted)
const falseCheck = await SELF.fetch(
`${ORIGIN}/makerai/user/access?roomInstanceSpecificCheck=False`,
{
headers: await bearer(),
}
)
expect(await trueCheck.text()).toBe('false')
expect(await falseCheck.json()).toEqual(granted)
})
it('401s without a bearer token', async () => {
+2
View File
@@ -8,6 +8,8 @@ API surface. Database-backed queries and on-disk JSON files are stubbed for now
- **Auth-gated routes** validate the Bearer JWT issued by the `auth` worker
(same dev secret, see `src/jwt.ts`) and 401 when it's missing/invalid.
- **API-owned uploads** enforce `RECFLARE_MAX_API_UPLOAD_BYTES` per file (64 MiB
by default) before copying a parsed file into an `ArrayBuffer` or writing it to R2.
- **Static data** is served verbatim:
- `src/default-avatar-items.ts``GET /api/avatar/v4/items`
- `src/default-settings.ts``GET /api/settings/v2`
@@ -15,6 +15,10 @@
-- Partial index: event reports are a small minority of rows, so indexing only the ones
-- that name an event keeps "reports against this event" off a full scan without paying
-- for the NULLs.
--
-- SUPERSEDED by 0016_report_invention.sql, which DROPS that index: nothing ever queried
-- `event_id`, so it only cost writes. Left here so an unmigrated database still applies
-- the migrations in order and ends up in the same place.
ALTER TABLE report ADD COLUMN event_id INTEGER;
CREATE INDEX IF NOT EXISTS idx_report_event ON report (event_id) WHERE event_id IS NOT NULL;
@@ -0,0 +1,8 @@
-- The room_id index behind a room's event shelf (`GET /api/playerevents/v1/room/{roomId}`).
-- Owned by the `api` worker; generated from src/events-db.ts (SCHEMA_DDL) — keep in sync.
--
-- The column itself has been on the `event` table since 0006; only the index is new. The
-- club feed has had one since that migration and the room feed now reads the same way, so
-- without this a room's shelf scans every event in the database.
CREATE INDEX IF NOT EXISTS idx_event_room ON event (room_id);
+50
View File
@@ -0,0 +1,50 @@
-- Player reputation (the cheer counters on a profile) and the daily cheer credit that
-- pays for handing one out. Both owned by the `api` worker, which is the only reader and
-- the only writer — `POST /api/PlayerCheer/v1/create` writes them and the
-- `/api/playerReputation/…` reads serve them. Generated from src/reputation-db.ts
-- (SCHEMA_DDL) — keep in sync.
--
-- Two tables because they answer two different questions. `reputation` is what a player
-- has RECEIVED: one counter per cheer category. `player_cheer` is what they have left to
-- GIVE, refilling to 20 once the window in `created` is a day old — a lazy reset, so
-- nothing has to run on a schedule.
--
-- Neither row is created until it is needed: a missing `reputation` row means nobody has
-- cheered that player, which is the all-zero record the endpoints already served, and a
-- missing `player_cheer` row means they have never spent a cheer, i.e. full credit. So
-- reads fall back to the defaults instead of inserting on a GET.
--
-- `CheerCredit` on the DTO is NOT a column here even though the client's record carries it
-- next to the counters: it is `player_cheer.cheers_left` with the rollover applied. Storing
-- it in both places would let the number a player reads drift from the one the spend checks.
--
-- `noteriety` keeps the reference's spelling (the client's field is `Noteriety`). It and
-- the subscriber counts are stored but nothing writes them yet — they are per-player
-- numbers that will have a source one day, so the column is here and turning them on later
-- is a write rather than a migration.
--
-- `IsCheerful` and `SelectedCheer` were left off here on the theory that nothing varied
-- them per player; 0014 adds them — `SelectedCheer` is written by `SetSelectedCheer`.
CREATE TABLE IF NOT EXISTS reputation (
account_id INTEGER PRIMARY KEY,
noteriety INTEGER NOT NULL DEFAULT 0,
cheer_general INTEGER NOT NULL DEFAULT 0,
cheer_helpful INTEGER NOT NULL DEFAULT 0,
cheer_creative INTEGER NOT NULL DEFAULT 0,
cheer_great_host INTEGER NOT NULL DEFAULT 0,
cheer_sportsman INTEGER NOT NULL DEFAULT 0,
subscriber_count INTEGER NOT NULL DEFAULT 0,
subscribed_count INTEGER NOT NULL DEFAULT 0
);
-- `created` is the START of the live credit window, not the row's creation time: spending
-- a cheer inside a window leaves it alone, so a player refills 24h after their FIRST cheer
-- rather than sliding the deadline forward with every one they hand out. Stored as an
-- ISO-8601 UTC string, which is fixed-width and so orders correctly under SQLite's plain
-- string comparison — the spend compares against a cutoff without any date functions.
CREATE TABLE IF NOT EXISTS player_cheer (
player_id INTEGER PRIMARY KEY,
cheers_left INTEGER NOT NULL,
created TEXT NOT NULL
);
@@ -0,0 +1,12 @@
-- The two profile fields 0013 left off the `reputation` table on the theory that nothing
-- varied them per player. Owned by the `api` worker; generated from src/reputation-db.ts
-- (SCHEMA_DDL) — keep in sync.
--
-- `selected_cheer` is the cheer a player has PINNED to their profile, written by
-- `POST /api/PlayerCheer/v1/SetSelectedCheer` (form `CheerCategory`), which every reference
-- server stores per player — 0013's "no endpoint sets one" was wrong. `is_cheerful` is the
-- profile flag the client's DTO and `ReputationUpdate` frame both carry, read straight off
-- the record like every reference does; it is a column so it can vary one day without a
-- migration, defaulted true because that is what every reference serves.
ALTER TABLE reputation ADD COLUMN is_cheerful INTEGER NOT NULL DEFAULT 1;
ALTER TABLE reputation ADD COLUMN selected_cheer INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,33 @@
-- Custom avatar items (the player-designed shirts/hats built on a base catalog item).
-- Owned by the `api` worker: `POST /api/customAvatarItems/v1` inserts a row. Generated
-- from src/custom-avatar-items-db.ts (SCHEMA_DDL) — keep in sync.
--
-- One column per field of the client's `CustomAvatarItem` DTO, so a row IS the response.
-- The two uploads (the design and the thumbnail PNG) live in the `recflare-img` bucket
-- under `avatar-item/<date>/<id>-thumb.png` / `<id>-design.png`; the filename columns
-- hold those bucket keys.
--
-- `ranking_context` and `purchase_info` are served as null and `current_saves` as an
-- empty list; none of them has a source yet, so they are not columns.
CREATE TABLE IF NOT EXISTS custom_avatar_item (
custom_avatar_item_id TEXT PRIMARY KEY,
creator_account_id INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
price INTEGER NOT NULL DEFAULT 0,
accessibility INTEGER NOT NULL DEFAULT 0,
force_cannot_publish INTEGER NOT NULL DEFAULT 0,
is_featured INTEGER NOT NULL DEFAULT 0,
is_rec_room_approved INTEGER NOT NULL DEFAULT 0,
base_avatar_item_id INTEGER NOT NULL,
base_avatar_item_color TEXT NOT NULL,
design_filename TEXT NOT NULL,
thumbnail_image_filename TEXT NOT NULL,
created_at TEXT NOT NULL,
modified_at TEXT NOT NULL,
preview_orientation INTEGER NOT NULL DEFAULT 0,
outfit_type INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id);
@@ -0,0 +1,22 @@
-- Reporting an INVENTION (`POST /api/inventions/v1/report`) reuses the report table, as
-- the event report next to it does: same fields, same moderation life — a moderator
-- acting on one sets `banned` on the row exactly as they would for a player report.
-- Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `invention_id` names the reported invention; NULL on every other kind of report. It
-- sits beside `event_id` and the two are mutually exclusive: a row names an event, or an
-- invention, or neither (an ordinary player report), which is what tells the kinds apart.
--
-- The row's `reported_player_id` is the invention's CREATOR, read from the invention
-- rather than sent by the client — the column is NOT NULL, and "who is answerable for
-- this invention" is the only honest answer. No `room_id`: an invention is not tied to
-- one room the way an event is, so there is nothing to fill it in from.
--
-- Neither id column is INDEXED. Both are written on every report of their kind and read
-- by nothing — no query in any worker filters on either, and the moderation reads that do
-- exist go by player (`idx_report_reported`) or by the ban flag. So the partial index
-- 0011 built over `event_id` is dropped here rather than being mirrored for
-- `invention_id`: it only cost writes. Add one back with the query that needs it.
ALTER TABLE report ADD COLUMN invention_id INTEGER;
DROP INDEX IF EXISTS idx_report_event;
@@ -0,0 +1,26 @@
-- Reporting a CUSTOM AVATAR ITEM (`POST /api/customAvatarItems/v1/{id}/report`) reuses the
-- report table, exactly as the event and invention reports beside it do: same fields, same
-- moderation life — a moderator acting on one sets `banned` on the row as they would for a
-- player report. Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync.
--
-- `custom_avatar_item_id` names the reported item. TEXT, not INTEGER, because a custom
-- avatar item is keyed by a GUID (`aeef6bfa-09b1-4859-a77c-47a6e7523543`) where an event and
-- an invention are keyed by a number — the three columns are the same idea in three key
-- types, which is why they are three columns rather than one polymorphic id.
--
-- NULL on every other kind of report. It joins `event_id` and `invention_id`, and all three
-- are mutually exclusive: a row names an event, an invention, an item, or none of them (an
-- ordinary player report), which is what tells the kinds apart.
--
-- The row's `reported_player_id` is the item's CREATOR, read from the item rather than sent
-- by the client. The client sends `ReportedPlayerId: null` on this route — it does not know
-- who made the item — and the column is NOT NULL, so deriving it is the only way to fill it,
-- and "who is answerable for this item" is the only honest answer anyway.
--
-- No `room_id`: an item is not tied to one room the way an event is.
--
-- NOT indexed, for the same reason 0016 gave and then dropped 0011's index over `event_id`:
-- it is written on every report of this kind and read by nothing. Every moderation read goes
-- by player (`idx_report_reported`) or by the ban flag. Add an index with the query needing it.
ALTER TABLE report ADD COLUMN custom_avatar_item_id TEXT;
@@ -0,0 +1,13 @@
-- A player's interaction with an invention. One row per (player, invention); `cheered`
-- is toggled in place and the invention JSON's denormalized `CheerCount` is resynced
-- after every write. Generated from src/inventions-db.ts (SCHEMA_DDL) — keep in sync.
CREATE TABLE IF NOT EXISTS invention_interaction (
player_id INTEGER NOT NULL,
invention_id INTEGER NOT NULL,
cheered INTEGER NOT NULL DEFAULT 0,
created_at TEXT,
PRIMARY KEY (player_id, invention_id)
);
CREATE INDEX IF NOT EXISTS idx_invention_interaction_invention
ON invention_interaction (invention_id);
+2 -2
View File
@@ -22,7 +22,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -34,6 +34,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+2
View File
@@ -15,6 +15,8 @@ export type Env = SharedHonoEnv & {
* for local dev and tests.
*/
DOMAIN: string
/** Maximum accepted size of each API-owned image upload, in bytes. */
RECFLARE_MAX_API_UPLOAD_BYTES?: 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
+437
View File
@@ -0,0 +1,437 @@
/**
* Custom avatar items — player-designed items built on a base catalog item — on the
* shared `recflare` D1 database. One column per field of the client's `CustomAvatarItem`
* DTO, so a row maps straight onto the response.
*
* The two uploads that accompany a creation (the design blob and the thumbnail PNG) live
* in the shared image bucket (`recflare-img`, the `IMAGES` binding) under
* `avatar-item/<date>/<id>-thumb.png` and `<id>-design.png`; the two filename columns hold
* those bucket keys, which the `img` worker serves back by key.
*
* The `api` worker owns the schema/migration (migrations/0015_custom_avatar_item.sql,
* applied under its own `migrations_table`).
*/
/** Schema DDL (mirror of migrations/0015_custom_avatar_item.sql). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS custom_avatar_item (
custom_avatar_item_id TEXT PRIMARY KEY,
creator_account_id INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
price INTEGER NOT NULL DEFAULT 0,
accessibility INTEGER NOT NULL DEFAULT 0,
force_cannot_publish INTEGER NOT NULL DEFAULT 0,
is_featured INTEGER NOT NULL DEFAULT 0,
is_rec_room_approved INTEGER NOT NULL DEFAULT 0,
base_avatar_item_id INTEGER NOT NULL,
base_avatar_item_color TEXT NOT NULL,
design_filename TEXT NOT NULL,
thumbnail_image_filename TEXT NOT NULL,
created_at TEXT NOT NULL,
modified_at TEXT NOT NULL,
preview_orientation INTEGER NOT NULL DEFAULT 0,
outfit_type INTEGER NOT NULL DEFAULT 0
)`,
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`,
]
/** The client's `CustomAvatarItem` record (PascalCase, as served). */
export interface CustomAvatarItem {
CustomAvatarItemId: string
CreatorAccountId: number
Name: string
Description: string
Price: number
Accessibility: number
ForceCannotPublish: boolean
IsFeatured: boolean
IsRecRoomApproved: boolean
BaseAvatarItemId: number
BaseAvatarItemColor: string
DesignFilename: string
ThumbnailImageFilename: string
CreatedAt: string
ModifiedAt: string
PreviewOrientation: number
RankingContext: null
OutfitType: number
CurrentSaves: never[]
PurchaseInfo: null
}
/** 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. */
customAvatarItemId: string
creatorAccountId: number
name: string
description: string
price: number
baseAvatarItemId: number
baseAvatarItemColor: string
accessibility: number
designFilename: string
thumbnailImageFilename: string
}
interface Row {
custom_avatar_item_id: string
creator_account_id: number
name: string
description: string
price: number
accessibility: number
force_cannot_publish: number
is_featured: number
is_rec_room_approved: number
base_avatar_item_id: number
base_avatar_item_color: string
design_filename: string
thumbnail_image_filename: string
created_at: string
modified_at: string
preview_orientation: number
outfit_type: number
}
function toDto(row: Row): CustomAvatarItem {
return {
CustomAvatarItemId: row.custom_avatar_item_id,
CreatorAccountId: row.creator_account_id,
Name: row.name,
Description: row.description,
Price: row.price,
Accessibility: row.accessibility,
ForceCannotPublish: row.force_cannot_publish === 1,
IsFeatured: row.is_featured === 1,
IsRecRoomApproved: row.is_rec_room_approved === 1,
BaseAvatarItemId: row.base_avatar_item_id,
BaseAvatarItemColor: row.base_avatar_item_color,
DesignFilename: row.design_filename,
ThumbnailImageFilename: row.thumbnail_image_filename,
CreatedAt: row.created_at,
ModifiedAt: row.modified_at,
PreviewOrientation: row.preview_orientation,
RankingContext: null,
OutfitType: row.outfit_type,
CurrentSaves: [],
PurchaseInfo: null,
}
}
/** Inserts a new custom avatar item and returns it as the client's DTO. */
export async function createCustomAvatarItem(
db: D1Database,
input: CreateCustomAvatarItemInput,
now: Date = new Date()
): Promise<CustomAvatarItem> {
const ts = now.toISOString()
const row = await db
.prepare(
`INSERT INTO custom_avatar_item (
custom_avatar_item_id, creator_account_id, name, description, price, accessibility,
base_avatar_item_id, base_avatar_item_color, design_filename, thumbnail_image_filename,
created_at, modified_at
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)
RETURNING *`
)
.bind(
input.customAvatarItemId,
input.creatorAccountId,
input.name,
input.description,
input.price,
input.accessibility,
input.baseAvatarItemId,
input.baseAvatarItemColor,
input.designFilename,
input.thumbnailImageFilename,
ts
)
.first<Row>()
if (!row) throw new Error('custom_avatar_item insert returned no row')
return toDto(row)
}
/**
* The `ItemType` that names a custom avatar item in a UGC-purchasable reference
* (`POST /api/ugcPurchasables/v1/items/bulk`'s `Ids[].itemType`).
*/
export const UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM = 3
/** A custom avatar item as the client's `UgcPurchasableItem` (the store-facing view). */
export interface UgcPurchasableItem {
ItemType: number
ItemId: string
Name: string
Description: string
ImageName: string
RoomId: number
Price: number
PurchaseCurrencyId: string | null
CreatedAt: string
ModifiedAt: string
}
/**
* The store-facing projection of a custom avatar item. `RoomId` is echoed from the
* request — the item table has no room; what the client wants it for is still unknown.
* `PurchaseCurrencyId` is null (the client's field is nullable) until a currency exists.
*/
export function toUgcPurchasable(item: CustomAvatarItem, roomId: number): UgcPurchasableItem {
return {
ItemType: UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
ItemId: item.CustomAvatarItemId,
Name: item.Name,
Description: item.Description,
ImageName: item.ThumbnailImageFilename,
RoomId: roomId,
Price: item.Price,
PurchaseCurrencyId: null,
CreatedAt: item.CreatedAt,
ModifiedAt: item.ModifiedAt,
}
}
/** Fetches the items with these ids, in the order asked; unknown ids are skipped. */
export async function getCustomAvatarItems(
db: D1Database,
ids: string[]
): Promise<CustomAvatarItem[]> {
if (ids.length === 0) return []
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT * FROM custom_avatar_item WHERE custom_avatar_item_id IN (${placeholders})`)
.bind(...ids)
.all<Row>()
const byId = new Map(results.map((r) => [r.custom_avatar_item_id, toDto(r)]))
return ids.flatMap((id) => byId.get(id) ?? [])
}
/**
* The featured feed (`GET /api/customAvatarItems/v1/featured`): items flagged
* `is_featured` that are also published — `Accessibility` 0 is the unpublished state, so
* those are excluded even when flagged. Newest first. Nothing sets the flag yet, so the
* feed is empty until an operator writes `is_featured = 1`.
*/
export async function listFeaturedCustomAvatarItems(
db: D1Database,
limit = 50
): Promise<CustomAvatarItem[]> {
const { results } = await db
.prepare(
`SELECT * FROM custom_avatar_item WHERE is_featured = 1 AND accessibility != 0
ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1`
)
.bind(limit)
.all<Row>()
return results.map(toDto)
}
/**
* The "hot" (trending) feed (`GET /api/customAvatarItems/v1/hot`): every PUBLISHED item —
* `Accessibility` 0 is the unpublished state and is the only thing held back. Newest
* first, standing in for a trend ranking there is nothing to compute one from yet (no
* purchase or wear counts are recorded).
*/
export async function listHotCustomAvatarItems(
db: D1Database,
limit = 50
): Promise<CustomAvatarItem[]> {
const { results } = await db
.prepare(
`SELECT * FROM custom_avatar_item WHERE accessibility != 0
ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1`
)
.bind(limit)
.all<Row>()
return results.map(toDto)
}
/**
* The "Coach" system account — this server's stock content is authored by it, the same id the
* `econ` worker attributes a self-buy or an anonymous gift to.
*/
export const COACH_ACCOUNT_ID = 1
/** What `GET /api/customAvatarItems/v1/search` narrows the catalog by. */
export interface CustomAvatarItemSearch {
/** Free text, matched against an item's NAME or its DESCRIPTION. Blank means no filter. */
searchQuery?: string
/**
* `OutfitType`s to include. EMPTY means no filter rather than no results: the client sends
* the full set of types it can render, so an absent parameter is "everything", not "nothing".
*/
outfitTypes?: number[]
/** Whether items authored by the Coach — this server's stock content — are included. */
includeCoachItems?: boolean
/** Lowest price to include, inclusive. */
minPrice?: number
/** Highest price to include, inclusive. */
maxPrice?: number
/** Rows to skip, for paging. */
skip?: number
/** Rows to return. Capped at {@link SEARCH_MAX_TAKE}. */
take?: number
}
/** The most rows one search returns, whatever `take` asks for. The client asks for 100. */
export const SEARCH_MAX_TAKE = 200
/**
* The store's item search (`GET /api/customAvatarItems/v1/search`), newest first.
*
* PUBLISHED items only — `Accessibility` 0 is the unpublished state, and this is the browse
* surface everyone shares, so an unpublished item must not appear here even to its creator (who
* has `fromCreator` for that).
*
* `searchQuery` matches an item's NAME or its DESCRIPTION, case-insensitively, as a substring.
* Both sides are lowered rather than relying on `LIKE`, which folds case for ASCII only and
* would miss half of what players type. `%` and `_` in the needle are escaped, so searching for
* a literal one finds it instead of matching everything.
*
* `outfitTypes` is a WHITELIST when non-empty and no filter when empty, which is the opposite of
* how an empty IN () clause reads in SQL: the client sends every type it can render, so treating
* an absent parameter as "match nothing" would empty the store.
*
* Ordered by recency because there is nothing else to order by — no purchase counts, no wear
* counts, no ratings are recorded — which is the same stand-in the `hot` feed makes. The
* `custom_avatar_item_id` tiebreak is what makes paging stable: without it, two items sharing a
* `created_at` can swap places between pages and one is served twice while the other is missed.
*/
export async function searchCustomAvatarItems(
db: D1Database,
search: CustomAvatarItemSearch = {}
): Promise<CustomAvatarItem[]> {
const take = Math.min(Math.max(search.take ?? 50, 0), SEARCH_MAX_TAKE)
const skip = Math.max(search.skip ?? 0, 0)
if (take === 0) return []
const where = ['accessibility != 0']
const binds: Array<number | string> = []
/** Bind a value and get its placeholder, so the numbering can't drift as clauses are added. */
const bind = (value: number | string): string => `?${binds.push(value)}`
const needle = search.searchQuery?.trim() ?? ''
if (needle !== '') {
// Escaped so a needle of LIKE metacharacters matches them literally rather than everything.
const escaped = needle.toLowerCase().replace(/[\\%_]/g, (ch) => `\\${ch}`)
const pattern = bind(`%${escaped}%`)
where.push(
`(lower(name) LIKE ${pattern} ESCAPE '\\' OR lower(description) LIKE ${pattern} ESCAPE '\\')`
)
}
const outfitTypes = search.outfitTypes ?? []
if (outfitTypes.length > 0) {
where.push(`outfit_type IN (${outfitTypes.map((t) => bind(t)).join(', ')})`)
}
if (search.includeCoachItems === false) {
where.push(`creator_account_id != ${bind(COACH_ACCOUNT_ID)}`)
}
if (search.minPrice !== undefined) where.push(`price >= ${bind(search.minPrice)}`)
if (search.maxPrice !== undefined) where.push(`price <= ${bind(search.maxPrice)}`)
const limit = bind(take)
const offset = bind(skip)
const { results } = await db
.prepare(
`SELECT * FROM custom_avatar_item WHERE ${where.join(' AND ')}
ORDER BY created_at DESC, custom_avatar_item_id
LIMIT ${limit} OFFSET ${offset}`
)
.bind(...binds)
.all<Row>()
return results.map(toDto)
}
/**
* What an account has authored (`GET /api/customAvatarItems/v2/fromCreator/:id`), newest
* first, with the total for the client's paginated envelope. `includeUnpublished` is for
* the creator looking at their own shelf: it adds the `Accessibility` 0 items everyone
* else is not shown. Paging is not applied yet (the client sends none), so `TotalResults`
* always equals the list length.
*/
export async function listCustomAvatarItemsByCreator(
db: D1Database,
creatorAccountId: number,
includeUnpublished = false
): Promise<{ Results: CustomAvatarItem[]; TotalResults: number }> {
const { results } = await db
.prepare(
`SELECT * FROM custom_avatar_item
WHERE creator_account_id = ?1 AND (accessibility != 0 OR ?2)
ORDER BY created_at DESC, custom_avatar_item_id`
)
.bind(creatorAccountId, includeUnpublished ? 1 : 0)
.all<Row>()
const items = results.map(toDto)
return { Results: items, TotalResults: items.length }
}
/** The editable fields of `PUT /api/customAvatarItems/v1/:id`; null/undefined = leave alone. */
export interface UpdateCustomAvatarItemInput {
name?: string | null
description?: string | null
price?: number | null
accessibility?: number | null
}
/**
* Applies a partial edit to one item, bumping `modified_at`. Fields the caller leaves
* null keep their value (the client sends every field, nulling the untouched ones).
* Returns the updated item, or null when no row has that id.
*/
export async function updateCustomAvatarItem(
db: D1Database,
id: string,
patch: UpdateCustomAvatarItemInput,
now: Date = new Date()
): Promise<CustomAvatarItem | null> {
const row = await db
.prepare(
`UPDATE custom_avatar_item SET
name = COALESCE(?2, name),
description = COALESCE(?3, description),
price = COALESCE(?4, price),
accessibility = COALESCE(?5, accessibility),
modified_at = ?6
WHERE custom_avatar_item_id = ?1
RETURNING *`
)
.bind(
id,
patch.name ?? null,
patch.description ?? null,
patch.price ?? null,
patch.accessibility ?? null,
now.toISOString()
)
.first<Row>()
return row ? toDto(row) : null
}
/** Deletes one item's row. Returns the deleted item, or null when no row had that id. */
export async function deleteCustomAvatarItem(
db: D1Database,
id: string
): Promise<CustomAvatarItem | null> {
const row = await db
.prepare('DELETE FROM custom_avatar_item WHERE custom_avatar_item_id = ?1 RETURNING *')
.bind(id)
.first<Row>()
return row ? toDto(row) : null
}
/** Fetches one item by id, or null. */
export async function getCustomAvatarItem(
db: D1Database,
id: string
): Promise<CustomAvatarItem | null> {
const row = await db
.prepare('SELECT * FROM custom_avatar_item WHERE custom_avatar_item_id = ?1')
.bind(id)
.first<Row>()
return row ? toDto(row) : null
}
+169 -38
View File
@@ -17,7 +17,12 @@
* relational table rather than a JSON blob.
*/
import { glyphLength, MAX_EVENT_DESCRIPTION_LENGTH, MAX_EVENT_NAME_LENGTH } from '@repo/domain'
import {
glyphLength,
MAX_EVENT_DESCRIPTION_LENGTH,
MAX_EVENT_DURATION_MS,
MAX_EVENT_NAME_LENGTH,
} from '@repo/domain'
/**
* Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql, sans any
@@ -36,6 +41,7 @@ export const SCHEMA_DDL: string[] = [
`CREATE UNIQUE INDEX IF NOT EXISTS idx_event_id ON event (id)`,
`CREATE INDEX IF NOT EXISTS idx_event_creator ON event (creator_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_event_club ON event (club_id)`,
`CREATE INDEX IF NOT EXISTS idx_event_room ON event (room_id)`,
`CREATE INDEX IF NOT EXISTS idx_event_start ON event (start_time)`,
`CREATE TABLE IF NOT EXISTS event_attendee (
event_id INTEGER NOT NULL,
@@ -168,13 +174,28 @@ interface EventRow {
}
/**
* The event as the `v2` envelope carries it: {@link PlayerEventBase} plus `Tags`, a plain
* array of tag NAMES. (The stored tags are `{ tag, type }` pairs, which is what the v1
* read's lowercase `tags` serves.) Defined on top of the base rather than beside it, so the
* feed and the envelope cannot drift apart on the fields they share.
* A tag as the 2023 build's `v2` envelope carries it: the PascalCase form of the stored
* `{ tag, type }` pair. NOT the lowercase pair the v1 read serves — three casings of one
* tag, and the client parses each in exactly one place.
*/
export interface PlayerEventEnvelopeTag {
Tag: string
Type: number
}
/**
* The event as the `v2` envelope carries it: {@link PlayerEventBase} plus `Tags`. Defined
* on top of the base rather than beside it, so the feed and the envelope cannot drift
* apart on the fields they share.
*
* `Tags` is the one field whose shape depends on the caller's BUILD, because Rec Room
* changed it under the same unversioned path rather than minting a `v3`: the 2023 build
* parses `[{ Tag, Type }]` and the 2025 build parses `["celebration"]`. Serving either
* one to the other build leaves the event's tag chips empty — the decoder drops what it
* can't read rather than erroring. {@link toEventResult} picks; nothing else should.
*/
export interface PlayerEventEnvelope extends PlayerEventBase {
Tags: string[]
Tags: string[] | PlayerEventEnvelopeTag[]
}
/**
@@ -193,19 +214,47 @@ export interface PlayerEventResult {
}
/**
* Wrap a stored event and its tags in the `v2` envelope. `tags` are the event's stored tag
* names — pass what `getEventTags` returns, so the answer reflects what was actually
* Wrap a stored event and its tags in the `v2` envelope. `tags` are the event's stored
* tags — pass what `getEventTags` returns, so the answer reflects what was actually
* written rather than what was asked for.
*
* `legacyTags` picks the shape of `PlayerEvent.Tags` for the caller's build (see
* {@link PlayerEventEnvelope}): the 2023 pairs when set, the 2025 names when not. It
* changes nothing else — `TagModifyResult.Tags` is a name list to both builds.
*/
export function toEventResult(event: PlayerEvent, tags: EventTag[] = []): PlayerEventResult {
export function toEventResult(
event: PlayerEvent,
tags: EventTag[] = [],
legacyTags = false
): PlayerEventResult {
const names = tags.map((t) => t.tag)
const carried = legacyTags ? tags.map((t) => ({ Tag: t.tag, Type: t.type })) : names
return {
PlayerEvent: { Tags: names, ...toEventBase(event) },
PlayerEvent: { Tags: carried, ...toEventBase(event) },
Result: 0,
TagModifyResult: { Result: 0, Tags: names },
}
}
/**
* The envelope a DELETE answers with. Both payload fields are null: the reference reports
* only that the delete happened, and the client reads nothing but `Result` — there is no
* event left to redraw. Deliberately NOT {@link toEventResult}'s shape, even though both
* are the v2 envelope.
*/
export interface PlayerEventDeletedResult {
PlayerEvent: null
Result: number
TagModifyResult: null
}
/** The one value {@link PlayerEventDeletedResult} ever takes: a successful delete. */
export const EVENT_DELETED_RESULT: PlayerEventDeletedResult = {
PlayerEvent: null,
Result: 0,
TagModifyResult: null,
}
/**
* The projection of an event carried on a hub notification frame (`PlayerEventCreated`
* and its siblings). Deliberately NOT the stored record, in three ways — don't unify
@@ -241,9 +290,13 @@ export interface PlayerEventNotification {
}
/**
* The client's BASE event — the 17-key shape the browse feed (`GET /api/playerevents/v1`)
* serves, and the same thing the v2 envelope carries once `Tags` is added. PascalCase like
* the stored record, but not identical to it — don't unify them:
* The client's BASE event — the 17-key shape the browse feed (`GET /api/playerevents/v1`),
* the room shelf (`.../room/{roomId}`) and the bulk read (`POST|GET .../bulk`) all serve,
* and the same thing the v2 envelope carries once `Tags` is added. Those three are one
* generic helper over one element type on the client side, so they are shape-identical by
* construction there; `toEventBase` is what holds that here.
*
* PascalCase like the stored record, but not identical to it — don't unify them:
*
* - it drops `State`, which neither the feed nor the envelope carries;
* - it carries `BroadcastingRoomInstanceId`, which the record has no field for (nothing
@@ -251,8 +304,8 @@ export interface PlayerEventNotification {
* - its `ImageName` is a string: an event with no image reads `""`, where the record holds
* null.
*
* The by-id / bulk / search reads serve the stored RECORD verbatim instead, `State` and
* nullable `ImageName` included. Two shapes; keep them apart.
* The by-id, search, searchlive and club reads serve the stored RECORD verbatim instead,
* `State` and nullable `ImageName` included. Two shapes; keep them apart.
*/
export interface PlayerEventBase extends Omit<PlayerEvent, 'State' | 'ImageName'> {
ImageName: string
@@ -315,6 +368,21 @@ function eventTime(ms: number): string {
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
}
/**
* Normalize one posted timestamp into the stored form, or undefined when it isn't a
* usable date.
*
* Exported for the single-field time edit (`PUT …/v2/{id}/time`), which has to tell an
* ABSENT bound — leave the stored one alone — from an unusable one, which it refuses.
* {@link parseEventBody} collapses the two, since a create/update posting rubbish for a
* time is better off defaulting than failing.
*/
export function parseEventTime(raw: unknown): string | undefined {
if (typeof raw !== 'string') return undefined
const parsed = Date.parse(raw)
return Number.isNaN(parsed) ? undefined : eventTime(parsed)
}
/** An event's tags, alphabetical so a list read is stable. */
export async function getEventTags(db: D1Database, eventId: number): Promise<EventTag[]> {
const { results } = await db
@@ -384,26 +452,46 @@ function asInt(value: unknown): number | undefined {
}
/**
* Parse a posted event body into an {@link EventInput}.
* The window a write would end up storing, resolved the way {@link createEvent} and
* {@link updateEvent} resolve it: a bound the body carries wins, otherwise the stored one
* (an edit), otherwise the create defaults — now, and an hour later.
*
* Accepts the event's fields either at the top level or nested under `PlayerEvent`:
* the client posts the same envelope it reads back, and both forms are in circulation.
* A field the body doesn't carry stays undefined (create defaults it, update keeps the
* stored value); an explicit `null` on one of the nullable ids is preserved so it can
* clear the value. Timestamps are normalized here, so an unparseable one is dropped
* rather than stored.
* Exists so the duration rule below and the writes themselves can't drift apart on what
* "the event's window" means for a body that moves only one bound.
*/
function resolvedWindow(
input: EventInput,
existing?: PlayerEvent,
now = Date.now()
): { start: number; end: number } {
const start = Date.parse(input.startTime ?? existing?.StartTime ?? eventTime(now))
const stored = input.endTime ?? existing?.EndTime
return { start, end: stored === undefined ? start + DEFAULT_DURATION_MS : Date.parse(stored) }
}
/**
* Why a parsed event body can't be stored, or `null` when it's fine.
*
* Length only. An event name is a title, not an identifier — "Building a Better Room
* Using Trigonometry" is a real one — so the alphanumeric rule the account and room
* names carry would be wrong here. Absent fields are skipped: an update posts only what
* it changes, and create defaults a missing name rather than refusing it.
* Two rules: the stored lengths, and the window.
*
* The name is measured AFTER trimming, matching what create/update actually store.
* Lengths are a cap, not a charset — an event name is a title, not an identifier
* ("Building a Better Room Using Trigonometry" is a real one), so the alphanumeric rule
* the account and room names carry would be wrong here. Absent fields are skipped: an
* update posts only what it changes, and create defaults a missing name rather than
* refusing it. The name is measured AFTER trimming, matching what the writes store.
*
* The window is checked on what the write RESOLVES to rather than on the fields the body
* carries, which is why `existing` is passed for an edit: moving the start alone still
* has to leave a window that ends after it and runs no longer than
* {@link MAX_EVENT_DURATION_MS}. A create resolves against the same defaults
* {@link createEvent} applies, so a body naming neither bound — or only a start — can
* never fail this.
*
* A backwards window is refused here too. It isn't a duration rule as such, but it's the
* hole in one: `end - start` on a window running a month backwards is negative, which
* would sail past a "no longer than a day" check.
*/
export function eventInputRejection(input: EventInput): string | null {
export function eventInputRejection(input: EventInput, existing?: PlayerEvent): string | null {
const name = input.name?.trim()
if (name !== undefined && glyphLength(name) > MAX_EVENT_NAME_LENGTH) {
return `Event names can be at most ${MAX_EVENT_NAME_LENGTH} characters.`
@@ -414,6 +502,16 @@ export function eventInputRejection(input: EventInput): string | null {
) {
return `Event descriptions can be at most ${MAX_EVENT_DESCRIPTION_LENGTH} characters.`
}
const { start, end } = resolvedWindow(input, existing)
// Unparseable can't happen from `parseEventBody` (it drops what it can't read) but can
// from a stored blob edited by hand; skip the rule rather than refusing an edit that
// says nothing about the times.
if (Number.isNaN(start) || Number.isNaN(end)) return null
if (end < start) return 'An event cannot end before it starts.'
if (end - start > MAX_EVENT_DURATION_MS) {
return `An event can run for at most ${MAX_EVENT_DURATION_MS / (60 * 60 * 1000)} hours.`
}
return null
}
@@ -427,7 +525,7 @@ export function eventInputRejection(input: EventInput): string | null {
* are lowercased (the search matches them lowercased, and `#Workshops` and `#workshops`
* are the same chip), a leading `#` is stripped, and blanks/duplicates are dropped.
*/
function parseEventTags(raw: unknown): EventTag[] | undefined {
export function parseEventTags(raw: unknown): EventTag[] | undefined {
if (!Array.isArray(raw)) return undefined
const byTag = new Map<string, EventTag>()
for (const entry of raw) {
@@ -444,6 +542,16 @@ function parseEventTags(raw: unknown): EventTag[] | undefined {
return [...byTag.values()]
}
/**
* Parse a posted event body into an {@link EventInput}.
*
* Accepts the event's fields either at the top level or nested under `PlayerEvent`:
* the client posts the same envelope it reads back, and both forms are in circulation.
* A field the body doesn't carry stays undefined (create defaults it, update keeps the
* stored value); an explicit `null` on one of the nullable ids is preserved so it can
* clear the value. Timestamps are normalized here, so an unparseable one is dropped
* rather than stored.
*/
export function parseEventBody(body: unknown): EventInput {
const outer = (typeof body === 'object' && body !== null ? body : {}) as Record<string, unknown>
const nested = outer.PlayerEvent
@@ -458,12 +566,7 @@ export function parseEventBody(body: unknown): EventInput {
if (!has(key)) return undefined
return obj[key] === null ? null : asInt(obj[key])
}
const time = (key: string): string | undefined => {
const raw = obj[key]
if (typeof raw !== 'string') return undefined
const parsed = Date.parse(raw)
return Number.isNaN(parsed) ? undefined : eventTime(parsed)
}
const time = (key: string): string | undefined => parseEventTime(obj[key])
const bool = (key: string): boolean | undefined => {
const raw = obj[key]
if (typeof raw === 'boolean') return raw
@@ -757,9 +860,12 @@ export async function getEventById(db: D1Database, eventId: number): Promise<Pla
}
/**
* Several events by id — the bulk fetch. Answers in the order the ids were asked for
* (the client renders them in the order it requested), skipping ids with no row rather
* than leaving a hole. Duplicated ids resolve to the same event.
* Several events by id — the bulk fetch behind `POST /api/playerevents/v1/bulk` (the form
* body the client sends) and the query-string GET on the same path. Answers in the order
* the ids were asked for (the client renders them in the order it requested), skipping ids
* with no row rather than leaving a hole. Duplicated ids resolve to the same event.
*
* Returns stored records; both routes project them with `toEventBase` before serving.
*/
export async function getEventsByIds(db: D1Database, ids: number[]): Promise<PlayerEvent[]> {
if (ids.length === 0) return []
@@ -806,6 +912,31 @@ export async function getEventsByClubs(db: D1Database, clubIds: number[]): Promi
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}
/**
* A room's events — what is happening in this room and what is coming up, soonest first.
* Backs the room's event shelf (`GET /api/playerevents/v1/room/{roomId}`), which serves
* them through `toEventBase` like the browse feed and the bulk read.
*
* FINISHED events are left out, like the browse feed's: this answers "what can I still turn
* up to in this room", and an event that ended last month is not that. Running events count
* as current — the filter is on the END time, so an event stays listed until it is over
* rather than disappearing the moment it starts.
*
* Selected on the indexed room_id column, with the time bound in SQL too: end_time is a
* generated column of an ISO-8601 UTC string, so it compares lexicographically.
*/
export async function getEventsByRoom(
db: D1Database,
roomId: number,
now = Date.now()
): Promise<PlayerEvent[]> {
const { results } = await db
.prepare('SELECT data FROM event WHERE room_id = ?1 AND end_time >= ?2')
.bind(roomId, eventTime(now))
.all<EventRow>()
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
}
/**
* The events happening right now — started and not yet finished. Backs the "happening
* now" browse query. Both bounds compare lexicographically on the generated ISO-8601
+20 -6
View File
@@ -27,13 +27,27 @@ export function unauthorized(c: Context<App>) {
return c.body(null, 401)
}
/** Reads the `Ids` form field into a list of integer ids. */
/**
* Reads the `Ids` form field of a bulk POST into a list of integer ids.
*
* BOTH spellings, because the client uses both: `Ids` REPEATED once per id
* (`Ids=101&Ids=102&Ids=103`, what the player-events bulk sends) and a single
* comma-separated `Ids=1,2,3`. `parseBody({ all: true })` is what keeps the repeated form
* from collapsing to its last value — plain `parseBody()` would answer one id out of
* three, which reads as a short result rather than as an error.
*
* `ids` is accepted alongside `Ids` so a hand-written request doesn't silently come back
* empty. Values that aren't integers are dropped; duplicates and order are left alone,
* since the caller renders them in request order.
*/
export async function parseFormIds(c: Context<App>): Promise<number[]> {
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const ids = body.Ids
if (typeof ids !== 'string') return []
return ids
.split(',')
const body = await c.req
.parseBody({ all: true })
.catch(() => ({}) as Record<string, string | string[] | File | File[]>)
const raw = [body.Ids, body.ids].flat()
return raw
.filter((v): v is string => typeof v === 'string')
.flatMap((v) => v.split(','))
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n))
}
+553 -35
View File
@@ -23,8 +23,8 @@ import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domai
/**
* 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` the "may anyone see this" filter
* every feed shares; json_extract of a JSON `true` is 1, so those columns are 1/0 — and
* 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.
*/
export const SCHEMA_DDL: string[] = [
@@ -39,6 +39,15 @@ export const SCHEMA_DDL: string[] = [
`CREATE UNIQUE INDEX IF NOT EXISTS idx_invention_id ON invention (id)`,
`CREATE INDEX IF NOT EXISTS idx_invention_creator ON invention (creator_player_id)`,
`CREATE INDEX IF NOT EXISTS idx_invention_featured ON invention (is_featured)`,
`CREATE TABLE IF NOT EXISTS invention_interaction (
player_id INTEGER NOT NULL,
invention_id INTEGER NOT NULL,
cheered INTEGER NOT NULL DEFAULT 0,
created_at TEXT,
PRIMARY KEY (player_id, invention_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_invention_interaction_invention
ON invention_interaction (invention_id)`,
]
/** A single saved version of an invention (Rec Room's `RRInventionVersion`). */
@@ -53,6 +62,16 @@ export interface InventionVersion {
ChipsCost: number
CloudVariablesCost: number
AICost: number
/**
* Whether the blob uses content still in beta, sent from `v9/save` on. It sits on the
* VERSION, where the client's own `RRInventionVersion` carries it and for the same
* reason the costs do: it describes the one revision saved, not the invention across
* all of them. `UgcVersion`, which reads like its twin, is an INVENTION field — see
* {@link SavedInvention}. Absent on a record saved through `v6/save`, which sends
* neither; the client's decoder reads a missing member as its default, so an old
* record is not retroactively wrong.
*/
HasBetaContent?: boolean
}
/**
@@ -103,9 +122,32 @@ export interface SavedInvention {
HideFromPlayer: boolean
ReferencedInventions: number[]
/**
* Tags served by `v1/details` and written by `v1/settags`. Optional and unset on
* save: the real `RRInvention` carries no Tags field and the client sends no tags
* when saving, so an untagged invention's DTO stays identical to the real one.
* The rest of what `v9/save` sends, kept beside `ReferencedInventions` — the field
* they most resemble, and the one this record has always carried on the invention.
* Note the v9 client's own `RRInvention` has no `Referenced*` at all (its VERSION
* carries them) and no `LongDescription`/`ConvertedFromInventionId` (it sends both and
* never reads them back); they are stored anyway, because what the client sent is
* worth keeping, and `toSaveResultV9` puts each where that client expects it.
*
* `UgcVersion` is the exception that has to be got right rather than tolerated: it is
* an invention field there, not a version one, next to `CurrentVersionNumber`.
*
* `DisplayMetadataJson` is stored as the opaque string the client sent: it is the
* client's own display state (`{"0":0,"99":0}`), and re-encoding it would be
* inventing a schema for something only the client reads.
*
* Each is absent on a `v6/save` record, which sends none of them.
*/
ReferencedUnityAssetIds?: string[]
UgcVersion?: number
LongDescription?: string
DisplayMetadataJson?: string
ConvertedFromInventionId?: number
/**
* Tags served by `v1/details` and written by `v1/settags`. Optional: the real
* `RRInvention` carries no Tags field, so an untagged invention's DTO stays
* identical to the real one. `v6/save` never sets it (that client tags in a second
* call); `v9/save` sets it when its `tagsRequest` names at least one tag.
*/
Tags?: InventionTag[]
}
@@ -130,6 +172,224 @@ export function toSaveResult(invention: SavedInvention): InventionSaveResult {
return { Status: 0, Invention: invention, InventionVersion: invention.CurrentVersion }
}
/**
* `TagsResponse.Result` on a v9 save — the client's own tag-result enum, whose members
* run Success 0 … ReservedWordViolation 13. Only Success is named: the members between
* were not recovered from the client, and it never reads this field anyway, so a refused
* tag needs only to be something other than Success.
*/
export const INVENTION_TAG_RESULT = {
success: 0,
rejected: 1,
} as const
/**
* The `Invention` inside a v9 save response — the client's 28-key `RRInvention`, which is
* NOT the record this server stores (that one mirrors the older shape the read endpoints
* still serve). The differences that matter: no nested `CurrentVersion` (the version rides
* beside it), no `Referenced*` (they moved onto the version), no `IsPublished` (the client
* infers it from `FirstPublishedAt`), and `UgcVersion`/`LatestVersionNumber`/
* `ForceCannotPublish`/`IsRecRoomApproved` that the stored record has no equivalent for.
*
* The client reads exactly one of these keys — `InventionId` — and its decoder null-checks
* every member and drops the ones it doesn't know, so this projection is about being right
* rather than about being parseable.
*/
export interface InventionV9Dto {
InventionId: number
ReplicationId: string
CreatorPlayerId: number
Name: string
Description: string
ImageName: string
UgcVersion: number
CurrentVersionNumber: number
LatestVersionNumber: number
Accessibility: number
ForceCannotPublish: boolean
ModifiedAt: string
CreatedAt: string
FirstPublishedAt: string | null
CreationRoomId: number | null
NumPlayersHaveUsedInRoom: number
NumDownloads: number
CheerCount: number
CreatorPermission: number
GeneralPermission: number
IsAGInvention: boolean
IsCertifiedInvention: boolean
IsRecRoomApproved: boolean
AllowTrial: boolean
Price: number | null
HideFromPlayer: boolean
DisplayMetadataJson: string | null
}
/**
* The `InventionVersion` inside a v9 save response — the client's 13-key
* `RRInventionVersion`. It carries `HasBetaContent`, a `CreatedAt` of its own and a
* nullable `UgcAccessibility` the stored version has no field for, and notably NO
* `AICost`, which the request body still sends and this server still stores.
*
* Both `Referenced*` lists are emitted here even though the client's DTO has room for one:
* which of the two it is wasn't recovered, and an unknown member is dropped silently while
* a missing one would be the list the client asked for going astray.
*/
export interface InventionVersionV9Dto {
InventionId: number
ReplicationId: string
VersionNumber: number
HasBetaContent: boolean
InstantiationCost: number
LightsCost: number
ChipsCost: number
CloudVariablesCost: number
BlobName: string
BlobHash: string | null
CreatedAt: string
UgcAccessibility: number | null
ReferencedInventions: number[]
ReferencedUnityAssetIds: string[]
}
/** The tag half of a v9 save — `v1/settags`' answer, folded into the save response. */
export interface InventionTagsV9Dto {
Result: number
Tags: string[]
}
/**
* The four keys inside a v9 save envelope's `Value`. `Status` is 0 on success.
*
* `InventionVersion` and `TagsResponse` are nullable because the envelope is not the save
* route's alone: econ's `POST /api/storefronts/v3/buyInvention` answers in it too, and a
* BUY mints neither a version nor a tag result — it sends both as null, and the client
* (which reads only `Success` and `Value.Invention`) never looks. The keys stay present.
*/
export interface InventionSaveV9Value {
Status: number
Invention: InventionV9Dto
InventionVersion: InventionVersionV9Dto | null
TagsResponse: InventionTagsV9Dto | null
}
/**
* What `v9/save` answers, and the whole reason it isn't just v6 with a bigger body: the
* result is ENVELOPED, where v6 serves the bare `{ Status, Invention, InventionVersion }`.
*
* The client's contract is two fields deep. It checks `Success`, then reads
* `Value.Invention.InventionId` and tags the invention with it; `Error` is the only text
* that ever reaches a human (it is logged as "Invention datablob upload failed"). `Status`
* is deserialized and never read on this route — the failure channel is the envelope, not
* the 55-member status enum — and so are `InventionVersion` and `TagsResponse`.
*
* The one shape that CRASHES the client is `Success: true` with `Value` null or absent: it
* dereferences `Value.Invention` unguarded. `Success: false` with a null `Value` is safe —
* that branch reads only `Error` — which is why every refusal goes through
* {@link inventionSaveV9Failure} rather than answering a bare `{ error }` like v6 does. A
* body that doesn't deserialize into this envelope at all is the same crash, so even the
* 401 answers it.
*/
export interface InventionSaveV9Result {
Value: InventionSaveV9Value | null
Success: boolean
Error: string | null
error_id: string | null
}
/**
* Project a stored invention into the client's 28-key `RRInvention`. Shared by the v9 save
* envelope below and by econ's `v3/buyInvention`, which answers in that same envelope — so
* the two can never drift into serving one build two different inventions.
*
* Fields the stored record has no equivalent for are served as what they are here rather
* than guessed: nothing forces an invention not to publish, and nothing in this server
* approves one.
*/
export function toInventionV9(invention: SavedInvention): InventionV9Dto {
return {
InventionId: invention.InventionId,
ReplicationId: invention.ReplicationId,
CreatorPlayerId: invention.CreatorPlayerId,
Name: invention.Name,
Description: invention.Description,
ImageName: invention.ImageName,
UgcVersion: invention.UgcVersion ?? 0,
CurrentVersionNumber: invention.CurrentVersionNumber,
// One save, one version: the newest is the current one.
LatestVersionNumber: invention.CurrentVersionNumber,
Accessibility: invention.Accessibility,
ForceCannotPublish: false,
ModifiedAt: invention.ModifiedAt,
CreatedAt: invention.CreatedAt,
FirstPublishedAt: invention.FirstPublishedAt,
CreationRoomId: invention.CreationRoomId,
NumPlayersHaveUsedInRoom: invention.NumPlayersHaveUsedInRoom,
NumDownloads: invention.NumDownloads,
CheerCount: invention.CheerCount,
CreatorPermission: invention.CreatorPermission,
GeneralPermission: invention.GeneralPermission,
IsAGInvention: invention.IsAGInvention,
IsCertifiedInvention: invention.IsCertifiedInvention,
IsRecRoomApproved: false,
AllowTrial: invention.AllowTrial,
Price: invention.Price,
HideFromPlayer: invention.HideFromPlayer,
DisplayMetadataJson: invention.DisplayMetadataJson ?? null,
}
}
/**
* Project a stored invention into the v9 save envelope. `tags` are the ones stored with
* it, answered as the bare names `v1/settags` answers with; `tagResult` says whether they
* were taken (see {@link INVENTION_TAG_RESULT}) — a tag the rules refuse costs the tags,
* never the save, because the save is the thing the player would have to redo.
*/
export function toSaveResultV9(
invention: SavedInvention,
tags: InventionTag[],
tagResult: number = INVENTION_TAG_RESULT.success
): InventionSaveV9Result {
const version = invention.CurrentVersion
return {
Value: {
Status: 0,
Invention: toInventionV9(invention),
InventionVersion: {
InventionId: version.InventionId,
ReplicationId: version.ReplicationId,
VersionNumber: version.VersionNumber,
HasBetaContent: version.HasBetaContent ?? false,
InstantiationCost: version.InstantiationCost,
LightsCost: version.LightsCost,
ChipsCost: version.ChipsCost,
CloudVariablesCost: version.CloudVariablesCost,
BlobName: version.BlobName,
BlobHash: version.BlobHash,
// The version is minted with the invention, so they share a timestamp.
CreatedAt: invention.CreatedAt,
UgcAccessibility: null,
ReferencedInventions: invention.ReferencedInventions,
ReferencedUnityAssetIds: invention.ReferencedUnityAssetIds ?? [],
},
TagsResponse: { Result: tagResult, Tags: tags.map((t) => t.Tag) },
},
Success: true,
Error: null,
error_id: null,
}
}
/**
* A refused v9 save. `Value` is null, which is safe precisely because `Success` is false:
* the client reads `Error` on that branch and nothing else. See
* {@link InventionSaveV9Result} for why the alternative — a bare `{ error }` body — would
* take the client down instead.
*/
export function inventionSaveV9Failure(message: string): InventionSaveV9Result {
return { Value: null, Success: false, Error: message, error_id: null }
}
/**
* Invention data blobs are named `<name>.inv`, and the client expects the extension
* on the `BlobName` it reads back. Uploads through the `storage` worker already land
@@ -189,6 +449,19 @@ export interface NewInvention {
aiCost?: number
creationRoomId?: number | null
referencedInventions?: number[]
/**
* The rest of what `v9/save` sends. Every one is optional and is written onto the
* record only when the caller actually supplied it, so a `v6/save` — which sends
* none of them — stores and answers exactly the record it always did.
*/
ugcVersion?: number
hasBetaContent?: boolean
referencedUnityAssetIds?: string[]
longDescription?: string | null
displayMetadataJson?: string | null
convertedFromInventionId?: number | null
/** Tags to store with the record, already normalized by {@link normalizeInventionTags}. */
tags?: InventionTag[]
}
/**
@@ -201,6 +474,11 @@ export interface NewInvention {
* what narrows `GeneralPermission` down (to UseOnly by default). Trials are allowed.
* The client's `creatorAccountRole` is ignored: it's the player's role in the room
* they built it in, not a permission over the invention.
*
* The fields `v9/save` added over `v6/save` are written only when the caller supplies
* them, so the record a v6 client stores is byte-for-byte the one it always stored —
* the new keys appear on new records rather than being back-filled with defaults onto
* every old one.
*/
export async function createInvention(
db: D1Database,
@@ -233,6 +511,7 @@ export async function createInvention(
ChipsCost: input.chipsCost ?? 0,
CloudVariablesCost: input.cloudVariablesCost ?? 0,
AICost: input.aiCost ?? 0,
...(input.hasBetaContent === undefined ? {} : { HasBetaContent: input.hasBetaContent }),
},
Accessibility: 0,
IsPublished: false,
@@ -252,6 +531,16 @@ export async function createInvention(
AllowTrial: true,
HideFromPlayer: false,
ReferencedInventions: input.referencedInventions ?? [],
...(input.referencedUnityAssetIds === undefined
? {}
: { ReferencedUnityAssetIds: input.referencedUnityAssetIds }),
...(input.ugcVersion === undefined ? {} : { UgcVersion: input.ugcVersion }),
...(input.longDescription ? { LongDescription: input.longDescription } : {}),
...(input.displayMetadataJson ? { DisplayMetadataJson: input.displayMetadataJson } : {}),
...(typeof input.convertedFromInventionId === 'number'
? { ConvertedFromInventionId: input.convertedFromInventionId }
: {}),
...(input.tags?.length ? { Tags: input.tags } : {}),
}
await db.prepare('INSERT INTO invention (data) VALUES (?1)').bind(JSON.stringify(invention)).run()
return invention
@@ -344,12 +633,30 @@ export async function ownsAllInventions(
}
/**
* Invention search — the browse/search list the client shows when picking an
* invention to spawn. Only published, non-hidden inventions are visible here (a
* player's own unpublished ones come from `getInventionsByCreator`). `value` is
* matched case-insensitively against the name and description, term by term; an
* empty `value` browses everything published. Paginated via skip/take, newest
* first. Returns a bare array — the shape the client expects from v2/search.
* Invention search — the browse/search list the client shows when picking an invention to
* spawn. Only published, non-hidden inventions are visible here (a player's own unpublished
* ones come from `getInventionsByCreator`). Newest first, paginated via skip/take. Returns a
* bare array — the shape the client expects from v2/search.
*
* `value` is split into terms on whitespace and `+`, and EVERY term must match (AND, not OR),
* which is what makes typing more words narrow the list. Each is matched case-insensitively
* against the NAME and the DESCRIPTION.
*
* Filtered, ordered and paged entirely IN SQL. It used to read every published invention into
* memory, filter there and slice — which meant the cost of a search grew with the whole
* catalogue no matter how narrow the query or how small the page, and a browse screen asking
* for 100 rows paid for all of them. `Name`, `Description` and `CreatedAt` live inside the JSON
* blob, so they are reached with `json_extract`; `is_published`/`hide_from_player` are already
* generated columns.
*
* Both sides of the comparison are lowered rather than leaning on `LIKE`, which folds case for
* ASCII only — and invention names are full of things it would not fold. `%` and `_` in a term
* are escaped so a player searching for one finds it instead of matching everything.
*
* A term starting with `#` is NOT special here: the browse screen's filter chips send `#small`,
* 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.
*/
export async function searchInventions(
db: D1Database,
@@ -357,22 +664,39 @@ export async function searchInventions(
skip: number,
take: number
): Promise<SavedInvention[]> {
let inventions = await publicInventions(db)
const limit = Math.max(take, 0)
const offset = Math.max(skip, 0)
if (limit === 0) return []
const terms = value
const where = [...VISIBLE_IN_FEEDS]
const binds: Array<string | number> = []
/** 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
.trim()
.toLowerCase()
.split(/[\s+]+/)
.filter(Boolean)
for (const term of terms) {
inventions = inventions.filter(
(i) => i.Name.toLowerCase().includes(term) || i.Description.toLowerCase().includes(term)
.filter(Boolean)) {
const escaped = term.replace(/[\\%_]/g, (ch) => `\\${ch}`)
const pattern = bind(`%${escaped}%`)
where.push(
`(lower(json_extract(data, '$.Name')) LIKE ${pattern} ESCAPE '\\'` +
` OR lower(json_extract(data, '$.Description')) LIKE ${pattern} ESCAPE '\\')`
)
}
return inventions
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
.slice(skip, skip + take)
const limitAt = bind(limit)
const offsetAt = bind(offset)
const { results } = await db
.prepare(
`SELECT data FROM invention WHERE ${where.join(' AND ')}
ORDER BY json_extract(data, '$.CreatedAt') DESC, id DESC
LIMIT ${limitAt} OFFSET ${offsetAt}`
)
.bind(...binds)
.all<InventionRow>()
return results.map((r) => JSON.parse(r.data) as SavedInvention)
}
/**
@@ -386,8 +710,7 @@ async function publicInventions(db: D1Database, featuredOnly = false): Promise<S
const { results } = await db
.prepare(
`SELECT data FROM invention
WHERE is_published = 1
AND hide_from_player = 0
WHERE ${VISIBLE_IN_FEEDS.join(' AND ')}
${featuredOnly ? 'AND is_featured = 1' : ''}`
)
.all<InventionRow>()
@@ -464,10 +787,9 @@ export async function getFeaturedInventions(
/**
* Replace an invention's tags (the `v1/settags` write). Auto tags are the ones the
* client derives from the invention itself (Type 2); custom tags are the creator's
* own (Type 0). Both lists are replaced wholesale — auto first, then custom, the
* order the tags come back in — and are lowercased/trimmed and de-duplicated so
* `details` doesn't echo back near-duplicates. Returns the stored tag list, or null
* when there's no such invention.
* own (Type 0). Both lists are replaced wholesale, normalized as
* {@link normalizeInventionTags} describes. Returns the stored tag list, or null when
* there's no such invention.
*/
export async function setInventionTags(
db: D1Database,
@@ -478,6 +800,21 @@ export async function setInventionTags(
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
const tags = normalizeInventionTags(autoTags, customTags)
await writeInvention(db, { ...invention, Tags: tags })
return tags
}
/**
* The two tag lists as they are stored: auto first (Type 2), then custom (Type 0) —
* the order they come back in — each trimmed, lowercased and de-duplicated across both
* lists so `details` doesn't echo back near-duplicates. Blanks are dropped: the client
* pads its lists with empties.
*
* Shared by `v1/settags` and by `v9/save`, which carries the same two lists in its
* `tagsRequest` — a tag has to mean the same thing however it arrived.
*/
export function normalizeInventionTags(autoTags: string[], customTags: string[]): InventionTag[] {
const tags: InventionTag[] = []
const seen = new Set<string>()
for (const [list, type] of [
@@ -491,8 +828,6 @@ export async function setInventionTags(
tags.push({ Tag: tag, Type: type })
}
}
await writeInvention(db, { ...invention, Tags: tags })
return tags
}
@@ -505,6 +840,9 @@ export async function setInventionTags(
export const INVENTION_PERMISSION = {
unassigned: 0,
limitedoneuseonly: 10,
// Recovered from the client's own ladder; nothing here sends it, and no name for it
// appears in `v1/update`'s picker.
disallowkeylock: 15,
useonly: 20,
editandsave: 40,
publish: 60,
@@ -512,6 +850,36 @@ export const INVENTION_PERMISSION = {
unlimited: 100,
} 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.
*
* 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.
*
* 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.
*/
export const INVENTION_ACCESSIBILITY = {
private: 0,
public: 1,
unlisted: 2,
} 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.
*/
const VISIBLE_IN_FEEDS = [
'is_published = 1',
'hide_from_player = 0',
`COALESCE(json_extract(data, '$.Accessibility'), 0) <> ${INVENTION_ACCESSIBILITY.unlisted}`,
]
/**
* Parse a permission level the way the client sends it: a name (`useonly`,
* `edit_and_save`) or the raw number. Undefined when it's neither.
@@ -532,6 +900,13 @@ export interface InventionPatch {
imageName?: string
allowTrial?: boolean
generalPermission?: number
/**
* The rest of what `v2/metadata` can edit. Undefined leaves the stored value alone,
* which is how both editors say "not this field" — `v1/update` by omitting the query
* param, `v2/metadata` by sending the key as null.
*/
longDescription?: string
tags?: InventionTag[]
}
/**
@@ -556,11 +931,27 @@ export async function updateInvention(
ImageName: patch.imageName ?? invention.ImageName,
AllowTrial: patch.allowTrial ?? invention.AllowTrial,
GeneralPermission: patch.generalPermission ?? invention.GeneralPermission,
// Both of these are optional ON the record, so an untouched one resolves to
// undefined and JSON.stringify drops the key — an invention that never had a long
// description doesn't acquire an empty one by being edited.
LongDescription: patch.longDescription ?? invention.LongDescription,
Tags: patch.tags ?? invention.Tags,
}
await writeInvention(db, updated)
return updated
}
/**
* What a publish decides. Each is optional and an omitted one keeps what the invention
* has — except the permission, which falls back to UseOnly, the level the older
* `v3/publish` has always defaulted to when its query string named none.
*/
export interface InventionPublish {
permissionLevel?: number
accessibility?: number
price?: number
}
/**
* Publish an invention (`v3/publish`) — what puts it into search and the feeds.
* Publishing sets the permission other players get (UseOnly unless the creator asks
@@ -570,8 +961,7 @@ export async function updateInvention(
export async function publishInvention(
db: D1Database,
inventionId: number,
permissionLevel: number | undefined,
price: number | undefined
publish: InventionPublish = {}
): Promise<SavedInvention | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
@@ -579,8 +969,13 @@ export async function publishInvention(
const updated: SavedInvention = {
...invention,
IsPublished: true,
GeneralPermission: permissionLevel ?? INVENTION_PERMISSION.useonly,
Price: price ?? 0,
GeneralPermission: publish.permissionLevel ?? INVENTION_PERMISSION.useonly,
Accessibility: publish.accessibility ?? invention.Accessibility,
// An unmentioned price is the price it already has, not zero: a republish that says
// nothing about money must not quietly give away something that was for sale. A
// first publish is unaffected — a fresh invention's price is 0 either way.
Price: publish.price ?? invention.Price,
// The FIRST publish is the one that gets dated; re-publishing doesn't reset it.
FirstPublishedAt: invention.FirstPublishedAt ?? new Date().toISOString(),
}
await writeInvention(db, updated)
@@ -603,6 +998,110 @@ export async function setInventionPrice(
return updated
}
/**
* Delete an invention (`v2/delete`), returning the record that was removed, or null
* when there's no such row. The whole invention lives in the one JSON blob, so its
* versions, tags and referenced-invention lists go with it in a single DELETE.
*
* Two things are deliberately LEFT behind.
*
* The data blob in R2 stays: it is named by the file the creator uploaded through the
* `storage` worker, and nothing here knows whether another record still points at that
* name (a converted invention carries the same lineage, and a save that reuses a
* filename reuses the object). An orphan blob costs storage; a missing one breaks
* whatever still references it.
*
* The `inventory_invention` rows stay too — deleting a creator's invention must not
* rewrite what other players bought. They already fall out of every list on their own:
* `getMyInventions` resolves owned ids against this table and an id with no row left
* simply drops out, and `ownsAllInventions` reads a missing row as not-owned. Purging
* them would also erase the acquisition history that ranks the "top today" feed.
*/
export async function deleteInvention(
db: D1Database,
inventionId: number
): Promise<SavedInvention | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
await db.batch([
db.prepare('DELETE FROM invention WHERE id = ?1').bind(inventionId),
db.prepare('DELETE FROM invention_interaction WHERE invention_id = ?1').bind(inventionId),
])
return invention
}
/**
* Set or clear one player's cheer on an invention and resync the invention's denormalized
* `CheerCount`. Repeating either state is idempotent because the interaction row is keyed by
* `(player_id, invention_id)` and the public count is always derived from those rows.
*/
export async function setInventionCheer(
db: D1Database,
playerId: number,
inventionId: number,
cheer: boolean
): Promise<number> {
await db
.prepare(
`INSERT INTO invention_interaction (player_id, invention_id, cheered, created_at)
VALUES (?1, ?2, ?3, ?4)
ON CONFLICT(player_id, invention_id) DO UPDATE SET cheered = ?3`
)
.bind(playerId, inventionId, cheer ? 1 : 0, new Date().toISOString())
.run()
const row = await db
.prepare(
'SELECT COUNT(*) AS n FROM invention_interaction WHERE invention_id = ?1 AND cheered = 1'
)
.bind(inventionId)
.first<{ n: number }>()
const count = row?.n ?? 0
await db
.prepare(
"UPDATE invention SET data = json_set(data, '$.CheerCount', CAST(?2 AS INTEGER)) WHERE id = ?1"
)
.bind(inventionId, count)
.run()
return count
}
/** Whether one player currently cheers an invention. */
export async function isInventionCheered(
db: D1Database,
playerId: number,
inventionId: number
): Promise<boolean> {
const row = await db
.prepare(
`SELECT 1 AS found FROM invention_interaction
WHERE player_id = ?1 AND invention_id = ?2 AND cheered = 1`
)
.bind(playerId, inventionId)
.first<{ found: number }>()
return row !== null
}
/**
* What `v2/delete` answers: the same `{ Value, Success, Error, error_id }` envelope the
* other newer-client invention routes use, with `Value` always NULL — the invention is
* gone, so there is nothing for the client to redraw from and it reads only `Success`
* (and `Error`, the one string that reaches a human). This is why the delete does not
* borrow {@link InventionSaveV9Result}: that envelope's `Value` carries an invention the
* client dereferences, and a delete has none to give.
*/
export interface InventionDeleteResult {
Value: null
Success: boolean
Error: string | null
error_id: string | null
}
/** The delete envelope: a refusal when given a message, success when given null. */
export function inventionDeleteResult(error: string | null = null): InventionDeleteResult {
return { Value: null, Success: error === null, Error: error, error_id: null }
}
/** The tag filter chips the client offers when browsing inventions. */
export interface InventionTagFilters {
PinnedFilters: string[]
@@ -679,8 +1178,7 @@ export async function getInventionsByRoom(
.prepare(
`SELECT data FROM invention
WHERE json_extract(data, '$.CreationRoomId') = ?1
AND is_published = 1
AND hide_from_player = 0`
AND ${VISIBLE_IN_FEEDS.join(' AND ')}`
)
.bind(roomId)
.all<InventionRow>()
@@ -690,6 +1188,14 @@ export async function getInventionsByRoom(
.slice(skip, skip + take)
}
/**
* The `version` that means "whichever is current" rather than a version number to match.
*
* Zero is not a version any invention has — a fresh save is version 1 — so a caller sending
* it does not know which version it wants, and reading it literally finds nothing.
*/
const CURRENT_INVENTION_VERSION = 0
/**
* A single version of an invention (`v1/version?inventionId=…&version=…`), which
* is how the client resolves the blob to download for a given version number.
@@ -698,6 +1204,13 @@ export async function getInventionsByRoom(
* (there's no `v4/addversion` yet), and a fresh save is always version 1. So this
* answers for the current version number and reports null for any other, rather
* than inventing a version whose blob doesn't exist.
*
* VERSION 0 is the exception: it means "whichever version is current" rather than a
* version number to match, and gets {@link CURRENT_INVENTION_VERSION}. The client asks
* for 0 when it has an invention id but no version to go with it — a discovery row or a
* spawn that carries the id alone — and there is no version 0 to find, so matching it
* literally 404s and the invention silently fails to load. Answering with the current
* version is what it would have asked for had it known the number.
*/
export async function getInventionVersion(
db: D1Database,
@@ -707,7 +1220,12 @@ export async function getInventionVersion(
): Promise<InventionVersion | null> {
const invention = await getInventionById(db, inventionId)
if (invention === null) return null
if (invention.CurrentVersionNumber !== versionNumber) return null
if (
versionNumber !== CURRENT_INVENTION_VERSION &&
invention.CurrentVersionNumber !== versionNumber
) {
return null
}
// A version saved before its blob finished uploading (or before we hashed on
// save at all) carries no hash. Hash it now and keep the result, so the other
+611 -47
View File
@@ -50,6 +50,13 @@ export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer t
/** Bearer-JWT security requirement, for the auth-gated routes. */
export const AUTHED = [{ bearerAuth: [] }]
/**
* A bearer token is honoured but not required: anonymous is a valid alternative. For routes
* that serve public data but show more to a known caller (a creator's own unpublished
* custom avatar items) instead of 401ing.
*/
export const OPTIONAL_AUTHED: OpenAPIV3_1.SecurityRequirementObject[] = [{}, { bearerAuth: [] }]
/** An integer path parameter (ids are constrained to `[0-9]+` by the route pattern). */
export function idParam(name: string, description: string): OpenAPIV3_1.ParameterObject {
return { name, in: 'path', required: true, description, schema: { type: 'integer' } }
@@ -91,6 +98,9 @@ export const JsonArray = z.array(z.unknown())
/** A bare JSON boolean — several routes answer `true`/`false` with no envelope. */
export const BareBoolean = z.boolean()
/** A bare JSON integer (e.g. `/api/customAvatarItems/v1/minPriceForPublicItem`). */
export const BareInteger = z.number().int()
/** A bare JSON string (`POST /api/sanitize/v1` echoes one back). */
export const BareString = z.string()
@@ -109,12 +119,14 @@ export const SuccessErrorEnvelope = z.object({
// ---- Config ----------------------------------------------------------------
/** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */
/** `GET /api/config/v1/amplitude` — the client's analytics keys (blank; RudderStack and StatSig off). */
export const AmplitudeConfig = z.object({
AmplitudeKey: z.string(),
StatSigKey: z.string(),
RudderStackKey: z.string(),
UseRudderStack: z.boolean(),
RudderStackKey: z.string(),
UseStatSig: z.boolean(),
StatSigKey: z.string(),
StatSigEnvironment: z.number().int(),
})
/** `GET /api/config/v1/azurespeech` — speech-to-text config; `Enabled` is false here. */
@@ -213,6 +225,15 @@ export const SendMultipleMessagesRequest = z.object({
Data: z.string().optional().describe('The message payload; often empty'),
})
/**
* `POST /api/messages/v3/delete` JSON body — the messages the client is dropping from
* its inbox. Ids are the `Id` of a stored message, which this server has never issued:
* with no message store the list is only ever echoed back as accepted.
*/
export const DeleteMessagesRequest = z.object({
MessageIds: z.array(z.int()).describe('Ids of the messages to delete'),
})
/**
* `POST /api/messages/v1/friendOnlineStatus` — how many of the caller's friends are
* online, wrapped in the client's `{ success, value }` envelope.
@@ -241,16 +262,20 @@ export const MutualFriendDto = z.object({
// ---- Progression -----------------------------------------------------------
/**
* A player's reputation (cheer counters). Nobody has earned cheers yet, so every
* counter is 0 and everyone has their full credit. `SelectedCheer` is an int (0 = none),
* not null, and `IsCheerful` is true — the client reads it to decide whether the player
* may hand out cheers at all.
* A player's reputation (cheer counters), read from the `reputation` table. A player
* nobody has cheered yet has no row and reads back all-zero with full cheer credit.
* `SelectedCheer` is an int (0 = none), not null, and `IsCheerful` is a bool the client
* reads to decide whether the player may hand out cheers at all.
*
* `CheerCredit` is the odd one out: it is what the player has left to GIVE (out of 20 per
* day), not something they have received, and it comes from `player_cheer` rather than
* from the reputation row.
*/
export const ReputationDto = z.object({
AccountId: z.int(),
IsCheerful: z.boolean(),
Noteriety: z.int(),
SelectedCheer: z.int().describe('0 = none selected'),
SelectedCheer: z.int().describe('The cheer pinned to the profile; 0 = none selected'),
CheerCredit: z.int(),
CheerGeneral: z.int(),
CheerHelpful: z.int(),
@@ -268,9 +293,122 @@ export const ProgressionDto = z.object({
XP: z.int(),
})
/** The `Ids` form body the bulk POST endpoints take. */
/**
* 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
* `RoomId` is dropped outright — see the route.
*/
export const CheerPlayerRequest = z.object({
PlayerIdTo: z.string().describe('The account being cheered'),
CheerCategory: z
.string()
.describe('0 General, 10 Helpful, 20 Sportmanship, 30 GreatHost, 40 Creative'),
RoomId: z
.string()
.optional()
.describe(
'The room it happened in. Accepted but NOT used — the audience for the cheers ' +
'effect comes from the callers live presence, so a client cannot aim it at a room ' +
'it is not in'
),
Anonymous: z
.string()
.optional()
.describe(
'`True`/`False` (default `False`). Not stored — it picks the `PlayerCheerAnonymous` ' +
'message type (sender 0) over `PlayerCheer` for the frame that plays the cheer'
),
})
/** The form body of `POST /api/PlayerCheer/v1/SetSelectedCheer`. */
export const SetSelectedCheerRequest = z.object({
CheerCategory: z
.string()
.describe(
'The category to pin: 0 General, 10 Helpful, 20 Sportmanship, 30 GreatHost, 40 Creative; -1 unpins'
),
})
/**
* What a cheer answers — the reference's PascalCase `{ Success, Message }`, NOT the
* lowercase `{ success, error }` envelope the reports use, and `Message` is NULL on success
* where that one sends an empty string. On a refusal it names the reason (out of credit,
* bad category, cheering yourself), which the client shows the player.
*/
export const CheerPlayerResponse = z.object({
Success: z.boolean(),
Message: z.string().nullable().describe('Null when the cheer landed'),
})
/** The `metadata` JSON field of a custom-avatar-item creation. */
export const CreateCustomAvatarItemMetadata = z.object({
Name: z.string(),
Description: z.string().optional(),
Price: z.number().int().optional(),
BaseAvatarItemId: z.number().int(),
BaseAvatarItemColor: z.string().describe('Hex colour, e.g. `#F55C1A`'),
Accessibility: z.number().int().optional(),
})
/** The multipart body `POST /api/customAvatarItems/v1` takes. */
export const CreateCustomAvatarItemRequest = z.object({
metadata: z.string().describe('JSON `CreateCustomAvatarItemMetadata`, posted as a text field'),
thumbnailImage: z.string().describe('The thumbnail PNG (binary file part)'),
design: z.string().describe('The design blob (binary file part)'),
})
/** The client's `CustomAvatarItem` record. */
export const CustomAvatarItemDto = z.object({
CustomAvatarItemId: z.string(),
CreatorAccountId: z.number().int(),
Name: z.string(),
Description: z.string(),
Price: z.number().int(),
Accessibility: z.number().int(),
ForceCannotPublish: z.boolean(),
IsFeatured: z.boolean(),
IsRecRoomApproved: z.boolean(),
BaseAvatarItemId: z.number().int(),
BaseAvatarItemColor: z.string(),
DesignFilename: z.string(),
ThumbnailImageFilename: z.string(),
CreatedAt: z.string(),
ModifiedAt: z.string(),
PreviewOrientation: z.number().int(),
RankingContext: z.null(),
OutfitType: z.number().int(),
CurrentSaves: z.array(z.unknown()),
PurchaseInfo: z.null(),
})
/** The JSON body `PUT /api/customAvatarItems/v1/:id` takes; null leaves a field unchanged. */
export const UpdateCustomAvatarItemRequest = z.object({
Name: z.string().nullable().optional(),
Description: z.string().nullable().optional(),
Price: z.number().int().nullable().optional(),
Accessibility: z.number().int().nullable().optional(),
})
/** A bare list of custom avatar items (the featured feed). */
export const CustomAvatarItemList = z.array(CustomAvatarItemDto)
/** The PascalCase `{ Value, Success, Error, error_id }` envelope custom-avatar-item routes answer with. */
export const CustomAvatarItemResponse = z.object({
Value: CustomAvatarItemDto.nullable(),
Success: z.boolean(),
Error: z.string().nullable(),
error_id: z.string().nullable(),
})
/**
* The `Ids` form body the bulk POST endpoints take, in either of the two spellings the
* client sends: `Ids` REPEATED once per id (`Ids=101&Ids=102&Ids=103`) or a single
* comma-separated `Ids=1,2,3`. Both are read by `parseFormIds`.
*/
export const BulkIdsRequest = z.object({
Ids: z.string().describe('Comma-separated account ids, e.g. `1,2,3`'),
Ids: z
.union([z.string(), z.array(z.string())])
.describe('Repeated (`Ids=101&Ids=102`) or comma-separated (`Ids=1,2,3`)'),
})
// ---- Inventions ------------------------------------------------------------
@@ -290,6 +428,10 @@ export const InventionVersionDto = z.object({
ChipsCost: z.int(),
CloudVariablesCost: z.int(),
AICost: z.int(),
HasBetaContent: z
.boolean()
.optional()
.describe('Set from `v9/save` on — absent on a version saved through `v6/save`'),
})
/** A tag on an invention. `Type` 0 = custom (creator-submitted), 2 = auto-derived. */
@@ -326,10 +468,30 @@ export const InventionDto = z.object({
AllowTrial: z.boolean(),
HideFromPlayer: z.boolean(),
ReferencedInventions: z.array(z.int()),
ReferencedUnityAssetIds: z
.array(z.string())
.optional()
.describe('Set from `v9/save` on — absent on an invention saved through `v6/save`'),
UgcVersion: z
.int()
.optional()
.describe('An invention field, not a version one — set from `v9/save` on'),
LongDescription: z.string().optional().describe('Set from `v9/save` on, when non-empty'),
DisplayMetadataJson: z
.string()
.optional()
.describe('The clients own display state, stored as the opaque string it sent'),
ConvertedFromInventionId: z
.int()
.optional()
.describe('The invention this one was converted from, when `v9/save` named one'),
Tags: z
.array(InventionTagDto)
.optional()
.describe('Unset on save — the real RRInvention carries no Tags field'),
.describe(
'The real RRInvention carries no Tags field. Unset by `v6/save`; set by `v9/save` ' +
'when its `tagsRequest` names at least one tag'
),
})
/** The `{ Status, Invention, InventionVersion }` envelope every invention write answers. */
@@ -339,6 +501,88 @@ export const InventionSaveResult = z.object({
InventionVersion: InventionVersionDto,
})
/**
* The `Invention` a v9 save answers with — the newer client's own `RRInvention`, which is
* not the record this server stores or the read endpoints serve: no nested
* `CurrentVersion` (the version rides beside it), no `Referenced*` (those moved onto the
* version), no `IsPublished`.
*/
export const InventionV9Dto = z.object({
InventionId: z.int(),
ReplicationId: z.string(),
CreatorPlayerId: z.int(),
Name: z.string(),
Description: z.string(),
ImageName: z.string(),
UgcVersion: z.int().describe('The UGC format the blob was written in; 0 when unsent'),
CurrentVersionNumber: z.int(),
LatestVersionNumber: z.int().describe('The same as CurrentVersionNumber on a fresh save'),
Accessibility: z.int(),
ForceCannotPublish: z.boolean().describe('Always false — nothing here forbids publishing'),
ModifiedAt: z.string(),
CreatedAt: z.string(),
FirstPublishedAt: z.string().nullable(),
CreationRoomId: z.int().nullable(),
NumPlayersHaveUsedInRoom: z.int(),
NumDownloads: z.int(),
CheerCount: z.int(),
CreatorPermission: z.int(),
GeneralPermission: z.int(),
IsAGInvention: z.boolean(),
IsCertifiedInvention: z.boolean(),
IsRecRoomApproved: z.boolean().describe('Always false — nothing here approves an invention'),
AllowTrial: z.boolean(),
Price: z.int().nullable(),
HideFromPlayer: z.boolean(),
DisplayMetadataJson: z.string().nullable(),
})
/**
* The `InventionVersion` a v9 save answers with. It carries `HasBetaContent`, a `CreatedAt`
* of its own and a nullable `UgcAccessibility`, and notably no `AICost` — which the request
* still sends and this server still stores.
*/
export const InventionVersionV9Dto = z.object({
InventionId: z.int(),
ReplicationId: z.string(),
VersionNumber: z.int(),
HasBetaContent: z.boolean(),
InstantiationCost: z.int(),
LightsCost: z.int(),
ChipsCost: z.int(),
CloudVariablesCost: z.int(),
BlobName: z.string(),
BlobHash: z.string().nullable(),
CreatedAt: z.string(),
UgcAccessibility: z.int().nullable().describe('Always null — versions carry no accessibility'),
ReferencedInventions: z.array(z.int()),
ReferencedUnityAssetIds: z.array(z.string()),
})
/**
* What `v9/save` answers — the enveloped result. The client checks `Success` and then reads
* `Value.Invention.InventionId`; `Error` is the only text it shows a human, and `Status`,
* `InventionVersion` and `TagsResponse` are deserialized and never read. `Success: true`
* with a null `Value` crashes it, so a refusal is `Success: false` with `Value: null`.
*/
export const InventionSaveV9Result = z.object({
Value: z
.object({
Status: z.int().describe('0 = success; the client never reads it on this route'),
Invention: InventionV9Dto,
InventionVersion: InventionVersionV9Dto,
TagsResponse: z.object({
Result: z.int().describe('0 = success; non-zero when a tag broke the tag rule'),
Tags: z.array(z.string()).describe('The stored tag NAMES, auto first, then custom'),
}),
})
.nullable()
.describe('Null when Success is false — and only then'),
Success: z.boolean(),
Error: z.string().nullable().describe('The refusal message; the only text the client shows'),
error_id: z.string().nullable().describe('Always null'),
})
/** The tag filter chips on a browse screen, derived from the tags actually in use. */
export const TagFilters = z.object({
PinnedFilters: z.array(z.string()),
@@ -354,7 +598,13 @@ export const InventionDetails = z.object({ Tags: z.array(InventionTagDto) })
/** `GET /api/inventions/v1/personaldetails/:id` — the caller's own relation to it. */
export const InventionPersonalDetails = z.object({
IsCheering: z.boolean().describe('Always false — nothing can cheer an invention yet'),
IsCheering: z.boolean().describe('Whether the caller currently cheers this invention'),
})
/** `POST /api/inventions/v1/cheer` JSON body. */
export const InventionCheerRequest = z.object({
InventionId: z.int().describe('The invention whose cheer state is changing'),
Cheer: z.boolean().describe('True to cheer; false to remove the cheer'),
})
/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
@@ -376,6 +626,76 @@ export const SetTagsResponse = z.object({
Tags: z.array(z.string()).describe('Auto tags first, then custom'),
})
/**
* `PUT /api/inventions/v2/metadata` JSON body — PascalCase, and every field but the id is
* NULLABLE: the newer client sends the whole shape on every edit and marks the fields it
* isn't touching as null. An empty string is not a null — it clears the field.
*/
export const UpdateInventionMetadataRequest = z.object({
InventionId: z.int(),
Name: z
.string()
.nullable()
.optional()
.describe('324 chars, letters/digits/spaces/dashes/colons; null leaves it alone'),
Description: z.string().nullable().optional().describe('Max 512 chars; empty clears it'),
LongDescription: z.string().nullable().optional().describe('Empty clears it'),
ImageName: z.string().nullable().optional().describe('New thumbnail; empty clears it'),
TagsRequest: z
.object({
AutoTags: z.array(z.string()).nullable().optional(),
CustomTags: z.array(z.string()).nullable().optional(),
})
.nullable()
.optional()
.describe('Replaces both lists wholesale, as `v1/settags` does; null leaves them alone'),
})
/**
* `POST /api/inventions/v4/publish` JSON body — PascalCase, and nullable the way
* `v2/metadata`'s is: a null field keeps what the invention already has.
*/
export const PublishInventionRequest = z.object({
InventionId: z.int(),
Permission: z
.int()
.nullable()
.optional()
.describe(
'The `GeneralPermission` other players get, as a raw ladder number: Unassigned 0, ' +
'LimitedOneUseOnly 10, DisallowKeyLock 15, UseOnly 20, EditAndSave 40, Publish 60, ' +
'Charge 80, Unlimited 100. Null publishes as UseOnly'
),
Accessibility: z
.int()
.nullable()
.optional()
.describe('Private 0, Public 1, Unlisted 2. Unlisted stays out of browse and search'),
Price: z
.int()
.nullable()
.optional()
.describe('Price in tokens; null leaves it as it is, and a negative one is ignored'),
})
/** `POST /api/inventions/v2/delete` JSON body — the id and nothing else. */
export const DeleteInventionRequest = z.object({
InventionId: z.int().describe('The invention to delete; the caller must have created it'),
})
/**
* What `v2/delete` answers — the same `{ Value, Success, Error, error_id }` envelope the
* other newer-client invention routes use, with `Value` always null. The invention is
* gone, so there is nothing for the client to redraw from: it reads `Success`, and
* `Error` when that is false.
*/
export const InventionDeleteResult = z.object({
Value: z.null().describe('Always null — the invention no longer exists'),
Success: z.boolean(),
Error: z.string().nullable().describe('The refusal message; null on success'),
error_id: z.string().nullable().describe('Always null'),
})
/** `POST /api/inventions/v1/updateprice` JSON body. */
export const UpdatePriceRequest = z.object({
InventionId: z.int(),
@@ -403,6 +723,34 @@ export const SaveInventionRequest = z.object({
aiCost: z.int().optional(),
creationRoomId: z.int().optional(),
referencedInventions: z.array(z.int()).optional(),
creatorAccountRole: z
.int()
.optional()
.describe('Accepted and ignored — a room role, not a permission over the invention'),
})
/**
* `POST /api/inventions/v9/save` JSON body — `v6`s fields plus what the invention
* points at, what it says about itself, and the tags that used to need a second
* `v1/settags` call.
*/
export const SaveInventionV9Request = SaveInventionRequest.extend({
ugcVersion: z.int().optional().describe('The UGC format the blob was written in'),
hasBetaContent: z.boolean().optional(),
referencedUnityAssetIds: z.array(z.string()).optional(),
longDescription: z.string().optional().describe('Stored when non-empty'),
displayMetadataJson: z
.string()
.optional()
.describe('Opaque client display state, e.g. `{"0":0,"99":0}`; stored verbatim'),
convertedFromInventionId: z.int().nullable().optional(),
tagsRequest: z
.object({
AutoTags: z.array(z.string()).nullable().optional(),
CustomTags: z.array(z.string()).nullable().optional(),
})
.optional()
.describe('The same two lists `v1/settags` takes, folded into the save'),
})
// ---- Avatar / custom avatar items ------------------------------------------
@@ -447,9 +795,9 @@ export const BulkCustomAvatarItemsRequest = z.object({
.describe('The ids to resolve; repeat the field once per id'),
})
/** A paginated custom-avatar-item page (no storage yet, so always empty). */
/** A paginated custom-avatar-item page, out of the `custom_avatar_item` table. */
export const CustomAvatarItemsPage = z.object({
Results: JsonArray,
Results: CustomAvatarItemList,
TotalResults: z.int(),
})
@@ -478,13 +826,8 @@ export const LegacyAvatarItemSaves = z.object({
customAvatarItemSavesByAvatarItemDesc: z.record(z.string(), CustomAvatarItemSave),
})
/**
* `GET /outfits/me` — the outfit envelope. Either the outfit stored in slot 0, served
* back exactly as it was saved, or (for a player who has never saved) the brand-new-
* account form, where every field that would carry an outfit is null/empty and
* `DataVersion` is 9.
*/
export const OutfitsMeResponse = z.object({
/** `GET /outfits/me` — the outfit stored in slot 0, served back exactly as it was saved. */
export const StoredOutfit = z.object({
LegacyData: z.object({
SelectionsV1: z.string().nullable().describe('Semicolon-delimited legacy descriptors'),
SelectionsV2: z.string().nullable().describe('JSON-in-a-string: `{ selections: [...] }`'),
@@ -493,7 +836,7 @@ export const OutfitsMeResponse = z.object({
HairColor: z.string().nullable(),
}),
Selections: JsonArray,
DataVersion: z.int().describe('9 in the new-account envelope; whatever was saved otherwise'),
DataVersion: z.int().describe('The clients outfit format version, as saved'),
CustomizationSettings: z
.string()
.nullable()
@@ -504,6 +847,22 @@ export const OutfitsMeResponse = z.object({
Slot: z.int().describe('0 — the outfit being worn'),
})
/**
* `GET /outfits/me` for a player who has never saved — the brand-new-account envelope.
* Flatter than a stored outfit rather than a nulled-out copy of it: four empty strings and
* nothing else, no `LegacyData`, no `Selections`, no `DataVersion`. `OutfitSelections` is
* the flat field name here, not `SelectionsV1`/`SelectionsV2`.
*/
export const EmptyOutfit = z.object({
FaceFeatures: z.string(),
HairColor: z.string(),
OutfitSelections: z.string(),
SkinColor: z.string(),
})
/** `GET /outfits/me` — the stored outfit, or the empty envelope for a new player. */
export const OutfitsMeResponse = z.union([StoredOutfit, EmptyOutfit])
/**
* `PUT /outfits/me` JSON body — the outfit the client is saving, in the newer envelope.
* The heavy fields are JSON-in-a-string, exactly as the client serialises them:
@@ -533,6 +892,36 @@ export const OutfitsMeRequest = z.object({
ThumbnailFileName: z.string().nullable(),
})
/**
* `POST /outfits/bulk` JSON body — whose outfits to fetch. The client sends the accounts it
* needs to dress (a room's roster, typically), and the two `UnityAsset*` fields name the
* baked-asset build it would like them for.
*/
export const OutfitsBulkRequest = z.object({
AccountIds: z.array(z.int()).describe('The accounts whose worn outfit is wanted'),
UnityAssetTarget: z
.string()
.nullable()
.describe('Baked-asset platform. Accepted and ignored — nothing bakes assets here'),
UnityAssetVersion: z
.string()
.nullable()
.describe('Baked-asset version. Accepted and ignored, like its sibling'),
})
/**
* `POST /outfits/bulk` — the worn outfit of each account asked for, keyed by account id.
*
* The key is the id as a STRING (a JSON object key always is) and the value is the same
* stored outfit `GET /outfits/me` serves. An account with nothing saved is ABSENT from the
* map rather than present with a null — a map expresses "no outfit" by not carrying the key.
*/
export const OutfitsBulkResponse = z.object({
OutfitsByAccountId: z
.record(z.string(), StoredOutfit)
.describe('Keyed by account id as a string. Accounts with no saved outfit are omitted'),
})
/**
* `PUT /outfits/me` — the base envelope, with NO `Value` key: three keys and that is the
* whole body. The save answers only whether it worked; the client keeps the outfit it just
@@ -610,7 +999,9 @@ export const PlayerEventDto = z.object({
Name: z.string(),
Description: z.string(),
StartTime: z.string().describe('ISO 8601 UTC, seconds precision (`2020-11-29T22:00:00Z`)'),
EndTime: z.string().describe('ISO 8601 UTC, seconds precision'),
EndTime: z
.string()
.describe('ISO 8601 UTC, seconds precision; at most 24 hours after `StartTime`'),
AttendeeCount: z.int().describe('Starts at 1 — the creator attends their own event'),
State: z.int().describe('0 = scheduled'),
Accessibility: z.int(),
@@ -634,13 +1025,23 @@ export const PlayerEventDetailsDto = PlayerEventDto.extend({
})
/**
* The client's BASE event, 17 keys — what `GET /api/playerevents/v1` serves, and what the
* v2 envelope carries once `Tags` is added. The stored record minus `State`, with
* `ImageName` as a string (`""`, not null) and a `BroadcastingRoomInstanceId` (always null —
* nothing broadcasts an event yet).
* The client's BASE event, 17 keys — the stored record minus `State`, with `ImageName` as a
* string (`""`, not null) and a `BroadcastingRoomInstanceId` (always null — nothing
* broadcasts an event yet). It is also what the v2 envelope carries once `Tags` is added.
*
* The by-id, bulk and search reads serve the stored RECORD verbatim instead, so don't unify
* the two.
* THREE reads serve exactly this, through one generic helper on the client and one element
* type: the browse feed (`GET /api/playerevents/v1`), the room shelf
* (`GET /api/playerevents/v1/room/{roomId}`) and the bulk read
* (`POST|GET /api/playerevents/v1/bulk`). They are shape-identical by construction on the
* client side; keep them that way here.
*
* The remaining reads — by id, search, searchlive and the club feeds — serve the stored
* RECORD verbatim, `State` and nullable `ImageName` included. Two projections; don't unify
* them.
*
* `DefaultBroadcastPermissions` and `CanRequestBroadcastPermissions` are the client's
* broadcast-permission enum, whose members are NOT 0/1/2: None 0, RoomOwners 256, All
* 2147483647. Reading them as an ordinal is the classic way to break broadcast.
*/
export const PlayerEventBaseDto = PlayerEventDto.omit({ State: true, ImageName: true }).extend({
ImageName: z.string().describe('Empty string when the event has no image, never null'),
@@ -652,11 +1053,21 @@ export const PlayerEventBaseDto = PlayerEventDto.omit({ State: true, ImageName:
/**
* The event as the v2 envelope carries it: the stored record MINUS `State`, PLUS `Tags`
* (tag names, not the `{ tag, type }` pairs the v1 read's lowercase `tags` serves) and
* `BroadcastingRoomInstanceId`. `ImageName` is `""` rather than null when there is no image.
* and `BroadcastingRoomInstanceId`. `ImageName` is `""` rather than null when there is no
* image.
*
* `Tags` has two shapes, picked from the caller's build: Rec Room reshaped it without
* minting a new path, so a build newer than `20230414` gets the tag NAMES and every older
* one (and any caller whose token names no build) gets the `{ Tag, Type }` pairs. Neither
* is the lowercase `{ tag, type }` the v1 read serves.
*/
export const PlayerEventEnvelopeDto = PlayerEventBaseDto.extend({
Tags: z.array(z.string()).describe('The events tag names'),
Tags: z
.union([z.array(z.string()), z.array(z.object({ Tag: z.string(), Type: z.int() }))])
.describe(
'The events tags: names for a build newer than 20230414, `{ Tag, Type }` pairs for ' +
'that build and older'
),
})
/**
@@ -676,6 +1087,17 @@ export const PlayerEventResultDto = z.object({
}),
})
/**
* The envelope a delete answers with. Same three keys as {@link PlayerEventResultDto},
* but both payload fields are null — the event is gone, so there is nothing to redraw
* and the client reads only `Result`.
*/
export const PlayerEventDeletedDto = z.object({
PlayerEvent: z.null(),
Result: z.int().describe('0 = success'),
TagModifyResult: z.null(),
})
/**
* The JSON body of an event create / update. Every field is optional: create defaults
* what's missing, update leaves anything absent at its stored value. The fields may be
@@ -691,6 +1113,50 @@ export const PlayerEventRequest = PlayerEventDto.partial().extend({
.describe('The events fields, if nested rather than posted at the top level'),
})
/**
* `PUT /api/playerevents/v2/{eventId}/time` form body — the event's window, moved. Both
* bounds are optional; an absent one keeps its stored value, so the start can be nudged
* without restating the end. The RESOLVED window must end after it starts and run no
* longer than 24 hours.
*/
export const PlayerEventTimeRequest = z.object({
startTime: z
.string()
.optional()
.describe('New start, any parseable ISO 8601 — the client sends .NET tick precision'),
endTime: z.string().optional().describe('New end, same form'),
})
/**
* `PUT /api/playerevents/v2/{eventId}/accessibility` form body. The client sends the
* `RoomAccessibility` NAME, as it does on the subroom route in `rooms`; the ordinal is
* accepted too.
*/
export const PlayerEventAccessibilityRequest = z.object({
accessibility: z
.string()
.describe(
'`Private`, `Public`, `Unlisted`, `Dev_only` or `Dev_Unlisted` (case-insensitive) — ' +
'or its ordinal 04'
),
})
/** `PUT /api/playerevents/v2/{eventId}/name` form body. */
export const PlayerEventNameRequest = z.object({
name: z.string().describe('The new title; blank is refused — an event always has a name'),
})
/** `PUT /api/playerevents/v2/{eventId}/description` form body. */
export const PlayerEventDescriptionRequest = z.object({
description: z.string().optional().describe('The new blurb; absent clears it'),
})
/**
* `PUT /api/playerevents/v2/{eventId}/tags` body — a BARE JSON ARRAY of tag names
* (`["tag1","class"]`), not an object. The whole set the event should carry.
*/
export const PlayerEventTagsRequest = z.array(z.string()).describe('The events whole tag set')
/**
* `GET /api/playerevents/v1/:eventId/responses` — one player's RSVP to one event, as
* the guest list serves it.
@@ -728,6 +1194,39 @@ export const PlayerEventReportRequest = z.object({
Details: z.string().optional().describe('The free-text description the reporter typed'),
})
/**
* `POST /api/inventions/v1/report` JSON body — a report against an invention. JSON, like
* the event report and unlike the form-encoded player report. The reporter is NOT in the
* body: it's the bearer token's player, and neither is the invention's creator, who is
* read from the invention.
*/
/**
* `POST /api/customAvatarItems/v1/{id}/report` JSON body. The item is named by the PATH, not
* the body, and `ReportedPlayerId` arrives NULL — the client does not know who made the item,
* so the creator is read off the item instead.
*/
export const CustomAvatarItemReportRequest = z.object({
ReportCategory: z
.int()
.optional()
.describe('The reason picked in the report UI. Stored verbatim; unmapped'),
Details: z.string().optional().describe('The free-text description the reporter typed'),
ReportedPlayerId: z
.int()
.nullable()
.optional()
.describe('Sent as null and IGNORED — the reported player is the items creator'),
})
export const InventionReportRequest = z.object({
InventionId: z.int().describe('The invention being reported'),
ReportCategory: z
.int()
.optional()
.describe('The reason picked in the report UI. Stored verbatim; unmapped'),
Details: z.string().optional().describe('The free-text description the reporter typed'),
})
/** `POST /api/playerevents/v1/bulkInvite` JSON body — who to invite to which event. */
export const PlayerEventBulkInviteRequest = z.object({
PlayerEventId: z.int(),
@@ -765,24 +1264,62 @@ export const VoteToKickReason = z.object({
})
/**
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — always the "not blocked"
* answer (no ban storage yet), 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` are on the DTO but unset by that stub, so
* they carry their C# defaults (false / null).
* `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()`:
* `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`
* are on the DTO but unset by that stub, so they carry their C# defaults (false / null).
*
* Sixteen keys on the wire — every one the 2025 client's `ModerationBlockDetail` formatter
* reads. The seven past the stub's nine (`IsDeviceBan` … `BottomMessageOverride`) are
* block kinds and screen dressings this server never hands out, so they always carry their
* "none" value; they are sent so a decoder that wants the key present finds it.
*/
export const ModerationBlockDetails = z.object({
ReportCategory: z.int().describe('-1 = ReportCategory.Unknown (0 is a real category)'),
Duration: z.int(),
ReportCategory: z
.int()
.describe(
'The category the bans report was filed under; -1 = ReportCategory.Unknown when not blocked (0 is a real category)'
),
Duration: z
.int()
.describe(
'Length of the block in seconds from `TimeoutStartedAt`; 2147483647 (int32 max) for a permanent ban; 0 when not blocked'
),
GameSessionId: z.int(),
IsBan: z.boolean(),
IsHostKick: z.boolean(),
IsVoiceModAutoban: z.boolean(),
Message: z.string().nullable(),
PlayerIdReporter: z.int().nullable(),
TimeoutStartedAt: z.string().nullable(),
IsHostKick: z.boolean().describe('Always false — no host kick is ever recorded here'),
Message: z.string().nullable().describe('“Rule violation” on a ban; null when not blocked'),
PlayerIdReporter: z
.int()
.nullable()
.describe('Always null — the reporter is not shown to the reported'),
IsBan: z.boolean().describe('True when an account-wide ban is in force'),
IsVoiceModAutoban: z.boolean().describe('Always false'),
IsDeviceBan: z.boolean().describe('Always false — bans here are account-wide, not per device'),
IsWarning: z
.boolean()
.describe('Always false — warnings are delivered as notifications, not here'),
VoteKickReason: z.string().nullable().describe('Always null — no vote-kick is recorded here'),
TimeoutStartedAt: z
.string()
.nullable()
.describe(
'When the block began — the bans report `created_at` (ISO-8601 UTC); `Duration` runs from it. Null when not blocked'
),
AssociatedAccountUsername: z.string().nullable().describe('Always null'),
ShowCreatorCodeOfConduct: z.boolean().describe('Always false'),
TopMessageOverride: z
.string()
.nullable()
.describe('Always null — the clients default block-screen text stands'),
BottomMessageOverride: z
.string()
.nullable()
.describe('Always null — the clients default block-screen text stands'),
})
/**
@@ -827,6 +1364,33 @@ export const CreateWarningRequest = z.object({
ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'),
})
/**
* `POST /api/PlayerReporting/v3/voteToKick` form body — a player calling a vote on
* another. Everything is a string on the wire (it's form-encoded). `Reason` is one of the
* labels `GET /api/PlayerReporting/v1/voteToKickReasons` serves; the voter is NOT in the
* body — it's the bearer token's subject.
*/
export const VoteToKickRequest = z.object({
PlayerId: z.string().describe('Account id of the player being voted on'),
Response: z.string().describe('The callers own vote, e.g. `True`'),
Reason: z
.string()
.optional()
.describe('A `voteToKickReasons` label, e.g. `Inactive in games (AFK)`'),
GameSessionId: z.string().describe('The room instance both players are standing in'),
})
/**
* `POST /api/PlayerReporting/v1/instantKick` JSON body — the players a room's staff are
* ejecting from one live instance. JSON, not a form, unlike its neighbours in this
* controller. `GameSessionId` is the room INSTANCE id (`roomInstanceId`); the kick is
* scoped to it, so a player named here who is standing somewhere else is left alone.
*/
export const InstantKickRequest = z.object({
GameSessionId: z.int().describe('The room instance (game session) to eject them from'),
PlayerIds: z.array(z.int()).describe('Account ids to kick out of that instance'),
})
/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
export const DeviceIdRequest = z.object({
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
+56 -23
View File
@@ -7,28 +7,38 @@
* what a player submitted: the table is a log of exactly what was reported.
*
* The `api` worker owns this schema/migration (migrations/0004_report.sql,
* 0009_report_ban.sql and 0011_report_event.sql, applied under its own
* `migrations_table` so it doesn't clash with the other workers' migrations that share
* the database).
* 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql and
* 0017_report_custom_avatar_item.sql, applied under its own `migrations_table` so it
* doesn't clash with the other workers' migrations that share the database).
*
* A reported player EVENT lands here too, rather than in a table of its own: same
* fields, same moderation life. Such a row carries `event_id`, and its
* `reported_player_id` is the event's creator — see `POST /api/playerevents/v1/report`.
* A reported player EVENT, INVENTION or CUSTOM AVATAR ITEM lands here too, rather than in a
* table of its own: same fields, same moderation life. Such a row carries `event_id`,
* `invention_id` or `custom_avatar_item_id`, and its `reported_player_id` is that thing's
* CREATOR — see `POST /api/playerevents/v1/report`, `POST /api/inventions/v1/report` and
* `POST /api/customAvatarItems/v1/{id}/report`. The three id columns are mutually exclusive;
* a row with none of them is an ordinary player report. They are three columns rather than
* one polymorphic id because the keys differ in TYPE: two numbers and a guid.
*
* A report is also where an ACCOUNT-WIDE ban lives: acting on a report sets `banned`
* on that same row (see `banFromReport`), so the ban carries the evidence for it. Two
* workers read it — `match` refuses every matchmake for a banned player, and `auth`
* refuses to issue them a token at all — both via `isPlayerBanned`. This is distinct
* from the per-room `room_ban` table the rooms worker owns: that one keeps a player
* out of ONE room, this one out of the game.
*
* `/api/PlayerReporting/v1/moderationBlockDetails` is NOT wired to it yet and still
* answers "not blocked" unconditionally.
* on that same row (see `banFromReport`), so the ban carries the evidence for it. It is
* ENFORCED by `match`, which refuses every matchmake for a banned player, and DESCRIBED
* by `/api/PlayerReporting/v1/moderationBlockDetails`, which tells the banned player why
* (via `getActiveBan`). `auth` still issues a banned account a token — that is what lets
* the client reach the block screen — and reads this table only for ban EVASION (an
* account sharing a device or network with a banned one; see bans-db). This is distinct
* from the per-room `room_ban` table the rooms worker owns: that one keeps a player out
* of ONE room, this one out of the game.
*/
/**
* Schema DDL (mirror of migrations/0004_report.sql + 0009_report_ban.sql +
* 0011_report_event.sql).
* 0011_report_event.sql + 0016_report_invention.sql + 0017_report_custom_avatar_item.sql).
*
* None of `event_id`, `invention_id` or `custom_avatar_item_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
* needs it.
*/
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS report (
@@ -44,12 +54,13 @@ export const SCHEMA_DDL: string[] = [
created_at TEXT NOT NULL,
banned INTEGER NOT NULL DEFAULT 0,
ban_expires TEXT,
event_id INTEGER
event_id INTEGER,
invention_id INTEGER,
custom_avatar_item_id TEXT
)`,
`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)`,
`CREATE INDEX IF NOT EXISTS idx_report_banned ON report (reported_player_id) WHERE banned = 1`,
`CREATE INDEX IF NOT EXISTS idx_report_event ON report (event_id) WHERE event_id IS NOT NULL`,
]
/** A stored report row (snake_case columns, one row per submission). */
@@ -76,6 +87,22 @@ export interface ReportRow {
* `reported_player_id` and `room_id` are filled in from the event itself.
*/
event_id: number | null
/**
* The invention this report is against, or NULL for any other kind — mutually exclusive
* with `event_id`. See `POST /api/inventions/v1/report`: `reported_player_id` is the
* invention's creator, read from the invention itself. No `room_id` comes with it; an
* invention isn't tied to one room the way an event is.
*/
invention_id: number | null
/**
* The custom avatar item this report is against, or NULL for any other kind — mutually
* exclusive with the two above. TEXT because such an item is keyed by a GUID where an
* event and an invention are keyed by numbers. See
* `POST /api/customAvatarItems/v1/{id}/report`: `reported_player_id` is the item's
* creator, read from the item, because the client sends `ReportedPlayerId: null` here —
* it does not know who made it.
*/
custom_avatar_item_id: string | null
}
/**
@@ -95,6 +122,10 @@ export interface NewReport {
roomInstanceType?: string | null
/** Set only when reporting a player EVENT; absent on an ordinary player report. */
eventId?: number | null
/** Set only when reporting an INVENTION; never set alongside `eventId`. */
inventionId?: number | null
/** Set only when reporting a CUSTOM AVATAR ITEM; never set alongside the two above. */
customAvatarItemId?: string | null
}
/** Record a submitted report, returning the stored row (with its assigned id). */
@@ -104,8 +135,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
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
event_id, invention_id, custom_avatar_item_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
RETURNING *`
)
.bind(
@@ -118,7 +149,9 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
input.roomId ?? null,
input.roomInstanceType ?? null,
new Date().toISOString(),
input.eventId ?? null
input.eventId ?? null,
input.inventionId ?? null,
input.customAvatarItemId ?? null
)
.first<ReportRow>()
// RETURNING always yields the inserted row; the non-null assert keeps the caller
@@ -166,9 +199,9 @@ export async function getActiveBan(
}
/**
* Whether a player is banned right now. The hot-path form of `getActiveBan` — `match`
* calls it on every matchmake and `auth` on every token grant, and neither has anything
* to say about WHICH report did it.
* Whether a player is banned right now. The hot-path form of `getActiveBan`, for a caller
* that has nothing to say about WHICH report did it. `moderationBlockDetails` is the
* caller that does, and reads `getActiveBan` itself.
*/
export async function isPlayerBanned(
db: D1Database,
+345
View File
@@ -0,0 +1,345 @@
/**
* Player reputation — the cheer counters on a profile — and the daily cheer credit that
* pays for handing one out, on the shared `recflare` D1 database.
*
* Two tables, because they answer two different questions:
*
* - `reputation` is what a player has RECEIVED: one counter per cheer category, plus the
* assorted profile numbers the DTO carries. One row per account, created the first time
* somebody cheers them — a missing row means "nobody has cheered them yet", which is
* exactly the all-zero default the reputation endpoints already served, so reads fall
* back to it rather than inserting on a GET.
* - `player_cheer` is what a player has left to GIVE: a credit that refills to
* {@link DAILY_CHEER_CREDIT} once the window in `created` is a day old. One row per
* account, created the first time they spend one.
*
* One of the client's fields is deliberately NOT a column. `CheerCredit` sits alongside
* the counters in the client's record but is `player_cheer.cheers_left` with the rollover
* applied — storing it twice would let the number a player reads drift from the one the
* spend checks. `SelectedCheer` (the cheer pinned to the profile, set by
* `POST /api/PlayerCheer/v1/SetSelectedCheer`) and `IsCheerful` (a profile flag every
* reference serves as true) ARE columns, added in 0014 — the `ReputationUpdate` frame is
* the record trimmed, nothing more, so both come off the row.
*
* The `api` worker owns the schema/migrations (migrations/0013_reputation.sql and
* 0014_reputation_selected_cheer.sql, applied under its own `migrations_table` so they
* don't clash with the other workers' migrations that share the database).
*/
/** Schema DDL (mirror of migrations/0013_reputation.sql + 0014, folded into one CREATE). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS reputation (
account_id INTEGER PRIMARY KEY,
noteriety INTEGER NOT NULL DEFAULT 0,
cheer_general INTEGER NOT NULL DEFAULT 0,
cheer_helpful INTEGER NOT NULL DEFAULT 0,
cheer_creative INTEGER NOT NULL DEFAULT 0,
cheer_great_host INTEGER NOT NULL DEFAULT 0,
cheer_sportsman INTEGER NOT NULL DEFAULT 0,
subscriber_count INTEGER NOT NULL DEFAULT 0,
subscribed_count INTEGER NOT NULL DEFAULT 0,
is_cheerful INTEGER NOT NULL DEFAULT 1,
selected_cheer INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS player_cheer (
player_id INTEGER PRIMARY KEY,
cheers_left INTEGER NOT NULL,
created TEXT NOT NULL
)`,
]
/**
* The cheer categories the client posts as `CheerCategory`. The gaps are the client's —
* the enum steps by ten, so it can grow without renumbering.
*/
export enum CheerCategory {
None = -1,
General = 0,
Helpful = 10,
Sportmanship = 20,
GreatHost = 30,
Creative = 40,
}
/**
* The `reputation` column each category counts into. Doubles as the whitelist the spend
* interpolates into its SQL: a category that isn't a key here never reaches the query.
* `None` is absent deliberately — it is the client's "no category", not a counter.
*/
const CHEER_COLUMN: Partial<Record<CheerCategory, string>> = {
[CheerCategory.General]: 'cheer_general',
[CheerCategory.Helpful]: 'cheer_helpful',
[CheerCategory.Sportmanship]: 'cheer_sportsman',
[CheerCategory.GreatHost]: 'cheer_great_host',
[CheerCategory.Creative]: 'cheer_creative',
}
/** Whether `value` names a category that counts — i.e. anything but `None`. */
export function isCheerCategory(value: number): value is CheerCategory {
return value in CHEER_COLUMN
}
/** How many cheers a player may hand out per window. */
export const DAILY_CHEER_CREDIT = 20
/** How long a credit window lasts before it refills. */
export const CHEER_WINDOW_MS = 24 * 60 * 60 * 1000
/** A stored reputation row (snake_case columns, one row per account). */
interface ReputationRow {
account_id: number
noteriety: number
cheer_general: number
cheer_helpful: number
cheer_creative: number
cheer_great_host: number
cheer_sportsman: number
subscriber_count: number
subscribed_count: number
/** SQLite boolean: 0 / 1. */
is_cheerful: number
selected_cheer: number
}
/**
* A player's reputation as the client's DTO renders it — and, trimmed, as the
* `ReputationUpdate` frame carries it.
*
* `Noteriety` (the reference's spelling), `SubscriberCount` and `SubscribedCount` are
* columns nothing writes yet. `IsCheerful` is a column nothing writes either, defaulted
* true like every reference serves it. `SelectedCheer` is the pinned cheer, 0 = none.
*/
export interface Reputation {
AccountId: number
IsCheerful: boolean
Noteriety: number
SelectedCheer: number
CheerCredit: number
CheerGeneral: number
CheerHelpful: number
CheerCreative: number
CheerGreatHost: number
CheerSportsman: number
SubscriberCount: number
SubscribedCount: number
}
/**
* What a player with no row has: nobody has cheered them, and they hold their full credit.
* `credit` is passed in rather than defaulted because a player can have spent cheers
* without having received any — the two tables are independent.
*/
export function defaultReputation(accountId: number, credit = DAILY_CHEER_CREDIT): Reputation {
return {
AccountId: accountId,
IsCheerful: true,
Noteriety: 0,
SelectedCheer: 0,
CheerCredit: credit,
CheerGeneral: 0,
CheerHelpful: 0,
CheerCreative: 0,
CheerGreatHost: 0,
CheerSportsman: 0,
SubscriberCount: 0,
SubscribedCount: 0,
}
}
/** Project a stored row onto the DTO, with the credit read from `player_cheer`. */
function toReputation(row: ReputationRow, credit: number): Reputation {
return {
AccountId: row.account_id,
IsCheerful: row.is_cheerful !== 0,
Noteriety: row.noteriety,
SelectedCheer: row.selected_cheer,
CheerCredit: credit,
CheerGeneral: row.cheer_general,
CheerHelpful: row.cheer_helpful,
CheerCreative: row.cheer_creative,
CheerGreatHost: row.cheer_great_host,
CheerSportsman: row.cheer_sportsman,
SubscriberCount: row.subscriber_count,
SubscribedCount: row.subscribed_count,
}
}
/**
* The instant a credit window has to have started AFTER for the stored `cheers_left` to
* still apply. Anything at or before it has rolled over. ISO-8601 UTC is fixed-width, so
* SQLite's string comparison orders these correctly — no date functions needed.
*/
function windowCutoff(now: Date): string {
return new Date(now.getTime() - CHEER_WINDOW_MS).toISOString()
}
/**
* How many cheers each of `playerIds` has left to give, in the order asked — a read, so
* a window that has rolled over reads as a full credit WITHOUT writing the reset back.
* The reset is the spend's job; doing it here would refill a player's credit every time
* somebody looked at their profile.
*/
export async function getCheerCredits(
db: D1Database,
playerIds: number[],
now: Date = new Date()
): Promise<Map<number, number>> {
const credits = new Map<number, number>()
if (playerIds.length === 0) return credits
const placeholders = playerIds.map((_, i) => `?${i + 2}`).join(', ')
const { results } = await db
.prepare(
`SELECT player_id, cheers_left FROM player_cheer
WHERE created > ?1 AND player_id IN (${placeholders})`
)
.bind(windowCutoff(now), ...playerIds)
.all<{ player_id: number; cheers_left: number }>()
for (const row of results) credits.set(row.player_id, row.cheers_left)
return credits
}
/** One player's remaining cheer credit (see {@link getCheerCredits} — also a pure read). */
export async function getCheerCredit(
db: D1Database,
playerId: number,
now: Date = new Date()
): Promise<number> {
const credits = await getCheerCredits(db, [playerId], now)
return credits.get(playerId) ?? DAILY_CHEER_CREDIT
}
/**
* Reputations for a list of ids, in the order asked and one per id — the bulk lookups
* render a profile card per entry, so an id with no row still gets its default rather
* than being dropped from the list.
*/
export async function getReputations(
db: D1Database,
accountIds: number[],
now: Date = new Date()
): Promise<Reputation[]> {
if (accountIds.length === 0) return []
const placeholders = accountIds.map((_, i) => `?${i + 1}`).join(', ')
const [{ results }, credits] = await Promise.all([
db
.prepare(`SELECT * FROM reputation WHERE account_id IN (${placeholders})`)
.bind(...accountIds)
.all<ReputationRow>(),
getCheerCredits(db, accountIds, now),
])
const stored = new Map(results.map((r) => [r.account_id, r]))
return accountIds.map((id) => {
const credit = credits.get(id) ?? DAILY_CHEER_CREDIT
const row = stored.get(id)
return row === undefined ? defaultReputation(id, credit) : toReputation(row, credit)
})
}
/** One player's reputation, defaulted when nobody has cheered them yet. */
export async function getReputation(
db: D1Database,
accountId: number,
now: Date = new Date()
): Promise<Reputation> {
const [reputation] = await getReputations(db, [accountId], now)
return reputation!
}
/**
* Take one cheer out of a player's daily credit, resolving the credit they have left, or
* null when they had none to spend.
*
* One statement, so two cheers fired off together can't both read the same stale credit
* and write it back — the client lets a player cheer several people in a row. The three
* cases fold into the upsert:
*
* - no row: insert one at `DAILY_CHEER_CREDIT - 1`, window starting now;
* - the window rolled over (`created` at or before the cutoff): reset to
* `DAILY_CHEER_CREDIT - 1` and start a fresh window, which is what makes the credit
* refill lazily rather than needing a cron;
* - the window is live: decrement, keeping the window's original start so a player who
* spends all day still refills 24h after their FIRST cheer, not their last.
*
* The `WHERE` on the update is the refusal: a live window with nothing left updates no
* row, so `RETURNING` yields nothing and the caller answers "out of cheers".
*/
export async function spendCheerCredit(
db: D1Database,
playerId: number,
now: Date = new Date()
): Promise<number | null> {
const cutoff = windowCutoff(now)
const row = await db
.prepare(
`INSERT INTO player_cheer (player_id, cheers_left, created) VALUES (?1, ?2, ?3)
ON CONFLICT (player_id) DO UPDATE SET
cheers_left = CASE WHEN player_cheer.created <= ?4
THEN ?2 ELSE player_cheer.cheers_left - 1 END,
created = CASE WHEN player_cheer.created <= ?4
THEN ?3 ELSE player_cheer.created END
WHERE player_cheer.created <= ?4 OR player_cheer.cheers_left > 0
RETURNING cheers_left`
)
.bind(playerId, DAILY_CHEER_CREDIT - 1, now.toISOString(), cutoff)
.first<{ cheers_left: number }>()
return row === null ? null : row.cheers_left
}
/**
* Count a received cheer against `accountId`'s category counter, returning the reputation
* they now hold. Creates the row on the first cheer they ever receive.
*
* The column is looked up in {@link CHEER_COLUMN} rather than built from the category, so
* only the five known names can reach the SQL; an unknown category is rejected by the
* route before it gets here.
*/
export async function addCheer(
db: D1Database,
accountId: number,
category: CheerCategory,
now: Date = new Date()
): Promise<Reputation> {
const column = CHEER_COLUMN[category]
if (column === undefined) throw new Error(`unknown cheer category ${category}`)
const [row, credit] = await Promise.all([
db
.prepare(
`INSERT INTO reputation (account_id, ${column}) VALUES (?1, 1)
ON CONFLICT (account_id) DO UPDATE SET ${column} = reputation.${column} + 1
RETURNING *`
)
.bind(accountId)
.first<ReputationRow>(),
getCheerCredit(db, accountId, now),
])
// RETURNING always yields the upserted row; the non-null assert keeps the caller from
// having to handle an impossible null.
return toReputation(row!, credit)
}
/**
* Pin `category` as `accountId`'s selected cheer — the badge the client shows on their
* profile — returning the reputation they now hold. `None` (-1) unpins it, stored as 0 the
* way the DTO reads "nothing selected". Creates the row if nobody has cheered them yet:
* a player can pin a badge before ever receiving a cheer.
*/
export async function setSelectedCheer(
db: D1Database,
accountId: number,
category: CheerCategory,
now: Date = new Date()
): Promise<Reputation> {
const selected = category === CheerCategory.None ? 0 : category
const [row, credit] = await Promise.all([
db
.prepare(
`INSERT INTO reputation (account_id, selected_cheer) VALUES (?1, ?2)
ON CONFLICT (account_id) DO UPDATE SET selected_cheer = ?2
RETURNING *`
)
.bind(accountId, selected)
.first<ReputationRow>(),
getCheerCredit(db, accountId, now),
])
return toReputation(row!, credit)
}
+1221 -125
View File
@@ -4,15 +4,31 @@ import { describeRoute } from 'hono-openapi'
import {
CURRENT_OUTFIT_SLOT,
getOutfit,
getOutfits,
getOutfitsByAccounts,
inventionDescriptionRejection,
inventionLongDescriptionRejection,
inventionNameRejection,
inventionTagRejection,
MAX_BULK_OUTFIT_ACCOUNTS,
setOutfit,
} from '@repo/domain'
import {
createCustomAvatarItem,
deleteCustomAvatarItem,
getCustomAvatarItem,
getCustomAvatarItems,
listCustomAvatarItemsByCreator,
listFeaturedCustomAvatarItems,
listHotCustomAvatarItems,
searchCustomAvatarItems,
updateCustomAvatarItem,
} from '../custom-avatar-items-db'
import { authedId, unauthorized } from '../http'
import {
createInvention,
deleteInvention,
getFeaturedInventions,
getInventionById,
getInventionsByIds,
@@ -22,73 +38,263 @@ import {
getInventionVersion,
getMyInventions,
getTopInventions,
INVENTION_TAG_RESULT,
inventionDeleteResult,
inventionSaveV9Failure,
isInventionCheered,
normalizeInventionTags,
ownsAllInventions,
parsePermissionLevel,
publishInvention,
searchInventions,
setInventionCheer,
setInventionPrice,
setInventionTags,
toSaveResult,
toSaveResultV9,
updateInvention,
} from '../inventions-db'
import {
AUTHED,
BareBoolean,
BareInteger,
BulkCustomAvatarItemsRequest,
CreateCustomAvatarItemRequest,
CustomAvatarItemList,
CustomAvatarItemReportRequest,
CustomAvatarItemResponse,
CustomAvatarItemsPage,
DeleteInventionRequest,
ErrorResponse,
form,
GeneratedGift,
GenerateGiftRequest,
idParam,
intQuery,
InventionCheerRequest,
InventionDeleteResult,
InventionDetails,
InventionDto,
InventionPersonalDetails,
InventionReportRequest,
InventionSaveResult,
InventionSaveV9Result,
InventionVersionDto,
json,
JsonArray,
jsonBody,
LegacyAvatarItemSaves,
OPTIONAL_AUTHED,
OutfitSaveResponse,
OutfitsBulkRequest,
OutfitsBulkResponse,
OutfitsMeRequest,
OutfitsMeResponse,
pageParams,
PublishInventionRequest,
SaveInventionRequest,
SaveInventionV9Request,
SetTagsRequest,
SetTagsResponse,
stringParam,
stringQuery,
SuccessErrorEnvelope,
SuccessValueEnvelope,
TagFilters,
UNAUTHORIZED_RESPONSE,
UpdateCustomAvatarItemRequest,
UpdateInventionMetadataRequest,
UpdatePriceRequest,
} from '../openapi'
import { createReport } from '../reports-db'
import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit'
import type { Context } from 'hono'
import type { App } from '../context'
import type { SavedInvention } from '../inventions-db'
import type { InventionTag, SavedInvention } from '../inventions-db'
/**
* The most ids `POST /api/customAvatarItems/v1/bulk` will resolve. A batch over this answers
* EMPTY rather than being truncated.
*
* Empty rather than the first 100, because a truncated answer is indistinguishable from the
* items simply not existing — the client reads the items it got back, not the ids it asked
* about, so it cannot tell a cut-off batch from a batch of misses and would cache the
* difference. Nothing renders this many custom items at once, so a batch this size is the
* client doing something other than filling a screen.
*/
const BULK_CUSTOM_AVATAR_ITEM_CAP = 100
/**
* The ids `POST /api/customAvatarItems/v1/bulk` was asked to resolve. They ride as repeated
* `customAvatarItemIds` form fields, and the same spelling is read off the query string
* too — the client's exact encoding here has not been pinned down, so both are accepted
* rather than guessing one and answering nothing when it's the other.
*
* Each value may itself be a comma-separated list, and blanks are dropped rather than
* failing the request: a stray id must not cost the caller the rest of the batch. The order
* asked for is preserved, since `getCustomAvatarItems` answers in it.
*/
async function bulkCustomAvatarItemIds(c: Context<App>): Promise<string[]> {
const raw = [...(c.req.queries('customAvatarItemIds') ?? [])]
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
const key = Object.keys(body).find((k) => k.toLowerCase() === 'customavataritemids')
const posted = key === undefined ? [] : body[key]
for (const value of Array.isArray(posted) ? posted : [posted]) {
if (typeof value === 'string') raw.push(value)
}
return raw
.flatMap((value) => value.split(','))
.map((v) => v.trim())
.filter((v) => v !== '')
}
/**
* The gate every invention write runs through: the caller must be signed in, the
* invention must exist, and it must be theirs. Yields the loaded invention, or the
* error response to return as-is (401 / 404 / 403).
* invention must exist, and it must be theirs. Yields the loaded invention, or why not —
* as a reason and the status it maps to, so that a caller answering an envelope can put
* the reason where its client will read it instead of in a body that client can't parse.
* {@link creatorsInvention} is the rendering the older routes want.
*/
async function creatorsInventionResult(
c: Context<App>,
inventionId: number
): Promise<
{ invention: SavedInvention } | { rejection: string; status: 400 | 401 | 403 | 404 }
> {
const playerId = await authedId(c)
if (playerId === null) return { rejection: 'Unauthorized', status: 401 }
if (Number.isNaN(inventionId)) return { rejection: 'inventionId is required', status: 400 }
const invention = await getInventionById(c.env.DB, inventionId)
if (invention === null) return { rejection: 'No such invention', status: 404 }
if (invention.CreatorPlayerId !== playerId) {
return { rejection: 'Not your invention', status: 403 }
}
return { invention }
}
/**
* {@link creatorsInventionResult} as the older invention writes answer it: the loaded
* invention, or the response to return as-is (400 / 401 / 403 / 404).
*/
async function creatorsInvention(
c: Context<App>,
inventionId: number
): Promise<{ invention: SavedInvention } | { response: Response | Promise<Response> }> {
const playerId = await authedId(c)
if (playerId === null) return { response: unauthorized(c) }
if (Number.isNaN(inventionId)) {
return { response: c.json({ error: 'inventionId is required' }, 400) }
const gate = await creatorsInventionResult(c, inventionId)
if ('invention' in gate) return gate
if (gate.status === 401) return { response: unauthorized(c) }
if (gate.status === 404) return { response: c.notFound() }
return { response: c.json({ error: gate.rejection }, gate.status) }
}
/**
* The tags a `{ AutoTags, CustomTags }` request asks for, and whether they were taken —
* the block the v9 save sends as `tagsRequest` and `v2/metadata` sends as `TagsRequest`.
* Null when the client named no block at all, which each caller reads its own way: a save
* stores no tags, an edit leaves the stored ones alone.
*
* Tags are held to the same rule `v1/settags` applies, but a tag that breaks it costs the
* TAGS and not the write: both replies carry a tag result of their own precisely because
* the two outcomes are separate, and refusing a save would make the player redo a build
* over a hyphen. All the tags go rather than the offending one alone, so nothing is
* silently half-applied — the creator re-submits the list and sees what took. Blanks are
* skipped rather than counted against it; the client pads its lists with empties.
*/
function requestedTags(request: unknown): { tags: InventionTag[]; tagResult: number } | null {
if (typeof request !== 'object' || request === null) return null
const lists = request as Record<string, unknown>
const strings = (v: unknown): string[] =>
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
const autoTags = strings(lists.AutoTags)
const customTags = strings(lists.CustomTags)
const rejected = [...autoTags, ...customTags].some((raw) => {
const tag = raw.trim().toLowerCase()
return tag !== '' && inventionTagRejection(tag) !== null
})
return rejected
? { tags: [], tagResult: INVENTION_TAG_RESULT.rejected }
: { tags: normalizeInventionTags(autoTags, customTags), tagResult: INVENTION_TAG_RESULT.success }
}
/**
* What an invention save produced: the stored record and how its tags fared, or the one
* message that refuses it. Both save routes go through {@link createInventionFromBody} to
* get one of these and then render it their own way — v6 bare, v9 enveloped — because the
* two versions disagree about the shape of a reply, not about what a save is.
*/
type InventionSaveOutcome =
| { rejection: string }
| { invention: SavedInvention; tags: InventionTag[]; tagResult: number }
/**
* The invention save both `v6/save` and `v9/save` run through. v9 sends everything v6 does
* plus what the invention points at (`referencedUnityAssetIds`), what it says about itself
* (`longDescription`, `displayMetadataJson`, `convertedFromInventionId`), `ugcVersion` and
* `hasBetaContent`, and the tags that until now needed a second `v1/settags` call. One
* reader takes them all: a v6 client sends none of them, and each is optional, so parsing
* them here changes nothing about the record a v6 save stores.
*/
async function createInventionFromBody(
c: Context<App>,
creatorPlayerId: number,
body: Record<string, unknown>
): Promise<InventionSaveOutcome> {
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
const bool = (v: unknown): boolean | undefined => (typeof v === 'boolean' ? v : undefined)
const list = <T>(v: unknown, is: (x: unknown) => x is T): T[] | undefined =>
Array.isArray(v) ? v.filter(is) : undefined
const isString = (v: unknown): v is string => typeof v === 'string'
const isNumber = (v: unknown): v is number => typeof v === 'number'
const inventionDataFilename = str(body.inventionDataFilename)?.trim()
if (!inventionDataFilename) return { rejection: 'inventionDataFilename is required' }
// An omitted or blank name/description is defaulted by `createInvention` ("Untitled",
// "No description yet"), so only a supplied one is held to the rules — otherwise
// saving an unnamed invention would fail the 3-character minimum on a name the
// player never typed.
const name = str(body.name)?.trim()
const nameRejection = name === undefined || name === '' ? null : inventionNameRejection(name)
if (nameRejection !== null) return { rejection: nameRejection }
const description = str(body.description)
const descriptionRejection =
description === undefined ? null : inventionDescriptionRejection(description)
if (descriptionRejection !== null) return { rejection: descriptionRejection }
// v9 folds `v1/settags` into the save; a client that names no tags gets none.
const requested = requestedTags(body.tagsRequest) ?? {
tags: [],
tagResult: INVENTION_TAG_RESULT.success,
}
const invention = await getInventionById(c.env.DB, inventionId)
if (invention === null) return { response: c.notFound() }
if (invention.CreatorPlayerId !== playerId) {
return { response: c.json({ error: 'Not your invention' }, 403) }
}
return { invention }
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
creatorPlayerId,
inventionDataFilename,
name,
description,
imageName: str(body.imageName),
instantiationCost: num(body.instantiationCost),
lightsCost: num(body.lightsCost),
chipsCost: num(body.chipsCost),
cloudVariablesCost: num(body.cloudVariablesCost),
aiCost: num(body.aiCost),
creationRoomId: num(body.creationRoomId),
referencedInventions: list(body.referencedInventions, isNumber),
ugcVersion: num(body.ugcVersion),
hasBetaContent: bool(body.hasBetaContent),
referencedUnityAssetIds: list(body.referencedUnityAssetIds, isString),
longDescription: str(body.longDescription),
displayMetadataJson: str(body.displayMetadataJson),
convertedFromInventionId: num(body.convertedFromInventionId),
tags: requested.tags,
})
return { invention, ...requested }
}
/**
@@ -223,38 +429,386 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}),
(c) => c.json(true)
)
.get(
'/api/customAvatarItems/v1/minPriceForPublicItem',
describeRoute({
tags: ['Avatar'],
summary: 'Minimum token price for a public custom item',
description:
'The floor the creation UI enforces when listing a custom item publicly. A fixed `100`.',
responses: { 200: json(BareInteger, 'A bare `100`') },
}),
(c) => c.json(100)
)
.post(
'/api/customAvatarItems/v1',
describeRoute({
tags: ['Avatar'],
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 ' +
'by the caller and answers with it in the PascalCase `{ Value, Success, Error, ' +
'error_id }` envelope.\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.',
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'),
401: UNAUTHORIZED_RESPONSE,
413: json(CustomAvatarItemResponse, 'Either file exceeds the configured per-file limit'),
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// The featured custom-avatar-item feed. No curated items yet → an empty list.
const fail = (message: string) =>
c.json({ Value: null, Success: false, Error: message, error_id: null }, 400)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
if (typeof body.metadata !== 'string') return fail('metadata is required')
let meta: Record<string, unknown>
try {
const parsed: unknown = JSON.parse(body.metadata)
if (!parsed || typeof parsed !== 'object') return fail('metadata must be a JSON object')
meta = parsed as Record<string, unknown>
} catch {
return fail('metadata is not valid JSON')
}
if (typeof meta.Name !== 'string' || meta.Name.trim() === '') return fail('Name is required')
if (typeof meta.BaseAvatarItemId !== 'number') return fail('BaseAvatarItemId is required')
if (typeof meta.BaseAvatarItemColor !== 'string')
return fail('BaseAvatarItemColor is required')
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)
// Each file gets the full per-file ceiling. Check both before either is copied into
// an ArrayBuffer or written, so a rejected request never leaves half an item in R2.
if (exceedsApiUploadLimit(body.thumbnailImage, limit)) {
return c.json(
{
Value: null,
Success: false,
Error: `thumbnailImage exceeds the ${limit}-byte upload limit`,
error_id: null,
},
413
)
}
if (exceedsApiUploadLimit(body.design, limit)) {
return c.json(
{
Value: null,
Success: false,
Error: `design exceeds the ${limit}-byte upload limit`,
error_id: null,
},
413
)
}
// 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`
await Promise.all([
c.env.IMAGES.put(thumbnailImageFilename, await body.thumbnailImage.arrayBuffer(), {
httpMetadata: { contentType: body.thumbnailImage.type || 'image/png' },
}),
c.env.IMAGES.put(designFilename, await body.design.arrayBuffer(), {
httpMetadata: { contentType: body.design.type || 'image/png' },
}),
])
const item = await createCustomAvatarItem(c.env.DB, {
customAvatarItemId,
creatorAccountId: id,
name: meta.Name,
description: typeof meta.Description === 'string' ? meta.Description : '',
price: typeof meta.Price === 'number' ? meta.Price : 0,
baseAvatarItemId: meta.BaseAvatarItemId,
baseAvatarItemColor: meta.BaseAvatarItemColor,
accessibility: typeof meta.Accessibility === 'number' ? meta.Accessibility : 0,
designFilename,
thumbnailImageFilename,
})
return c.json({ Value: item, Success: true, Error: null, error_id: null })
}
)
.put(
'/api/customAvatarItems/v1/:id{[0-9a-fA-F-]{36}}',
describeRoute({
tags: ['Avatar'],
summary: 'Edit a custom avatar item',
description:
'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.',
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'),
401: UNAUTHORIZED_RESPONSE,
403: json(CustomAvatarItemResponse, 'Not the creator'),
404: json(CustomAvatarItemResponse, 'No such item'),
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const fail = (status: 400 | 403 | 404, message: string) =>
c.json({ Value: null, Success: false, Error: message, error_id: null }, status)
const itemId = c.req.param('id')
const existing = await getCustomAvatarItem(c.env.DB, itemId)
if (!existing) return fail(404, 'No such item')
if (existing.CreatorAccountId !== id) return fail(403, 'Not your item')
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (!body) return fail(400, 'A JSON body is required')
const str = (v: unknown, field: string): string | null | undefined => {
if (v === null || v === undefined) return null
if (typeof v !== 'string') throw new TypeError(`${field} must be a string`)
return v
}
const int = (v: unknown, field: string): number | null => {
if (v === null || v === undefined) return null
if (typeof v !== 'number' || !Number.isInteger(v))
throw new TypeError(`${field} must be an integer`)
return v
}
let patch
try {
patch = {
name: str(body.Name, 'Name'),
description: str(body.Description, 'Description'),
price: int(body.Price, 'Price'),
accessibility: int(body.Accessibility, 'Accessibility'),
}
} catch (e) {
return fail(400, (e as Error).message)
}
if (patch.name !== null && patch.name?.trim() === '')
return fail(400, 'Name must not be blank')
const item = await updateCustomAvatarItem(c.env.DB, itemId, patch)
if (!item) return fail(404, 'No such item')
return c.json({ Value: item, Success: true, Error: null, error_id: null })
}
)
.delete(
'/api/customAvatarItems/v1/:id{[0-9a-fA-F-]{36}}',
describeRoute({
tags: ['Avatar'],
summary: 'Delete a custom avatar item',
description:
'Removes the item and its two bucket objects (thumbnail and design). Only the ' +
'creator may delete. Answers the deleted item in the `{ Value, Success, Error, ' +
'error_id }` envelope.',
security: AUTHED,
parameters: [stringParam('id', 'The `CustomAvatarItemId`')],
responses: {
200: json(CustomAvatarItemResponse, 'The deleted item'),
401: UNAUTHORIZED_RESPONSE,
403: json(CustomAvatarItemResponse, 'Not the creator'),
404: json(CustomAvatarItemResponse, 'No such item'),
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const fail = (status: 403 | 404, message: string) =>
c.json({ Value: null, Success: false, Error: message, error_id: null }, status)
const itemId = c.req.param('id')
const existing = await getCustomAvatarItem(c.env.DB, itemId)
if (!existing) return fail(404, 'No such item')
if (existing.CreatorAccountId !== id) return fail(403, 'Not your item')
const item = await deleteCustomAvatarItem(c.env.DB, itemId)
if (!item) return fail(404, 'No such item')
// The row is gone; the objects follow. A missing key is a no-op for R2.
await c.env.IMAGES.delete([item.ThumbnailImageFilename, item.DesignFilename])
return c.json({ Value: item, Success: true, Error: null, error_id: null })
}
)
// The featured custom-avatar-item feed: flagged (`is_featured`) AND published
// (`Accessibility` != 0) items from the `custom_avatar_item` table.
.get(
'/api/customAvatarItems/v1/featured',
describeRoute({
tags: ['Avatar'],
summary: 'Featured custom avatar items',
description: 'The curated feed. Nothing is curated yet, so it is empty.',
responses: { 200: json(JsonArray, 'An empty list') },
description:
'The curated feed: items with `IsFeatured` set that are also published ' +
'(`Accessibility` 0 is unpublished and is excluded even when flagged), newest first, ' +
'up to 50. Nothing sets the flag yet, so it stays empty until an operator does.',
responses: { 200: json(CustomAvatarItemList, 'The items, newest first') },
}),
(c) => c.json([])
async (c) => c.json(await listFeaturedCustomAvatarItems(c.env.DB))
)
// The "hot" (trending) custom-avatar-item feed. No items yet → an empty list.
// The store's item search. The client sends the full set of `outfitTypes` it can render
// plus paging, and expects a BARE ARRAY of items back — not the `{ Results, TotalResults }`
// envelope `fromCreator` uses.
//
// Several parameters are accepted and not yet acted on; they are listed in the description
// rather than dropped silently, because a caller cannot tell the difference between a filter
// that was applied and one that was ignored by looking at the results.
.get(
'/api/customAvatarItems/v1/search',
describeRoute({
tags: ['Avatar'],
summary: 'Search custom avatar items',
description: [
'The stores item search: published items (`Accessibility` 0 is unpublished and is',
'left out, from its creator too — `fromCreator` is where they see their own),',
'newest first, as a BARE ARRAY.',
'`searchQuery` matches an items NAME or its DESCRIPTION, case-insensitively, as a',
'substring; `%` and `_` in it are literal.',
'`outfitTypes` may repeat and acts as a whitelist; sending none means no filter',
'rather than no results, since the client sends every type it can render.',
'`minPrice`/`maxPrice` bound the price, inclusive.',
'`skip`/`take` page the results, `take` capped at 200.',
'`includeCoachItems=false` leaves out this servers stock content.',
'`itemTypes`, `ordering`, `unityAssetTarget` and `unityAssetVersion` are accepted and',
'NOT yet acted on — nothing records purchase or wear counts to rank by, no per-target',
'asset variants are stored, and custom avatar items are the only item type there is.',
'`includePurchaseInfos` likewise: `PurchaseInfo` is null on every item for now,',
'whatever it says.',
].join(' '),
parameters: [
{
name: 'searchQuery',
in: 'query',
required: false,
description: 'Free text matched against the items name or description',
schema: { type: 'string' },
},
{
name: 'outfitTypes',
in: 'query',
required: false,
description: 'OutfitType to include; repeat for several. None means all.',
schema: { type: 'array', items: { type: 'integer' } },
},
{
name: 'skip',
in: 'query',
required: false,
description: 'Rows to skip (default 0)',
schema: { type: 'integer', minimum: 0 },
},
{
name: 'take',
in: 'query',
required: false,
description: 'Rows to return (default 50, capped at 200)',
schema: { type: 'integer', minimum: 0 },
},
{
name: 'minPrice',
in: 'query',
required: false,
description: 'Lowest price to include, inclusive',
schema: { type: 'integer', minimum: 0 },
},
{
name: 'maxPrice',
in: 'query',
required: false,
description: 'Highest price to include, inclusive',
schema: { type: 'integer', minimum: 0 },
},
{
name: 'includeCoachItems',
in: 'query',
required: false,
description: 'Include the Coachs stock items (default true)',
schema: { type: 'boolean' },
},
],
responses: { 200: json(CustomAvatarItemList, 'The matching items, newest first') },
}),
async (c) => {
// `?outfitTypes=0&outfitTypes=2&…` — repeated, so read every value. A non-numeric one is
// dropped rather than turned into NaN, which would match nothing and quietly empty a
// filter the caller believes they set.
const outfitTypes = c.req
.queries('outfitTypes')
?.map((v) => Number.parseInt(v, 10))
.filter((n) => Number.isInteger(n))
// The client capitalises its booleans (`includeCoachItems=True`), so this is folded
// before comparing; anything that isn't recognisably false leaves the default alone.
const includeCoachItems = c.req.query('includeCoachItems')?.toLowerCase() !== 'false'
const int = (name: string): number | undefined => {
const raw = c.req.query(name)
if (raw === undefined) return undefined
const n = Number.parseInt(raw, 10)
return Number.isInteger(n) ? n : undefined
}
return c.json(
await searchCustomAvatarItems(c.env.DB, {
searchQuery: c.req.query('searchQuery'),
outfitTypes,
includeCoachItems,
minPrice: int('minPrice'),
maxPrice: int('maxPrice'),
skip: int('skip'),
take: int('take'),
})
)
}
)
// The "hot" (trending) custom-avatar-item feed: every published (`Accessibility` != 0)
// item from the `custom_avatar_item` table. There is nothing to rank a trend from yet,
// so it is the accessible items, newest first.
.get(
'/api/customAvatarItems/v1/hot',
describeRoute({
tags: ['Avatar'],
summary: 'Trending custom avatar items',
description: 'The “hot” feed. No custom items exist yet, so it is empty.',
responses: { 200: json(JsonArray, 'An empty list') },
description:
'The “hot” feed: the published items (`Accessibility` 0 is unpublished and is left ' +
'out), newest first, up to 50. No purchase or wear counts are recorded, so there is ' +
'no trend to rank by and recency stands in for one.',
responses: { 200: json(CustomAvatarItemList, 'The items, newest first') },
}),
(c) => c.json([])
async (c) => c.json(await listHotCustomAvatarItems(c.env.DB))
)
// A batch lookup of custom avatar items by id. The reference filters a static catalog
// down to the posted ids and returns the MATCHES AS A BARE ARRAY — not the
// `{ Results, TotalResults }` page its catalog file is written in, and not a 404 for
// ids it doesn't hold. Nothing stores custom items here (the reference's own catalog
// ships empty too), so every id misses and the array is empty.
// A batch lookup of custom avatar items by id, out of the `custom_avatar_item` table.
// The reference filters its catalog down to the posted ids and returns the MATCHES AS A
// BARE ARRAY — not the `{ Results, TotalResults }` page that catalog is written in, and
// not a 404 for ids it doesn't hold.
//
// This is how a `1.<guid>` entity in a GENERIC discovery row (`lists`
// `/algorithmiclists/:list?type=5`) gets resolved, so a row naming a custom item renders
// nothing at all when this doesn't answer. It stubbed out `[]` while nothing stored custom
// items; the table has existed since migration 0015 and the stub outlived it.
//
// Auth-gated, and the token is checked before anything else, as the reference does.
//
// A batch over {@link BULK_CUSTOM_AVATAR_ITEM_CAP} ids answers EMPTY. The client has been
// seen posting far more ids than a screen could draw, and serving those is both a large
// query and a large response for a request that is already not what it looks like. Empty is
// the safe answer because a miss here is not an error: unknown ids are simply absent, so the
// client already handles getting back fewer items than it asked about.
.post(
'/api/customAvatarItems/v1/bulk',
describeRoute({
@@ -262,42 +816,72 @@ export const avatarRoutes = new Hono<App>({ strict: false })
summary: 'Custom avatar items in bulk',
description:
'Resolves a batch of custom-avatar-item ids to their items: the posted ' +
'`customAvatarItemIds` filtered against the catalog, returned as a BARE ARRAY of ' +
'the ones that matched. Not the `{ Results, TotalResults }` page the sibling ' +
'custom-item reads serve — the reference keeps its catalog in that shape but ' +
'answers this route with the filtered array alone.\n\n' +
'`customAvatarItemIds` filtered against the `custom_avatar_item` table, returned ' +
'as a BARE ARRAY of the ones that matched, in the order they were asked for. Not ' +
'the `{ Results, TotalResults }` page the sibling custom-item reads serve — the ' +
'reference keeps its catalog in that shape but answers this route with the ' +
'filtered array alone.\n\n' +
'A miss is not an error: unknown ids are simply absent from the response, and the ' +
'client reads the items it got back rather than the ids it asked for. Nothing ' +
'stores custom items here, so every id misses and this is always `[]` — which is ' +
'why the posted ids are not parsed.',
'client reads the items it got back rather than the ids it asked for. Unpublished ' +
'items (`Accessibility` 0) miss for everyone but their creator, the same rule the ' +
'feeds and the creator shelf apply.\n\n' +
'Ids ride as repeated `customAvatarItemIds` form fields; a comma-separated value ' +
'and the same spelling on the query string are both accepted, since the clients ' +
'exact encoding here has not been pinned down.\n\n' +
'A batch of more than 100 ids answers an EMPTY array without reading the table: the ' +
'client has been seen posting more than a screen could draw, and a miss is already ' +
'not an error here.',
security: AUTHED,
requestBody: form(BulkCustomAvatarItemsRequest, 'The custom-avatar-item ids to resolve'),
responses: {
200: json(JsonArray, 'The matching items — always empty here'),
200: json(CustomAvatarItemList, 'The items that matched, in request order'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([])
const ids = await bulkCustomAvatarItemIds(c)
// Over the cap: empty, and the table is not touched. Answering the batch would be a
// large query and a large response for a request that is already not what it looks
// like — a screen does not draw this many items.
if (ids.length > BULK_CUSTOM_AVATAR_ITEM_CAP) return c.json([])
const items = await getCustomAvatarItems(c.env.DB, ids)
// Unpublished items are held back from everyone but their creator — the same rule
// the featured/hot feeds and the creator shelf apply, so an item can't be surfaced
// through this route that the feeds hide.
return c.json(
items.filter((item) => item.Accessibility !== 0 || item.CreatorAccountId === id)
)
}
)
// Custom avatar items created by a given account. No storage yet → an empty
// paginated result (matches the econ `customAvatarItems/v1/owned` shape).
// Custom avatar items created by a given account, from the `custom_avatar_item` table,
// in the paginated shape (matches the econ `customAvatarItems/v1/owned` shape). Auth is
// optional: the creator themselves also sees their unpublished (`Accessibility` 0) items.
.get(
'/api/customAvatarItems/v2/fromCreator/:accountId{[0-9]+}',
describeRoute({
tags: ['Avatar'],
summary: 'A creators custom avatar items',
description:
'The items an account has authored. Nothing stores custom items yet, so this is an ' +
'empty page — in the same shape as the `econ` workers `customAvatarItems/v1/owned`.',
'The items an account has authored, newest first, in the same page shape as the ' +
'`econ` workers `customAvatarItems/v1/owned`. Published items only — unless the ' +
'bearer token is the creators, in which case their unpublished (`Accessibility` 0) ' +
'items are included too. Paging is not applied (the client sends none), so ' +
'`TotalResults` is the length of `Results`.',
security: OPTIONAL_AUTHED,
parameters: [idParam('accountId', 'Creator account id')],
responses: { 200: json(CustomAvatarItemsPage, 'An empty page') },
responses: { 200: json(CustomAvatarItemsPage, 'The creators items') },
}),
(c) => c.json({ Results: [], TotalResults: 0 })
async (c) => {
const accountId = Number.parseInt(c.req.param('accountId'), 10)
const viewer = await authedId(c)
return c.json(await listCustomAvatarItemsByCreator(c.env.DB, accountId, viewer === accountId))
}
)
// The client asks which legacy avatar items have been rebuilt as custom items, so it
@@ -327,15 +911,16 @@ export const avatarRoutes = new Hono<App>({ strict: false })
.get(
'/outfits/me',
describeRoute({
tags: ['Avatar'],
tags: ['Avatar', '2025'],
summary: 'The callers outfit',
description:
'The newer outfit read, on a bare un-prefixed path. Served from slot 0 of the shared ' +
'`outfit` table — the newer client treats slot 0 as the outfit currently worn — and ' +
'handed back exactly as it was saved, since the payloads heavy fields are the ' +
'clients own JSON-in-a-string documents.\n\n' +
'A player who has never saved gets the brand-new-account envelope: all-null ' +
'`LegacyData`, no `Selections`, `DataVersion` 9.',
'A player who has never saved gets the brand-new-account envelope, which is a ' +
'different, flatter shape than a stored outfit: the four empty-string fields ' +
'`FaceFeatures`, `HairColor`, `OutfitSelections` and `SkinColor`, and nothing else.',
security: AUTHED,
responses: {
200: json(OutfitsMeResponse, 'The stored outfit, or the empty envelope'),
@@ -350,20 +935,10 @@ export const avatarRoutes = new Hono<App>({ strict: false })
if (outfit !== null) return c.json(outfit)
return c.json({
LegacyData: {
SelectionsV1: null,
SelectionsV2: null,
FaceFeatures: null,
SkinColor: null,
HairColor: null,
},
Selections: [],
DataVersion: 9,
CustomizationSettings: null,
ThumbnailFileName: null,
Name: null,
Accessibility: 0,
Slot: 0,
FaceFeatures: '',
HairColor: '',
OutfitSelections: '',
SkinColor: '',
})
}
)
@@ -379,7 +954,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
.put(
'/outfits/me',
describeRoute({
tags: ['Avatar'],
tags: ['Avatar', '2025'],
summary: 'Save the callers outfit',
description:
'Saves into the shared `outfit` table, in the slot the body names — slot 0 being the ' +
@@ -415,29 +990,108 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// The caller's outfit wardrobe. An empty list for now — the outfits saved through
// `PUT /outfits/me` are in the shared `outfit` table already, but which of them
// belong in this list (and in what shape) has not been pinned down, so it answers []
// rather than guessing.
.get(
'/outfits/me/saved',
// Several players' worn outfits at once — what the client calls to dress everyone in a
// room rather than asking per player. POST because the account list rides in the body.
//
// The answer is a MAP keyed by account id, not a list: the client looks each player up by
// id, and a list would make it match up the order itself. An account with nothing saved is
// left out of the map — see `getOutfitsByAccounts`.
//
// `UnityAssetTarget` / `UnityAssetVersion` name the baked-asset build the client would
// like the outfits for. Nothing here bakes assets, so both are accepted and ignored.
.post(
'/outfits/bulk',
describeRoute({
tags: ['Avatar'],
summary: 'The callers saved outfits',
summary: 'Several players outfits',
description:
'The wardrobe behind the newer outfit screen. Empty for now: the outfits saved ' +
'through `PUT /outfits/me` are in the shared `outfit` table, but which of them this ' +
'list should carry, and in what shape, is not pinned down yet.',
'The worn outfit (slot 0) of each account in `AccountIds`, keyed by account id — the ' +
'call the client makes to dress a room full of players in one request.\n\n' +
'A MAP rather than a list: the client looks each player up by id. The key is the id ' +
'as a string, and the value is the same stored outfit `GET /outfits/me` serves, ' +
'handed back exactly as it was saved. An account with nothing saved in slot 0 is ' +
'ABSENT from the map rather than carrying a null — a map says “no outfit” by not ' +
'having the key, and inventing one for a player who has never saved would dress them ' +
'in something they never chose.\n\n' +
'Repeated ids collapse, and at most 99 distinct accounts may be named — one query, ' +
'one round trip, and a room holds nothing like that many players. A longer list is ' +
'a 400 rather than a partial answer, which would read as “those players have no ' +
'outfit”. `UnityAssetTarget` / `UnityAssetVersion` name a baked-asset build and are ' +
'accepted and ignored: nothing here bakes assets.',
security: AUTHED,
requestBody: jsonBody(OutfitsBulkRequest, 'The accounts whose outfits are wanted'),
responses: {
200: json(JsonArray, 'An empty list'),
200: json(OutfitsBulkResponse, 'The outfits that exist, keyed by account id'),
400: json(ErrorResponse, 'Unparseable body, or more than 99 accounts'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json([])
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
// Only the integers survive: the field is the client's, and a malformed entry is
// dropped rather than turned into a NaN lookup that can never match a row.
const accountIds = Array.isArray(body.AccountIds)
? body.AccountIds.filter((v): v is number => Number.isInteger(v))
: []
// One query, one round trip — so the list has to fit D1's parameter cap. A room
// holds nothing like this many players; a longer list is refused rather than
// quietly answered in part, which would look like those accounts have no outfit.
if (new Set(accountIds).size > MAX_BULK_OUTFIT_ACCOUNTS) {
return c.json({ error: `At most ${MAX_BULK_OUTFIT_ACCOUNTS} accounts per request` }, 400)
}
const outfits = await getOutfitsByAccounts(c.env.DB, accountIds, CURRENT_OUTFIT_SLOT)
const OutfitsByAccountId: Record<string, unknown> = {}
for (const [accountId, outfit] of outfits) OutfitsByAccountId[String(accountId)] = outfit
return c.json({ OutfitsByAccountId })
}
)
// The caller's outfit wardrobe — every slot they have saved, ordered by slot. The same
// read as `econ`'s `GET /api/avatar/v3/saved`, on the bare path the newer client uses:
// both worker's write paths land in the shared `outfit` table, so both list endpoints
// serve the same rows.
//
// Slot 0 is INCLUDED. It is the outfit being worn (what `/outfits/me` reads), but it is
// also a saved slot: the newer client picks the slot it saves into (`/api/avatar/v4/saved/set`
// 400s without one), so filtering slot 0 out would hide a real saved outfit whenever a
// wardrobe entry lands there. Showing the worn outfit as a wardrobe entry is the cheaper
// mistake of the two.
//
// Rows are served exactly as they were stored, unprojected — see the note atop
// `outfits-db.ts`: econ's saved slots hold the old flat PascalCase outfit while
// `/outfits/me` holds the newer envelope, and neither is converted into the other.
.get(
'/outfits/me/saved',
describeRoute({
tags: ['Avatar', '2025'],
summary: 'The callers saved outfits',
description:
'The wardrobe behind the newer outfit screen: every slot the caller has saved, ' +
'ordered by slot, and `[]` when they have saved none. The same rows `econ`s ' +
'`GET /api/avatar/v3/saved` serves — both write paths land in the shared `outfit` ' +
'table.\n\n' +
'Slot 0 is included. It is the outfit being worn (what `GET /outfits/me` reads) but ' +
'it is a saved slot too, and the client chooses the slot it saves into, so omitting ' +
'it would hide a real outfit whenever a wardrobe entry lands there.\n\n' +
'Each outfit is served exactly as it was stored, unprojected: slots written through ' +
'`PUT /outfits/me` hold the newer envelope while `econ`s saved-set slots hold the ' +
'old flat shape, and neither is converted into the other.',
security: AUTHED,
responses: {
200: json(JsonArray, 'The saved outfits, ordered by slot (empty when none)'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getOutfits(c.env.DB, id))
}
)
@@ -568,30 +1222,36 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// The signed-in player's own relationship to an invention (`/personaldetails/2`)
// just whether they're cheering it. We store no cheers (nothing can cheer an
// invention yet), so this is always false; it stays a 200 for signed-out callers
// too, since the client only reads the flag.
// The signed-in player's own relationship to an invention (`/personaldetails/2`)
// just whether they're cheering it. Signed-out callers read false: there is no player
// whose interaction could be looked up, and the client still needs a flag to render.
.get(
'/api/inventions/v1/personaldetails/:inventionId{[0-9]+}',
describeRoute({
tags: ['Inventions'],
summary: 'The callers own relation to an invention',
description:
'Just whether the caller is cheering it. We store no cheers, so it is always false ' +
'— and this stays a 200 for signed-out callers too, since the client only reads the ' +
'flag.',
'Whether the caller is cheering this invention. Signed-out callers receive false, ' +
'since there is no player interaction to look up.',
parameters: [idParam('inventionId', 'Invention id')],
responses: { 200: json(InventionPersonalDetails, 'Always not cheering') },
responses: { 200: json(InventionPersonalDetails, 'The callers cheer state') },
}),
(c) => c.json({ IsCheering: false })
async (c) => {
const playerId = await authedId(c)
if (playerId === null) return c.json({ IsCheering: false })
const inventionId = Number.parseInt(c.req.param('inventionId'), 10)
return c.json({ IsCheering: await isInventionCheered(c.env.DB, playerId, inventionId) })
}
)
// A single version of an invention (`?inventionId=…&version=…`) — the bare
// RRInventionVersion, which carries the blob name the client downloads and the
// SHA-256 of that blob. Public. Only the current version exists (nothing writes
// version history yet), so any other version number 404s rather than naming a
// blob that isn't there.
// blob that isn't there — except `version=0`, which means "whichever is current"
// rather than a number to match. Nothing has a version 0, so a caller sending it
// doesn't know which version it wants, and matching it literally 404s an invention
// that exists.
.get(
'/api/inventions/v1/version',
describeRoute({
@@ -602,15 +1262,19 @@ export const avatarRoutes = new Hono<App>({ strict: false })
'and `BlobHash`, the base64 SHA-256 of that blob (null when the named blob was ' +
'never uploaded). Only the current version exists — nothing writes version ' +
'history yet — so any other version number 404s rather than naming a blob that ' +
'is not there.',
'is not there.\n\n' +
'`version=0` is the exception: it means “whichever is current” rather than a ' +
'number to match, and gets the current version. No invention has a version 0 — a ' +
'fresh save is version 1 — so a caller sending it does not know which version it ' +
'wants, and matching it literally 404s an invention that exists.',
parameters: [
intQuery('inventionId', 'Invention id; required'),
intQuery('version', 'Version number; required'),
intQuery('version', 'Version number; required. `0` means the current version'),
],
responses: {
200: json(InventionVersionDto, 'The version'),
400: json(ErrorResponse, 'Missing inventionId or version'),
404: { description: 'No such invention, or not the current version' },
404: { description: 'No such invention, or a version number that is not the current one' },
},
}),
async (c) => {
@@ -742,12 +1406,11 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const permissionLevel = c.req.query('permissionLevel')
const price = Number.parseInt(c.req.query('price') ?? '', 10)
const published = await publishInvention(
c.env.DB,
gate.invention.InventionId,
permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel),
Number.isNaN(price) || price < 0 ? undefined : price
)
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
permissionLevel:
permissionLevel === undefined ? undefined : parsePermissionLevel(permissionLevel),
price: Number.isNaN(price) || price < 0 ? undefined : price,
})
return published === null ? c.notFound() : c.json(toSaveResult(published))
}
)
@@ -927,6 +1590,19 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// The featured dorm-skin feed (inventions that reskin the dorm). Nothing curates these
// yet → an empty list, so the client's shelf renders empty rather than 404ing.
.get(
'/api/inventions/v1/featureddormskins',
describeRoute({
tags: ['Inventions'],
summary: 'The featured dorm-skin feed',
description: 'Curated dorm-skin inventions. Nothing is curated yet, so it is empty.',
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
// Inventions by particular creators (`?id=207&id=…`) — what the client fills a creator's
// shelf, and the "from creators you follow" row, from.
//
@@ -957,15 +1633,20 @@ export const avatarRoutes = new Hono<App>({ strict: false })
// 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.
//
// Filtered, ordered and paged in SQL — it must not read the catalogue into memory to
// answer one page.
.get(
'/api/inventions/v2/search',
describeRoute({
tags: ['Inventions'],
summary: 'Search / browse inventions',
description:
'Published inventions matching `value` (matched against name and description), ' +
'newest first. An absent `value` browses everything published — that is the ' +
'browse screens initial request.',
'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 screens initial ' +
'request. Tags are NOT searched: a `#tag` term from the browse screens filter ' +
'chips is treated as text and matches nothing.',
parameters: [
stringQuery('value', 'Search text; absent browses everything'),
...pageParams(100),
@@ -1007,6 +1688,175 @@ export const avatarRoutes = new Hono<App>({ strict: false })
}
)
// Report a custom avatar item. Stored in the `report` table the player, event and invention
// reports use — same fields, same moderation life — with `custom_avatar_item_id` set. See
// migrations/0017_report_custom_avatar_item.sql.
//
// The item is named by the PATH, not the body, which is what distinguishes this from its
// siblings; the body's `ReportedPlayerId` arrives NULL and is ignored, since the client does
// not know who made the item.
.post(
'/api/customAvatarItems/v1/:id{[0-9a-fA-F-]{36}}/report',
describeRoute({
tags: ['Avatar', 'Moderation'],
summary: 'Report a custom avatar item',
description:
'Files a report against a custom avatar item, named by the PATH. Stored as a row in ' +
'the same `report` table a player report goes to (`POST /api/PlayerReporting/v3/create`), ' +
'an event report and an invention report — the same submission with the same ' +
'moderation life, which a moderator converts into a ban the same way. What marks it ' +
'as an item report is `custom_avatar_item_id`; the rows `reported_player_id` is the ' +
'items CREATOR, read from the item. The bodys `ReportedPlayerId` is sent as null ' +
'and IGNORED even when set — the client does not know who made the item, and letting ' +
'a client name who a report is against would let it point one at anybody. Nothing ' +
'fills `room_id`: an item isnt tied to one room the way an event is.\n\n' +
'The reporter is the caller (from the bearer token), never a body field. ' +
'`ReportCategory` is stored verbatim — the enum is not mapped here. Nothing dedupes ' +
'the rows: reporting the same item twice files two reports, and reporting your own ' +
'is allowed rather than being a special case.\n\n' +
'Answers the `{ success, error }` envelope the event and invention reports use, ' +
'`error` being an empty string rather than null, on the rejected branches too so ' +
'there is only one shape to parse.',
security: AUTHED,
parameters: [idParam('id', 'The custom avatar items guid')],
requestBody: jsonBody(CustomAvatarItemReportRequest, 'The report'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
401: UNAUTHORIZED_RESPONSE,
404: json(SuccessErrorEnvelope, 'No such custom avatar item'),
},
}),
async (c) => {
const reporterId = await authedId(c)
if (reporterId === null) return unauthorized(c)
const customAvatarItemId = c.req.param('id')
// The item supplies the reported player. An unknown item is refused rather than filed
// against nobody: the row's reported player has to be someone, and a report naming an
// item that never existed isn't actionable.
const item = await getCustomAvatarItem(c.env.DB, customAvatarItemId)
if (item === null) return c.json({ success: false, error: 'No such item' }, 404)
// A body that won't parse is not a reason to lose the report: the path already names
// what is being reported and the token names who reported it, so an unreadable body
// costs the category and the description, not the row.
const body = await c.req
.json<{ ReportCategory?: unknown; Details?: unknown }>()
.catch(() => ({}) as Record<string, unknown>)
const category = Number(body.ReportCategory)
await createReport(c.env.DB, {
reporterPlayerId: reporterId,
reportedPlayerId: item.CreatorAccountId,
reportCategory: Number.isInteger(category) ? category : 0,
details: typeof body.Details === 'string' ? body.Details : null,
customAvatarItemId,
})
return c.json({ success: true, error: '' })
}
)
// Cheer or un-cheer an invention. The interaction row is per player and the stored
// invention's public CheerCount is derived from all active cheers.
.post(
'/api/inventions/v1/cheer',
describeRoute({
tags: ['Inventions'],
summary: 'Cheer or un-cheer an invention',
description:
'Persists the callers cheer state and resyncs the inventions `CheerCount`. ' +
'Repeating the same state is idempotent.',
security: AUTHED,
requestBody: jsonBody(InventionCheerRequest, 'The invention and new cheer state'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'Invalid body'),
401: UNAUTHORIZED_RESPONSE,
404: json(SuccessErrorEnvelope, 'No such invention'),
},
}),
async (c) => {
const playerId = await authedId(c)
if (playerId === null) return unauthorized(c)
const body = await c.req
.json<{ InventionId?: unknown; Cheer?: unknown }>()
.catch(() => ({}) as Record<string, unknown>)
const inventionId = Number(body.InventionId)
if (!Number.isInteger(inventionId) || typeof body.Cheer !== 'boolean') {
return c.json({ success: false, error: 'InventionId and Cheer are required' }, 400)
}
if ((await getInventionById(c.env.DB, inventionId)) === null) {
return c.json({ success: false, error: 'No such invention' }, 404)
}
await setInventionCheer(c.env.DB, playerId, inventionId, body.Cheer)
return c.json({ success: true, error: '' })
}
)
// 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.
.post(
'/api/inventions/v1/report',
describeRoute({
tags: ['Inventions', 'Moderation'],
summary: 'Report an invention',
description:
'Files a report against an invention. Stored as a row in the same `report` table a ' +
'player report goes to (`POST /api/PlayerReporting/v3/create`) and an event report ' +
'(`POST /api/playerevents/v1/report`) — it is the same submission with the same ' +
'moderation life, and a moderator converts any of them into a ban the same way. ' +
'What marks it as an invention report is `invention_id`; the rows ' +
'`reported_player_id` is the inventions CREATOR — who a moderator would act ' +
'against — read from the invention rather than sent by the client. Nothing fills ' +
'`room_id`: an invention isnt tied to one room the way an event is.\n\n' +
'The reporter is the caller (from the bearer token), never a body field. ' +
'`ReportCategory` is stored verbatim — the enum is not mapped here. Nothing ' +
'dedupes the rows: reporting the same invention twice files two reports, and ' +
'reporting your own is allowed rather than being a special case.\n\n' +
'Answers the same `{ success, error }` envelope as the event report, `error` being ' +
'an empty string rather than null, on the rejected branches too so there is only ' +
'one shape to parse.',
security: AUTHED,
requestBody: jsonBody(InventionReportRequest, 'The report'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No usable `InventionId` in the body'),
401: UNAUTHORIZED_RESPONSE,
404: json(SuccessErrorEnvelope, 'No such invention'),
},
}),
async (c) => {
const reporterId = await authedId(c)
if (reporterId === null) return unauthorized(c)
const body = await c.req
.json<{ InventionId?: unknown; ReportCategory?: unknown; Details?: unknown }>()
.catch(() => ({}) as Record<string, unknown>)
const inventionId = Number(body.InventionId)
if (!Number.isInteger(inventionId)) {
return c.json({ success: false, error: 'InventionId is required' }, 400)
}
// The invention supplies the reported player. An unknown invention is refused rather
// than filed against nobody: the row's reported player has to be someone, and a
// report naming an invention that never existed isn't actionable.
const invention = await getInventionById(c.env.DB, inventionId)
if (invention === null) return c.json({ success: false, error: 'No such invention' }, 404)
const category = Number(body.ReportCategory)
await createReport(c.env.DB, {
reporterPlayerId: reporterId,
reportedPlayerId: invention.CreatorPlayerId,
reportCategory: Number.isInteger(category) ? category : 0,
details: typeof body.Details === 'string' ? body.Details : null,
inventionId,
})
return c.json({ success: true, error: '' })
}
)
// Save an invention's metadata. The data file itself is uploaded separately
// through the `storage` worker and referenced here by `inventionDataFilename` —
// the one required field, since an invention with no data blob is unusable. An
@@ -1045,43 +1895,289 @@ export const avatarRoutes = new Hono<App>({ strict: false })
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json({ error: 'Invalid request body' }, 400)
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
const outcome = await createInventionFromBody(c, id, body)
if ('rejection' in outcome) return c.json({ error: outcome.rejection }, 400)
return c.json(toSaveResult(outcome.invention))
}
)
const inventionDataFilename = str(body.inventionDataFilename)?.trim()
if (!inventionDataFilename) {
return c.json({ error: 'inventionDataFilename is required' }, 400)
// The same save as the newer client sends it: v6's body plus the invention's
// references, its long description and display metadata, what the saved blob is, and
// the tags — which v6 clients set afterwards through `v1/settags`. It stores the same
// record; what differs is the REPLY, which is enveloped. See `InventionSaveV9Result`:
// the client reads `Success` and then `Value.Invention.InventionId`, and a body that
// isn't this envelope — a bare `{ error }`, or the empty 401 the other routes answer —
// takes it down rather than failing it, which is why every branch below answers one.
.post(
'/api/inventions/v9/save',
describeRoute({
tags: ['Inventions'],
summary: 'Save a new invention (v9)',
description:
'`v6/save` plus the fields the newer client sends: `referencedUnityAssetIds`, ' +
'`longDescription`, `displayMetadataJson`, `convertedFromInventionId`, ' +
'`ugcVersion`, `hasBetaContent`, and a `tagsRequest` carrying the same ' +
'`AutoTags`/`CustomTags` lists `v1/settags` takes. Every one is optional and is ' +
'stored only when sent, so a body v6 would accept produces the same record here.' +
'\n\n' +
'The reply is where the two versions part: v9 is ENVELOPED as ' +
'`{ Value, Success, Error, error_id }`, with v6s ' +
'`{ Status, Invention, InventionVersion }` inside `Value` alongside a ' +
'`TagsResponse`. The client reads `Success` and then ' +
'`Value.Invention.InventionId`; `Error` is the only text it ever shows a human.' +
'\n\n' +
'So a refusal is *also* a 200 carrying `{ Success: false, Error, Value: null }` — ' +
'the client dereferences `Value` unguarded when `Success` is true, and treats ' +
'anything that isnt this envelope as a null one. Tags are held to the ' +
'`v1/settags` rule (at most 15 letters each), but one that breaks it costs the ' +
'tags and not the save: `TagsResponse.Result` comes back non-zero and the creator ' +
're-submits them through `v1/settags`.\n\n' +
'A freshly saved invention is private: it shows up only in the creators own list ' +
'until they call `v3/publish`.',
security: AUTHED,
requestBody: jsonBody(SaveInventionV9Request, 'The invention metadata (camelCase)'),
responses: {
200: json(
InventionSaveV9Result,
'The envelope — the stored invention under `Value`, or `Success: false` with ' +
'`Error` when the save was refused'
),
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.json(inventionSaveV9Failure('Unauthorized'), 401)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
const outcome = await createInventionFromBody(c, id, body)
return c.json(
'rejection' in outcome
? inventionSaveV9Failure(outcome.rejection)
: toSaveResultV9(outcome.invention, outcome.tags, outcome.tagResult)
)
}
)
// Edit an invention's metadata, as the newer client sends it: one PUT with a PascalCase
// body where every field but the id is nullable, and NULL means "leave this alone" —
// the client sends the whole shape every time and marks the fields it isn't touching.
// The tags ride along the way they do on `v9/save`, and the reply is that same
// envelope: `v1/update` is the older client's version of this endpoint, query params
// and a bare body and all.
.put(
'/api/inventions/v2/metadata',
describeRoute({
tags: ['Inventions'],
summary: 'Edit an inventions metadata (v2)',
description:
'Creator only. Every field but `InventionId` is nullable and a null one is left ' +
'as it is — the client sends the whole shape on every edit — so this is a patch, ' +
'not a replace. An empty string is not a null: it is how a creator CLEARS a ' +
'description, long description or image. `Name` is the exception, since a nameless ' +
'invention isnt a thing the client can draw: it is held to the same 324 ' +
'character rule `v6/save` enforces, which an empty name fails.\n\n' +
'`TagsRequest` replaces both tag lists wholesale, exactly as `v1/settags` does; a ' +
'null one leaves the stored tags alone. A tag that breaks the tag rule costs the ' +
'tags and not the edit — `TagsResponse.Result` comes back non-zero.\n\n' +
'Answers the enveloped result `v9/save` answers, carrying the UPDATED invention: ' +
'the client re-renders the detail page from `Value.Invention`. Refusals — an ' +
'unknown invention and someone elses alike — are `Success: false` with a null ' +
'`Value` rather than a bare error body, which that client cannot parse.',
security: AUTHED,
requestBody: jsonBody(UpdateInventionMetadataRequest, 'The fields to change'),
responses: {
200: json(
InventionSaveV9Result,
'The envelope — the updated invention under `Value`, or `Success: false` with ' +
'`Error` when the edit 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'))
// The id rides in the body here, not the query string.
const gate = await creatorsInventionResult(
c,
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
)
// Only a missing token is answered as a transport failure. An unknown invention
// or someone else's is a domain answer the client is meant to read — its own
// status enum has DoesNotExist and NotCreator members — so it goes in the
// envelope, where the message reaches a human.
if ('rejection' in gate) {
return gate.status === 401
? c.json(inventionSaveV9Failure(gate.rejection), 401)
: c.json(inventionSaveV9Failure(gate.rejection))
}
// Null is "leave it"; a string, empty or not, is an edit.
const edited = (key: string): string | undefined =>
typeof body[key] === 'string' ? body[key] : undefined
const name = edited('Name')?.trim()
const description = edited('Description')
const longDescription = edited('LongDescription')
// An omitted or blank name/description is defaulted by `createInvention` ("Untitled",
// "No description yet"), so only a supplied one is held to the rules — otherwise
// saving an unnamed invention would fail the 3-character minimum on a name the
// player never typed.
const name = str(body.name)?.trim()
const nameRejection = name === undefined || name === '' ? null : inventionNameRejection(name)
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
const description = str(body.description)
const descriptionRejection =
description === undefined ? null : inventionDescriptionRejection(description)
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
const invention = await createInvention(c.env.DB, c.env.CDN_ASSETS, {
creatorPlayerId: id,
inventionDataFilename,
for (const rejection of [
name === undefined ? null : inventionNameRejection(name),
description === undefined ? null : inventionDescriptionRejection(description),
longDescription === undefined
? null
: inventionLongDescriptionRejection(longDescription),
]) {
if (rejection !== null) return c.json(inventionSaveV9Failure(rejection))
}
// A null TagsRequest leaves the stored tags alone, and the reply still reports
// them: the client reads the list back as the tags the invention now has, not as
// the ones this call changed.
const requested = requestedTags(body.TagsRequest)
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
name,
description,
imageName: str(body.imageName),
instantiationCost: num(body.instantiationCost),
lightsCost: num(body.lightsCost),
chipsCost: num(body.chipsCost),
cloudVariablesCost: num(body.cloudVariablesCost),
aiCost: num(body.aiCost),
creationRoomId: num(body.creationRoomId),
referencedInventions: Array.isArray(body.referencedInventions)
? body.referencedInventions.filter((v): v is number => typeof v === 'number')
: undefined,
longDescription,
imageName: edited('ImageName'),
tags: requested?.tags,
})
return c.json(toSaveResult(invention))
if (updated === null) return c.json(inventionSaveV9Failure('No such invention'))
return c.json(
toSaveResultV9(
updated,
updated.Tags ?? [],
requested?.tagResult ?? INVENTION_TAG_RESULT.success
)
)
}
)
// Publish an invention, as the newer client sends it: a PascalCase body instead of a
// query string, and an Accessibility of its own — where `v3/publish` only ever flipped
// the published flag, this decides whether the result can be FOUND. Same enveloped
// reply as `v9/save`, carrying the published invention.
.post(
'/api/inventions/v4/publish',
describeRoute({
tags: ['Inventions'],
summary: 'Publish an invention (v4)',
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 ' +
'than zeroing it, so re-publishing something that was for sale doesnt 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 doesnt re-date the first ' +
'publish. Refusals answer `Success: false` with a null `Value`, the way ' +
'`v9/save` does.',
security: AUTHED,
requestBody: jsonBody(PublishInventionRequest, 'What the publish decides'),
responses: {
200: json(
InventionSaveV9Result,
'The envelope — the published invention under `Value`, or `Success: false` ' +
'with `Error` when the publish 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
)
// As on `v2/metadata`: only a missing token is a transport failure. The rest are
// answers the client is meant to read out of the envelope.
if ('rejection' in gate) {
return gate.status === 401
? c.json(inventionSaveV9Failure(gate.rejection), 401)
: c.json(inventionSaveV9Failure(gate.rejection))
}
// Null is "leave it". The permission and accessibility are taken as sent rather
// than checked against the ladder, the way `parsePermissionLevel` already accepts
// a raw number: the ladders are the client's, and a level this server hasn't heard
// of is better stored than swapped for one the creator didn't pick.
const int = (key: string): number | undefined =>
typeof body[key] === 'number' && Number.isInteger(body[key]) ? body[key] : undefined
const price = int('Price')
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
permissionLevel: int('Permission'),
accessibility: int('Accessibility'),
// A negative price is dropped rather than stored, as it is on `v3/publish`.
price: price !== undefined && price < 0 ? undefined : price,
})
if (published === null) return c.json(inventionSaveV9Failure('No such invention'))
return c.json(toSaveResultV9(published, published.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.
//
// The record and everything inside it (versions, tags, referenced-invention lists)
// go in one DELETE; the data blob in R2 and the `inventory_invention` rows of
// players who bought it are left alone. See `deleteInvention` for why.
.post(
'/api/inventions/v2/delete',
describeRoute({
tags: ['Inventions'],
summary: 'Delete an invention',
description:
'Creator only — a buyer or a co-owner cannot delete someone elses invention. ' +
'The record goes entirely: its versions, tags and referenced-invention lists live ' +
'in the same row.\n\n' +
'What survives is deliberate. The data blob stays in storage, because nothing ' +
'here knows whether another record still points at that filename. The ownership ' +
'rows of players who bought it stay too — a delete must not rewrite what someone ' +
'else paid for — and they fall out of every list on their own, since an owned id ' +
'with no invention row behind it is skipped.\n\n' +
'Answers the `{ Value, Success, Error, error_id }` envelope the other v2+ ' +
'invention routes use, with `Value` NULL: the invention is gone, so there is ' +
'nothing to redraw from and the client reads only `Success`. Refusals — an ' +
'unknown invention and someone elses alike — are `Success: false` with a ' +
'message, not a bare error body that client cannot parse.',
security: AUTHED,
requestBody: jsonBody(DeleteInventionRequest, 'The invention to delete'),
responses: {
200: json(InventionDeleteResult, 'The delete envelope, `Value` null either way'),
401: json(InventionDeleteResult, '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(inventionDeleteResult('Invalid request body'))
// The id rides in the body, as it does on `v2/metadata` and `v4/publish`.
const gate = await creatorsInventionResult(
c,
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
)
// As on those two: only a missing token is a transport failure. An unknown
// invention or someone else's is a domain answer the client reads out of the
// envelope, where the message reaches a human.
if ('rejection' in gate) {
return gate.status === 401
? c.json(inventionDeleteResult(gate.rejection), 401)
: c.json(inventionDeleteResult(gate.rejection))
}
// The gate already loaded the row, so a null here is a race — someone deleted it
// between the two reads — and lands where the client would put it anyway: gone.
const deleted = await deleteInvention(c.env.DB, gate.invention.InventionId)
return c.json(
deleted === null ? inventionDeleteResult('No such invention') : inventionDeleteResult()
)
}
)
+8 -6
View File
@@ -29,17 +29,19 @@ export const configRoutes = new Hono<App>({ strict: false })
tags: ['Config'],
summary: 'Analytics keys',
description:
'The Amplitude / StatSig / RudderStack keys the client initialises its analytics ' +
'with. This server collects nothing, so the keys are placeholders and RudderStack ' +
'is off — but the client needs the object to finish loading.',
'The Amplitude / RudderStack / StatSig keys the client initialises its analytics ' +
'with. This server collects nothing, so the keys are blank and RudderStack and ' +
'StatSig are off — but the client needs the object to finish loading.',
responses: { 200: json(AmplitudeConfig, 'Placeholder analytics keys') },
}),
(c) =>
c.json({
AmplitudeKey: 'a',
StatSigKey: 'a',
RudderStackKey: 'a',
AmplitudeKey: '',
UseRudderStack: false,
RudderStackKey: '',
UseStatSig: false,
StatSigKey: '',
StatSigEnvironment: 0,
})
)
.get(
+408 -32
View File
@@ -1,7 +1,9 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { Accessibility, GAME_VERSION } from '@repo/domain'
import { logger } from '@repo/hono-helpers'
import { validateAndGetVersion } 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.
@@ -9,6 +11,7 @@ import { NotificationType } from '../../../notify/src/notification-types'
import {
createEvent,
deleteEvent,
EVENT_DELETED_RESULT,
eventInputRejection,
getEventAttendees,
getEventById,
@@ -16,11 +19,14 @@ import {
getEventsByClubs,
getEventsByCreator,
getEventsByIds,
getEventsByRoom,
getEventTags,
getLiveEvents,
inviteToEvent,
isEventResponseType,
parseEventBody,
parseEventTags,
parseEventTime,
searchEvents,
setEventResponse,
toEventBase,
@@ -29,18 +35,24 @@ import {
toEventResult,
updateEvent,
} from '../events-db'
import { authedId, queryIds, unauthorized } from '../http'
import { authedId, parseFormIds, queryIds, unauthorized } from '../http'
import {
AUTHED,
BulkIdsRequest,
form,
idParam,
intQuery,
json,
jsonBody,
pageParams,
PlayerEventAccessibilityRequest,
PlayerEventBaseDto,
PlayerEventBulkInviteRequest,
PlayerEventDeletedDto,
PlayerEventDescriptionRequest,
PlayerEventDetailsDto,
PlayerEventDto,
PlayerEventNameRequest,
PlayerEventReportRequest,
PlayerEventRequest,
PlayerEventRespondRequest,
@@ -48,6 +60,8 @@ import {
PlayerEventResultDto,
PlayerEventsAll,
PlayerEventsPage,
PlayerEventTagsRequest,
PlayerEventTimeRequest,
stringQuery,
SuccessErrorEnvelope,
TagFilters,
@@ -58,7 +72,7 @@ import { createReport } from '../reports-db'
import type { Context } from 'hono'
import type { PlayerEventResponsePayload } from '../../../notify/src/notification-payloads'
import type { App } from '../context'
import type { EventAttendeeRow, EventTag, PlayerEvent } from '../events-db'
import type { EventAttendeeRow, EventInput, EventTag, PlayerEvent } from '../events-db'
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
@@ -133,6 +147,86 @@ async function notifyInvited(
}
}
/**
* Wrap an event in the v2 envelope for THIS caller's build.
*
* Rec Room reshaped `PlayerEvent.Tags` without minting a new path, so the same endpoint
* owes the 2023 build `[{ Tag, Type }]` and the 2025 build `["celebration"]`. The build
* comes off the token's `rn.ver` claim — the request carries no version of its own — and
* the split is the one `/api/gameconfigs/v1/all` already makes: anything NEWER than
* `GAME_VERSION` (20230414) is the 2025 client; that build, anything older, and a request
* with no readable token version all get the 2023 shape. Builds are date-stamped, so they
* order as strings.
*
* 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
return toEventResult(event, tags, !isModernBuild)
}
/**
* The shared front half of the single-field event edits (`PUT …/v2/{id}/{field}`):
* authenticate, load the event, check the caller created it, then apply whatever patch
* `parse` reads out of the body and answer the same `{ Result, TagModifyResult,
* PlayerEvent }` envelope the other v2 writes do — the client re-renders the event from
* the response rather than refetching it.
*
* `parse` answers `null` to refuse the body, which becomes the empty-bodied 400 the rest
* of this file uses. It gets the stored event so a rule can depend on it (the time edit
* checks the new window against the bound it isn't changing).
*
* These edits are creator-only like the whole-event update, and they go through the same
* {@link updateEvent}, so a patch touching one field leaves the rest of the event alone.
*/
function editEventField(
parse: (c: Context<App>, event: PlayerEvent) => Promise<EventInput | null>
) {
return async (c: Context<App>) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// `?? ''` only to satisfy the untyped-path signature — the route pattern already
// constrains the segment to digits, so it is always there.
const eventId = Number.parseInt(c.req.param('eventId') ?? '', 10)
const existing = await getEventById(c.env.DB, eventId)
if (existing === null) return c.body(null, 404)
if (existing.CreatorPlayerId !== id) return c.body(null, 403)
const input = await parse(c, existing)
if (input === null) return c.body(null, 400)
const updated = await updateEvent(c.env.DB, eventId, input)
// updateEvent only returns null when the row vanished, which the read above rules out.
return c.json(await eventResult(c, updated!, await getEventTags(c.env.DB, eventId)))
}
}
/** The form body of a single-field edit; an unparseable one reads as empty. */
async function formBody(c: Context<App>): Promise<Record<string, unknown>> {
return (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
}
/**
* Parse an `accessibility` field into an {@link Accessibility} value. The client sends
* the enum NAME here (`accessibility=Unlisted`), as it does on the subroom route in
* `rooms`; the ordinal is accepted alongside it. Undefined when the field names nothing
* in the enum — which the route refuses rather than defaulting, since guessing a
* visibility wrong is the kind of mistake that shows a private event to everyone.
*/
function parseEventAccessibility(value: unknown): number | undefined {
if (typeof value !== 'string') return undefined
const raw = value.trim()
const named = Object.entries(Accessibility).find(
([name, ordinal]) => typeof ordinal === 'number' && name.toLowerCase() === raw.toLowerCase()
)
if (named) return named[1] as number
if (!/^\d+$/.test(raw)) return undefined
const ordinal = Number.parseInt(raw, 10)
return ordinal in Accessibility ? ordinal : undefined
}
/**
* Player events — scheduled events players and clubs host in a room.
*
@@ -289,6 +383,41 @@ export const eventRoutes = new Hono<App>({ strict: false })
}
)
// A room's event shelf (`/room/12`) — what is on in this room, current and upcoming.
// A bare array, no envelope: the single-club form's `{ ContinuationToken, Events }` is
// the odd one out, and a room's shelf is small enough that there is nothing to page.
//
// The BASE event, not the stored record. The client reads this through the same generic
// helper and the same element type as the browse feed and the bulk read, so those three
// are shape-identical on its side — `toEventBase` is what keeps them identical here.
.get(
'/api/playerevents/v1/room/:roomId{[0-9]+}',
describeRoute({
tags: ['Events'],
summary: 'Player events in one room',
description:
'The events scheduled in a room — the shelf on the rooms page — soonest first. A ' +
'bare array of the clients BASE event (17 keys — no `State`, `ImageName` as `""` ' +
'rather than null, plus `BroadcastingRoomInstanceId`), the same projection the ' +
'browse feed and the bulk read serve: the client decodes all three through one ' +
'generic helper and one element type. `/searchlive` and the club shelves serve the ' +
'stored record instead.\n\n' +
'CURRENT and UPCOMING only: the filter is on the END time, so a running event stays ' +
'listed until it is over rather than vanishing the moment it starts, and an event ' +
'that has finished is dropped — this answers what someone can still turn up to. A ' +
'room with nothing scheduled, and a room id that does not exist, both answer an ' +
'empty array; the shelf is about events, not about whether the room is real.',
parameters: [idParam('roomId', 'Room id')],
responses: {
200: json(PlayerEventBaseDto.array(), 'The rooms current and upcoming events'),
},
}),
async (c) => {
const events = await getEventsByRoom(c.env.DB, Number.parseInt(c.req.param('roomId'), 10))
return c.json(events.map(toEventBase))
}
)
// Live player-event search (the "happening now" browse query) — events that have
// started and not yet finished. A bare array, like the multi-club feed.
.get(
@@ -340,22 +469,62 @@ export const eventRoutes = new Hono<App>({ strict: false })
}
)
// Bulk fetch (`?id=1&id=2`) — the events behind a list of ids the client already
// holds. Answers in the order asked for; ids with no event are skipped.
.get(
// Bulk fetch — the events behind a list of ids the client already holds. What the
// client actually calls is the POST, with the ids in a form body
// (`Ids=101&Ids=102&Ids=103`, or `Ids=13` for one); the GET below is the same read with
// the ids in the query, kept for hand-written calls.
//
// The BASE event, like the browse feed and the room shelf: one generic helper and one
// element type decode all three on the client, so this is "the feed, filtered to these
// ids" and must not drift into the stored-record shape the by-id read serves.
//
// Answers in the order asked for — the client renders them in request order — and skips
// ids with no event rather than leaving a hole, so the result may be shorter than the
// request. A bare array either way: no envelope, no `{ ContinuationToken, Events }`.
.post(
'/api/playerevents/v1/bulk',
describeRoute({
tags: ['Events'],
summary: 'Several player events by id',
description:
'The events behind a list of ids the client already holds (`?id=1&id=2`). Answers ' +
'in the order the ids were asked for — the client renders them in request order — ' +
'and skips ids with no event rather than leaving a hole, so the result may be ' +
'shorter than the request. A bare array.',
parameters: [intQuery('id', 'Repeatable event id')],
responses: { 200: json(PlayerEventDto.array(), 'The events that exist, in request order') },
'The events behind a list of ids the client already holds, as a form body: `Ids` ' +
'repeated once per id (`Ids=101&Ids=102&Ids=103`), or one comma-separated `Ids=1,2,3`. ' +
'A bare array of the clients BASE event the same projection the browse feed and ' +
'the room shelf serve, this one filtered to the requested ids.\n\n' +
'Answers in the order the ids were asked for and skips ids with no event rather ' +
'than leaving a hole, so the result may be shorter than the request. No ids at all ' +
'is an empty array, not a 400.',
requestBody: form(BulkIdsRequest, 'The event ids to look up'),
responses: {
200: json(PlayerEventBaseDto.array(), 'The events that exist, in request order'),
},
}),
async (c) => c.json(await getEventsByIds(c.env.DB, queryIds(c)))
async (c) => {
const events = await getEventsByIds(c.env.DB, await parseFormIds(c))
return c.json(events.map(toEventBase))
}
)
// The same read with the ids in the query (`?id=1&id=2`) — not what the client sends,
// but the shape stays identical to the POST's so the path can't answer two things.
.get(
'/api/playerevents/v1/bulk',
describeRoute({
tags: ['Events'],
summary: 'Several player events by id (query form)',
description:
'The same read as the POST on this path, with the ids in the query (`?id=1&id=2`) ' +
'rather than a form body — the client sends the POST. Identical response: a bare ' +
'array of the BASE event, in request order, skipping ids with no event.',
parameters: [intQuery('id', 'Repeatable event id')],
responses: {
200: json(PlayerEventBaseDto.array(), 'The events that exist, in request order'),
},
}),
async (c) => {
const events = await getEventsByIds(c.env.DB, queryIds(c))
return c.json(events.map(toEventBase))
}
)
// RSVP. One row per player per event, so responding again replaces the previous
@@ -403,7 +572,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
const updated = await setEventResponse(c.env.DB, eventId, id, type)
if (updated === null) return c.body(null, 404)
return c.json(toEventResult(updated, await getEventTags(c.env.DB, eventId)))
return c.json(await eventResult(c, updated, await getEventTags(c.env.DB, eventId)))
}
)
@@ -534,7 +703,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
const result = await inviteToEvent(c.env.DB, eventId, invited)
// inviteToEvent only returns null when the row vanished, which the read above rules out.
await notifyInvited(c, result!.event, result!.added)
return c.json(toEventResult(result!.event, await getEventTags(c.env.DB, eventId)))
return c.json(await eventResult(c, result!.event, await getEventTags(c.env.DB, eventId)))
}
)
@@ -554,6 +723,10 @@ export const eventRoutes = new Hono<App>({ strict: false })
'which is what makes `AttendeeCount` start at 1, since that count is derived from ' +
'the table. Answers the `{ Result, TagModifyResult, PlayerEvent }` envelope — NOT ' +
'the bare event the read endpoints serve.\n\n' +
'The window is capped at 24 hours and must end after it starts — an event is a ' +
'scheduled get-together, not a season. Since a missing end defaults to an hour ' +
'after the start, only a body naming both bounds (or an end alone, which is ' +
'measured from now) can fail this.\n\n' +
'Also pushes a `PlayerEventCreated` (80) hub notification to the creator, carrying ' +
'the event in its camelCase notification projection. A hub failure is logged and ' +
'swallowed — the event is already stored by then.',
@@ -561,7 +734,11 @@ export const eventRoutes = new Hono<App>({ strict: false })
requestBody: jsonBody(PlayerEventRequest, 'The event to schedule'),
responses: {
200: json(PlayerEventResultDto, 'The created event'),
400: { description: 'Name over 64 or description over 512 characters (empty body)' },
400: {
description:
'Name over 64 or description over 512 characters, or a window that is ' +
'backwards or longer than 24 hours (empty body)',
},
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -579,7 +756,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
await notifyEventCreated(c, event, input.tags ?? [])
// Read the tags back rather than echoing what was posted: the envelope reports what
// the event now carries, which is what the client redraws its chips from.
return c.json(toEventResult(event, await getEventTags(c.env.DB, event.PlayerEventId)))
return c.json(await eventResult(c, event, await getEventTags(c.env.DB, event.PlayerEventId)))
}
)
@@ -591,8 +768,9 @@ export const eventRoutes = new Hono<App>({ strict: false })
// which is how the reference exposes it, and a client that reaches for the HTTP verb
// instead should not get a 404 for being right.
//
// Answers the v2 envelope carrying the event as it WAS, so the caller can report what it
// removed; an unknown event is 404, and someone else's is 403.
// Answers the v2 envelope with both payload fields nulled —
// `{ PlayerEvent: null, Result: 0, TagModifyResult: null }`, which is what the reference
// sends: there is nothing left to redraw. An unknown event is 404, and someone else's 403.
.on(
['POST', 'DELETE'],
'/api/playerevents/v2/delete/:eventId{[0-9]+}',
@@ -604,12 +782,14 @@ export const eventRoutes = new Hono<App>({ strict: false })
'whose attendee rows outlived it would still be counted, and its tags would still ' +
'answer `#tag` searches.\n\n' +
'Creator only: anyone else gets 403, and an unknown event 404. Answers the v2 ' +
'envelope carrying the event as it was just before it went. Both POST and DELETE ' +
'reach it — the path names the verb, which is the form the client uses.',
'envelope with `PlayerEvent` and `TagModifyResult` both null — the event is gone, ' +
'so there is nothing for the client to redraw from, and it reads only `Result`. ' +
'Both POST and DELETE reach it — the path names the verb, which is the form the ' +
'client uses.',
security: AUTHED,
parameters: [idParam('eventId', 'Event id')],
responses: {
200: json(PlayerEventResultDto, 'The event that was deleted'),
200: json(PlayerEventDeletedDto, 'The nulled envelope a delete answers with'),
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the events creator (empty body)' },
404: { description: 'No such event (empty body)' },
@@ -624,12 +804,10 @@ export const eventRoutes = new Hono<App>({ strict: false })
if (existing === null) return c.body(null, 404)
if (existing.CreatorPlayerId !== id) return c.body(null, 403)
// Read the tags before the delete takes them, so the envelope can still report what
// the event carried.
const tags = await getEventTags(c.env.DB, eventId)
const deleted = await deleteEvent(c.env.DB, eventId)
// deleteEvent only answers null when the row vanished, which the read above rules out.
return c.json(toEventResult(deleted!, tags))
await deleteEvent(c.env.DB, eventId)
// Both payload fields are null here — the delete envelope is not the one the other
// v2 routes answer with. Nothing is left to redraw, and the client reads `Result`.
return c.json(EVENT_DELETED_RESULT)
}
)
@@ -661,7 +839,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
const eventId = Number.parseInt(c.req.param('eventId'), 10)
const event = await getEventById(c.env.DB, eventId)
if (event === null) return c.body(null, 404)
return c.json(toEventResult(event, await getEventTags(c.env.DB, eventId)))
return c.json(await eventResult(c, event, await getEventTags(c.env.DB, eventId)))
}
)
@@ -678,13 +856,20 @@ export const eventRoutes = new Hono<App>({ strict: false })
'clear it.\n\n' +
'The id, the creator and the attendee count are not editable: ownership doesnt ' +
'transfer and RSVPs arent set by hand. Creator only — anyone else gets 403, and ' +
'an unknown event is 404. Answers the same envelope as create.',
'an unknown event is 404. Answers the same envelope as create.\n\n' +
'The 24-hour window cap applies to what the post RESOLVES to, not to what it ' +
'carries: moving the start alone still has to leave a window that ends after it ' +
'and runs no longer than a day against the STORED end.',
security: AUTHED,
parameters: [idParam('eventId', 'Event id')],
requestBody: jsonBody(PlayerEventRequest, 'The fields to change'),
responses: {
200: json(PlayerEventResultDto, 'The updated event'),
400: { description: 'Name over 64 or description over 512 characters (empty body)' },
400: {
description:
'Name over 64 or description over 512 characters, or a resolved window that ' +
'is backwards or longer than 24 hours (empty body)',
},
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the events creator (empty body)' },
404: { description: 'No such event (empty body)' },
@@ -700,13 +885,204 @@ export const eventRoutes = new Hono<App>({ strict: false })
const body = await c.req.json<unknown>().catch(() => ({}))
const input = parseEventBody(body)
if (eventInputRejection(input) !== null) return c.body(null, 400)
// The stored event is passed so the window rule resolves against the bound this
// post isn't moving: an edit that only shifts the start still has to land inside
// a day of the end already stored.
if (eventInputRejection(input, existing) !== null) return c.body(null, 400)
const updated = await updateEvent(c.env.DB, eventId, input)
// updateEvent only returns null when the row vanished, which the read above rules out.
return c.json(toEventResult(updated!, await getEventTags(c.env.DB, eventId)))
return c.json(await eventResult(c, updated!, await getEventTags(c.env.DB, eventId)))
}
)
// ---- Single-field edits -------------------------------------------------
// The event-settings screen edits one field at a time rather than posting the whole
// event back, so each of these is a PUT alongside the whole-event update above. They
// share its rules — creator-only, 404/403/401 the same way — and answer the same v2
// envelope, which is what the client re-renders the event from.
//
// Note the bodies are FORM-encoded (the tags one excepted), where the whole-event
// writes next to them are JSON. That is what the client sends; don't unify them.
// Move an event's window. Either bound alone is enough — an absent one keeps its
// stored value, so the start can be nudged without restating the end.
.put(
'/api/playerevents/v2/:eventId{[0-9]+}/time',
describeRoute({
tags: ['Events'],
summary: 'Reschedule a player event',
description:
'Moves an events window. `startTime` and `endTime` are both optional and both ' +
'independent: an absent bound keeps the stored one, so the start can be nudged ' +
'without restating the end. Any parseable ISO 8601 is accepted — the client sends ' +
'.NET tick precision (`2026-08-31T17:30:00.0000000Z`) — and stored trimmed to ' +
'seconds, the form every read serves.\n\n' +
'A bound that is present but unparseable is a 400 rather than being dropped: a ' +
'reschedule that silently did nothing is worse than a refusal. So is a window that ' +
'ends before it starts, or one running longer than 24 HOURS — an event lasts at ' +
'most a day. Both are checked against the RESOLVED window, so sending one bound ' +
'is measured against the stored other one.\n\n' +
'Creator only, like the whole-event update; answers the same v2 envelope.',
security: AUTHED,
parameters: [idParam('eventId', 'Event id')],
requestBody: form(PlayerEventTimeRequest, 'The new window'),
responses: {
200: json(PlayerEventResultDto, 'The rescheduled event'),
400: {
description:
'An unparseable time, an end before the start, or a window over 24 hours ' +
'(empty body)',
},
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the events creator (empty body)' },
404: { description: 'No such event (empty body)' },
},
}),
editEventField(async (c, event) => {
const body = await formBody(c)
// Absent leaves the stored bound alone; present-but-unusable is refused, which is
// the distinction `parseEventBody` deliberately collapses for the JSON writes.
const startTime = parseEventTime(body.startTime)
const endTime = parseEventTime(body.endTime)
if (body.startTime !== undefined && startTime === undefined) return null
if (body.endTime !== undefined && endTime === undefined) return null
// The window rules — ends after it starts, runs no longer than a day — live with
// the other write validation, resolved against the bound this edit isn't moving.
const input = { startTime, endTime }
return eventInputRejection(input, event) === null ? input : null
})
)
// Change an event's visibility. The NAME of the enum, as the subroom route in `rooms`
// takes it — not the ordinal the event's JSON writes carry.
.put(
'/api/playerevents/v2/:eventId{[0-9]+}/accessibility',
describeRoute({
tags: ['Events'],
summary: 'Set a player events accessibility',
description:
'Sets an events visibility. The client sends the `RoomAccessibility` NAME here ' +
'(`accessibility=Unlisted`), the way it does on the subroom route in `rooms` — not ' +
'the ordinal the events JSON writes carry, though the ordinal is accepted too.\n\n' +
'A value naming nothing in the enum is a 400 rather than being defaulted or stored ' +
'verbatim: guessing a visibility wrong is what shows a private event to everyone. ' +
'Creator only; answers the same v2 envelope.',
security: AUTHED,
parameters: [idParam('eventId', 'Event id')],
requestBody: form(PlayerEventAccessibilityRequest, 'The new visibility'),
responses: {
200: json(PlayerEventResultDto, 'The updated event'),
400: { description: 'Missing or unrecognized `accessibility` (empty body)' },
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the events creator (empty body)' },
404: { description: 'No such event (empty body)' },
},
}),
editEventField(async (c) => {
const accessibility = parseEventAccessibility((await formBody(c)).accessibility)
return accessibility === undefined ? null : { accessibility }
})
)
// Replace an event's tags. A BARE JSON ARRAY of names, unlike the other edits here.
.put(
'/api/playerevents/v2/:eventId{[0-9]+}/tags',
describeRoute({
tags: ['Events'],
summary: 'Set a player events tags',
description:
'Replaces an events whole tag set. The body is a BARE JSON ARRAY of names — ' +
'`["tag1","class"]` — not the form encoding the other single-field edits use, and ' +
'not an object; the `{ tag, type }` pairs the create/update bodies accept work too. ' +
'A replace, not a merge: untagging is a PUT with the tag left out, and `[]` clears ' +
'them all.\n\n' +
'Names are lowercased and a leading `#` stripped, matching what the `#tag` search ' +
'looks for. A body that is not an array is a 400. Creator only; answers the same v2 ' +
'envelope, whose `TagModifyResult` carries the set the event now has.',
security: AUTHED,
parameters: [idParam('eventId', 'Event id')],
requestBody: jsonBody(PlayerEventTagsRequest, 'The whole tag set'),
responses: {
200: json(PlayerEventResultDto, 'The updated event, with its new tags'),
400: { description: 'The body is not a JSON array (empty body)' },
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the events creator (empty body)' },
404: { description: 'No such event (empty body)' },
},
}),
editEventField(async (c) => {
const body = await c.req.json<unknown>().catch(() => undefined)
const tags = parseEventTags(body)
return tags === undefined ? null : { tags }
})
)
// Rewrite an event's blurb. An absent field clears it — the client sends no field for
// an emptied box, like the room description route in `rooms`.
.put(
'/api/playerevents/v2/:eventId{[0-9]+}/description',
describeRoute({
tags: ['Events'],
summary: 'Set a player events description',
description:
'Rewrites an events blurb. An absent `description` CLEARS it — an emptied text box ' +
'sends no field, the same way the room description route in `rooms` behaves — so ' +
'this is the one edit here that cant be a no-op.\n\n' +
'Capped at 512 characters, the stored length, and refused rather than truncated: ' +
'silently cutting a players text off is worse than telling them. Creator only; ' +
'answers the same v2 envelope.',
security: AUTHED,
parameters: [idParam('eventId', 'Event id')],
requestBody: form(PlayerEventDescriptionRequest, 'The new description'),
responses: {
200: json(PlayerEventResultDto, 'The updated event'),
400: { description: 'Description over 512 characters (empty body)' },
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the events creator (empty body)' },
404: { description: 'No such event (empty body)' },
},
}),
editEventField(async (c) => {
const raw = (await formBody(c)).description
const description = typeof raw === 'string' ? raw : ''
return eventInputRejection({ description }) === null ? { description } : null
})
)
// Rename an event. Unlike the description, a blank one is refused: `updateEvent` reads
// an empty name as "leave it alone", so storing one is impossible anyway — and an event
// with no title renders as a blank row.
.put(
'/api/playerevents/v2/:eventId{[0-9]+}/name',
describeRoute({
tags: ['Events'],
summary: 'Rename a player event',
description:
'Retitles an event. Capped at 64 characters, the stored length, and refused rather ' +
'than truncated. A blank name is refused too — an event with no title renders as a ' +
'blank row, and the whole-event update reads an empty name as “leave it alone”, so ' +
'there is no way to store one regardless. The name is stored trimmed.\n\n' +
'No uniqueness rule: two events may share a title, unlike a room name. Creator ' +
'only; answers the same v2 envelope.',
security: AUTHED,
parameters: [idParam('eventId', 'Event id')],
requestBody: form(PlayerEventNameRequest, 'The new title'),
responses: {
200: json(PlayerEventResultDto, 'The renamed event'),
400: { description: 'A blank name, or one over 64 characters (empty body)' },
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the events creator (empty body)' },
404: { description: 'No such event (empty body)' },
},
}),
editEventField(async (c) => {
const raw = (await formBody(c)).name
const name = typeof raw === 'string' ? raw.trim() : ''
if (name === '' || eventInputRejection({ name }) !== null) return null
return { name }
})
)
// An event's guest list — every RSVP row, whatever the answer.
.get(
'/api/playerevents/v1/:eventId{[0-9]+}/responses',
+49 -2
View File
@@ -1,6 +1,7 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import charadesAprilWords from '../../static/charades-april.json'
import charadesWords from '../../static/charades.json'
import communityBoard from '../../static/community-board.json'
import { authedId, unauthorized } from '../http'
@@ -69,6 +70,24 @@ async function sanitizeRequest(
}
}
/**
* The Charades word bank for a given moment: the April Fools list on April 1st, the
* ordinary list every other day.
*
* A REPLACEMENT, not an addition — the joke list stands alone for the day, which is why
* its ids (1258+) start past the end of the ordinary one rather than overlapping it. The
* client fetches the bank when the activity starts, so a game already running keeps
* whichever list it drew.
*
* The date is read in UTC, so the swap runs 00:00-23:59 UTC on April 1 for everyone
* rather than rolling around the world with local midnight. `now` is injectable so tests
* can pick a day.
*/
export function charadesWordsFor(now: Date = new Date()) {
const isAprilFools = now.getUTCMonth() === 3 && now.getUTCDate() === 1
return isAprilFools ? charadesAprilWords : charadesWords
}
// Text sanitization, keepsakes, objectives/events/rewards, and the misc analytics
// sinks the client hits during load.
export const gameplayRoutes = new Hono<App>({ strict: false })
@@ -128,6 +147,7 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
// ---- Activities -----------------------------------------------------------
// Word bank for the Charades activity. The client requests the list by
// activity name (`.../words/Charades`); other activities have no data yet.
// On April 1st (UTC) the joke list replaces it — see `charadesWordsFor`.
.get(
'/api/activities/charades/v1/words/:activity',
describeRoute({
@@ -136,11 +156,12 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
description:
'The words the Charades activity draws from. The client asks by activity name ' +
'(`.../words/Charades`); the name is not matched on, so every activity gets the ' +
'charades list — no other activity has data yet.',
'charades list — no other activity has data yet. On April 1st (UTC) the April ' +
'Fools word list is served in place of the ordinary one.',
parameters: [stringParam('activity', 'Activity name, e.g. `Charades`. Not matched on.')],
responses: { 200: json(JsonArray, 'The word list') },
}),
(c) => c.json(charadesWords)
(c) => c.json(charadesWordsFor())
)
// Keepsakes (room mementos). Stubbed empty.
@@ -206,6 +227,32 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
}),
(c) => c.json(communityBoard)
)
// Circuit chip lists — the palettes the Maker Pen's circuit board groups its chips into
// (`/api/CircuitChipLists/Favorites`, `/api/CircuitChipLists/Recent`, and so on). The path
// segment names the list; nothing here records which chips a player has used or favourited,
// so every one of them is empty.
//
// EMPTY rather than 404 for a name this server doesn't know: the client asks for whichever
// palettes its build has, and an unknown one is a palette this server has no opinion about
// rather than an error — a 404 shows as a palette that failed to load, where an empty list
// shows as one with nothing in it, which is the truth for all of them.
.get(
'/api/CircuitChipLists/:list',
describeRoute({
tags: ['Gameplay'],
summary: 'One circuit chip list',
description:
'A palette on the Maker Pens circuit board, named by the path (`Favorites`, ' +
'`Recent`, …). Always empty: nothing records which chips a player has used or ' +
'favourited yet. An unknown name is empty too rather than a 404 — the client asks ' +
'for whichever palettes its build has, and a 404 renders as a palette that failed ' +
'to load rather than one with nothing in it.',
parameters: [stringParam('list', 'The palette name, e.g. `Favorites`')],
responses: { 200: json(JsonArray, 'An empty list') },
}),
(c) => c.json([])
)
// Player events live in their own controller (routes/events.ts) — they're D1-backed
// now, unlike the stubs around them here.
.get(
+8
View File
@@ -46,6 +46,7 @@ import {
UploadImageRequest,
UploadImageResponse,
} from '../openapi'
import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit'
import type { Context } from 'hono'
import type { App } from '../context'
@@ -210,6 +211,7 @@ export const imageRoutes = new Hono<App>({ strict: false })
200: json(UploadImageResponse, 'The stored bucket key'),
400: json(ErrorResponse, 'No file in the request'),
401: UNAUTHORIZED_RESPONSE,
413: json(ErrorResponse, 'The image exceeds the configured per-file limit'),
},
}),
async (c) => {
@@ -221,6 +223,12 @@ export const imageRoutes = new Hono<App>({ strict: false })
const candidate = body.image ?? body.file
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
const file = candidate
const limit = maxApiUploadBytes(c.env)
// parseBody has already materialized the multipart part. Reject it before arrayBuffer()
// creates another full-size allocation and before the object can consume R2 storage.
if (exceedsApiUploadLimit(file, limit)) {
return c.json({ error: `image exceeds the ${limit}-byte upload limit` }, 413)
}
// `imgMeta` is a JSON blob describing the upload (`SavedImageMetaDTO`),
// posted as a multipart field. It carries the metadata we record on the image
+465 -34
View File
@@ -1,6 +1,22 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import {
canModerateRoom,
deletePresence,
getPlayerIdsInInstance,
getPresences,
getRoomById,
getStoredRoomInstance,
MessageType,
refreshInstanceFullness,
} from '@repo/domain'
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 { KickReportCategory } from '../../../notify/src/notification-payloads'
import { NotificationType } from '../../../notify/src/notification-types'
import { authedId, authedRoles, unauthorized } from '../http'
import {
AUTHED,
@@ -9,18 +25,23 @@ import {
CreateWarningRequest,
DeviceIdRequest,
form,
InstantKickRequest,
json,
JsonArray,
jsonBody,
ModerationBlockDetails,
SuccessErrorEnvelope,
UNAUTHORIZED_RESPONSE,
VoteToKickReason,
VoteToKickRequest,
} from '../openapi'
import { createReport } from '../reports-db'
import { createReport, getActiveBan } from '../reports-db'
import { createWarning } from '../warnings-db'
import type { Context } from 'hono'
import type { ModerationKickPayload } from '../../../notify/src/notification-payloads'
import type { App } from '../context'
import type { ReportRow } from '../reports-db'
/**
* Roles allowed to hand down a warning — the operator-granted elevated roles the auth
@@ -82,16 +103,209 @@ const VOTE_TO_KICK_REASONS = [
{ Reason: 'Not following game rules', ReportCategory: 6 },
] as const
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
/**
* Eject players from the instance they're standing in — the `ModerationKick` frame (id 22)
* the client acts on to leave a room, the same one a room ban sends (`rooms`:
* `pushRoomBan`). This one only kicks: `IsBan` is false, so nothing keeps them from walking
* straight back in, and no ban row exists to lift.
*
* Sent EPHEMERALLY, unlike the ban's frame, and to the whole batch in one round-trip. A
* kick is only true of the moment it happened: queued and delivered on the player's next
* connect it would throw them out of some unrelated session hours later. A recipient who
* has already gone offline needs no kick anyway.
*
* `GameSessionId` is the instance they're being removed from — every recipient is in it,
* which is what the caller checked before this runs. `IsHostKick` is always true: this
* endpoint is the room's own staff acting, never the room majority vote-kicking (that path
* would carry `VoteKick` and false). Built against the client's recovered payload interface
* so a renamed key fails the build rather than vanishing on the wire.
*
* Best-effort — presence is already deleted by the time this runs, so a hub hiccup must
* not fail the request.
*/
async function pushInstantKick(
c: Context<App>,
playerIds: number[],
gameSessionId: number,
roomName: string,
moderatorId: number
): Promise<void> {
const frame: ModerationKickPayload = {
ReportCategory: KickReportCategory.Moderator,
Duration: 0,
GameSessionId: gameSessionId,
IsHostKick: true,
Message: `You have been kicked from ${roomName}.`,
PlayerIdReporter: moderatorId,
IsBan: false,
IsVoiceModAutoban: false,
IsWarning: false,
VoteKickReason: '',
TimeoutStartedAt: null,
}
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
playerIds,
NotificationType.ModerationKick,
{ ...frame }
)
} catch (err) {
logger.error('failed to push ModerationKick notification', {
playerIds,
gameSessionId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* What a vote-to-kick Message's `Data` says, BEFORE it is serialized. It reaches the
* client as an escaped JSON string, never as a nested object — a Message's `Data` is a
* string on the wire like every other Message's, and the client's decoder rejects an
* object outright: `expected:'String Begin Token', actual:'{'`, which aborts the whole
* notification rather than dropping the field. Serialize it with {@link voteToKickData}.
*
* `PlayerId` is the account id as a STRING — the reference passes the posted form field
* straight through, and this mirrors it verbatim.
*
* `Response` is the empty string even though the caller posted their own vote: the frame
* is the PROMPT put to everyone else, so it carries no answer yet. The caller's `Response`
* is theirs alone and is not relayed.
*/
interface VoteToKickData {
PlayerId: string
Response: string
GameSessionId: number
}
/** Serialize a {@link VoteToKickData} into the escaped JSON string `Data` carries. */
const voteToKickData = (data: VoteToKickData): string => JSON.stringify(data)
/**
* The Message a vote-to-kick frame carries — the same four fields as every other Message
* this server sends (see the `social` routes' `Message`), `Data` string included. A type
* alias rather than an interface: the hub's send takes an index-signature record, which
* only aliases satisfy implicitly.
*/
type VoteToKickMessage = {
FromPlayerId: number
ToPlayerId: number
Type: number
Data: string
}
/**
* Put a vote-to-kick to one player — a `MessageReceived` frame carrying a Message of type
* 5 (`VoteToKick`), the frame their client raises the vote prompt from. Resolves false when
* the hub could not be reached, which the caller reports honestly: nothing stores a vote,
* so the notification is the whole delivery.
*
* EPHEMERAL, unlike the messages the social routes send. A vote belongs to the moment it
* was called: queued for an offline player, it would raise a prompt on their next connect
* about a session that ended hours ago, and there would be nothing left to vote on.
*/
async function pushVoteToKick(c: Context<App>, message: VoteToKickMessage): Promise<boolean> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayerEphemeral(
message.ToPlayerId,
NotificationType.MessageReceived,
message
)
return true
} catch (err) {
logger.error('failed to push VoteToKick MessageReceived notification', {
toPlayerId: message.ToPlayerId,
error: err instanceof Error ? err.message : String(err),
})
return false
}
}
/**
* `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.
*/
const PERMANENT_BAN_DURATION = 2_147_483_647
/**
* The "not blocked" answer — the reference server's stub `ReturnModerationBlockDetails()`,
* widened to every key the client's `ModerationBlockDetail` decoder names (16 on the wire;
* the 2025 build's formatter reads them all). The ones past the stub's nine are the block
* kinds and screen dressings this server never uses — a device ban, a warning, the
* vote-kick reason, an associated account, the creator code of conduct, the top/bottom
* message overrides — so they carry their "none" values on every answer.
*/
const NOT_BLOCKED = {
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsHostKick: false,
Message: null,
PlayerIdReporter: null,
IsBan: false,
IsVoiceModAutoban: false,
IsDeviceBan: false,
IsWarning: false,
VoteKickReason: null,
TimeoutStartedAt: null,
AssociatedAccountUsername: null,
ShowCreatorCodeOfConduct: false,
TopMessageOverride: null,
BottomMessageOverride: null,
}
/**
* The block details for a ban in force — the `report` row a moderator set `banned` on.
*
* `Duration` and `TimeoutStartedAt` are a PAIR in the client: the block runs from the
* start for the duration. The start is the report's `created_at` — nothing records when
* the ban itself was handed down, and the report is the record the ban rests on — 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. The category is the one the report was
* filed under, so the client's ban screen names the reason. `Message` is a fixed "Rule
* violation" rather than the report's `details` — those are the REPORTER's words, and the
* banned player isn't shown them, for the same reason `PlayerIdReporter` stays null: the
* reporter is not a host who kicked them, and naming them would tell the banned player who
* reported them. Everything else keeps its `NOT_BLOCKED` value: the other block kinds and
* screen dressings, none of which this server hands out.
*/
function banBlockDetails(ban: ReportRow) {
const startedAt = Date.parse(ban.created_at)
const duration =
ban.ban_expires === null
? PERMANENT_BAN_DURATION
: Math.max(1, Math.ceil((Date.parse(ban.ban_expires) - startedAt) / 1000))
return {
...NOT_BLOCKED,
ReportCategory: ban.report_category,
Duration: duration,
IsBan: true,
Message: 'Rule violation',
TimeoutStartedAt: ban.created_at,
}
}
// ---- Player reporting ------------------------------------------------------
export const moderationRoutes = new Hono<App>({ strict: false })
// Whether the caller is currently blocked (banned / timed out / host-kicked). Bans
// are stored (a report row with `banned` set) and enforced at matchmake and at login,
// but this endpoint is not wired to them, so it's always the "not blocked" answer —
// the reference server's stub `ReturnModerationBlockDetails()`.
// `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category;
// `Message` is null, not the empty string that stub sends — the client distinguishes
// "no message" from a blank one. `IsVoiceModAutoban`/`TimeoutStartedAt` are on the
// DTO but left unset there, so they go out with their C# defaults.
// Whether the caller is currently blocked (banned / timed out / host-kicked). The one
// kind of block this server has is the account-wide ban — a `report` row with `banned`
// set (see `getActiveBan`), the same row matchmake refuses on — so a caller with one in
// force gets it described here, and everyone else gets the "not blocked" answer of the
// reference server's stub `ReturnModerationBlockDetails()`. This is the screen a banned
// player is shown, which is why `auth` still issues them a token: without one the client
// never gets here, and the ban reads as a failed sign-in.
// Only the caller's OWN account is consulted, not the evasion arms `resolveBan` adds
// at matchmake and login: this screen explains a ban handed to this account, and a
// player blocked for sharing a network with a banned one has no report row to show.
// In the "not blocked" answer `ReportCategory` is `Unknown` (-1) rather than 0, which
// is a real category, and `Message` is null, not the empty string that stub sends —
// the client distinguishes "no message" from a blank one. `IsVoiceModAutoban` /
// `TimeoutStartedAt` are on the DTO but unset there, so they go out with their C#
// defaults.
// The newer client POSTs this with no body despite it being a pure read; it answers
// GET too, so the path is reachable from either build.
.on(
@@ -101,29 +315,40 @@ export const moderationRoutes = new Hono<App>({ strict: false })
tags: ['Moderation'],
summary: 'Whether the caller is blocked',
description:
'Ban / timeout / host-kick state for the caller. Bans are stored (a `report` row ' +
'with `banned` set) and enforced at matchmake and at login, but this endpoint is ' +
'not wired to them, so it is always the “not blocked” answer, following the ' +
'reference servers stub: `ReportCategory` is `Unknown` (-1) rather than 0, which ' +
'is a real category, and `Message` is null rather than the empty string that stub ' +
'sends — the client distinguishes “no message” from a blank one. ' +
'`IsVoiceModAutoban` and `TimeoutStartedAt` are on the DTO but unset by that ' +
'stub, so they carry their defaults. Answers GET or POST: the newer client POSTs ' +
'it with no body.',
responses: { 200: json(ModerationBlockDetails, 'Always “not blocked”') },
'Ban / timeout / host-kick state for the caller. The one block this server hands ' +
'out is the account-wide ban — a `report` row with `banned` set, the same row ' +
'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 blocks span as the pair the client reads ' +
'them as: `TimeoutStartedAt` is the reports `created_at` and `Duration` the ' +
'seconds from there to `ban_expires` (2147483647, the int32 max, for a permanent ' +
'ban). ' +
'`PlayerIdReporter` stays null: it names a kicking host, and the reporter is not ' +
'shown to the player they reported. Only the callers own account is consulted, ' +
'not the ban-evasion arms.\n\n' +
'Everyone else gets the reference servers stub “not blocked” answer: ' +
'`ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and ' +
'`Message` is null rather than the empty string that stub sends — the client ' +
'distinguishes “no message” from a blank one. `IsVoiceModAutoban` and ' +
'`TimeoutStartedAt` are on the DTO but unset by that stub, so they carry their ' +
'defaults, as do the seven keys past the stubs nine that the 2025 clients decoder ' +
'names (`IsDeviceBan`, `IsWarning`, `VoteKickReason`, `AssociatedAccountUsername`, ' +
'`ShowCreatorCodeOfConduct`, `TopMessageOverride`, `BottomMessageOverride`) — ' +
'block kinds and screen dressings this server never uses. Answers GET or POST: ' +
'the newer client POSTs it with no body.',
security: AUTHED,
responses: {
200: json(ModerationBlockDetails, 'The callers block, or “not blocked”'),
401: UNAUTHORIZED_RESPONSE,
},
}),
(c) =>
c.json({
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsBan: false,
IsHostKick: false,
IsVoiceModAutoban: false,
Message: null,
PlayerIdReporter: null,
TimeoutStartedAt: null,
})
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)
}
)
// The reasons the client offers when a player starts a vote-to-kick. Order matters —
// the client renders them in the order they arrive — and the list is grouped by the
@@ -209,10 +434,10 @@ export const moderationRoutes = new Hono<App>({ strict: false })
tags: ['Moderation'],
summary: 'Submit a player report',
description:
'Records a player report in the `report` table; nothing dedupes the rows, and ' +
'`moderationBlockDetails` still answers “not blocked” unconditionally. A report ' +
'Records a player report in the `report` table; nothing dedupes the rows. A report ' +
'is filed unbanned — a moderator converts one into an account-wide ban by setting ' +
'`banned` on the row, which is what matchmaking and `/connect/token` refuse on.\n\n' +
'`banned` on the row, which is what matchmaking refuses on and what ' +
'`moderationBlockDetails` describes to the banned player.\n\n' +
'The reporter is the caller (from the bearer token), NOT a body field. Only ' +
'`PlayerIdReported` is required; the client omits whatever it has no value for ' +
'(a report raised outside a room carries no `RoomId`), and those are stored as ' +
@@ -257,6 +482,212 @@ export const moderationRoutes = new Hono<App>({ strict: false })
}
)
// A player calling a vote to kick another. Ungated by role — anyone may start one —
// but both players have to be standing in the session the vote is called in, which is
// what stops a client putting a vote to a room it isn't in, about someone who isn't
// there. Nothing tallies the votes yet: this relays the prompt and no more.
.post(
'/api/PlayerReporting/v3/voteToKick',
describeRoute({
tags: ['Moderation'],
summary: 'Call a vote to kick a player',
description:
'Puts a vote-to-kick to the room instance. Open to any player — no role is ' +
'required — but BOTH the caller and `PlayerId` must have a live `presence` row in ' +
'the instance `GameSessionId` names, or the call is refused with a 403. That is ' +
'the whole gate: without it a client could raise a vote in a session it is not ' +
'in, or against a player who is not there.\n\n' +
'Everyone else in that instance — the player being voted on included, since a ' +
'vote is called in front of them — gets a `MessageReceived` frame carrying a ' +
'Message of type 5 (`VoteToKick`). The caller is left out: they have voted ' +
'already, and their own `Response` is what they posted.\n\n' +
'`Data` is an ESCAPED JSON STRING — `"{\\"PlayerId\\":\\"205\\",…}"`, not a nested ' +
'object. A Messages `Data` is a string on the wire, and an object there fails the ' +
"clients decoder outright (`expected:'String Begin Token', actual:'{'`), " +
'aborting the notification rather than dropping the field. Inside it, `PlayerId` ' +
'is the account id as a STRING, as the reference relays it, and `Response` is ' +
'empty — the frame is the question, not an answer.\n\n' +
'The frames are EPHEMERAL: a vote belongs to the moment it was called, so an ' +
'offline player gets nothing rather than a prompt about a dead session on their ' +
'next connect.\n\n' +
'Nothing is stored — no tally, no report row, and `Reason` is accepted and ' +
'unused. Answers the same lowercase `{ success, error }` envelope as the report ' +
'write; a hub failure for any recipient is reported honestly as a 500, since ' +
'with nothing behind it the frame is the whole delivery.',
security: AUTHED,
requestBody: form(VoteToKickRequest, 'The vote'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'No `PlayerId` or no `GameSessionId`'),
401: UNAUTHORIZED_RESPONSE,
403: json(SuccessErrorEnvelope, 'Either player is not in that game session'),
500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'),
},
}),
async (c) => {
const voterId = await authedId(c)
if (voterId === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
// Kept as posted for the frame — `Data.PlayerId` goes out as the string the
// reference relays — but parsed here to check it against presence.
const playerIdField = formField(body, c, 'PlayerId')
const playerId = asInt(playerIdField)
if (playerIdField === undefined || playerId === null) {
return c.json({ success: false, error: 'PlayerId is required' }, 400)
}
const gameSessionId = asInt(formField(body, c, 'GameSessionId'))
if (gameSessionId === null) {
return c.json({ success: false, error: 'GameSessionId is required' }, 400)
}
// One read for both players. A vote may only be called by someone standing in the
// session, about someone standing in the same one — the session is read from live
// presence, never from the body, so neither side can be asserted by the client.
const presences = await getPresences<{ roomInstanceId?: number }>(c.env.DB, [
voterId,
playerId,
])
const isHere = (id: number) =>
presences.get(id)?.roomInstance?.roomInstanceId === gameSessionId
if (!isHere(voterId)) {
return c.json({ success: false, error: 'You are not in that game session!' }, 403)
}
if (!isHere(playerId)) {
return c.json({ success: false, error: 'That player is not in that game session!' }, 403)
}
// The room votes, so the audience is everyone standing there — the player being
// voted on included; a vote is called in front of them. The caller is dropped:
// their vote is the one they just posted.
const audience = (await getPlayerIdsInInstance(c.env.DB, gameSessionId)).filter(
(id) => id !== voterId
)
// Every recipient is attempted even if an earlier one fails, so the reachable
// players still get the vote.
const results = await Promise.all(
audience.map((toPlayerId) =>
pushVoteToKick(c, {
FromPlayerId: voterId,
ToPlayerId: toPlayerId,
Type: MessageType.VoteToKick,
// An escaped JSON STRING, not a nested object — see VoteToKickData.
Data: voteToKickData({
PlayerId: playerIdField,
Response: '',
GameSessionId: gameSessionId,
}),
})
)
)
if (results.includes(false)) {
return c.json({ success: false, error: 'Failed to deliver vote' }, 500)
}
return c.json({ success: true, error: '' })
}
)
// The kick a room's own staff hand out from the moderation menu: eject named players
// from ONE live instance. Two gates, and both matter — the caller must be able to
// moderate the room the instance belongs to, and each named player must actually be
// standing in that instance. Without the second, a creator could name any account id
// and kick a stranger out of somebody else's room.
.post(
'/api/PlayerReporting/v1/instantKick',
describeRoute({
tags: ['Moderation'],
summary: 'Kick players out of a room instance',
description:
'Ejects the named players from one live room instance. `GameSessionId` is that ' +
'instance (`roomInstanceId`); the body is JSON, unlike the form posts elsewhere in ' +
'this controller.\n\n' +
'Gated to the instances room: the caller must be its creator or hold a role of ' +
'Moderator (20) or above on it — anyone else with a valid token gets a 403. ' +
'Nobody who can moderate the room can be kicked out of it, and a caller cannot ' +
'kick themselves.\n\n' +
'A player is only kicked if their live `presence` row puts them in **that** ' +
'instance. Anyone else named — offline, or standing in another room — is skipped ' +
'in silence, so naming an account id cannot reach into a session the caller has ' +
'no authority over.\n\n' +
'Each kicked player loses their presence row (they read offline at once and the ' +
'instance frees a slot) and gets a `ModerationKick` frame (id 22) — the frame the ' +
'client acts on to leave. It is the same frame a room ban sends, but `IsBan` is ' +
'false: this only removes them from the session they are in, and nothing stops ' +
'them rejoining. The frame is EPHEMERAL — a kick is true of the moment it ' +
'happened, and queueing one would eject the player from an unrelated session on ' +
'their next connect.\n\n' +
'Answers the same lowercase `{ success, error }` envelope the report write uses, ' +
'and says nothing about who was actually kicked — the response shape is ' +
'unverified against the real service.',
security: AUTHED,
requestBody: jsonBody(InstantKickRequest, 'The instance and the players to eject'),
responses: {
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
400: json(SuccessErrorEnvelope, 'Unparseable body, no `GameSessionId` or no `PlayerIds`'),
401: UNAUTHORIZED_RESPONSE,
403: json(SuccessErrorEnvelope, 'The caller cannot moderate the instances room'),
404: json(SuccessErrorEnvelope, 'No such game session'),
},
}),
async (c) => {
const moderatorId = await authedId(c)
if (moderatorId === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as {
GameSessionId?: unknown
PlayerIds?: unknown
} | null
if (body === null) return c.json({ success: false, error: 'Invalid request body' }, 400)
const gameSessionId = typeof body.GameSessionId === 'number' ? body.GameSessionId : Number.NaN
if (!Number.isInteger(gameSessionId)) {
return c.json({ success: false, error: 'GameSessionId is required' }, 400)
}
const playerIds = Array.isArray(body.PlayerIds)
? body.PlayerIds.filter((id): id is number => Number.isInteger(id))
: []
if (playerIds.length === 0) {
return c.json({ success: false, error: 'PlayerIds is required' }, 400)
}
// The instance names the room, and the room carries the roles this is gated on —
// a game session with no room behind it can't authorise anything.
const instance = await getStoredRoomInstance(c.env.DB, gameSessionId)
const room = instance && (await getRoomById(c.env.DB, instance.roomId))
if (!room) return c.json({ success: false, error: 'This game session does not exist!' }, 404)
if (!canModerateRoom(room, moderatorId)) {
return c.json({ success: false, error: 'Forbidden' }, 403)
}
// One read for the batch. A player is kicked only when their LIVE presence puts
// them in this very instance: offline, expired or standing elsewhere are all the
// same "not here", and are skipped rather than refused — the client sends a list
// and one stale id in it must not sink the rest.
const presences = await getPresences<{ roomInstanceId?: number }>(c.env.DB, playerIds)
const kicked: number[] = []
for (const playerId of playerIds) {
// The room's own staff are not kickable out of their room — otherwise a
// moderator could throw the creator out of it. Nor is the caller themselves.
if (playerId === moderatorId || canModerateRoom(room, playerId)) continue
if (presences.get(playerId)?.roomInstance?.roomInstanceId !== gameSessionId) continue
await deletePresence(c.env.DB, playerId)
kicked.push(playerId)
}
if (kicked.length > 0) {
// The instance just lost players — recompute its fullness so a full room opens
// back up, exactly as the `match` worker does when someone logs out.
await refreshInstanceFullness(c.env.DB, gameSessionId)
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
await pushInstantKick(c, kicked, gameSessionId, roomName, moderatorId)
}
return c.json({ success: true, error: '' })
}
)
// A warning handed down by a moderator — the staff-side counterpart to a report.
// Gated on the `moderator` role in the token, not just a valid one.
.post(
+327 -32
View File
@@ -1,15 +1,25 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { getProgression, getProgressions } from '@repo/domain'
import {
getPlayerIdsInInstance,
getPresence,
getProgression,
getProgressions,
MessageType,
} from '@repo/domain'
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 { parseFormIds, queryIds } from '../http'
import { authedId, parseFormIds, queryIds, unauthorized } from '../http'
import {
AUTHED,
BulkIdsRequest,
CheerPlayerRequest,
CheerPlayerResponse,
SetSelectedCheerRequest,
form,
idParam,
intQuery,
@@ -17,11 +27,24 @@ import {
JsonArray,
ProgressionDto,
ReputationDto,
UNAUTHORIZED_RESPONSE,
} from '../openapi'
import {
addCheer,
CheerCategory,
DAILY_CHEER_CREDIT,
getReputation,
getReputations,
isCheerCategory,
setSelectedCheer,
spendCheerCredit,
} from '../reputation-db'
import type { Context } from 'hono'
import type { Progression } from '@repo/domain'
import type { ReputationPayload } from '../../../notify/src/notification-payloads'
import type { App } from '../context'
import type { Reputation } from '../reputation-db'
/** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global'
@@ -49,28 +72,160 @@ async function pushProgression(c: Context<App>, progression: Progression): Promi
}
/**
* Default reputation for an account — the fallback used with no DB. Nobody has
* earned cheers yet, so every counter is 0 and everyone has their full cheer credit.
* `SelectedCheer` is an int (0 = none selected), not null, and `IsCheerful` is true:
* the client reads it to decide whether the player may hand out cheers at all.
* Push the `MessageReceived` frame that actually plays a cheer on the cheered player's
* client — a Message of type `PlayerCheer` (or its anonymous twin). That message, NOT
* `ReputationUpdate`, is what the client renders the cheer from; every reference server
* (meownet-api, DorkNet, the E12354 C# server) sends it, and a cheer that only pushes
* `ReputationUpdate` moves the counters and plays nothing.
*
* `Data` is the category given, as a string (a Message's `Data` is always a string). An
* anonymous cheer uses the anonymous type and names sender 0, so the recipient's client
* neither shows nor can look up who gave it — `Anonymous` decides nothing else.
*
* Durable, like the rest of the target's frames: the cheer is theirs whether or not they are
* connected right now. Best-effort — the cheer is already counted.
*/
function defaultReputation(id: number) {
return {
AccountId: id,
IsCheerful: true,
Noteriety: 0,
SelectedCheer: 0,
CheerCredit: 20,
CheerGeneral: 0,
CheerHelpful: 0,
CheerCreative: 0,
CheerGreatHost: 0,
CheerSportsman: 0,
SubscriberCount: 0,
SubscribedCount: 0,
async function pushCheerMessage(
c: Context<App>,
fromId: number,
toId: number,
category: number,
anonymous: boolean
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
toId,
NotificationType.MessageReceived,
{
FromPlayerId: anonymous ? 0 : fromId,
ToPlayerId: toId,
Type: anonymous ? MessageType.PlayerCheerAnonymous : MessageType.PlayerCheer,
Data: String(category),
}
)
} catch (err) {
logger.error('failed to push PlayerCheer MessageReceived notification', {
fromId,
toId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Trim a stored reputation to the fields a `ReputationUpdate` frame carries — the client's
* decoder has no `Noteriety` or subscriber counts on this payload. Nothing else changes:
* `IsCheerful` and `SelectedCheer` are the record's, exactly as the profile DTO serves them.
* (This once overrode both per send to "play" the cheer — no reference does that, and the
* client plays a cheer off the `PlayerCheer` MESSAGE, not this frame.) Built against the
* recovered interface so a renamed key fails the build rather than vanishing on the wire.
*
* `AccountId` is who the frame is ABOUT, which is not who it is sent to: a cheer's frame
* names the player being cheered and goes to everyone watching.
*/
function reputationFrame(reputation: Reputation): ReputationPayload {
const { Noteriety: _n, SubscriberCount: _sr, SubscribedCount: _sd, ...payload } = reputation
return payload
}
/**
* Push a `ReputationUpdate` frame to one player, durably — it survives them being offline
* and lands on their next connect. For the frames that report a real change to the player
* they name: their counters moved, or their credit did.
*
* Best-effort: the cheer is already stored by the time this runs, so a hub hiccup must not
* fail the request — the numbers are right on the next read either way.
*/
async function pushReputation(
c: Context<App>,
playerId: number,
frame: ReputationPayload
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId,
NotificationType.ReputationUpdate,
{ ...frame }
)
} catch (err) {
logger.error('failed to push ReputationUpdate notification', {
playerId,
accountId: frame.AccountId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Push the same frame to a roomful of players, EPHEMERALLY — delivered to whoever is
* connected and dropped for anyone who isn't.
*
* That is the right send for an audience frame. A cheer's effect belongs to the moment it
* happened; queueing it would play someone else's cheer at a bystander when they next log
* in, hours later and somewhere else. The people the cheer actually changed something for
* get their own durable frame instead.
*/
async function pushReputationToRoom(
c: Context<App>,
playerIds: number[],
frame: ReputationPayload
): Promise<void> {
if (playerIds.length === 0) return
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
playerIds,
NotificationType.ReputationUpdate,
{ ...frame }
)
} catch (err) {
logger.error('failed to push ReputationUpdate notification to room', {
playerIds,
accountId: frame.AccountId,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* Read one field of the cheer form. The client posts it form-encoded, but the same names
* also arrive as a query string on some builds, so both are accepted (same helper the
* moderation routes use on their forms).
*/
function formField(
body: Record<string, unknown>,
c: Context<App>,
name: string
): string | undefined {
const raw = body[name]
if (typeof raw === 'string' && raw !== '') return raw
return c.req.query(name) || undefined
}
/** Parse a form field as an integer, or null when absent / not a number. */
function asInt(value: string | undefined): number | null {
if (value === undefined) return null
const n = Number.parseInt(value, 10)
return Number.isNaN(n) ? null : n
}
/**
* Parse a form field as a bool. The client sends .NET's `True`/`False`, so the match is
* case-insensitive; anything else — an absent field included — reads as false, which is the
* safe default for `Anonymous` (a cheer nobody asked to hide is a signed one).
*/
function asBool(value: string | undefined): boolean {
return value !== undefined && /^(true|1)$/i.test(value.trim())
}
/**
* The `{ Success, Message }` body a cheer answers with — PascalCase, as the reference, and
* `Message` is NULL on success rather than an empty string. That is not the same envelope
* as the lowercase `{ success, error: "" }` the reports and warnings use; don't unify them.
*/
function cheerResult(c: Context<App>, message: string | null = null) {
return c.json({ Success: message === null, Message: message })
}
/**
* The repeated `id` query param the 2023 client uses on the bulk GET forms — each value
* may itself be a comma-separated list, so `?id=1,2&id=3` is three ids.
@@ -90,12 +245,15 @@ export const progressionRoutes = new Hono<App>({ strict: false })
tags: ['Progression'],
summary: 'A players reputation',
description:
'The cheer counters shown on a players profile. No cheers are stored yet, so ' +
'every player gets the same all-zero record with full cheer credit.',
'The cheer counters shown on a players profile, from the `reputation` table. A ' +
'player nobody has cheered has no row and reads back all-zero. `CheerCredit` is ' +
'the odd one out — what they have left to GIVE today, out of ' +
`${DAILY_CHEER_CREDIT}; it refills lazily, so a stale window reads as full ` +
'without being reset here.',
parameters: [idParam('id', 'Account id')],
responses: { 200: json(ReputationDto, 'The players reputation') },
}),
(c) => c.json(defaultReputation(Number.parseInt(c.req.param('id'), 10)))
async (c) => c.json(await getReputation(c.env.DB, Number.parseInt(c.req.param('id'), 10)))
)
.get(
'/api/players/v1/progression/:id',
@@ -131,23 +289,19 @@ export const progressionRoutes = new Hono<App>({ strict: false })
}),
(c) => c.json([])
)
// Synthesize a default reputation per requested id (the intended behavior;
// the DB-less fallback reads a static JSON file instead).
.post(
'/api/playerReputation/v2/bulk',
describeRoute({
tags: ['Progression'],
summary: 'Reputations in bulk',
description:
'One default reputation per requested id, in request order. Ids that name no ' +
'account still get a record the client renders a profile card from it.',
'One reputation per requested id, in request order. Ids that name no account — or ' +
'that nobody has cheered — still get an all-zero record rather than being dropped: ' +
'the client renders a profile card from each entry.',
requestBody: BULK_ID_BODY,
responses: { 200: json(ReputationDto.array(), 'One reputation per requested id') },
}),
async (c) => {
const ids = await parseFormIds(c)
return c.json(ids.map(defaultReputation))
}
async (c) => c.json(await getReputations(c.env.DB, await parseFormIds(c)))
)
// The 2023 client calls this as a GET with repeated `id` query params.
.get(
@@ -161,7 +315,148 @@ export const progressionRoutes = new Hono<App>({ strict: false })
parameters: BULK_ID_QUERY,
responses: { 200: json(ReputationDto.array(), 'One reputation per requested id') },
}),
(c) => c.json(queryIds(c).map(defaultReputation))
async (c) => c.json(await getReputations(c.env.DB, queryIds(c)))
)
// Cheering another player: spend one of the caller's daily credits, count it against the
// target's category counter, and play it in front of the room.
//
// Nothing stores an individual cheer — this keeps a per-player counter, not a log of who
// cheered whom. So neither `RoomId` nor `Anonymous` reaches storage: both are spent
// immediately on the notification, one deciding who sees it and the other whether it is
// seen at all.
.post(
'/api/PlayerCheer/v1/create',
describeRoute({
tags: ['Progression'],
summary: 'Cheer another player',
description:
'Hands one cheer to `PlayerIdTo` in the category `CheerCategory` names (0 General, ' +
'10 Helpful, 20 Sportmanship, 30 GreatHost, 40 Creative), counting it on their ' +
'`reputation` row.\n\n' +
`A player may give ${DAILY_CHEER_CREDIT} cheers per day. The credit refills lazily: ` +
'the first cheer opens a 24-hour window, and the first cheer after that window has ' +
'passed starts a fresh one at full credit — so a player who spends all day refills ' +
'24h after their FIRST cheer, not their last.\n\n' +
'The cheered player gets a durable `MessageReceived` frame carrying a Message of ' +
'type 50 (`PlayerCheer`) — 51 (`PlayerCheerAnonymous`, sender 0) when `Anonymous` — ' +
'with `Data` = the category. That message is what plays the cheer on their ' +
'client; the `ReputationUpdate` frames below only refresh the numbers.\n\n' +
'A cheer is played in front of people, so the `ReputationUpdate` frame naming the ' +
'cheered player goes to EVERYONE in the room instance the caller is standing in, ' +
'not just the two of them. The cheered player gets it durably (their counters ' +
'really moved); the rest of the room gets it only if they are connected, since ' +
'the effect belongs to the moment. The caller gets a second frame of their own ' +
'because their `CheerCredit` moved and the response body does not carry it.\n\n' +
'`Anonymous` swaps the message for its anonymous twin (type 51, sender 0) and ' +
'nothing else — the counters move the same either way.\n\n' +
'The audience comes from the callers live presence, not from `RoomId`, which is ' +
'accepted and unused: a client cannot aim its effect at a room it is not in. ' +
'Neither field is stored — this keeps counters, not a log of individual cheers.\n\n' +
'Refusals (no credit left, an unknown category, cheering yourself) answer 200 with ' +
'`{ Success: false, Message }` rather than an error status — the client shows the ' +
'message.',
security: AUTHED,
requestBody: form(CheerPlayerRequest, 'The cheer'),
responses: {
200: json(
CheerPlayerResponse,
'`{ Success: true, Message: null }` — see above for refusals'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const fromId = await authedId(c)
if (fromId === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const toId = asInt(formField(body, c, 'PlayerIdTo'))
if (toId === null) return cheerResult(c, 'PlayerIdTo is required')
if (toId === fromId) return cheerResult(c, 'You cannot cheer yourself')
// Validated BEFORE the credit is spent: a category we can't count would otherwise
// take a cheer off the caller and give nothing to anyone.
const category = asInt(formField(body, c, 'CheerCategory'))
if (category === null || !isCheerCategory(category)) {
return cheerResult(c, 'CheerCategory is not a cheer category')
}
const remaining = await spendCheerCredit(c.env.DB, fromId)
if (remaining === null) return cheerResult(c, 'You are out of cheers for today')
const cheered = await addCheer(c.env.DB, toId, category)
// The frame that PLAYS the cheer on the cheered player's client — a Message of
// type PlayerCheer (or its anonymous twin). `ReputationUpdate` alone moves the
// numbers and shows nothing.
await pushCheerMessage(c, fromId, toId, category, asBool(formField(body, c, 'Anonymous')))
// The frame the room sees: the cheered player's record, so everyone's copy of
// their counters moves. It is ABOUT them — `AccountId` is theirs — but goes to
// everyone standing there.
const frame = reputationFrame(cheered)
// The audience is read from the GIVER's live presence, not from the body's
// `RoomId` — a client that lied about the room would otherwise play its effect in
// someone else's. A cheer given outside a room instance (from a profile screen)
// simply has no audience.
const presence = await getPresence<{ roomInstanceId?: number }>(c.env.DB, fromId)
const instanceId = presence?.roomInstance?.roomInstanceId
const audience =
instanceId === undefined
? []
: (await getPlayerIdsInInstance(c.env.DB, instanceId)).filter((id) => id !== toId)
// The cheered player is deliberately not in that list: for them this is a real
// change to their own record, so they get it durably and get it whether or not
// they were in the room — the room gets a copy that expires with the moment.
await pushReputation(c, toId, frame)
await pushReputationToRoom(c, audience, frame)
// The caller's own record, with the credit the spend just resolved rather than a
// re-read — a cheer they fired off in parallel must not make this frame report a
// credit they no longer have.
await pushReputation(
c,
fromId,
reputationFrame({ ...(await getReputation(c.env.DB, fromId)), CheerCredit: remaining })
)
return cheerResult(c)
}
)
// Pinning a cheer to the caller's own profile: the badge the client shows next to
// their name, read back as `SelectedCheer` on the reputation DTO.
.post(
'/api/PlayerCheer/v1/SetSelectedCheer',
describeRoute({
tags: ['Progression'],
summary: 'Pin a cheer to your profile',
description:
'Stores `CheerCategory` as the callers `SelectedCheer` (-1 `None` unpins, read ' +
'back as 0) and pushes them a `ReputationUpdate` so a second device catches up. ' +
'Same `{ Success, Message }` reply as the cheer.',
security: AUTHED,
requestBody: form(SetSelectedCheerRequest, 'The category to pin'),
responses: {
200: json(CheerPlayerResponse, '`{ Success: true, Message: null }`'),
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 category = asInt(formField(body, c, 'CheerCategory'))
if (category === null || !(category === CheerCategory.None || isCheerCategory(category))) {
return cheerResult(c, 'CheerCategory is not a cheer category')
}
const reputation = await setSelectedCheer(c.env.DB, id, category)
await pushReputation(c, id, reputationFrame(reputation))
return cheerResult(c)
}
)
.post(
'/api/players/v1/progression/bulk',
+23
View File
@@ -22,6 +22,7 @@ import { authedId, unauthorized } from '../http'
import {
AckResponse,
AUTHED,
DeleteMessagesRequest,
ErrorResponse,
form,
FriendOnlineCountResponse,
@@ -624,6 +625,28 @@ export const socialRoutes = new Hono<App>({ strict: false })
}),
(c) => c.json([])
)
// The inbox's delete button. A POST rather than a DELETE because the ids arrive as
// a JSON array body — the client batches a multi-select into one call, and its HTTP
// layer only ever sends a body on POST/PUT.
.post(
'/api/messages/v3/delete',
describeRoute({
tags: ['Social'],
summary: 'Delete messages',
description:
'Drops the given messages from the callers inbox. Nothing happens: there is no ' +
'message store behind `GET /api/messages/v2/get` (the `MessageReceived` ' +
'notification is the whole delivery — see `POST /api/messages/v2/send`), so ' +
'there are no ids to match and nothing to remove.\n\n' +
'Accepted unconditionally and answered 200 with an empty body, which is what the ' +
'client wants: it removes the rows locally and re-reads the empty list either ' +
'way. Not auth-gated, for the same reason `GET /api/messages/v2/get` isnt — ' +
'the call reaches no state to protect.',
requestBody: jsonBody(DeleteMessagesRequest, 'The messages to delete'),
responses: { 200: { description: 'Accepted (empty body)' } },
}),
(c) => c.body(null, 200)
)
// How many of the caller's friends are online — the friends panel's header count.
// Answered from the friend graph joined to live presence, so it agrees with the
// friends the panel then lists. Auth-gated: the count is the CALLER's own.
+34
View File
@@ -60,6 +60,40 @@ export function containsSwears(value: string): boolean {
return value !== '' && filter.exists(value)
}
/**
* Where a space would be in a name if the charset allowed one: a lowercase-to-uppercase
* hop, the last capital of a run before a capitalised word, and either side of a run of
* digits. Applied in that order, so `ShitLord`, `XXFuckYou` and `Fucker123` each come
* apart at the seam a player wrote them with.
*/
const NAME_WORD_BOUNDARIES: Array<[RegExp, string]> = [
[/([a-z0-9])([A-Z])/g, '$1 $2'],
[/([A-Z]+)([A-Z][a-z])/g, '$1 $2'],
[/([A-Za-z])([0-9])/g, '$1 $2'],
]
/**
* 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.
*
* 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`,
* `Cumberland` and `Shiitake` all contain a swear as a substring, and refusing someone's
* name without being able to say why is worse than missing `Bitchy`.
*/
export function nameContainsSwears(value: string): boolean {
const spaced = NAME_WORD_BOUNDARIES.reduce(
(text, [pattern, replacement]) => text.replace(pattern, replacement),
value
)
return containsSwears(spaced)
}
/** The mask `POST /api/sanitize/v1` uses when the request names no `ReplacementChar`. */
export const DEFAULT_REPLACEMENT_CHAR = '*'
+3285 -95
View File
@@ -14,11 +14,13 @@ import {
LEVEL_REQUIRED_XP,
LEVEL_REWARDS,
MAX_LEVEL,
MessageType,
OUTFIT_SCHEMA_DDL,
PRESENCE_SCHEMA_DDL,
PRESENCE_TTL_SECONDS,
PROGRESSION_SCHEMA_DDL,
RELATIONSHIP_SCHEMA_DDL,
ROOM_INSTANCE_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
@@ -29,6 +31,10 @@ import '../../api.app'
import { PLATFORM_SCHEMA_DDL } from '../../../../auth/src/platform-db'
import { banEvasionMatch, resolveBan } from '../../bans-db'
import {
createCustomAvatarItem,
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
} from '../../custom-avatar-items-db'
import {
countGoing,
SCHEMA_DDL as EVENTS_SCHEMA_DDL,
@@ -44,12 +50,25 @@ import {
isPlayerBanned,
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
} from '../../reports-db'
import {
CheerCategory,
DAILY_CHEER_CREDIT,
getCheerCredit,
getReputation,
SCHEMA_DDL as REPUTATION_SCHEMA_DDL,
spendCheerCredit,
} from '../../reputation-db'
import { charadesWordsFor } from '../../routes/gameplay'
import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
import type { SavedImage } from '@repo/domain'
import type { Env } from '../../context'
import type { PlayerEvent, PlayerEventEnvelope, PlayerEventResult } from '../../events-db'
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
import type { EventTag, PlayerEvent, PlayerEventEnvelope, PlayerEventResult } from '../../events-db'
import type {
InventionSaveResult,
InventionSaveV9Result,
SavedInvention,
} from '../../inventions-db'
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
@@ -76,6 +95,19 @@ const TEST_ROOMS = [
SubRooms: [{ SubRoomId: 3 }],
Roles: [{ AccountId: 42, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }],
},
{
// The instant kick's room. Owned by account 42 (the default test token); 43 holds
// Moderator (20) and 44 only Host (10) — the tier just below that gate.
RoomId: 4,
Name: 'KickRoom',
IsDorm: false,
CreatorAccountId: 42,
SubRooms: [{ SubRoomId: 4 }],
Roles: [
{ AccountId: 43, Role: 20, LastChangedByAccountId: null, InvitedRole: 0 },
{ AccountId: 44, Role: 10, LastChangedByAccountId: null, InvitedRole: 0 },
],
},
]
beforeAll(async () => {
@@ -114,6 +146,10 @@ beforeAll(async () => {
// Presence (owned by the rooms worker) — the online-friend count joins onto it.
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Room instances (owned by the rooms worker) — the instant kick resolves the game
// session it is given to the room whose staff may kick from it.
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Outfit table (owned by the econ worker) — /outfits/me reads and writes slot 0.
for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -135,6 +171,12 @@ beforeAll(async () => {
// Player events table (owned by the api worker) — scheduled events live here.
for (const stmt of EVENTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Reputation + cheer credit (owned by the api worker) — cheering writes both.
for (const stmt of REPUTATION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Custom avatar items (owned by the api worker).
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
@@ -189,10 +231,12 @@ describe('public endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/amplitude`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
AmplitudeKey: 'a',
StatSigKey: 'a',
RudderStackKey: 'a',
AmplitudeKey: '',
UseRudderStack: false,
RudderStackKey: '',
UseStatSig: false,
StatSigKey: '',
StatSigEnvironment: 0,
})
})
@@ -318,40 +362,393 @@ describe('public endpoints', () => {
expect(reps.map((r) => r.AccountId)).toEqual([1, 2])
})
// Cheering: `POST /api/PlayerCheer/v1/create`. The giver comes from the token, so these
// use ids of their own (71xx) rather than the shared 42 — a spent credit is durable
// state, and the reputation reads above assert all-zero records.
const cheer = async (fields: Record<string, string>, sub = '7100') =>
exports.default.fetch(`${ORIGIN}/api/PlayerCheer/v1/create`, {
method: 'POST',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields),
})
const reputationOf = async (id: number) =>
(await (await exports.default.fetch(`${ORIGIN}/api/playerReputation/v1/${id}`)).json()) as {
CheerCredit: number
CheerGeneral: number
CheerHelpful: number
}
test('a cheer counts on the target and spends the givers credit', async () => {
// The body the client posts, verbatim from the live request.
const res = await cheer({
PlayerIdTo: '7101',
CheerCategory: '0',
RoomId: '112',
Anonymous: 'False',
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Success: true, Message: null })
// The target's counter moved and nothing else did — in particular their OWN credit is
// untouched, since receiving a cheer doesn't pay for giving one.
expect(await reputationOf(7101)).toMatchObject({
CheerGeneral: 1,
CheerHelpful: 0,
CheerCredit: DAILY_CHEER_CREDIT,
})
// The giver paid, and has no counters of their own.
expect(await reputationOf(7100)).toMatchObject({
CheerGeneral: 0,
CheerCredit: DAILY_CHEER_CREDIT - 1,
})
})
test('each category counts into its own column', async () => {
for (const category of [
CheerCategory.General,
CheerCategory.Helpful,
CheerCategory.Sportmanship,
CheerCategory.GreatHost,
CheerCategory.Creative,
]) {
expect(
(await cheer({ PlayerIdTo: '7102', CheerCategory: String(category) }, '7103')).status
).toBe(200)
}
expect(await getReputation(env.DB, 7102)).toEqual({
AccountId: 7102,
IsCheerful: true,
Noteriety: 0,
SelectedCheer: 0,
CheerCredit: DAILY_CHEER_CREDIT,
CheerGeneral: 1,
CheerHelpful: 1,
CheerCreative: 1,
CheerGreatHost: 1,
CheerSportsman: 1,
SubscriberCount: 0,
SubscribedCount: 0,
})
})
test('a cheer the server cant count is refused before it costs anything', async () => {
// Each refusal answers 200 with the reason — the client shows `Message` — and none of
// them may take a credit off the caller, which is what the closing assertion checks.
for (const [fields, Message] of [
[{ PlayerIdTo: '7105' }, 'CheerCategory is not a cheer category'],
// -1 is the enum's `None`: a real member, but not a counter.
[{ PlayerIdTo: '7105', CheerCategory: '-1' }, 'CheerCategory is not a cheer category'],
[{ PlayerIdTo: '7105', CheerCategory: '5' }, 'CheerCategory is not a cheer category'],
[{ CheerCategory: '0' }, 'PlayerIdTo is required'],
[{ PlayerIdTo: '7104', CheerCategory: '0' }, 'You cannot cheer yourself'],
] as Array<[Record<string, string>, string]>) {
const res = await cheer(fields, '7104')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Success: false, Message })
}
expect(await getCheerCredit(env.DB, 7104)).toBe(DAILY_CHEER_CREDIT)
expect(await reputationOf(7105)).toMatchObject({ CheerGeneral: 0 })
})
test('the cheer frame plays in front of the whole room instance', async () => {
// Presence is written by the `match` worker; seeded straight into the table here.
// 7108 (the giver), 7109 (the target) and 7120 (a bystander) share instance 8800;
// 7121 stands in a different instance and must hear nothing.
const standIn = async (accountId: number, roomInstanceId: number | null) =>
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
roomInstance: roomInstanceId === null ? null : { roomInstanceId, roomId: 112 },
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 0,
platform: 0,
appVersion: GAME_VERSION,
expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS,
})
)
.run()
// The notify DO is stubbed to record every notifyPlayer / notifyPlayersEphemeral call
// (see vitest.config).
const cheerHub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const framesFor = async (anonymous: string) => {
await cheerHub().fetch('http://do/all', { method: 'DELETE' })
expect(
(
await cheer(
{ PlayerIdTo: '7109', CheerCategory: '10', RoomId: '112', Anonymous: anonymous },
'7108'
)
).status
).toBe(200)
return (await (await cheerHub().fetch('http://do/all')).json()) as Array<{
playerId?: number
playerIds?: number[]
ephemeral?: boolean
notificationType: string
data: Record<string, unknown>
}>
}
for (const id of [7108, 7109, 7120]) await standIn(id, 8800)
await standIn(7121, 8801)
// A signed cheer. Four sends: the PlayerCheer message that plays the cheer on the
// target's client, then the ReputationUpdate for the target durably, the rest of their
// instance ephemerally, and the giver's own credit refresh.
const all = await framesFor('False')
expect(all).toHaveLength(4)
expect(all[0]).toMatchObject({
playerId: 7109,
notificationType: 2, // NotificationType.MessageReceived
data: { FromPlayerId: 7108, ToPlayerId: 7109, Type: MessageType.PlayerCheer, Data: '10' },
})
expect(all[0]!.ephemeral).toBeFalsy()
const signed = all.slice(1)
expect(signed.every((f) => f.notificationType === 'ReputationUpdate')).toBe(true)
// `AccountId` is who the frame is ABOUT, not who it goes to — the room hears about
// 7109. The frame is 7109's RECORD: `SelectedCheer` is their pinned cheer (none), not
// the category just given, and `IsCheerful` is the profile flag — the message above
// is what plays the cheer.
const played = {
AccountId: 7109,
IsCheerful: true,
SelectedCheer: 0,
CheerHelpful: 1,
}
expect(signed[0]).toMatchObject({ playerId: 7109, data: played })
// The bystander and the giver see it; the target is not in the room list (they got
// the durable copy), and 7121 is in another instance entirely.
expect(signed[1]).toMatchObject({ playerIds: [7108, 7120], ephemeral: true, data: played })
// The giver's second frame is about THEM: their record with the spent credit.
expect(signed[2]).toMatchObject({
playerId: 7108,
data: {
AccountId: 7108,
IsCheerful: true,
SelectedCheer: 0,
CheerCredit: DAILY_CHEER_CREDIT - 1,
},
})
// An anonymous cheer reaches exactly the same people and moves the same counter —
// it just doesn't announce who gave it: the message is the anonymous type from
// sender 0. The reputation frames are the same records as before.
const allAnonymous = await framesFor('True')
expect(allAnonymous).toHaveLength(4)
expect(allAnonymous[0]).toMatchObject({
playerId: 7109,
notificationType: 2, // NotificationType.MessageReceived
data: {
FromPlayerId: 0,
ToPlayerId: 7109,
Type: MessageType.PlayerCheerAnonymous,
Data: '10',
},
})
const anonymous = allAnonymous.slice(1)
expect(anonymous[0]).toMatchObject({
playerId: 7109,
data: { AccountId: 7109, IsCheerful: true, SelectedCheer: 0, CheerHelpful: 2 },
})
expect(anonymous[1]).toMatchObject({ playerIds: [7108, 7120], data: { IsCheerful: true } })
// The frame carries only the fields the client's decoder has — no Noteriety or
// subscriber counts, which live on the profile DTO alone.
expect(Object.keys(anonymous[0]!.data).sort()).toEqual([
'AccountId',
'CheerCreative',
'CheerCredit',
'CheerGeneral',
'CheerGreatHost',
'CheerHelpful',
'CheerSportsman',
'IsCheerful',
'SelectedCheer',
])
})
test('a cheer with no room instance still reaches the player cheered', async () => {
// Cheering from a profile screen: the giver has lobby presence (roomInstance null),
// so there is no audience — but the target's own frame is not the room's to lose.
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 7130,
roomInstance: null,
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 0,
platform: 0,
appVersion: GAME_VERSION,
expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS,
})
)
.run()
const cheerHub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await cheerHub().fetch('http://do/all', { method: 'DELETE' })
expect((await cheer({ PlayerIdTo: '7131', CheerCategory: '40' }, '7130')).status).toBe(200)
const frames = (await (await cheerHub().fetch('http://do/all')).json()) as Array<{
playerId?: number
ephemeral?: boolean
data: Record<string, unknown>
}>
// Three sends — the target's cheer message and reputation, then the giver's
// reputation — all durable and all addressed: nothing was broadcast.
expect(frames.map((f) => f.playerId)).toEqual([7131, 7131, 7130])
expect(frames.some((f) => f.ephemeral)).toBe(false)
expect(frames[0]!.data).toMatchObject({
ToPlayerId: 7131,
Type: MessageType.PlayerCheer,
Data: '40',
})
expect(frames[1]!.data).toMatchObject({ AccountId: 7131, CheerCreative: 1 })
})
test('SetSelectedCheer pins a cheer to the profile and pushes the record', async () => {
const pin = async (CheerCategory: string, sub: string) =>
exports.default.fetch(`${ORIGIN}/api/PlayerCheer/v1/SetSelectedCheer`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ CheerCategory }),
})
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
await hub().fetch('http://do/all', { method: 'DELETE' })
// 7140 has never been cheered — pinning still works, creating their row.
const res = await pin(String(CheerCategory.GreatHost), '7140')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Success: true, Message: null })
expect(await getReputation(env.DB, 7140)).toMatchObject({
SelectedCheer: CheerCategory.GreatHost,
IsCheerful: true,
CheerGreatHost: 0,
})
const frames = (await (await hub().fetch('http://do/all')).json()) as Array<{
playerId?: number
data: Record<string, unknown>
}>
expect(frames).toHaveLength(1)
expect(frames[0]).toMatchObject({
playerId: 7140,
data: { AccountId: 7140, SelectedCheer: CheerCategory.GreatHost },
})
// The pin survives a cheer landing on the row, and a cheer's frame carries it.
expect((await cheer({ PlayerIdTo: '7140', CheerCategory: '0' }, '7141')).status).toBe(200)
expect(await reputationOf(7140)).toMatchObject({ CheerGeneral: 1 })
expect(await getReputation(env.DB, 7140)).toMatchObject({
SelectedCheer: CheerCategory.GreatHost,
})
// -1 (`None`) unpins, read back as 0; a made-up category is refused.
expect(await (await pin('-1', '7140')).json()).toEqual({ Success: true, Message: null })
expect(await getReputation(env.DB, 7140)).toMatchObject({ SelectedCheer: 0 })
expect(await (await pin('7', '7140')).json()).toEqual({
Success: false,
Message: 'CheerCategory is not a cheer category',
})
expect(await pin('0', '7140').then((r) => r.status)).toBe(200)
expect(
(
await exports.default.fetch(`${ORIGIN}/api/PlayerCheer/v1/SetSelectedCheer`, {
method: 'POST',
body: new URLSearchParams({ CheerCategory: '0' }),
})
).status
).toBe(401)
})
test('cheering needs a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/PlayerCheer/v1/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ PlayerIdTo: '7101', CheerCategory: '0' }),
})
expect(res.status).toBe(401)
})
test('the daily credit runs out and refills a day after the FIRST cheer', async () => {
// Driven through spendCheerCredit with an injected clock: burning 20 cheers over HTTP
// says nothing more than this does, and the rollover can't be tested any other way.
const start = new Date('2026-08-25T09:00:00.000Z')
const at = (hours: number) => new Date(start.getTime() + hours * 60 * 60 * 1000)
// The first spend opens the window; the credit counts down to nothing.
for (let spent = 1; spent <= DAILY_CHEER_CREDIT; spent++) {
// Spread across the window — spending inside it must not slide the deadline.
expect(await spendCheerCredit(env.DB, 7110, at(spent === 1 ? 0 : 12))).toBe(
DAILY_CHEER_CREDIT - spent
)
}
expect(await spendCheerCredit(env.DB, 7110, at(12))).toBeNull()
expect(await getCheerCredit(env.DB, 7110, at(12))).toBe(0)
// 23 hours in, still empty: the window is measured from the first cheer, not the last.
expect(await spendCheerCredit(env.DB, 7110, at(23))).toBeNull()
// A day after that first cheer it refills — lazily, on the spend itself, so nothing
// has to run on a schedule.
expect(await getCheerCredit(env.DB, 7110, at(24.5))).toBe(DAILY_CHEER_CREDIT)
expect(await spendCheerCredit(env.DB, 7110, at(24.5))).toBe(DAILY_CHEER_CREDIT - 1)
expect(await getCheerCredit(env.DB, 7110, at(25))).toBe(DAILY_CHEER_CREDIT - 1)
})
test('a player out of credit is refused, and the target keeps their counters', async () => {
// Empty 7106's credit directly, then try to cheer over HTTP.
for (let i = 0; i < DAILY_CHEER_CREDIT; i++) await spendCheerCredit(env.DB, 7106)
const res = await cheer({ PlayerIdTo: '7107', CheerCategory: '10' }, '7106')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
Success: false,
Message: 'You are out of cheers for today',
})
expect(await reputationOf(7107)).toMatchObject({ CheerHelpful: 0 })
})
test('GET /api/activities/charades/v1/words/Charades returns the word bank', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/activities/charades/v1/words/Charades`)
expect(res.status).toBe(200)
const words = (await res.json()) as Array<{ Id: number; Difficulty: number; EN_US: string }>
expect(Array.isArray(words)).toBe(true)
expect(words.length).toBeGreaterThan(0)
expect(words[0]).toEqual({ Id: 1, Difficulty: 0, EN_US: 'David Bowie' })
// Which bank that is depends on the day — the April Fools list replaces the ordinary
// one on April 1st — so compare against the same selector the route uses rather than
// hard-coding a word here. The two banks' contents are pinned below.
expect(words).toEqual(charadesWordsFor())
})
// The client POSTs this with no body, despite it being a pure read; the route answers
// GET as well, and both methods serve the same body.
test.each(['GET', 'POST'])(
'%s /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"',
async (method) => {
const res = await exports.default.fetch(
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`,
{ method }
)
expect(res.status).toBe(200)
// ReportCategory -1 = Unknown (0 is a real category). Message is null, not the
// reference stub's empty string — the client tells "no message" from a blank one.
expect(await res.json()).toEqual({
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsBan: false,
IsHostKick: false,
IsVoiceModAutoban: false,
Message: null,
PlayerIdReporter: null,
TimeoutStartedAt: null,
})
}
)
test('serves the April Fools charades words on April 1st and the ordinary list otherwise', () => {
// A replacement, not an addition: the joke list stands alone for the day, and its ids
// start past the end of the ordinary one rather than overlapping it.
const april = charadesWordsFor(new Date('2026-04-01T12:00:00Z'))
expect(april[0]).toEqual({ Id: 1258, Difficulty: 10, EN_US: 'Nothing' })
const ordinary = charadesWordsFor(new Date('2026-04-02T12:00:00Z'))
expect(ordinary[0]).toEqual({ Id: 1, Difficulty: 0, EN_US: 'David Bowie' })
expect(ordinary.some((w) => w.Id === april[0].Id)).toBe(false)
// Every other day gets the ordinary list, including the edges of April 1 UTC and the
// first of other months.
expect(charadesWordsFor(new Date('2026-03-31T23:59:59Z'))).toBe(ordinary)
expect(charadesWordsFor(new Date('2026-04-02T00:00:00Z'))).toBe(ordinary)
expect(charadesWordsFor(new Date('2026-05-01T12:00:00Z'))).toBe(ordinary)
expect(charadesWordsFor(new Date('2026-01-01T12:00:00Z'))).toBe(ordinary)
// The window is the whole of April 1 in UTC, not local time.
expect(charadesWordsFor(new Date('2026-04-01T00:00:00Z'))).toBe(april)
expect(charadesWordsFor(new Date('2026-04-01T23:59:59Z'))).toBe(april)
})
// A fixed list, in render order — the client shows the buttons in the order they
// arrive, so the order is part of the contract, not just the contents.
@@ -555,22 +952,392 @@ describe('public endpoints', () => {
expect(await res.json()).toBe(true)
})
test('GET /api/customAvatarItems/v1/featured returns []', async () => {
test('GET /api/customAvatarItems/v1/featured lists flagged, published items, newest first', async () => {
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
const older = await createCustomAvatarItem(
env.DB,
item('Older', 1),
new Date('2026-08-01T00:00:00Z')
)
const newer = await createCustomAvatarItem(
env.DB,
item('Newer', 1),
new Date('2026-08-02T00:00:00Z')
)
const unpublished = await createCustomAvatarItem(env.DB, item('Unpublished', 0))
const unflagged = await createCustomAvatarItem(env.DB, item('Unflagged', 1))
// Nothing flags items yet, so flag straight in the table — the unflagged one stays.
await env.DB.prepare(
'UPDATE custom_avatar_item SET is_featured = 1 WHERE custom_avatar_item_id != ?1'
)
.bind(unflagged.CustomAvatarItemId)
.run()
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/featured`)
expect(res.status).toBe(200)
const got = (await res.json()) as Array<{ CustomAvatarItemId: string; Name: string }>
expect(got.map((i) => i.CustomAvatarItemId)).toEqual([
newer.CustomAvatarItemId,
older.CustomAvatarItemId,
])
expect(got.map((i) => i.CustomAvatarItemId)).not.toContain(unpublished.CustomAvatarItemId)
expect(got[0]).toMatchObject({ Name: 'Newer', IsFeatured: true, CurrentSaves: [] })
function item(name: string, accessibility: number) {
return {
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name,
description: '',
price: 0,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
accessibility,
designFilename: 'design_x.bin',
thumbnailImageFilename: 'thumb_x.png',
}
}
})
test('GET /api/CircuitChipLists/:list is empty for any name', async () => {
// A palette on the Maker Pen's circuit board, named by the path. Nothing records which
// chips a player has used or favourited, so every one of them is empty — including names
// this server has never heard of, which the client will ask for as its build changes.
// An unknown name being a 404 would render as a palette that FAILED to load rather than
// one with nothing in it.
for (const list of [
'Favorites',
'Recent',
'All',
'SomePaletteThisServerHasNeverHeardOf',
// Path-segment oddities: a name that needs escaping, and a numeric one.
encodeURIComponent('Weird Name/With Slash'),
'42',
]) {
const res = await exports.default.fetch(`${ORIGIN}/api/CircuitChipLists/${list}`)
expect(res.status, list).toBe(200)
expect(await res.json(), list).toEqual([])
}
})
test('GET /api/inventions/v1/featureddormskins returns []', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featureddormskins`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /api/customAvatarItems/v1/hot returns []', async () => {
test('GET /api/customAvatarItems/v1/hot lists the published items, newest first', async () => {
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
const empty = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/hot`)
expect(empty.status).toBe(200)
expect(await empty.json()).toEqual([])
const older = await createCustomAvatarItem(
env.DB,
item('Older', 1),
new Date('2026-08-01T00:00:00Z')
)
const newer = await createCustomAvatarItem(
env.DB,
item('Newer', 1),
new Date('2026-08-02T00:00:00Z')
)
const unpublished = await createCustomAvatarItem(env.DB, item('Unpublished', 0))
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/hot`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
const got = (await res.json()) as Array<{ CustomAvatarItemId: string; Name: string }>
// Unfeatured but published: the hot feed does not care about the featured flag.
expect(got.map((i) => i.CustomAvatarItemId)).toEqual([
newer.CustomAvatarItemId,
older.CustomAvatarItemId,
])
expect(got.map((i) => i.CustomAvatarItemId)).not.toContain(unpublished.CustomAvatarItemId)
expect(got[0]).toMatchObject({ Name: 'Newer', IsFeatured: false, CurrentSaves: [] })
function item(name: string, accessibility: number) {
return {
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name,
description: '',
price: 0,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
accessibility,
designFilename: 'design_x.bin',
thumbnailImageFilename: 'thumb_x.png',
}
}
})
test('GET /api/customAvatarItems/v2/fromCreator/:id returns an empty paginated result', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v2/fromCreator/2`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
test('GET /api/customAvatarItems/v1/search filters, pages and excludes unpublished', async () => {
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
const search = async (query: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/search${query}`)
expect(res.status, query).toBe(200)
return (await res.json()) as Array<{
CustomAvatarItemId: string
Name: string
OutfitType: number
PurchaseInfo: null
}>
}
// A BARE ARRAY, not the `{ Results, TotalResults }` envelope `fromCreator` uses.
expect(await search('')).toEqual([])
const made: Record<string, string> = {}
// Six published items across three outfit types, plus one unpublished and one Coach's.
// Creation times ascend with the index so "newest first" is unambiguous.
const spec: Array<[name: string, outfitType: number, accessibility: number, creator: number]> =
[
['Hat A', 0, 1, 205],
['Shirt A', 2, 1, 205],
['Shirt B', 2, 1, 205],
['Trousers A', 3, 1, 205],
['Coach Hat', 0, 1, 1],
['Hidden', 0, 0, 205],
]
for (const [i, [name, outfitType, accessibility, creator]] of spec.entries()) {
const created = await createCustomAvatarItem(
env.DB,
{
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: creator,
name,
description: '',
price: 0,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
accessibility,
designFilename: 'design_x.bin',
thumbnailImageFilename: 'thumb_x.png',
},
new Date(Date.UTC(2026, 7, 1 + i))
)
made[name] = created.CustomAvatarItemId
// Nothing sets outfit_type on creation yet, so set it straight in the table.
await env.DB.prepare(
'UPDATE custom_avatar_item SET outfit_type = ?2 WHERE custom_avatar_item_id = ?1'
)
.bind(created.CustomAvatarItemId, outfitType)
.run()
}
// Newest first, and `Hidden` never appears: Accessibility 0 is unpublished, and this is
// the shared browse surface — its creator sees it through `fromCreator`, not here.
const all = await search('')
expect(all.map((i) => i.Name)).toEqual([
'Coach Hat',
'Trousers A',
'Shirt B',
'Shirt A',
'Hat A',
])
// `outfitTypes` repeats and acts as a whitelist — the real client sends a dozen of them.
expect((await search('?outfitTypes=2')).map((i) => i.Name)).toEqual(['Shirt B', 'Shirt A'])
expect((await search('?outfitTypes=0&outfitTypes=3')).map((i) => i.Name)).toEqual([
'Coach Hat',
'Trousers A',
'Hat A',
])
// Sending NONE means no filter, not no results: the client sends every type it can render,
// so reading an absent parameter as an empty `IN ()` would empty the store.
expect((await search('?skip=0&take=100')).map((i) => i.Name)).toEqual(all.map((i) => i.Name))
// A non-numeric value is dropped rather than becoming NaN, which would match nothing and
// quietly empty a filter the caller believes they set.
expect((await search('?outfitTypes=2&outfitTypes=nonsense')).map((i) => i.Name)).toEqual([
'Shirt B',
'Shirt A',
])
// Paging, and it is STABLE: consecutive pages must not repeat or skip a row, which the
// id tiebreak in the ordering is what guarantees when timestamps collide.
const page1 = await search('?skip=0&take=2')
const page2 = await search('?skip=2&take=2')
expect(page1.map((i) => i.Name)).toEqual(['Coach Hat', 'Trousers A'])
expect(page2.map((i) => i.Name)).toEqual(['Shirt B', 'Shirt A'])
expect(
page1.some((i) => page2.some((j) => j.CustomAvatarItemId === i.CustomAvatarItemId))
).toBe(false)
expect(await search('?skip=99&take=10')).toEqual([])
expect(await search('?take=0')).toEqual([])
// The client capitalises its booleans (`includeCoachItems=True`), so the comparison folds
// case; only a recognisable "false" turns the stock content off.
expect((await search('?includeCoachItems=True')).map((i) => i.Name)).toContain('Coach Hat')
expect((await search('?includeCoachItems=false')).map((i) => i.Name)).not.toContain('Coach Hat')
// The whole query the client actually sends, unchanged — the parameters that aren't acted
// on yet must be accepted rather than 400 or throw.
const real = await search(
'?outfitTypes=0&outfitTypes=2&outfitTypes=3&outfitTypes=10&outfitTypes=20&outfitTypes=100' +
'&outfitTypes=101&outfitTypes=102&outfitTypes=103&outfitTypes=200&outfitTypes=300' +
'&outfitTypes=301&includePurchaseInfos=True&includeCoachItems=True&ordering=0&skip=0' +
'&take=100&unityAssetTarget=0&unityAssetVersion=3'
)
expect(real.map((i) => i.Name)).toEqual(all.map((i) => i.Name))
// `includePurchaseInfos=True` notwithstanding: nothing prices a custom item here yet, so
// the field is null on every item and the parameter changes nothing.
expect(real.every((i) => i.PurchaseInfo === null)).toBe(true)
})
test('GET /api/customAvatarItems/v1/search matches name or description, and bounds price', async () => {
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
const search = async (query: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/search${query}`)
expect(res.status, query).toBe(200)
return ((await res.json()) as Array<{ Name: string }>).map((i) => i.Name)
}
const make = async (name: string, description: string, price: number, i: number) =>
createCustomAvatarItem(
env.DB,
{
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name,
description,
price,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
accessibility: 1,
designFilename: 'design_x.bin',
thumbnailImageFilename: 'thumb_x.png',
},
new Date(Date.UTC(2026, 7, 1 + i))
)
await make('Room Hat', '', 100, 0)
// Matched on DESCRIPTION, not name — the two are searched together.
await make('Cosy Beanie', 'Warm in any ROOM', 500, 1)
// Matched case-insensitively and as a SUBSTRING, mid-word.
await make('Ballroom Shoes', '', 9000, 2)
await make('Unrelated Cap', 'nothing to do with it', 250, 3)
// A name holding LIKE metacharacters, for the escaping below.
await make('100% Wool', 'a_b', 50, 4)
// Newest first throughout, so the order also proves the query didn't disturb the ordering.
expect(await search('?searchQuery=room')).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
// Case folds both ways: SQLite's own LIKE only folds ASCII, so both sides are lowered.
expect(await search('?searchQuery=ROOM')).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
expect(await search('?searchQuery=beanie')).toEqual(['Cosy Beanie'])
expect(await search('?searchQuery=nothing%20to%20do')).toEqual(['Unrelated Cap'])
expect(await search('?searchQuery=zzzz')).toEqual([])
// Blank or absent is NO filter, not an empty result — a cleared search box must show the
// store rather than nothing.
expect(await search('?searchQuery=')).toHaveLength(5)
expect(await search('?searchQuery=%20%20')).toHaveLength(5)
// A needle of LIKE metacharacters matches them LITERALLY. Unescaped, `%` would match every
// item and `_` any single character, so a player searching for "100%" would get the lot.
expect(await search('?searchQuery=%25')).toEqual(['100% Wool'])
expect(await search('?searchQuery=a_b')).toEqual(['100% Wool'])
// Price bounds, inclusive at both ends.
expect(await search('?minPrice=250&maxPrice=9000')).toEqual([
'Unrelated Cap',
'Ballroom Shoes',
'Cosy Beanie',
])
expect(await search('?maxPrice=100')).toEqual(['100% Wool', 'Room Hat'])
expect(await search('?minPrice=9000')).toEqual(['Ballroom Shoes'])
expect(await search('?minPrice=100000')).toEqual([])
// Combined with the text search, since the client sends both together.
expect(await search('?searchQuery=room&maxPrice=500')).toEqual(['Cosy Beanie', 'Room Hat'])
// The whole query the client actually sends. `itemTypes` and the unity asset parameters are
// accepted and not acted on; `outfitTypes=105` matches nothing, so this is empty — which is
// the filter working, not the search failing.
expect(
await search(
'?searchQuery=room&itemTypes=-1&outfitTypes=105&minPrice=0&maxPrice=10000' +
'&includePurchaseInfos=True&includeCoachItems=False&ordering=0&skip=0&take=1000' +
'&unityAssetTarget=0&unityAssetVersion=3'
)
).toEqual([])
// Same query with the outfit-type filter dropped: the rest of it does match.
expect(
await search(
'?searchQuery=room&itemTypes=-1&minPrice=0&maxPrice=10000&includePurchaseInfos=True' +
'&includeCoachItems=False&ordering=0&skip=0&take=1000&unityAssetTarget=0' +
'&unityAssetVersion=3'
)
).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
})
test('GET /api/customAvatarItems/v2/fromCreator/:id shows unpublished items only to the creator', async () => {
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
const base = {
description: '',
price: 0,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
designFilename: 'design_x.bin',
thumbnailImageFilename: 'thumb_x.png',
}
const pub = await createCustomAvatarItem(
env.DB,
{
...base,
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name: 'Published',
accessibility: 1,
},
new Date('2026-08-01T00:00:00Z')
)
const draft = await createCustomAvatarItem(
env.DB,
{
...base,
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name: 'Draft',
accessibility: 0,
},
new Date('2026-08-02T00:00:00Z')
)
await createCustomAvatarItem(env.DB, {
...base,
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 9,
name: 'Other',
accessibility: 0,
})
type Page = { Results: Array<{ CustomAvatarItemId: string }>; TotalResults: number }
const url = `${ORIGIN}/api/customAvatarItems/v2/fromCreator/205`
// Anonymous, or someone else: only the published (Accessibility != 0) item.
for (const headers of [{}, await bearer('9')]) {
const res = await exports.default.fetch(url, { headers })
expect(res.status).toBe(200)
const page = (await res.json()) as Page
expect(page.TotalResults).toBe(1)
expect(page.Results.map((i) => i.CustomAvatarItemId)).toEqual([pub.CustomAvatarItemId])
}
// The creator: their unpublished item too, newest first.
const own = (await (
await exports.default.fetch(url, { headers: await bearer('205') })
).json()) as Page
expect(own.TotalResults).toBe(2)
expect(own.Results.map((i) => i.CustomAvatarItemId)).toEqual([
draft.CustomAvatarItemId,
pub.CustomAvatarItemId,
])
const none = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v2/fromCreator/2`)
expect(await none.json()).toEqual({ Results: [], TotalResults: 0 })
})
// Nothing locks avatar items here, so the array is empty and the posted ids are never
@@ -586,29 +1353,110 @@ describe('public endpoints', () => {
expect(await res.json()).toEqual([])
})
// A BARE ARRAY of the items that matched — not the `{ Results, TotalResults }` page
// the sibling custom-item reads serve. Nothing stores custom items, so every id
// misses, and a miss is an absent entry rather than an error.
test('POST /api/customAvatarItems/v1/bulk returns the matching items as an array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
/** Create a custom avatar item and return it, so a bulk lookup has something to find. */
async function createCustomItem(
creator: string,
metadata: Record<string, unknown>
): Promise<{ CustomAvatarItemId: string; Name: string }> {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({
Name: 'bulk item',
Description: '',
Price: 0,
BaseAvatarItemId: 2184,
BaseAvatarItemColor: '#F55C1A',
Accessibility: 1,
...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' }))
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: {
'content-type': 'application/x-www-form-urlencoded',
...(await bearer()),
},
// Repeated form field, as `[FromForm] List<string>` binds it.
body: new URLSearchParams([
['customAvatarItemIds', 'a'],
['customAvatarItemIds', 'b'],
]),
headers: await bearer(creator),
body: form,
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
return ((await res.json()) as { Value: { CustomAvatarItemId: string; Name: string } }).Value
}
/** POST the bulk lookup with `ids` as repeated form fields, as the client binds them. */
async function bulkLookup(
ids: string[],
as = '42'
): Promise<Array<{ CustomAvatarItemId: string; Name: string }>> {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', ...(await bearer(as)) },
// Repeated form field, as `[FromForm] List<string>` binds it.
body: new URLSearchParams(ids.map((id) => ['customAvatarItemIds', id])),
})
expect(res.status).toBe(200)
return (await res.json()) as Array<{ CustomAvatarItemId: string; Name: string }>
}
// A BARE ARRAY of the items that matched — not the `{ Results, TotalResults }` page
// the sibling custom-item reads serve — resolved out of the `custom_avatar_item` table.
// This is what a `1.<guid>` entity in a Generic discovery row resolves through, so it
// answering `[]` (as it did while it was a stub) renders that row's items as nothing.
test('POST /api/customAvatarItems/v1/bulk resolves the posted ids against the table', async () => {
const first = await createCustomItem('205', { Name: 'bulk one' })
const second = await createCustomItem('205', { Name: 'bulk two' })
// In REQUEST order, not creation order — the client reads the array positionally.
const items = await bulkLookup([second.CustomAvatarItemId, first.CustomAvatarItemId])
expect(items.map((i) => i.CustomAvatarItemId)).toEqual([
second.CustomAvatarItemId,
first.CustomAvatarItemId,
])
expect(items[0]).toMatchObject({ Name: 'bulk two', CreatorAccountId: 205, Accessibility: 1 })
// A miss is an absent entry, not an error: the client reads the items it got back
// rather than the ids it asked for, so an unknown id must not cost it the rest.
const mixed = await bulkLookup([
'00000000-0000-0000-0000-000000000000',
first.CustomAvatarItemId,
])
expect(mixed.map((i) => i.CustomAvatarItemId)).toEqual([first.CustomAvatarItemId])
// Ids also ride comma-separated inside one field, and on the query string — the
// client's exact encoding here isn't pinned down, so all three spellings are read.
const commas = await bulkLookup([`${first.CustomAvatarItemId},${second.CustomAvatarItemId}`])
expect(commas).toHaveLength(2)
const queried = await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/v1/bulk?customAvatarItemIds=${first.CustomAvatarItemId}`,
{ method: 'POST', headers: await bearer() }
)
expect(((await queried.json()) as unknown[]).length).toBe(1)
// Over 100 ids answers EMPTY without touching the table. The client has been seen posting
// far more than a screen could draw, and empty is safe precisely because a miss here is
// already not an error. Empty rather than the first 100: the client reads the items it got
// back, not the ids it asked about, so it cannot tell a truncated batch from a batch of
// misses and would cache the difference.
const padding = Array.from({ length: 99 }, () => '00000000-0000-0000-0000-000000000000')
expect(await bulkLookup([first.CustomAvatarItemId, ...padding])).toHaveLength(1)
expect(
await bulkLookup([first.CustomAvatarItemId, second.CustomAvatarItemId, ...padding])
).toEqual([])
})
// The ids are never parsed (nothing could match), so a missing body is still a 200
// rather than the 400 a body-reading handler would produce.
test('POST /api/customAvatarItems/v1/bulk ignores the body', async () => {
// Unpublished items are held back from everyone but their creator — the same rule the
// featured/hot feeds and the creator shelf apply, so this route can't surface an item
// the feeds hide.
test('POST /api/customAvatarItems/v1/bulk hides unpublished items from everyone but the creator', async () => {
const hidden = await createCustomItem('206', { Name: 'unpublished', Accessibility: 0 })
expect(await bulkLookup([hidden.CustomAvatarItemId], '42')).toEqual([])
const own = await bulkLookup([hidden.CustomAvatarItemId], '206')
expect(own.map((i) => i.Name)).toEqual(['unpublished'])
})
// A missing body is a 200 with an empty array rather than a 400: nothing was asked for,
// so nothing matched — the same shape as asking for ids that all miss.
test('POST /api/customAvatarItems/v1/bulk answers an empty array for an empty body', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
method: 'POST',
headers: await bearer(),
@@ -644,20 +1492,10 @@ describe('public endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, { headers: await bearer('77') })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
LegacyData: {
SelectionsV1: null,
SelectionsV2: null,
FaceFeatures: null,
SkinColor: null,
HairColor: null,
},
Selections: [],
DataVersion: 9,
CustomizationSettings: null,
ThumbnailFileName: null,
Name: null,
Accessibility: 0,
Slot: 0,
FaceFeatures: '',
HairColor: '',
OutfitSelections: '',
SkinColor: '',
})
})
@@ -728,15 +1566,143 @@ describe('public endpoints', () => {
expect(((await worn.json()) as { Name: string | null }).Name).toBe(null)
})
test('GET /outfits/me/saved 401s without a token, returns [] with one', async () => {
test('GET /outfits/me/saved lists every saved slot, ordered by slot', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`)
expect(anon.status).toBe(401)
// Empty even for account 42, which saved an outfit through PUT /outfits/me above.
const res = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, {
headers: await bearer(),
// A distinct account, so this doesn't depend on what the tests above saved for 42.
const saved = async (sub: string) => {
const res = await exports.default.fetch(`${ORIGIN}/outfits/me/saved`, {
headers: await bearer(sub),
})
expect(res.status).toBe(200)
return (await res.json()) as Array<Record<string, unknown>>
}
// A player who has never saved gets [], not the empty-outfit envelope `/outfits/me`
// serves — an empty wardrobe is an empty list.
expect(await saved('4242')).toEqual([])
const outfit = (slot: number, name: string | null) => ({
DataVersion: 2,
LegacyData: {
SelectionsV1: '193a3bf9-abc0-4d78-8d63-92046908b1c5,,0',
SelectionsV2: '{"selections":[]}',
FaceFeatures: '{"ver":7}',
SkinColor: 'Dc6StLFk60u5iUTrb3_C3w',
HairColor: 'UAT0OaWEkUG-mWDIyiX1Kg',
},
CustomizationSettings: '{"AvatarVersion":2,"AvatarBodyType":0}',
Selections: [],
Slot: slot,
Name: name,
Accessibility: 1,
ThumbnailFileName: null,
})
// Saved out of order, to prove the list is ordered by slot rather than by write time.
for (const [slot, name] of [
[2, 'two'],
[0, null],
] as Array<[number, string | null]>) {
await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { ...(await bearer('4242')), 'content-type': 'application/json' },
body: JSON.stringify(outfit(slot, name)),
})
}
// Slot 0 is in the list: it is the outfit being worn, but it is a saved slot too, and
// the client picks the slot it writes. Each row comes back verbatim.
expect(await saved('4242')).toEqual([outfit(0, null), outfit(2, 'two')])
// Another account's wardrobe is its own.
expect(await saved('4343')).toEqual([])
})
test('POST /outfits/bulk serves each accounts worn outfit, keyed by id', async () => {
const bulk = async (body: unknown, sub?: string) =>
exports.default.fetch(`${ORIGIN}/outfits/bulk`, {
method: 'POST',
headers: {
...(sub === undefined ? {} : await bearer(sub)),
'content-type': 'application/json',
},
body: JSON.stringify(body),
})
// Two accounts with a saved outfit, and one with none.
const outfitFor = (skin: string) => ({
DataVersion: 2,
LegacyData: {
SelectionsV1: '193a3bf9-abc0-4d78-8d63-92046908b1c5,,0',
SelectionsV2: '{"selections":[]}',
FaceFeatures: '{"ver":7}',
SkinColor: skin,
HairColor: 'UAT0OaWEkUG-mWDIyiX1Kg',
},
CustomizationSettings: '{"AvatarVersion":2,"AvatarBodyType":0}',
Selections: [],
Slot: 0,
Name: '',
Accessibility: 1,
ThumbnailFileName: null,
})
const saved = new Map([
[187, outfitFor('skin-187')],
[220, outfitFor('skin-220')],
])
for (const [accountId, outfit] of saved) {
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
method: 'PUT',
headers: { ...(await bearer(String(accountId))), 'content-type': 'application/json' },
body: JSON.stringify(outfit),
})
expect(res.status).toBe(200)
}
expect((await bulk({ AccountIds: [187] })).status).toBe(401)
const res = await bulk(
{ AccountIds: [187, 220], UnityAssetTarget: null, UnityAssetVersion: null },
'42'
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
// A map keyed by the account id as a STRING, each value the outfit exactly as saved —
// the JSON-in-a-string fields are still strings.
expect(await res.json()).toEqual({
OutfitsByAccountId: {
'187': saved.get(187),
'220': saved.get(220),
},
})
// An account with nothing saved is ABSENT rather than carrying a null, and a repeated
// id collapses instead of appearing twice.
const sparse = await bulk({ AccountIds: [187, 999888, 187] }, '42')
expect(await sparse.json()).toEqual({ OutfitsByAccountId: { '187': saved.get(187) } })
// No ids is an empty map, not every outfit on the server.
expect(await (await bulk({ AccountIds: [] }, '42')).json()).toEqual({ OutfitsByAccountId: {} })
// 99 distinct accounts is the most one request may name — one query, one round trip.
const atCap = [...Array.from({ length: 98 }, (_, i) => 500000 + i), 220]
expect(await (await bulk({ AccountIds: atCap }, '42')).json()).toEqual({
OutfitsByAccountId: { '220': saved.get(220) },
})
// One more is refused rather than answered in part, which would read as "those
// accounts have no outfit". Duplicates don't count against the cap.
expect((await bulk({ AccountIds: [...atCap, 500999] }, '42')).status).toBe(400)
expect((await bulk({ AccountIds: [...atCap, ...atCap] }, '42')).status).toBe(200)
// An unparseable body is a 400, like the save's. (A body that parses but isn't an
// object — a bare string, say — names no accounts and so answers an empty map.)
const bad = await exports.default.fetch(`${ORIGIN}/outfits/bulk`, {
method: 'POST',
headers: { ...(await bearer('42')), 'content-type': 'application/json' },
body: 'not json',
})
expect(bad.status).toBe(400)
})
test('PUT /outfits/me 400s on an unparseable body', async () => {
@@ -883,6 +1849,701 @@ describe('public endpoints', () => {
expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.InventionId })
})
test('POST /api/inventions/v9/save answers the enveloped result the client reads', async () => {
const body = {
name: '082926 13:42:46',
description: 'No description yet',
imageName: 'invention/2026-08-29/52c1e282-76f5-4974-975f-d85060884085.jpg',
hasBetaContent: false,
instantiationCost: 101,
lightsCost: 0,
chipsCost: 0,
cloudVariablesCost: 0,
aiCost: 0,
ugcVersion: 1,
creationRoomId: 398,
inventionDataFilename: '2026-08-29/cb608051-f38b-4ef2-aa8a-a26eb0195b2b.inv',
referencedInventions: [],
referencedUnityAssetIds: [],
creatorAccountRole: 255,
convertedFromInventionId: null,
displayMetadataJson: '{"0":0,"99":0}',
longDescription: '',
tagsRequest: { AutoTags: ['small'], CustomTags: null },
}
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5151')), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
expect(res.status).toBe(200)
// v9 is ENVELOPED where v6 is bare. The client checks Success and then reads
// Value.Invention.InventionId — unguarded, so a true Success with a null Value is
// the one shape that takes it down.
const result = (await res.json()) as InventionSaveV9Result
expect(result.Success).toBe(true)
expect(result.Error).toBeNull()
expect(result.error_id).toBeNull()
const value = result.Value
if (value === null) throw new Error('Value must not be null on a successful save')
expect(value.Status).toBe(0)
expect(Object.keys(value).sort()).toEqual([
'Invention',
'InventionVersion',
'Status',
'TagsResponse',
])
const saved = value.Invention
expect(saved.InventionId).toBeGreaterThan(0)
expect(saved.CreatorPlayerId).toBe(5151)
expect(saved.Name).toBe(body.name)
expect(saved.CreationRoomId).toBe(398)
expect(saved.DisplayMetadataJson).toBe('{"0":0,"99":0}')
// UgcVersion is an INVENTION field here, next to the version numbers — not a
// version one, where its twin HasBetaContent lives.
expect(saved.UgcVersion).toBe(1)
expect(saved.CurrentVersionNumber).toBe(1)
expect(saved.LatestVersionNumber).toBe(1)
// The v9 RRInvention has no nested version, no Referenced* and no IsPublished —
// the client reads publication from FirstPublishedAt.
expect(saved).not.toHaveProperty('CurrentVersion')
expect(saved).not.toHaveProperty('ReferencedInventions')
expect(saved).not.toHaveProperty('IsPublished')
expect(saved.FirstPublishedAt).toBeNull()
// Costs, the blob and the beta flag ride on the version beside it. No AICost: the
// request sends one and this DTO has nowhere to put it.
expect(value.InventionVersion).toMatchObject({
InventionId: saved.InventionId,
VersionNumber: 1,
InstantiationCost: 101,
HasBetaContent: false,
BlobName: body.inventionDataFilename,
UgcAccessibility: null,
ReferencedInventions: [],
ReferencedUnityAssetIds: [],
})
expect(value.InventionVersion).not.toHaveProperty('AICost')
// The tagsRequest is applied as `v1/settags` would have applied it, and answered
// the way settags answers: a result code and the bare tag NAMES.
expect(value.TagsResponse).toEqual({ Result: 0, Tags: ['small'] })
const details = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=${saved.InventionId}`
)
expect(await details.json()).toEqual({ Tags: [{ Tag: 'small', Type: 2 }] })
// Stored once, read by every version: the older lookup still serves the record it
// always did, nested CurrentVersion and all.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${saved.InventionId}`
)
expect((await one.json()) as SavedInvention).toMatchObject({
InventionId: saved.InventionId,
IsPublished: false,
CurrentVersion: { BlobName: body.inventionDataFilename },
})
})
test('POST /api/inventions/v9/save leaves the v9-only keys off the stored record', async () => {
// The same body a v6 client sends, posted at v9: nothing is back-filled, so the
// record is the one v6 has always stored. The response still carries the full v9
// projection — those fields have defaults there, not absences.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5152')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Bare Save', inventionDataFilename: 'bare.inv' }),
})
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.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.
expect(value.InventionVersion).not.toBeNull()
expect(value.InventionVersion?.HasBetaContent).toBe(false)
expect(value.TagsResponse).toEqual({ Result: 0, Tags: [] })
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${value.Invention.InventionId}`
)
const stored = (await one.json()) as SavedInvention
expect(stored).not.toHaveProperty('Tags')
expect(stored).not.toHaveProperty('UgcVersion')
expect(stored).not.toHaveProperty('ReferencedUnityAssetIds')
expect(stored.CurrentVersion).not.toHaveProperty('HasBetaContent')
})
test('POST /api/inventions/v9/save refuses through the envelope, never a bare error', async () => {
// A refusal the client can show is Success:false with a null Value — the branch
// that reads Error and nothing else. A bare `{ error }` body would deserialize to
// a null envelope and take the client down instead of failing the save.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5153')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'No Blob' }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
Value: null,
Success: false,
Error: 'inventionDataFilename is required',
error_id: null,
})
// Even the 401 answers the envelope: an empty body is a null envelope to the
// client, which is the crash, not a refusal.
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Anon', inventionDataFilename: 'anon.inv' }),
})
expect(anon.status).toBe(401)
expect((await anon.json()) as InventionSaveV9Result).toMatchObject({
Value: null,
Success: false,
})
})
test('POST /api/inventions/v9/save keeps the save when a tag breaks the tag rule', async () => {
// The reply carries a tag result of its own, so the two outcomes are separate: a
// hyphen in a tag must not cost the player the build they just saved.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5154')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Tagged Badly',
inventionDataFilename: 'tagged-badly.inv',
tagsRequest: { AutoTags: ['small'], CustomTags: ['bad-tag'] },
}),
})
expect(res.status).toBe(200)
const result = (await res.json()) as InventionSaveV9Result
expect(result.Success).toBe(true)
const value = result.Value
if (value === null) throw new Error('a refused tag must not refuse the save')
expect(value.Invention.InventionId).toBeGreaterThan(0)
// Non-zero result, and the whole list dropped rather than the offending tag alone —
// the creator re-submits it through `v1/settags` and sees what took.
expect(value.TagsResponse).toEqual({ Result: 1, Tags: [] })
const details = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=${value.Invention.InventionId}`
)
expect(await details.json()).toEqual({ Tags: [] })
// The invention is on the creator's shelf regardless.
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('5154'),
})
expect(((await mine.json()) as SavedInvention[]).map((i) => i.InventionId)).toEqual([
value.Invention.InventionId,
])
})
test('PUT /api/inventions/v2/metadata edits only the fields that arent null', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5160')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Before Edit',
description: 'the original description',
imageName: 'invention/before.jpg',
inventionDataFilename: 'before-edit.inv',
longDescription: 'the original blurb',
tagsRequest: { AutoTags: ['small'], CustomTags: null },
}),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
expect(inventionId).toBeGreaterThan(0)
// The client sends the whole shape every time and marks what it isn't touching as
// null — so a null Name must not blank the name.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { ...(await bearer('5160')), 'Content-Type': 'application/json' },
body: JSON.stringify({
InventionId: inventionId,
Name: null,
Description: 'devin test No description yet',
LongDescription: null,
ImageName: null,
TagsRequest: null,
}),
})
expect(res.status).toBe(200)
const result = (await res.json()) as InventionSaveV9Result
expect(result.Success).toBe(true)
const value = result.Value
if (value === null) throw new Error('Value must not be null on a successful edit')
// The edit answers the UPDATED invention — the client re-renders the detail page
// from it — in the same envelope the save answers.
expect(value.Invention.Description).toBe('devin test No description yet')
expect(value.Invention.Name).toBe('Before Edit')
expect(value.Invention.ImageName).toBe('invention/before.jpg')
expect(value.Invention.InventionId).toBe(inventionId)
// A null TagsRequest leaves the stored tags alone, and they are still reported: the
// list is what the invention HAS, not what this call changed.
expect(value.TagsResponse).toEqual({ Result: 0, Tags: ['small'] })
// And it stuck — including the long description, which the v9 Invention DTO has no
// key for but the record keeps.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect((await one.json()) as SavedInvention).toMatchObject({
Name: 'Before Edit',
Description: 'devin test No description yet',
LongDescription: 'the original blurb',
Tags: [{ Tag: 'small', Type: 2 }],
})
})
test('PUT /api/inventions/v2/metadata treats an empty string as a clear, not a null', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5161')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Clear Me',
description: 'to be cleared',
imageName: 'invention/clear-me.jpg',
inventionDataFilename: 'clear-me.inv',
longDescription: 'blurb to be cleared',
}),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { ...(await bearer('5161')), 'Content-Type': 'application/json' },
body: JSON.stringify({
InventionId: inventionId,
Name: null,
Description: '',
LongDescription: '',
ImageName: '',
TagsRequest: { AutoTags: ['large'], CustomTags: ['puzzle'] },
}),
})
const value = ((await res.json()) as InventionSaveV9Result).Value
if (value === null) throw new Error('Value must not be null on a successful edit')
expect(value.Invention.Description).toBe('')
expect(value.Invention.ImageName).toBe('')
// TagsRequest replaces both lists wholesale, auto first, then custom.
expect(value.TagsResponse).toEqual({ Result: 0, Tags: ['large', 'puzzle'] })
// An empty name is not how a name is cleared — nothing can draw a nameless
// invention, so it fails the same rule a save holds it to and nothing is written.
const named = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { ...(await bearer('5161')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Name: '' }),
})
expect(named.status).toBe(200)
expect((await named.json()) as InventionSaveV9Result).toMatchObject({
Value: null,
Success: false,
})
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect(((await one.json()) as SavedInvention).Name).toBe('Clear Me')
})
test('PUT /api/inventions/v2/metadata refuses another creators invention in-band', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5162')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Not Yours', inventionDataFilename: 'not-yours.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
// Someone else's invention and an unknown one are domain answers, not transport
// ones — the client's own status enum has NotCreator and DoesNotExist members — so
// they come back 200 in the envelope, where the message reaches a human.
const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { ...(await bearer('5163')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Description: 'hijacked' }),
})
expect(theirs.status).toBe(200)
expect(await theirs.json()).toEqual({
Value: null,
Success: false,
Error: 'Not your invention',
error_id: null,
})
const missing = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { ...(await bearer('5162')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: 987654, Description: 'nobody' }),
})
expect(missing.status).toBe(200)
expect((await missing.json()) as InventionSaveV9Result).toMatchObject({
Value: null,
Error: 'No such invention',
})
// A missing token is the one refusal that stays a transport failure — but it still
// answers the envelope, because an unparseable body crashes the client.
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Description: 'anon' }),
})
expect(anon.status).toBe(401)
expect((await anon.json()) as InventionSaveV9Result).toMatchObject({
Value: null,
Success: false,
})
// Untouched throughout.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect(((await one.json()) as SavedInvention).Description).toBe('No description yet')
})
test('PUT /api/inventions/v2/metadata keeps the edit when a tag breaks the tag rule', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5164')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Tag Trouble',
inventionDataFilename: 'tag-trouble.inv',
tagsRequest: { AutoTags: ['small'], CustomTags: null },
}),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/metadata`, {
method: 'PUT',
headers: { ...(await bearer('5164')), 'Content-Type': 'application/json' },
body: JSON.stringify({
InventionId: inventionId,
Description: 'edited anyway',
TagsRequest: { AutoTags: ['small'], CustomTags: ['bad-tag'] },
}),
})
const value = ((await res.json()) as InventionSaveV9Result).Value
if (value === null) throw new Error('a refused tag must not refuse the edit')
// The metadata edit lands; the tags are what didn't.
expect(value.Invention.Description).toBe('edited anyway')
expect(value.TagsResponse).toEqual({ Result: 1, Tags: [] })
const details = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/details?inventionId=${inventionId}`
)
expect(await details.json()).toEqual({ Tags: [] })
})
test('POST /api/inventions/v4/publish publishes with the permission and accessibility sent', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Publish Me', inventionDataFilename: 'publish-me.inv' }),
})
const saved = ((await save.json()) as InventionSaveV9Result).Value?.Invention
expect(saved?.FirstPublishedAt).toBeNull()
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' },
body: JSON.stringify({
InventionId: saved?.InventionId,
Permission: 20,
Accessibility: 1,
Price: null,
}),
})
expect(res.status).toBe(200)
const result = (await res.json()) as InventionSaveV9Result
expect(result.Success).toBe(true)
const value = result.Value
if (value === null) throw new Error('Value must not be null on a successful publish')
// Publishing narrows what everyone else gets down to what the sheet sent, and dates
// the invention — the client reads publication from FirstPublishedAt, not a flag.
expect(value.Invention.GeneralPermission).toBe(20)
expect(value.Invention.Accessibility).toBe(1)
expect(typeof value.Invention.FirstPublishedAt).toBe('string')
expect(value.Invention.Price).toBe(0)
// And it's findable now: the record the older reads serve says published, and it
// turns up in search.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${value.Invention.InventionId}`
)
expect((await one.json()) as SavedInvention).toMatchObject({ IsPublished: true })
const found = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/search?value=Publish Me`)
expect(((await found.json()) as SavedInvention[]).map((i) => i.InventionId)).toContain(
value.Invention.InventionId
)
// Taken back out: the browse tests below assert the exact published catalogue, and
// a test that publishes something publicly is a test that changes it.
await env.DB.prepare('DELETE FROM invention WHERE id = ?1')
.bind(value.Invention.InventionId)
.run()
})
test('POST /api/inventions/v4/publish keeps an unlisted invention out of the feeds', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Quietly Published',
inventionDataFilename: 'quietly-published.inv',
creationRoomId: 4171,
}),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: 20, Accessibility: 2 }),
})
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.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect((await one.json()) as SavedInvention).toMatchObject({
InventionId: inventionId,
IsPublished: true,
})
const found = await exports.default.fetch(
`${ORIGIN}/api/inventions/v2/search?value=Quietly Published`
)
expect(await found.json()).toEqual([])
const room = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/room?id=4171`)
expect(await room.json()).toEqual([])
})
test('POST /api/inventions/v4/publish leaves a price and a first-publish date alone', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'For Sale', inventionDataFilename: 'for-sale.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
const publish = async (body: Record<string, unknown>) => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, ...body }),
})
const value = ((await res.json()) as InventionSaveV9Result).Value
if (value === null) throw new Error('Value must not be null on a successful publish')
return value.Invention
}
const first = await publish({ Permission: 80, Accessibility: 1, Price: 250 })
expect(first.Price).toBe(250)
// A republish that says nothing about money must not give away something that was
// for sale, and must not re-date the first publish.
const again = await publish({ Permission: 20, Accessibility: 1, Price: null })
expect(again.Price).toBe(250)
expect(again.GeneralPermission).toBe(20)
expect(again.FirstPublishedAt).toBe(first.FirstPublishedAt)
// A negative price is dropped rather than stored.
expect((await publish({ Price: -5 })).Price).toBe(250)
// 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()
})
test('POST /api/inventions/v4/publish refuses another creators invention in-band', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5173')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Theirs Alone', inventionDataFilename: 'theirs-alone.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { ...(await bearer('5174')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: 20, Accessibility: 1 }),
})
expect(theirs.status).toBe(200)
expect(await theirs.json()).toEqual({
Value: null,
Success: false,
Error: 'Not your invention',
error_id: null,
})
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Permission: 20, Accessibility: 1 }),
})
expect(anon.status).toBe(401)
expect((await anon.json()) as InventionSaveV9Result).toMatchObject({ Value: null })
// Still unpublished throughout.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect((await one.json()) as SavedInvention).toMatchObject({
IsPublished: false,
FirstPublishedAt: null,
})
})
test('POST /api/inventions/v2/delete removes the creators invention', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5180')), 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'Delete Me',
inventionDataFilename: 'delete-me.inv',
tagsRequest: { AutoTags: ['small'], CustomTags: null },
}),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
expect(inventionId).toBeGreaterThan(0)
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5180')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
expect(res.status).toBe(200)
// `Value` is null even on success — there is no invention left to redraw from.
expect(await res.json()).toEqual({ Value: null, Success: true, Error: null, error_id: null })
// Gone from the read and from the creator's shelf.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect(one.status).toBe(404)
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('5180'),
})
expect(((await mine.json()) as SavedInvention[]).map((i) => i.InventionId)).not.toContain(
inventionId
)
// And the row itself, tags and all, rather than a hidden record still taking the id.
const row = await env.DB.prepare('SELECT COUNT(*) AS n FROM invention WHERE id = ?1')
.bind(inventionId)
.first<{ n: number }>()
expect(row?.n).toBe(0)
// Deleting it twice is a refusal, not a second success: the id resolves to nothing.
const again = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5180')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
expect(await again.json()).toEqual({
Value: null,
Success: false,
Error: 'No such invention',
error_id: null,
})
})
test('POST /api/inventions/v2/delete refuses anyone but the creator, in-band', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5181')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Not Yours To Bin', inventionDataFilename: 'not-yours.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
// 5182 BOUGHT it — owning a copy is still not the right to delete it.
await grantInvention(env.DB, 5182, inventionId as number)
const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5182')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
expect(theirs.status).toBe(200)
expect(await theirs.json()).toEqual({
Value: null,
Success: false,
Error: 'Not your invention',
error_id: null,
})
const missing = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5181')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: 987_655 }),
})
expect(missing.status).toBe(200)
expect(await missing.json()).toEqual({
Value: null,
Success: false,
Error: 'No such invention',
error_id: null,
})
// A missing token is the one refusal that stays a transport failure — and it still
// answers the envelope rather than a bare error body.
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
expect(anon.status).toBe(401)
expect(await anon.json()).toMatchObject({ Value: null, Success: false })
// Still there throughout.
const one = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
)
expect(one.status).toBe(200)
})
test('POST /api/inventions/v2/delete leaves a buyers ownership row behind', async () => {
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
method: 'POST',
headers: { ...(await bearer('5183')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Sold Then Binned', inventionDataFilename: 'sold.inv' }),
})
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention
.InventionId as number
await grantInvention(env.DB, 5184, inventionId)
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
method: 'POST',
headers: { ...(await bearer('5183')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId }),
})
expect((await res.json()) as { Success: boolean }).toMatchObject({ Success: true })
// The purchase record is not rewritten by someone else's delete...
const owned = await env.DB.prepare(
'SELECT COUNT(*) AS n FROM inventory_invention WHERE invention_id = ?1'
)
.bind(inventionId)
.first<{ n: number }>()
expect(owned?.n).toBe(1)
// ...but with no invention row behind it, it drops out of the buyer's shelf anyway.
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
headers: await bearer('5184'),
})
expect(((await mine.json()) as SavedInvention[]).map((i) => i.InventionId)).not.toContain(
inventionId
)
})
test('GET /api/inventions/v2/mine lists bought inventions alongside the callers own', async () => {
// Account 6100 creates one; 6101 buys it (the econ worker's buyInvention writes
// exactly this row) and also creates one of their own.
@@ -926,6 +2587,148 @@ describe('public endpoints', () => {
])
})
test('POST /api/customAvatarItems/v1/:id/report files a report against the items creator', async () => {
// 205 makes an item; 42 reports it. The creator is derived FROM the item — the client
// sends `ReportedPlayerId: null` because it does not know who made it.
const item = await createCustomItem('205', { Name: 'Reportable Hat' })
const res = await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}/report`,
{
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
body: JSON.stringify({
ReportCategory: 2,
Details: 'tesfsfsdf',
ReportedPlayerId: null,
}),
}
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
// One row in the shared report table, marked as an item report by `custom_avatar_item_id`.
// `room_id` stays null — an item isn't tied to one room the way an event is — and the
// other two id columns stay null, which is what tells the kinds apart.
const row = await env.DB.prepare('SELECT * FROM report WHERE custom_avatar_item_id = ?1')
.bind(item.CustomAvatarItemId)
.first<Record<string, unknown>>()
expect(row).toMatchObject({
reporter_player_id: 42,
reported_player_id: 205, // the item's creator
report_category: 2,
details: 'tesfsfsdf',
custom_avatar_item_id: item.CustomAvatarItemId,
invention_id: null,
event_id: null,
room_id: null,
banned: 0, // filed unbanned, like any report
})
// A body naming SOMEONE ELSE is ignored: the reported player is read off the item either
// way. Letting a client name who a report is against would let it point one at anybody.
await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}/report`,
{
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
body: JSON.stringify({ ReportCategory: 1, ReportedPlayerId: 999 }),
}
)
const reported = await env.DB.prepare(
'SELECT reported_player_id FROM report WHERE custom_avatar_item_id = ?1'
)
.bind(item.CustomAvatarItemId)
.all<{ reported_player_id: number }>()
// Nothing dedupes: two reports of the same item are two rows, both against the creator.
expect(reported.results.map((r) => r.reported_player_id)).toEqual([205, 205])
// An item that does not exist is refused rather than filed against nobody — the row's
// reported player has to be someone.
const unknown = await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/v1/00000000-0000-0000-0000-000000000000/report`,
{
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
body: JSON.stringify({ ReportCategory: 0 }),
}
)
expect(unknown.status).toBe(404)
expect(await unknown.json()).toEqual({ success: false, error: 'No such item' })
// Auth-gated: the reporter comes from the token, so there is no filing one signed out.
const anon = await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}/report`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ReportCategory: 0 }),
}
)
expect(anon.status).toBe(401)
})
test('POST /api/inventions/v1/report files a report row against the invention', async () => {
// 5150 saves an invention; 42 reports it. The creator is derived from the invention,
// so the reporter never gets to name who the report is against.
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Reportable', inventionDataFilename: 'blob' }),
})
const inventionId = ((await save.json()) as InventionSaveResult).Invention.InventionId
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/report`, {
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: inventionId, Details: 'test', ReportCategory: 0 }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
// One row in the shared report table, marked as an invention report by `invention_id`,
// with the reported player filled in FROM the invention. `room_id` stays null: an
// invention isn't tied to one room the way an event is, so there is nothing to read.
const row = await env.DB.prepare('SELECT * FROM report WHERE invention_id = ?1')
.bind(inventionId)
.first<Record<string, unknown>>()
expect(row).toMatchObject({
reporter_player_id: 42,
reported_player_id: 5150, // the invention's creator
report_category: 0,
details: 'test',
invention_id: inventionId,
event_id: null, // the two id columns are mutually exclusive
room_id: null,
banned: 0, // filed unbanned, like any report
})
// A body with no usable invention id, and one naming an invention that doesn't exist —
// both answer the same envelope shape as the success branch.
const noId = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/report`, {
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
body: JSON.stringify({ Details: 'x' }),
})
expect(noId.status).toBe(400)
expect(await noId.json()).toEqual({ success: false, error: 'InventionId is required' })
const unknown = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/report`, {
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: 999999 }),
})
expect(unknown.status).toBe(404)
expect(await unknown.json()).toEqual({ success: false, error: 'No such invention' })
// Auth-gated: the reporter comes from the token, so there's no filing one signed out.
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/report`, {
method: 'POST',
body: JSON.stringify({ InventionId: inventionId }),
})
expect(anon.status).toBe(401)
})
test('POST /api/inventions/v6/save 401s without a bearer token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
method: 'POST',
@@ -1178,6 +2981,82 @@ describe('public endpoints', () => {
expect(await miss.json()).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[]) =>
({
InventionId: id,
ReplicationId: crypto.randomUUID(),
CreatorPlayerId: 8081,
Name: name,
Description: description,
ImageName: '',
CurrentVersionNumber: 1,
CurrentVersion: { InventionId: id, VersionNumber: 1, BlobName: '' },
IsPublished: true,
HideFromPlayer: false,
CreatedAt: `2026-09-0${id - 200}T00:00:00Z`,
Tags: tags.map((Tag) => ({ Tag, Type: 2 })),
}) as unknown as SavedInvention
for (const inv of [
published(201, 'Devin Cube', 'i dont even know lol', ['small']),
published(202, 'Devin Cube 2', 'No description yet', ['small', 'dormanchor']),
published(203, 'Recflarian Flag', 'idk..... lol', ['medium']),
published(204, 'Smallest Table', 'a small table', ['medium']),
// Name holds LIKE metacharacters, for the escaping below.
published(205, '100% Cube_Thing', '', []),
]) {
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
.bind(JSON.stringify(inv))
.run()
}
const ids = async (query: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/search?${query}`)
expect(res.status, query).toBe(200)
return ((await res.json()) as SavedInvention[])
.map((i) => i.InventionId)
.filter((id) => id >= 201)
}
// Newest first, matched against name OR description, case-insensitively.
expect(await ids('value=cube&skip=0&take=100')).toEqual([205, 202, 201])
expect(await ids('value=CUBE&skip=0&take=100')).toEqual([205, 202, 201])
expect(await ids('value=small&skip=0&take=100')).toEqual([204])
expect(await ids('value=lol&skip=0&take=100')).toEqual([203, 201])
// Terms are ANDed, so more words narrow rather than widen.
expect(await ids(`value=${encodeURIComponent('devin cube')}&skip=0&take=100`)).toEqual([
202, 201,
])
expect(await ids(`value=${encodeURIComponent('devin flag')}&skip=0&take=100`)).toEqual([])
// LIKE metacharacters are escaped: unescaped, `%` would match everything and `_` any
// single character, so searching for "100%" would return the whole catalogue.
expect(await ids('value=%25&skip=0&take=100')).toEqual([205])
expect(await ids('value=cube_thing&skip=0&take=100')).toEqual([205])
// TAGS ARE NOT SEARCHED. The browse screen's chips send `#small`, and no name or
// description contains it, so the term matches nothing — the tag is on 201 and 202, and a
// tag search would have returned them. Deliberate for now: matching tags needs them out of
// the JSON blob and into something indexable, and doing it in memory would mean reading
// every row to answer one page.
expect(await ids(`value=${encodeURIComponent('#small')}&skip=0&take=100`)).toEqual([])
// Paged in SQL: consecutive pages neither repeat nor skip a row. The `id` tiebreak in the
// ordering is what guarantees that when two inventions share a `CreatedAt`.
const page1 = await ids('value=cube&skip=0&take=2')
const page2 = await ids('value=cube&skip=2&take=2')
expect(page1).toEqual([205, 202])
expect(page2).toEqual([201])
expect(page1.some((id) => page2.includes(id))).toBe(false)
expect(await ids('value=cube&skip=99&take=10')).toEqual([])
// Cleaned up: the feeds and the tag-filter chips are derived from EVERY published
// invention, so rows left behind here would change what those tests see.
await env.DB.prepare('DELETE FROM invention WHERE id >= 201 AND id <= 205').run()
})
test('GET /api/inventions/v1/tagfilters ranks the tags in use', async () => {
// Two published inventions tagged `furniture`, one `bed` — plus a tagged draft,
// whose tags must not leak into the public filter chips.
@@ -1345,17 +3224,71 @@ describe('public endpoints', () => {
expect(noId.status).toBe(400)
})
test('GET /api/inventions/v1/personaldetails/:id reports the cheer flag', async () => {
// No cheer storage yet, so nobody is ever cheering — signed in or not.
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/personaldetails/2`, {
headers: await bearer('42'),
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',
headers: { ...(await bearer('8200')), 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Cheerable Lamp', inventionDataFilename: 'cheerable.inv' }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ IsCheering: false })
const invention = ((await saved.json()) as InventionSaveResult).Invention
const path = `${ORIGIN}/api/inventions/v1/cheer`
const cheer = async (value: boolean, sub = '42') =>
exports.default.fetch(path, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: invention.InventionId, Cheer: value }),
})
const personal = async (sub?: string) =>
exports.default.fetch(
`${ORIGIN}/api/inventions/v1/personaldetails/${invention.InventionId}`,
sub ? { headers: await bearer(sub) } : undefined
)
const storedCount = async (): Promise<number> => {
const row = await env.DB.prepare('SELECT data FROM invention WHERE id = ?1')
.bind(invention.InventionId)
.first<{ data: string }>()
return (JSON.parse(row!.data) as SavedInvention).CheerCount
}
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/personaldetails/2`)
expect(anon.status).toBe(200)
expect(await anon.json()).toEqual({ IsCheering: false })
// The write requires a player; the read remains useful to signed-out callers.
expect(
(
await exports.default.fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: invention.InventionId, Cheer: true }),
})
).status
).toBe(401)
expect(await (await personal()).json()).toEqual({ IsCheering: false })
expect((await cheer(true)).status).toBe(200)
expect(await (await personal('42')).json()).toEqual({ IsCheering: true })
expect(await storedCount()).toBe(1)
// Repeating a state is idempotent, and a second player counts separately.
await cheer(true)
expect(await storedCount()).toBe(1)
await cheer(true, '43')
expect(await storedCount()).toBe(2)
await cheer(false)
expect(await (await personal('42')).json()).toEqual({ IsCheering: false })
expect(await (await personal('43')).json()).toEqual({ IsCheering: true })
expect(await storedCount()).toBe(1)
const unknown = await exports.default.fetch(path, {
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: 999999, Cheer: true }),
})
expect(unknown.status).toBe(404)
const malformed = await exports.default.fetch(path, {
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: invention.InventionId, Cheer: 'yes' }),
})
expect(malformed.status).toBe(400)
})
test('GET /api/inventions/v1/version serves the version; unknown versions 404', async () => {
@@ -1390,7 +3323,27 @@ describe('public endpoints', () => {
InstantiationCost: 42,
})
// Only the current version exists; anything else 404s, as does an unknown id.
// `version=0` means "whichever is current" rather than a number to match, and gets the
// same version 1 back. Nothing has a version 0 — a fresh save is version 1 — so a caller
// sending it does not know which version it wants, and matching it literally would 404 an
// invention that exists.
const v0 = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=0`
)
expect(v0.status).toBe(200)
expect(await v0.json()).toMatchObject({
InventionId: Invention.InventionId,
VersionNumber: 1,
BlobName: '2026-07-12/lamp.inv',
})
// The 0 shortcut does NOT make up an invention: an unknown id still 404s at 0.
const zeroUnknown = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=999999&version=0`
)
expect(zeroUnknown.status).toBe(404)
// Only the current version exists; any other NUMBER still 404s, as does an unknown id.
const v2 = await exports.default.fetch(
`${ORIGIN}/api/inventions/v1/version?inventionId=${Invention.InventionId}&version=2`
)
@@ -1898,6 +3851,646 @@ describe('auth-gated endpoints', () => {
})
})
describe('custom avatar items', () => {
test('minPriceForPublicItem is a bare 100', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/v1/minPriceForPublicItem`
)
expect(res.status).toBe(200)
expect(await res.json()).toBe(100)
})
test('POST creates an item from the multipart form and returns it', async () => {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({
Name: 'custom shirt 1',
Description: 'custom shirt 2',
Price: 0,
BaseAvatarItemId: 2184,
BaseAvatarItemColor: '#F55C1A',
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' }))
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer('205'),
body: form,
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
Value: Record<string, unknown>
Success: boolean
Error: null
error_id: null
}
expect(body.Success).toBe(true)
expect(body.Error).toBeNull()
expect(body.error_id).toBeNull()
expect(body.Value).toMatchObject({
CreatorAccountId: 205,
Name: 'custom shirt 1',
Description: 'custom shirt 2',
Price: 0,
Accessibility: 0,
ForceCannotPublish: false,
IsFeatured: false,
IsRecRoomApproved: false,
BaseAvatarItemId: 2184,
BaseAvatarItemColor: '#F55C1A',
PreviewOrientation: 0,
RankingContext: null,
OutfitType: 0,
CurrentSaves: [],
PurchaseInfo: null,
})
const itemId = body.Value.CustomAvatarItemId as string
expect(itemId).toMatch(/^[0-9a-f-]{36}$/)
const date = (body.Value.CreatedAt as string).slice(0, 10)
expect(body.Value.ThumbnailImageFilename).toBe(`avatar-item/${date}/${itemId}-thumb.png`)
expect(body.Value.DesignFilename).toBe(`avatar-item/${date}/${itemId}-design.png`)
expect(body.Value.CreatedAt).toBe(body.Value.ModifiedAt)
// 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(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])
)
const row = await env.DB.prepare(
'SELECT name, creator_account_id FROM custom_avatar_item WHERE custom_avatar_item_id = ?1'
)
.bind(body.Value.CustomAvatarItemId)
.first()
expect(row).toEqual({ name: 'custom shirt 1', creator_account_id: 205 })
})
test('PUT edits the creators item, leaving nulled fields alone', async () => {
const item = await createCustomAvatarItem(
env.DB,
{
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name: 'Visor',
description: 'shiny',
price: 0,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
accessibility: 0,
designFilename: 'd',
thumbnailImageFilename: 't',
},
new Date('2026-08-01T00:00:00Z')
)
const url = `${ORIGIN}/api/customAvatarItems/v1/${item.CustomAvatarItemId}`
const res = await exports.default.fetch(url, {
method: 'PUT',
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
body: JSON.stringify({ Name: null, Description: null, Price: 100, Accessibility: 1 }),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
Value: Record<string, unknown>
Success: boolean
Error: null
}
expect(body.Success).toBe(true)
expect(body.Value).toMatchObject({
CustomAvatarItemId: item.CustomAvatarItemId,
Name: 'Visor',
Description: 'shiny',
Price: 100,
Accessibility: 1,
CreatedAt: '2026-08-01T00:00:00.000Z',
})
expect(body.Value.ModifiedAt).not.toBe(item.ModifiedAt)
// Someone else can't edit it; an unknown id 404s; a bad type 400s.
const other = await exports.default.fetch(url, {
method: 'PUT',
headers: { ...(await bearer('9')), 'content-type': 'application/json' },
body: JSON.stringify({ Price: 5 }),
})
expect(other.status).toBe(403)
const missing = await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/v1/${crypto.randomUUID()}`,
{
method: 'PUT',
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
body: JSON.stringify({ Price: 5 }),
}
)
expect(missing.status).toBe(404)
const bad = await exports.default.fetch(url, {
method: 'PUT',
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
body: JSON.stringify({ Price: 'lots' }),
})
expect(bad.status).toBe(400)
expect(await bad.json()).toMatchObject({ Success: false, Value: null })
expect(await (await exports.default.fetch(url, { method: 'PUT' })).status).toBe(401)
})
test('DELETE removes the creators item and its bucket objects', async () => {
// Create through the endpoint so the objects really exist in the bucket.
const form = new FormData()
form.set(
'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' }))
const created = (await (
await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer('205'),
body: form,
})
).json()) as {
Value: { CustomAvatarItemId: string; ThumbnailImageFilename: string; DesignFilename: string }
}
const { CustomAvatarItemId, ThumbnailImageFilename, DesignFilename } = created.Value
expect(await env.IMAGES.get(ThumbnailImageFilename)).not.toBeNull()
const url = `${ORIGIN}/api/customAvatarItems/v1/${CustomAvatarItemId}`
// Not the creator → 403 and nothing changes.
const other = await exports.default.fetch(url, { method: 'DELETE', headers: await bearer('9') })
expect(other.status).toBe(403)
expect(await env.IMAGES.get(ThumbnailImageFilename)).not.toBeNull()
const res = await exports.default.fetch(url, { method: 'DELETE', headers: await bearer('205') })
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({
Success: true,
Error: null,
Value: { CustomAvatarItemId, Name: 'Gone' },
})
expect(await env.IMAGES.get(ThumbnailImageFilename)).toBeNull()
expect(await env.IMAGES.get(DesignFilename)).toBeNull()
expect(
await env.DB.prepare('SELECT 1 FROM custom_avatar_item WHERE custom_avatar_item_id = ?1')
.bind(CustomAvatarItemId)
.first()
).toBeNull()
// Gone now → 404; no token → 401.
const again = await exports.default.fetch(url, {
method: 'DELETE',
headers: await bearer('205'),
})
expect(again.status).toBe(404)
expect((await exports.default.fetch(url, { method: 'DELETE' })).status).toBe(401)
})
test('POST 400s without the files', async () => {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({ Name: 'x', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
)
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer(),
body: form,
})
expect(res.status).toBe(400)
expect(await res.json()).toMatchObject({ Success: false, Value: null })
})
test('POST rejects either oversized file before writing anything to R2', async () => {
const previous = env.RECFLARE_MAX_API_UPLOAD_BYTES
env.RECFLARE_MAX_API_UPLOAD_BYTES = '3'
try {
const objectsBefore = (await env.IMAGES.list({ prefix: 'avatar-item/' })).objects.length
const upload = async (thumbnail: Uint8Array, design: Uint8Array) => {
const form = new FormData()
form.set(
'metadata',
JSON.stringify({ Name: 'bounded', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
)
form.set('thumbnailImage', new File([thumbnail], 'thumb.png', { type: 'image/png' }))
form.set('design', new File([design], 'design.png', { type: 'image/png' }))
return exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
headers: await bearer('205'),
body: form,
})
}
const oversizedThumbnail = await upload(new Uint8Array(4), new Uint8Array(3))
expect(oversizedThumbnail.status).toBe(413)
expect(await oversizedThumbnail.json()).toMatchObject({
Success: false,
Error: 'thumbnailImage exceeds the 3-byte upload limit',
})
const oversizedDesign = await upload(new Uint8Array(3), new Uint8Array(4))
expect(oversizedDesign.status).toBe(413)
expect(await oversizedDesign.json()).toMatchObject({
Success: false,
Error: 'design exceeds the 3-byte upload limit',
})
// Neither rejected request may create metadata or leave one of its two objects behind.
const row = await env.DB.prepare(
"SELECT COUNT(*) AS n FROM custom_avatar_item WHERE name = 'bounded'"
).first<{ n: number }>()
expect(row?.n).toBe(0)
expect((await env.IMAGES.list({ prefix: 'avatar-item/' })).objects).toHaveLength(
objectsBefore
)
} finally {
env.RECFLARE_MAX_API_UPLOAD_BYTES = previous
}
})
test('POST 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
method: 'POST',
})
expect(res.status).toBe(401)
})
})
describe('instant kick', () => {
// The game session the kick names, and one belonging to the same room that must be
// left out of it.
const SESSION = 1013781
const OTHER_SESSION = 1013782
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
// Room instances are written by the `match` worker; seeded straight into the table
// here, the way the presence rows below are.
const seedInstance = async (roomInstanceId: number, maxCapacity = 0, isFull = false) =>
env.DB.prepare('INSERT OR REPLACE INTO room_instance (data) VALUES (?1)')
.bind(
JSON.stringify({
roomInstanceId,
ownerAccountId: 42,
roomId: 4,
subRoomId: 4,
location: '',
dataBlob: '',
eventId: 0,
photonRegionId: 'us',
photonRoomId: `photon-${roomInstanceId}`,
name: '',
maxCapacity,
isFull,
isPrivate: false,
isInProgress: false,
roomCode: '',
roomInstanceType: 0,
clubId: 0,
EncryptVoiceChat: false,
matchmakingPolicy: 0,
allowNewUsers: true,
joinDisabled: false,
gameVersion: GAME_VERSION,
createdAt: new Date().toISOString(),
})
)
.run()
const standIn = async (accountId: number, roomInstanceId: number) =>
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
roomInstance: { roomInstanceId, roomId: 4 },
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 0,
platform: 0,
appVersion: GAME_VERSION,
expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS,
})
)
.run()
const isPresent = async (accountId: number) =>
(await env.DB.prepare('SELECT COUNT(*) AS n FROM presence WHERE account_id = ?1')
.bind(accountId)
.first<{ n: number }>())!.n === 1
const isFull = async (roomInstanceId: number) =>
(await env.DB.prepare('SELECT is_full AS full FROM room_instance WHERE id = ?1')
.bind(roomInstanceId)
.first<{ full: number }>())!.full === 1
const kick = async (body: unknown, sub = '42') =>
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/instantKick`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const frames = async () =>
(await (await hub().fetch('http://do/all')).json()) as Array<{
playerIds?: number[]
ephemeral?: boolean
notificationType: number
data: Record<string, unknown>
}>
test('the rooms creator kicks a player out of the session they name', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
// A full two-player instance: 205 is kicked, 206 stays.
await seedInstance(SESSION, 2, true)
await standIn(205, SESSION)
await standIn(206, SESSION)
const res = await kick({ GameSessionId: SESSION, PlayerIds: [205] })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
// Presence is deleted, so they read offline at once — and only theirs is.
expect(await isPresent(205)).toBe(false)
expect(await isPresent(206)).toBe(true)
// The instance lost a player, so it is no longer full.
expect(await isFull(SESSION)).toBe(false)
// One EPHEMERAL ModerationKick, addressed to the kicked player only. `IsBan` is
// false — this ejects them from the session and nothing more.
expect(await frames()).toEqual([
{
playerIds: [205],
ephemeral: true,
notificationType: 22, // NotificationType.ModerationKick
data: {
ReportCategory: -1, // KickReportCategory.Moderator
Duration: 0,
GameSessionId: SESSION,
IsHostKick: true,
Message: 'You have been kicked from KickRoom.',
PlayerIdReporter: 42,
IsBan: false,
IsVoiceModAutoban: false,
IsWarning: false,
VoteKickReason: '',
TimeoutStartedAt: null,
},
},
])
})
// The gate that stops a creator kicking a stranger out of somebody else's session by
// naming their account id.
test('a player who is not in that session is skipped in silence', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
await seedInstance(SESSION)
await seedInstance(OTHER_SESSION)
await standIn(207, OTHER_SESSION)
// 207 stands in another instance; 208 is offline entirely.
const res = await kick({ GameSessionId: SESSION, PlayerIds: [207, 208] })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
expect(await isPresent(207)).toBe(true)
expect(await frames()).toEqual([])
})
test('a room moderator may kick; a host, a stranger and no token may not', async () => {
await seedInstance(SESSION)
await standIn(209, SESSION)
// 43 holds Moderator (20) on the room.
expect((await kick({ GameSessionId: SESSION, PlayerIds: [209] }, '43')).status).toBe(200)
expect(await isPresent(209)).toBe(false)
await standIn(209, SESSION)
// 44 is only a Host (10), and 99 holds nothing at all.
for (const sub of ['44', '99']) {
const res = await kick({ GameSessionId: SESSION, PlayerIds: [209] }, sub)
expect(res.status, sub).toBe(403)
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
}
expect(await isPresent(209)).toBe(true)
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/instantKick`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ GameSessionId: SESSION, PlayerIds: [209] }),
})
expect(anon.status).toBe(401)
})
// Otherwise a moderator could throw the room's own creator out of it.
test('the rooms staff — and the caller — cannot be kicked', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
await seedInstance(SESSION)
await standIn(42, SESSION)
await standIn(43, SESSION)
// 43 (a moderator) names the creator, a fellow moderator and themselves.
const res = await kick({ GameSessionId: SESSION, PlayerIds: [42, 43] }, '43')
expect(res.status).toBe(200)
expect(await isPresent(42)).toBe(true)
expect(await isPresent(43)).toBe(true)
expect(await frames()).toEqual([])
})
test('an unknown session 404s, and the body must name a session and players', async () => {
await seedInstance(SESSION)
const unknown = await kick({ GameSessionId: 999999, PlayerIds: [205] })
expect(unknown.status).toBe(404)
expect(await unknown.json()).toEqual({
success: false,
error: 'This game session does not exist!',
})
for (const [body, error] of [
[{ PlayerIds: [205] }, 'GameSessionId is required'],
[{ GameSessionId: 'nope', PlayerIds: [205] }, 'GameSessionId is required'],
[{ GameSessionId: SESSION }, 'PlayerIds is required'],
[{ GameSessionId: SESSION, PlayerIds: [] }, 'PlayerIds is required'],
[{ GameSessionId: SESSION, PlayerIds: ['205'] }, 'PlayerIds is required'],
] as Array<[unknown, string]>) {
const res = await kick(body)
expect(res.status, error).toBe(400)
expect(await res.json()).toEqual({ success: false, error })
}
// A body that isn't JSON at all is the same shape, not a crash.
const broken = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/instantKick`, {
method: 'POST',
headers: { ...(await bearer()), 'Content-Type': 'application/json' },
body: 'not json',
})
expect(broken.status).toBe(400)
expect(await broken.json()).toEqual({ success: false, error: 'Invalid request body' })
})
})
describe('vote to kick', () => {
// Two live sessions, so a vote called in one can be checked against a player in the
// other. Nothing reads `room_instance` here — the gate is presence alone.
const SESSION = 1014079
const OTHER_SESSION = 1014080
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
const standIn = async (accountId: number, roomInstanceId: number) =>
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
roomInstance: { roomInstanceId, roomId: 4 },
statusVisibility: 0,
deviceClass: 0,
vrMovementMode: 0,
platform: 0,
appVersion: GAME_VERSION,
expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS,
})
)
.run()
// The body the client posts: `PlayerId=205&Response=True&Reason=…&GameSessionId=…`.
const vote = async (fields: Record<string, string>, sub = '42') =>
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/voteToKick`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields),
})
const frames = async () =>
(await (await hub().fetch('http://do/all')).json()) as Array<{
playerId?: number
ephemeral?: boolean
notificationType: number
data: Record<string, unknown>
}>
const FIELDS = {
PlayerId: '205',
Response: 'True',
Reason: 'Inactive in games (AFK)',
GameSessionId: String(SESSION),
}
test('the vote goes to everyone in the session except the caller', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
// 42 calls the vote, 205 is voted on, 206 is a bystander; 207 stands elsewhere.
await standIn(42, SESSION)
await standIn(205, SESSION)
await standIn(206, SESSION)
await standIn(207, OTHER_SESSION)
const res = await vote(FIELDS)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
// One frame each for 205 and 206 — the player voted on gets it too (the vote is
// called in front of them), the caller does not, and 207 is in another session.
// `Data` is an ESCAPED JSON STRING, not a nested object: an object there fails the
// client's decoder (`expected:'String Begin Token', actual:'{'`) and takes the whole
// notification with it. `PlayerId` inside it is a STRING, as the reference relays it,
// and `Response` is empty because the frame is the question, not an answer.
const message = {
ephemeral: true,
notificationType: 2, // NotificationType.MessageReceived
data: {
FromPlayerId: 42,
Type: MessageType.VoteToKick,
Data: `{"PlayerId":"205","Response":"","GameSessionId":${SESSION}}`,
},
}
const sent = await frames()
expect(sent).toHaveLength(2)
expect(sent).toContainEqual({
...message,
playerId: 205,
data: { ...message.data, ToPlayerId: 205 },
})
expect(sent).toContainEqual({
...message,
playerId: 206,
data: { ...message.data, ToPlayerId: 206 },
})
})
test('both players have to be standing in the session', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
await standIn(42, OTHER_SESSION)
await standIn(205, SESSION)
// The caller is somewhere else — a vote can't be called into a session you're not in.
const away = await vote(FIELDS)
expect(away.status).toBe(403)
expect(await away.json()).toEqual({
success: false,
error: 'You are not in that game session!',
})
// And with the caller present, the player voted on has to be there too — offline,
// or standing elsewhere, both refuse.
await standIn(42, SESSION)
await standIn(205, OTHER_SESSION)
const elsewhere = await vote(FIELDS)
expect(elsewhere.status).toBe(403)
expect(await elsewhere.json()).toEqual({
success: false,
error: 'That player is not in that game session!',
})
expect((await vote({ ...FIELDS, PlayerId: '208' })).status).toBe(403)
// Nothing was put to the room on any of those.
expect(await frames()).toEqual([])
})
test('the body must name a player and a session, and the call needs a token', async () => {
await standIn(42, SESSION)
await standIn(205, SESSION)
for (const [fields, error] of [
[{ Response: 'True', GameSessionId: String(SESSION) }, 'PlayerId is required'],
[{ PlayerId: 'nope', GameSessionId: String(SESSION) }, 'PlayerId is required'],
[{ PlayerId: '205', Response: 'True' }, 'GameSessionId is required'],
[{ PlayerId: '205', GameSessionId: 'nope' }, 'GameSessionId is required'],
] as Array<[Record<string, string>, string]>) {
const res = await vote(fields)
expect(res.status, error).toBe(400)
expect(await res.json()).toEqual({ success: false, error })
}
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/voteToKick`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(FIELDS),
})
expect(anon.status).toBe(401)
})
// A vote called with nobody else there is a no-op rather than an error: the caller and
// the player voted on are both here, so the gate passes, and there is simply no room
// to put it to.
test('a session holding only the two of them sends nothing', async () => {
await hub().fetch('http://do/all', { method: 'DELETE' })
await standIn(42, SESSION)
await standIn(205, SESSION)
await env.DB.prepare('DELETE FROM presence WHERE account_id NOT IN (42, 205)').run()
const res = await vote(FIELDS)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true, error: '' })
// 205 is still in the session, so they still hear it — only the caller is dropped.
expect((await frames()).map((f) => f.playerId)).toEqual([205])
})
})
describe('player reports', () => {
const submit = async (fields: Record<string, string>, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, {
@@ -2056,6 +4649,111 @@ describe('player reports', () => {
test('banFromReport returns null for an unknown report', async () => {
expect(await banFromReport(env.DB, 999_999)).toBeNull()
})
// What the banned player is TOLD. The block screen reads this; it's the same row
// matchmake and login refuse on, described rather than merely enforced.
describe('moderationBlockDetails', () => {
// All sixteen keys the 2025 client's decoder names, every one at its "none" value.
const NOT_BLOCKED = {
ReportCategory: -1,
Duration: 0,
GameSessionId: 0,
IsHostKick: false,
Message: null,
PlayerIdReporter: null,
IsBan: false,
IsVoiceModAutoban: false,
IsDeviceBan: false,
IsWarning: false,
VoteKickReason: null,
TimeoutStartedAt: null,
AssociatedAccountUsername: null,
ShowCreatorCodeOfConduct: false,
TopMessageOverride: null,
BottomMessageOverride: null,
}
const details = async (method: string, sub: string) => {
const res = await exports.default.fetch(
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`,
{ method, headers: await bearer(sub) }
)
expect(res.status).toBe(200)
return res.json()
}
// The client POSTs this with no body, despite it being a pure read; the route
// answers GET as well, and both methods serve the same body.
test.each(['GET', 'POST'])(
'%s reports "not blocked" for an unbanned player',
async (method) => {
// A report against them that nobody acted on is not a block.
await submit({ PlayerIdReported: '220' }, await bearer())
// ReportCategory -1 = Unknown (0 is a real category). Message is null, not the
// reference stub's empty string — the client tells "no message" from a blank one.
expect(await details(method, '220')).toEqual(NOT_BLOCKED)
}
)
test('401s without a bearer token', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
)
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 the report's created_at (nothing records when
// the ban itself landed), and a permanent ban runs for the largest span the int
// holds.
test('describes a permanent ban', async () => {
await submit(
{ PlayerIdReported: '221', ReportCategory: '102', Details: 'slurs' },
await bearer()
)
const [row] = await getReportsAgainst(env.DB, 221)
await banFromReport(env.DB, row!.id)
expect(await details('POST', '221')).toEqual({
...NOT_BLOCKED,
ReportCategory: 102,
Duration: 2_147_483_647,
IsBan: true,
// A fixed message — the report's `details` are the reporter's words, and
// the reporter is not shown to the player they reported (PlayerIdReporter
// stays null).
Message: 'Rule violation',
TimeoutStartedAt: row!.created_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 reports created_at plus the span to expiry', async () => {
await submit({ PlayerIdReported: '222', ReportCategory: '103' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 222)
const banExpires = new Date(Date.parse(row!.created_at) + 3600 * 1000)
await banFromReport(env.DB, row!.id, { banExpires: banExpires.toISOString() })
expect(await details('GET', '222')).toEqual({
...NOT_BLOCKED,
ReportCategory: 103,
Duration: 3600,
IsBan: true,
Message: 'Rule violation',
TimeoutStartedAt: row!.created_at,
})
})
// A ban that has served its time is not a block, even though the row still says
// `banned = 1` — the same rule `getActiveBan` applies for matchmake and login.
test('reports "not blocked" once a ban has expired', async () => {
await submit({ PlayerIdReported: '223' }, await bearer())
const [row] = await getReportsAgainst(env.DB, 223)
await banFromReport(env.DB, row!.id, { banExpires: '2020-01-01T00:00:00.000Z' })
expect(await details('GET', '223')).toEqual(NOT_BLOCKED)
})
})
})
describe('player warnings', () => {
@@ -2706,6 +5404,26 @@ describe('images', () => {
expect(res.status).toBe(400)
})
test('POST /api/images/v4/uploadsaved rejects an oversized image before storing it', async () => {
const previous = env.RECFLARE_MAX_API_UPLOAD_BYTES
env.RECFLARE_MAX_API_UPLOAD_BYTES = '3'
try {
const objectsBefore = (await env.IMAGES.list()).objects.length
const fd = new FormData()
fd.append('image', new File([new Uint8Array(4)], 'large.png', { type: 'image/png' }))
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
method: 'POST',
headers: await bearer('42'),
body: fd,
})
expect(res.status).toBe(413)
expect(await res.json()).toEqual({ error: 'image exceeds the 3-byte upload limit' })
expect((await env.IMAGES.list()).objects).toHaveLength(objectsBefore)
} finally {
env.RECFLARE_MAX_API_UPLOAD_BYTES = previous
}
})
test('GET /api/images/v4/room/:id returns a public room feed, filtered/sorted/paginated', async () => {
// Seed images in room 54: two public (one with more cheers, of different
// types), one private (hidden), and one in another room (excluded).
@@ -3392,6 +6110,20 @@ describe('messages', () => {
expect(res.status).toBe(401)
expect(await pushed()).toEqual([])
})
test('POST /api/messages/v3/delete accepts anything with an empty 200', async () => {
// No message store, so no id can be real and nothing is gated — an unknown id, an
// empty list and a missing body all land the same way.
for (const body of [{ MessageIds: [1787377235629] }, { MessageIds: [] }, {}]) {
const res = await exports.default.fetch(`${ORIGIN}/api/messages/v3/delete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
expect(res.status).toBe(200)
expect(await res.text()).toBe('')
}
})
})
describe('mutual friends', () => {
@@ -3486,6 +6218,8 @@ describe('mutual friends', () => {
describe('player events', () => {
const HOUR = 60 * 60 * 1000
/** The longest window a write may store — an event lasts at most a day. */
const DAY = 24 * HOUR
/**
* Seconds precision, no milliseconds the form the client sends and reads back.
*
@@ -3530,6 +6264,16 @@ describe('player events', () => {
return { ...rest, ImageName: imageName, State: 0 }
}
/**
* The client's BASE event behind an envelope's event what the browse feed, the room
* shelf and the bulk read all serve. The envelope minus `Tags`, plus a null
* `BroadcastingRoomInstanceId`; `ImageName` is already `""` on the envelope.
*/
const asBase = (event: PlayerEventEnvelope): Record<string, unknown> => {
const { Tags: _tags, ...rest } = event
return { ...rest, BroadcastingRoomInstanceId: null }
}
// The fixture set every test below reads. Times are relative to the run so the
// upcoming/live/finished distinction the browse queries make is real.
let upcoming: PlayerEventEnvelope
@@ -3685,6 +6429,66 @@ describe('player events', () => {
expect(((await after.json()) as PlayerEvent).Name).toBe('EditMe')
})
test('the event writes cap the window at 24 hours', async () => {
const window = (StartTime: string, EndTime: string) =>
post('/api/playerevents/v2', { Name: 'Windowed', RoomId: 3, StartTime, EndTime })
// A day exactly is allowed — "at most one day", not "under one day".
expect((await window(at(0), at(DAY))).status).toBe(200)
// A second past it is not.
expect((await window(at(0), at(DAY + 1000))).status).toBe(400)
expect((await window(at(0), at(30 * DAY))).status).toBe(400)
// A missing end defaults to an hour after the start, so it can never fail…
expect(
(await post('/api/playerevents/v2', { Name: 'Open ended', RoomId: 3, StartTime: at(DAY) }))
.status
).toBe(200)
// …but an end alone is measured from now, which can.
expect(
(await post('/api/playerevents/v2', { Name: 'Far end', RoomId: 3, EndTime: at(2 * DAY) }))
.status
).toBe(400)
expect(
(await post('/api/playerevents/v2', { Name: 'Near end', RoomId: 3, EndTime: at(HOUR) }))
.status
).toBe(200)
// A body naming neither is defaulted, as before.
expect((await post('/api/playerevents/v2', { Name: 'Untimed', RoomId: 3 })).status).toBe(200)
// A backwards window is refused too — `end - start` on one running a month
// backwards is negative, which would sail past a "no longer than a day" check.
expect((await window(at(3 * HOUR), at(HOUR))).status).toBe(400)
})
test('the 24-hour cap is checked on the window a write RESOLVES to', async () => {
const event = await create({
RoomId: 3,
Name: 'Movable',
StartTime: at(5 * HOUR),
EndTime: at(6 * HOUR),
})
const path = `/api/playerevents/v2/${event.PlayerEventId}`
// Moving one bound is measured against the STORED other one, not against a default:
// a start dragged two days back leaves a window far longer than a day.
expect((await post(path, { StartTime: at(-2 * DAY) })).status).toBe(400)
expect((await post(path, { EndTime: at(2 * DAY) })).status).toBe(400)
// Both bounds moved together stay inside the cap, so this is fine.
expect((await post(path, { StartTime: at(2 * DAY), EndTime: at(2 * DAY + HOUR) })).status).toBe(
200
)
// An edit that says nothing about the times is unaffected.
expect((await post(path, { Name: 'Still Movable' })).status).toBe(200)
// …and a refusal left the event where it was.
const stored = (await (
await get(`/api/playerevents/v1/${event.PlayerEventId}`)
).json()) as PlayerEvent
expect(stored.StartTime).toBe(at(2 * DAY))
expect(stored.EndTime).toBe(at(2 * DAY + HOUR))
})
test('POST /api/playerevents/v2 answers the write envelope, not the bare event', async () => {
const res = await post('/api/playerevents/v2', {
Name: 'Enveloped',
@@ -3694,15 +6498,65 @@ describe('player events', () => {
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
expect(body.PlayerEvent.Name).toBe('Enveloped')
// The tags ride inline on the event AND in TagModifyResult, as NAMES — not the
// `{ tag, type }` pairs the v1 read's lowercase `tags` serves.
expect(body.PlayerEvent.Tags).toEqual(['music'])
// The tags ride inline on the event AND in TagModifyResult. Inline they take the
// caller's build shape — this token names no build, so the 2023 `{ Tag, Type }` pairs
// (PascalCase: not the lowercase pairs the v1 read's `tags` serves). TagModifyResult
// is names to every build.
expect(body.PlayerEvent.Tags).toEqual([{ Tag: 'music', Type: 0 }])
expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['music'] })
// No `State`, and the broadcast instance is present and null.
expect(body.PlayerEvent).not.toHaveProperty('State')
expect(body.PlayerEvent.BroadcastingRoomInstanceId).toBeNull()
})
test('the v2 envelope shapes PlayerEvent.Tags per the callers build', async () => {
// Rec Room reshaped this field without minting a new path, so one endpoint owes two
// shapes: the 2023 build parses `{ Tag, Type }` pairs, the 2025 build bare names.
// Serving either to the wrong build empties the event's chips instead of erroring.
// A tag of this test's own: the `#tag` search tests assert exact result sets, and the
// four events below would join any set they share a tag with.
const PAIRS = [{ Tag: 'buildversions', Type: 0 }]
const NAMES = ['buildversions']
const created = async (version?: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v2`, {
method: 'POST',
headers: {
...(await bearer('42', undefined, version)),
'content-type': 'application/json',
},
body: JSON.stringify({ Name: 'Versioned', RoomId: 3, Tags: NAMES }),
})
expect(res.status).toBe(200)
return (await res.json()) as PlayerEventResult
}
// Newer than 20230414 is the 2025 client; that build, an older one, and a token naming
// no build at all are all the 2023 client. Builds are date-stamped, so they compare as
// strings.
expect((await created('20250718.01')).PlayerEvent.Tags).toEqual(NAMES)
expect((await created('20230414')).PlayerEvent.Tags).toEqual(PAIRS)
expect((await created('20220101')).PlayerEvent.Tags).toEqual(PAIRS)
const legacy = await created()
expect(legacy.PlayerEvent.Tags).toEqual(PAIRS)
// Only the inline field moves: TagModifyResult carries names to both builds.
expect(legacy.TagModifyResult).toEqual({ Result: 0, Tags: NAMES })
// The gate is on the ENVELOPE, not on the create: the read and the field edits answer
// the same shape, so a client that made an event and one opening it cold agree.
const eventId = legacy.PlayerEvent.PlayerEventId
const read = async (version?: string) =>
(
(await (
await exports.default.fetch(`${ORIGIN}/api/playerevents/v2/${eventId}`, {
headers: await bearer('42', undefined, version),
})
).json()) as PlayerEventResult
).PlayerEvent.Tags
expect(await read('20250718.01')).toEqual(NAMES)
expect(await read()).toEqual(PAIRS)
})
test('GET /api/playerevents/v2/:eventId serves the same envelope as the write', async () => {
const written = await post('/api/playerevents/v2', {
Name: 'ReadBack',
@@ -3820,7 +6674,61 @@ describe('player events', () => {
expect(await (await get(path)).json()).toEqual(asRecord(upcoming))
})
test('GET /api/playerevents/v1/bulk answers in request order, skipping unknown ids', async () => {
test('POST /api/playerevents/v1/bulk answers the requested ids as base events', async () => {
// What the client sends: `Ids` repeated once per id, form-urlencoded.
const body = new URLSearchParams()
for (const id of [clubEvent.PlayerEventId, 999999, upcoming.PlayerEventId]) {
body.append('Ids', String(id))
}
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body,
})
expect(res.status).toBe(200)
const events = (await res.json()) as PlayerEvent[]
// Request order, not id order — and the missing id leaves no hole.
expect(events.map((e) => e.PlayerEventId)).toEqual([
clubEvent.PlayerEventId,
upcoming.PlayerEventId,
])
// A bare array — no envelope — of the BASE event, the same projection the browse feed
// and the room shelf serve. Not the stored record: no `State`.
const entry = events.find((e) => e.PlayerEventId === upcoming.PlayerEventId)!
expect(entry).toEqual(asBase(upcoming))
expect(Object.keys(entry)).toHaveLength(17)
expect(Object.hasOwn(entry, 'State')).toBe(false)
})
test('POST /api/playerevents/v1/bulk reads a single id and the comma-separated form', async () => {
const bulk = async (raw: string): Promise<PlayerEvent[]> => {
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: raw,
})
expect(res.status).toBe(200)
return (await res.json()) as PlayerEvent[]
}
// The raw one-id body the client sends for a single event.
expect((await bulk(`Ids=${upcoming.PlayerEventId}`)).map((e) => e.PlayerEventId)).toEqual([
upcoming.PlayerEventId,
])
// …and the comma-separated spelling the other bulk POSTs take.
expect(
(await bulk(`Ids=${clubEvent.PlayerEventId},${upcoming.PlayerEventId}`)).map(
(e) => e.PlayerEventId
)
).toEqual([clubEvent.PlayerEventId, upcoming.PlayerEventId])
// Nothing to look up is an empty array, not every event and not a 400.
expect(await bulk('')).toEqual([])
expect(await bulk('Ids=')).toEqual([])
})
test('GET /api/playerevents/v1/bulk answers the same shape as the POST', async () => {
const res = await get(
`/api/playerevents/v1/bulk?id=${clubEvent.PlayerEventId}&id=999999&id=${upcoming.PlayerEventId}`
)
@@ -3831,6 +6739,10 @@ describe('player events', () => {
clubEvent.PlayerEventId,
upcoming.PlayerEventId,
])
// The same base projection the POST serves: one path, one shape.
expect(events.find((e) => e.PlayerEventId === upcoming.PlayerEventId)).toEqual(
asBase(upcoming)
)
// No ids is an empty list, not every event.
expect(await (await get('/api/playerevents/v1/bulk')).json()).toEqual([])
@@ -3983,6 +6895,59 @@ describe('player events', () => {
expect(ids).not.toContain(pastEvent.PlayerEventId)
})
test('GET /api/playerevents/v1/room/:roomId serves that rooms current and upcoming events', async () => {
// A room of this test's own, so events other tests create can't drift into the shelf.
const soon = await create({
RoomId: 12,
Name: 'Room 12 Soon',
StartTime: at(2 * HOUR),
EndTime: at(3 * HOUR),
})
const running = await create({
RoomId: 12,
Name: 'Room 12 Running',
StartTime: at(-HOUR),
EndTime: at(HOUR),
})
const finished = await create({
RoomId: 12,
Name: 'Room 12 Finished',
StartTime: at(-3 * HOUR),
EndTime: at(-2 * HOUR),
})
const elsewhere = await create({
RoomId: 13,
Name: 'Room 13 Soon',
StartTime: at(HOUR),
EndTime: at(2 * HOUR),
})
const res = await get('/api/playerevents/v1/room/12')
expect(res.status).toBe(200)
const events = (await res.json()) as PlayerEvent[]
// Soonest first, and RUNNING counts as current: the filter is on the end time, so an
// event stays on the shelf until it is over rather than vanishing when it starts.
expect(events.map((e) => e.PlayerEventId)).toEqual([running.PlayerEventId, soon.PlayerEventId])
// A finished event is dropped — the shelf answers what you can still turn up to — and
// another room's event is not this room's business.
expect(events.map((e) => e.PlayerEventId)).not.toContain(finished.PlayerEventId)
expect(events.map((e) => e.PlayerEventId)).not.toContain(elsewhere.PlayerEventId)
// The BASE event, 17 keys — the same projection the browse feed and the bulk read
// serve, since the client decodes all three through one helper and one element type.
// Not the stored record (`/searchlive` and the club shelves keep that), and not the
// single-club envelope.
expect(events[0]).toEqual(asBase(running))
expect(Object.keys(events[0]!)).toHaveLength(17)
expect(Object.hasOwn(events[0]!, 'State')).toBe(false)
// An event created with no banner reads `""` here, never the record's null.
expect(events[0]!.ImageName).toBe('')
// A room with nothing scheduled, and a room id nothing knows about, are both empty.
expect(await (await get('/api/playerevents/v1/room/999999')).json()).toEqual([])
})
test('GET /api/playerevents/v1/clubs is a bare array; /club/:id is a paged envelope', async () => {
// The client deserializes the multi-club form as a list — an envelope here fails
// with "expected:'[', actual:'{'". Do not unify the two.
@@ -4379,12 +7344,9 @@ describe('player events', () => {
const res = await post(`/api/playerevents/v2/delete/${eventId}`, {})
expect(res.status).toBe(200)
// The envelope carries the event as it was, tags included — the caller can report
// what it removed.
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
expect(body.PlayerEvent.PlayerEventId).toBe(eventId)
expect(body.PlayerEvent.Tags).toEqual(['meetup'])
// Both payload fields are null: the event is gone, so the envelope reports only that
// the delete succeeded. NOT the shape the other v2 routes answer with.
expect(await res.json()).toEqual({ PlayerEvent: null, Result: 0, TagModifyResult: null })
// Gone, and nothing left hanging off it: orphan RSVPs would keep being counted and
// orphan tags would keep answering `#tag` searches.
@@ -4416,6 +7378,207 @@ describe('player events', () => {
expect(updated.CreatorPlayerId).toBe(42)
expect(updated.AttendeeCount).toBe(1)
})
// ---- Single-field edits (PUT …/v2/:eventId/:field) -----------------------
/** A form-encoded single-field edit — the encoding the client sends on these. */
const putForm = async (
path: string,
fields: Record<string, string>,
sub: string | null = '42'
): Promise<Response> =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'PUT',
headers: {
...(sub === null ? {} : await bearer(sub)),
'content-type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams(fields).toString(),
})
const putJson = async (path: string, body: unknown, sub = '42'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'PUT',
headers: { ...(await bearer(sub)), 'content-type': 'application/json' },
body: JSON.stringify(body),
})
/** The envelope's event out of a 200 from one of the edits. */
const edited = async (res: Response): Promise<PlayerEventEnvelope> => {
expect(res.status).toBe(200)
const body = (await res.json()) as PlayerEventResult
expect(body.Result).toBe(0)
return body.PlayerEvent
}
test('PUT /api/playerevents/v2/:eventId/time moves either bound independently', async () => {
const event = await create({
RoomId: 5,
Name: 'Reschedulable',
StartTime: at(5 * HOUR),
EndTime: at(6 * HOUR),
})
const path = `/api/playerevents/v2/${event.PlayerEventId}/time`
// The client sends .NET tick precision; it is stored trimmed to seconds.
const moved = await edited(
await putForm(path, {
startTime: at(7 * HOUR).replace('Z', '.0000000Z'),
endTime: at(9 * HOUR).replace('Z', '.0000000Z'),
})
)
expect(moved).toEqual({ ...event, StartTime: at(7 * HOUR), EndTime: at(9 * HOUR) })
// One bound alone keeps the other.
const nudged = await edited(await putForm(path, { endTime: at(10 * HOUR) }))
expect(nudged.StartTime).toBe(at(7 * HOUR))
expect(nudged.EndTime).toBe(at(10 * HOUR))
// A day exactly is allowed — the cap is "at most a day", not "under a day".
const full = await edited(await putForm(path, { endTime: at(7 * HOUR + DAY) }))
expect(full.EndTime).toBe(at(7 * HOUR + DAY))
})
test('PUT /api/playerevents/v2/:eventId/time refuses rubbish and a backwards window', async () => {
const event = await create({
RoomId: 5,
Name: 'Fixed Window',
StartTime: at(5 * HOUR),
EndTime: at(6 * HOUR),
})
const path = `/api/playerevents/v2/${event.PlayerEventId}/time`
// Present but unparseable is refused rather than dropped: a reschedule that
// silently did nothing is worse than a refusal.
expect((await putForm(path, { startTime: 'tomorrowish' })).status).toBe(400)
// An end before the start, checked against the STORED bound when only one is sent.
expect((await putForm(path, { endTime: at(4 * HOUR) })).status).toBe(400)
expect((await putForm(path, { startTime: at(9 * HOUR), endTime: at(8 * HOUR) })).status).toBe(
400
)
// And a window longer than a day, resolved the same way.
expect((await putForm(path, { endTime: at(5 * HOUR + DAY + 1000) })).status).toBe(400)
expect((await putForm(path, { startTime: at(-DAY) })).status).toBe(400)
expect(
(await putForm(path, { startTime: at(2 * DAY), endTime: at(2 * DAY + DAY + 1000) })).status
).toBe(400)
// An empty body changes nothing, and is not an error.
expect((await edited(await putForm(path, {}))).StartTime).toBe(at(5 * HOUR))
// …and nothing stuck.
const stored = (await (
await get(`/api/playerevents/v1/${event.PlayerEventId}`)
).json()) as PlayerEvent
expect(stored.EndTime).toBe(at(6 * HOUR))
})
test('PUT /api/playerevents/v2/:eventId/accessibility takes the enum name', async () => {
const event = await create({ RoomId: 5, Name: 'Visible', Accessibility: 1 })
const path = `/api/playerevents/v2/${event.PlayerEventId}/accessibility`
// The NAME is what the client sends here.
expect((await edited(await putForm(path, { accessibility: 'Unlisted' }))).Accessibility).toBe(2)
// Case-insensitively…
expect((await edited(await putForm(path, { accessibility: 'private' }))).Accessibility).toBe(0)
// …and the ordinal works too.
expect((await edited(await putForm(path, { accessibility: '4' }))).Accessibility).toBe(4)
// Anything else is refused rather than stored verbatim — guessing a visibility
// wrong is what shows a private event to everyone.
expect((await putForm(path, { accessibility: 'Secret' })).status).toBe(400)
expect((await putForm(path, { accessibility: '9' })).status).toBe(400)
expect((await putForm(path, {})).status).toBe(400)
expect(
((await (await get(`/api/playerevents/v1/${event.PlayerEventId}`)).json()) as PlayerEvent)
.Accessibility
).toBe(4)
})
test('PUT /api/playerevents/v2/:eventId/tags replaces the whole set from a bare array', async () => {
const event = await create({ RoomId: 5, Name: 'Taggable', Tags: ['meetup'] })
const path = `/api/playerevents/v2/${event.PlayerEventId}/tags`
// A bare JSON array, not an object — and a replace, not a merge, so `meetup` goes.
const tagged = await edited(await putJson(path, ['tag1', '#Class']))
expect(tagged.Tags).toEqual([
{ Tag: 'class', Type: 0 },
{ Tag: 'tag1', Type: 0 },
])
// The envelope's TagModifyResult reports the same set the client redraws chips from.
const body = (await (await putJson(path, ['workshops'])).json()) as PlayerEventResult
expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['workshops'] })
// `[]` clears them; a non-array body is refused.
expect((await edited(await putJson(path, []))).Tags).toEqual([])
expect((await putJson(path, { Tags: ['nope'] })).status).toBe(400)
expect(
(
(await (
await get(`/api/playerevents/v1/${event.PlayerEventId}?includeDetails=True`)
).json()) as PlayerEvent & { tags: EventTag[] }
).tags
).toEqual([])
})
test('PUT /api/playerevents/v2/:eventId/description rewrites the blurb; absent clears it', async () => {
const event = await create({ RoomId: 5, Name: 'Described', Description: 'The old blurb' })
const path = `/api/playerevents/v2/${event.PlayerEventId}/description`
const written = await edited(
await putForm(path, { description: 'fthe description of said event' })
)
expect(written).toEqual({ ...event, Description: 'fthe description of said event' })
// An emptied text box sends no field at all, which clears it.
expect((await edited(await putForm(path, {}))).Description).toBe('')
// Capped at the stored length, and refused rather than truncated.
expect((await putForm(path, { description: 'd'.repeat(513) })).status).toBe(400)
expect((await putForm(path, { description: 'd'.repeat(512) })).status).toBe(200)
})
test('PUT /api/playerevents/v2/:eventId/name retitles, refusing a blank or overlong one', async () => {
const event = await create({ RoomId: 5, Name: 'Before' })
const path = `/api/playerevents/v2/${event.PlayerEventId}/name`
const renamed = await edited(
await putForm(path, { name: 'an event in the future I should be able to editx' })
)
expect(renamed).toEqual({
...event,
Name: 'an event in the future I should be able to editx',
})
// Stored trimmed.
expect((await edited(await putForm(path, { name: ' Padded ' }))).Name).toBe('Padded')
// A blank name renders as a blank row, and the whole-event update reads one as
// "leave it alone" — so it is refused outright here.
expect((await putForm(path, { name: ' ' })).status).toBe(400)
expect((await putForm(path, {})).status).toBe(400)
expect((await putForm(path, { name: 'n'.repeat(65) })).status).toBe(400)
expect((await putForm(path, { name: 'n'.repeat(64) })).status).toBe(200)
})
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
for (const [field, fields] of [
['time', { startTime: at(8 * HOUR) }],
['accessibility', { accessibility: 'Public' }],
['description', { description: 'nope' }],
['name', { name: 'Hijacked' }],
] as Array<[string, Record<string, string>]>) {
expect((await putForm(`/api/playerevents/v2/${id}/${field}`, fields, null)).status).toBe(401)
// 43 didn't create it.
expect((await putForm(`/api/playerevents/v2/${id}/${field}`, fields, '43')).status).toBe(403)
expect((await putForm(`/api/playerevents/v2/999999/${field}`, fields)).status).toBe(404)
}
// The tags edit takes JSON rather than a form, but is gated the same way.
expect((await putJson(`/api/playerevents/v2/${id}/tags`, ['nope'], '43')).status).toBe(403)
expect((await putJson('/api/playerevents/v2/999999/tags', ['nope'])).status).toBe(404)
// Nothing moved.
expect(await (await get(`/api/playerevents/v1/${id}`)).json()).toEqual(asRecord(event, null))
})
})
describe('openapi', () => {
@@ -4442,8 +7605,10 @@ 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',
'GET /api/PlayerReporting/v1/voteToKickReasons',
'GET /api/activities/charades/v1/words/{activity}',
@@ -4459,6 +7624,8 @@ describe('openapi', () => {
'GET /api/customAvatarItems/v1/isCreationAllowedForAccount',
'GET /api/customAvatarItems/v1/isCreationEnabled',
'GET /api/customAvatarItems/v1/isRenderingEnabled',
'GET /api/customAvatarItems/v1/minPriceForPublicItem',
'GET /api/customAvatarItems/v1/search',
'GET /api/customAvatarItems/v2/fromCreator/{accountId}',
'GET /api/equipment/v2/getUnlocked',
'GET /api/gameconfigs/v1/all',
@@ -4474,6 +7641,7 @@ describe('openapi', () => {
'GET /api/inventions/v1',
'GET /api/inventions/v1/details',
'GET /api/inventions/v1/featured',
'GET /api/inventions/v1/featureddormskins',
'GET /api/inventions/v1/fromcreators',
'GET /api/inventions/v1/fulllineageowner',
'GET /api/inventions/v1/personaldetails/{inventionId}',
@@ -4498,6 +7666,7 @@ describe('openapi', () => {
'GET /api/playerevents/v1/bulk',
'GET /api/playerevents/v1/club/{clubId}',
'GET /api/playerevents/v1/clubs',
'GET /api/playerevents/v1/room/{roomId}',
'GET /api/playerevents/v1/search',
'GET /api/playerevents/v1/searchlive',
'GET /api/playerevents/v1/tagfilters',
@@ -4531,28 +7700,41 @@ describe('openapi', () => {
'GET /outfits/me',
'GET /outfits/me/saved',
'GET /voice/config',
'POST /api/PlayerCheer/v1/SetSelectedCheer',
'POST /api/PlayerCheer/v1/create',
'POST /api/PlayerReporting/v1/deviceId',
'POST /api/PlayerReporting/v1/hile',
'POST /api/PlayerReporting/v1/instantKick',
'POST /api/PlayerReporting/v1/moderationBlockDetails',
'POST /api/PlayerReporting/v1/referee',
'POST /api/PlayerReporting/v3/create',
'POST /api/PlayerReporting/v3/voteToKick',
'POST /api/avatar/v1/lockeditems/bulk',
'POST /api/avatar/v2/gifts/generate',
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
'POST /api/customAvatarItems/v1',
'POST /api/customAvatarItems/v1/bulk',
'POST /api/customAvatarItems/v1/{id}/report',
'POST /api/gamesight/event',
'POST /api/images/v1/cheer',
'POST /api/images/v4/uploadsaved',
'POST /api/images/v5/cheered/bulk',
'POST /api/inventions/v1/cheer',
'POST /api/inventions/v1/report',
'POST /api/inventions/v1/settags',
'POST /api/inventions/v1/update',
'POST /api/inventions/v1/updateprice',
'POST /api/inventions/v2/delete',
'POST /api/inventions/v4/publish',
'POST /api/inventions/v6/save',
'POST /api/inventions/v9/save',
'POST /api/messages/v1/friendOnlineStatus',
'POST /api/messages/v1/sendMultiple',
'POST /api/messages/v2/send',
'POST /api/messages/v3/delete',
'POST /api/playerReputation/v1/bulk',
'POST /api/playerReputation/v2/bulk',
'POST /api/playerevents/v1/bulk',
'POST /api/playerevents/v1/bulkInvite',
'POST /api/playerevents/v1/report',
'POST /api/playerevents/v1/respond',
@@ -4576,7 +7758,15 @@ describe('openapi', () => {
'POST /api/sanitize/v1',
'POST /api/sanitize/v1/isPure',
'POST /api/v1/progression/bulk',
'POST /outfits/bulk',
'POST /statsigUserProperties',
'PUT /api/customAvatarItems/v1/{id}',
'PUT /api/inventions/v2/metadata',
'PUT /api/playerevents/v2/{eventId}/accessibility',
'PUT /api/playerevents/v2/{eventId}/description',
'PUT /api/playerevents/v2/{eventId}/name',
'PUT /api/playerevents/v2/{eventId}/tags',
'PUT /api/playerevents/v2/{eventId}/time',
'PUT /api/players/v1/playerPhotoTaggingSetting',
'PUT /outfits/me',
])
+21
View File
@@ -0,0 +1,21 @@
import { intVar } from '@repo/hono-helpers'
import type { App } from './context'
/** Safe fallback when the deployment does not configure an API upload ceiling. */
export const DEFAULT_MAX_API_UPLOAD_BYTES = 64 * 1024 * 1024
/**
* Resolve the per-file ceiling shared by API-owned image uploads. A non-positive
* setting does not disable the protection: public upload routes must always remain
* bounded, so invalid values fall back to the safe default.
*/
export function maxApiUploadBytes(env: App['Bindings']): number {
const configured = intVar(env.RECFLARE_MAX_API_UPLOAD_BYTES, DEFAULT_MAX_API_UPLOAD_BYTES)
return configured > 0 ? configured : DEFAULT_MAX_API_UPLOAD_BYTES
}
/** Whether a parsed multipart file is safe to copy into memory and persist to R2. */
export function exceedsApiUploadLimit(file: File, limit: number): boolean {
return file.size > limit
}
+2122
View File
@@ -0,0 +1,2122 @@
[
{
"Id": 1258,
"Difficulty": 10,
"EN_US": "Nothing"
},
{
"Id": 1259,
"Difficulty": 10,
"EN_US": "Losing"
},
{
"Id": 1260,
"Difficulty": 10,
"EN_US": "Winning"
},
{
"Id": 1261,
"Difficulty": 10,
"EN_US": "Magnesium"
},
{
"Id": 1262,
"Difficulty": 10,
"EN_US": "Zirconium"
},
{
"Id": 1263,
"Difficulty": 10,
"EN_US": "Franco Prussian War"
},
{
"Id": 1264,
"Difficulty": 10,
"EN_US": "Neuschwanstein Castle"
},
{
"Id": 1265,
"Difficulty": 10,
"EN_US": "Mobius Strip"
},
{
"Id": 1266,
"Difficulty": 10,
"EN_US": "M.C. Escher"
},
{
"Id": 1267,
"Difficulty": 10,
"EN_US": "Pride and Prejudice"
},
{
"Id": 1268,
"Difficulty": 10,
"EN_US": "Giant's Causeway"
},
{
"Id": 1269,
"Difficulty": 10,
"EN_US": "Liechtenstein"
},
{
"Id": 1270,
"Difficulty": 10,
"EN_US": "Photosynthesis"
},
{
"Id": 1271,
"Difficulty": 10,
"EN_US": "Doppler Effect"
},
{
"Id": 1272,
"Difficulty": 10,
"EN_US": "Chaos Theory"
},
{
"Id": 1273,
"Difficulty": 10,
"EN_US": "Spacing Out"
},
{
"Id": 1274,
"Difficulty": 10,
"EN_US": "Design"
},
{
"Id": 1275,
"Difficulty": 10,
"EN_US": "Concept"
},
{
"Id": 1276,
"Difficulty": 10,
"EN_US": "Tectonic Plates"
},
{
"Id": 1277,
"Difficulty": 10,
"EN_US": "Chuck Norris"
},
{
"Id": 1278,
"Difficulty": 10,
"EN_US": "The Thing"
},
{
"Id": 1279,
"Difficulty": 10,
"EN_US": "Onomatopoeia"
},
{
"Id": 1280,
"Difficulty": 10,
"EN_US": "Alliteration"
},
{
"Id": 1281,
"Difficulty": 10,
"EN_US": "Amoeba"
},
{
"Id": 1282,
"Difficulty": 10,
"EN_US": "Carnivore"
},
{
"Id": 1283,
"Difficulty": 10,
"EN_US": "Herbivore"
},
{
"Id": 1284,
"Difficulty": 10,
"EN_US": "Omnivore"
},
{
"Id": 1285,
"Difficulty": 10,
"EN_US": "Translate"
},
{
"Id": 1286,
"Difficulty": 10,
"EN_US": "Rhyme"
},
{
"Id": 1287,
"Difficulty": 10,
"EN_US": "Cringe"
},
{
"Id": 1288,
"Difficulty": 10,
"EN_US": "Speed of light"
},
{
"Id": 1289,
"Difficulty": 10,
"EN_US": "Grapes of Wrath"
},
{
"Id": 1290,
"Difficulty": 10,
"EN_US": "Kicking"
},
{
"Id": 1291,
"Difficulty": 10,
"EN_US": "Blockchain"
},
{
"Id": 1292,
"Difficulty": 10,
"EN_US": "“Going Viral”"
},
{
"Id": 1293,
"Difficulty": 10,
"EN_US": "El Niño"
},
{
"Id": 1294,
"Difficulty": 10,
"EN_US": "Plot Twist"
},
{
"Id": 1295,
"Difficulty": 10,
"EN_US": "Velocity"
},
{
"Id": 1296,
"Difficulty": 10,
"EN_US": "Sea Monkeys"
},
{
"Id": 1297,
"Difficulty": 10,
"EN_US": "Pinball"
},
{
"Id": 1298,
"Difficulty": 10,
"EN_US": "Mayonnaise"
},
{
"Id": 1299,
"Difficulty": 10,
"EN_US": "Anchovies"
},
{
"Id": 1300,
"Difficulty": 10,
"EN_US": "Alexander Hamilton"
},
{
"Id": 1301,
"Difficulty": 10,
"EN_US": "Plagiarism"
},
{
"Id": 1302,
"Difficulty": 10,
"EN_US": "Courage"
},
{
"Id": 1303,
"Difficulty": 10,
"EN_US": "Estate Tax"
},
{
"Id": 1304,
"Difficulty": 10,
"EN_US": "Scientific Method"
},
{
"Id": 1305,
"Difficulty": 10,
"EN_US": "Imaginary Friend"
},
{
"Id": 1306,
"Difficulty": 10,
"EN_US": "Frustration"
},
{
"Id": 1307,
"Difficulty": 10,
"EN_US": "April Fools"
},
{
"Id": 1308,
"Difficulty": 10,
"EN_US": "Ventriloquism"
},
{
"Id": 1309,
"Difficulty": 10,
"EN_US": "August"
},
{
"Id": 1310,
"Difficulty": 10,
"EN_US": "Final Exams"
},
{
"Id": 1311,
"Difficulty": 10,
"EN_US": "Echolocation"
},
{
"Id": 1312,
"Difficulty": 10,
"EN_US": "19th Century"
},
{
"Id": 1313,
"Difficulty": 10,
"EN_US": "Calories"
},
{
"Id": 1314,
"Difficulty": 10,
"EN_US": "Telling a Lie"
},
{
"Id": 1315,
"Difficulty": 10,
"EN_US": "Gestures"
},
{
"Id": 1316,
"Difficulty": 10,
"EN_US": "Circling Things"
},
{
"Id": 1317,
"Difficulty": 10,
"EN_US": "Words"
},
{
"Id": 1318,
"Difficulty": 10,
"EN_US": "Hieroglyphics"
},
{
"Id": 1319,
"Difficulty": 10,
"EN_US": "Comedy Club"
},
{
"Id": 1320,
"Difficulty": 10,
"EN_US": "Nihilism"
},
{
"Id": 1321,
"Difficulty": 10,
"EN_US": "Ennui"
},
{
"Id": 1322,
"Difficulty": 10,
"EN_US": "International Date Line"
},
{
"Id": 1323,
"Difficulty": 10,
"EN_US": "Marianas Trench"
},
{
"Id": 1324,
"Difficulty": 10,
"EN_US": "Nobody"
},
{
"Id": 1325,
"Difficulty": 10,
"EN_US": "You"
},
{
"Id": 1326,
"Difficulty": 10,
"EN_US": "Me"
},
{
"Id": 1327,
"Difficulty": 10,
"EN_US": "Identical twin"
},
{
"Id": 1328,
"Difficulty": 10,
"EN_US": "Id"
},
{
"Id": 1329,
"Difficulty": 10,
"EN_US": "Ego"
},
{
"Id": 1330,
"Difficulty": 10,
"EN_US": "The Mumps"
},
{
"Id": 1331,
"Difficulty": 10,
"EN_US": "Asymptote"
},
{
"Id": 1332,
"Difficulty": 10,
"EN_US": "Synonym"
},
{
"Id": 1333,
"Difficulty": 10,
"EN_US": "Antigone"
},
{
"Id": 1334,
"Difficulty": 10,
"EN_US": "Ohms"
},
{
"Id": 1335,
"Difficulty": 10,
"EN_US": "Mitochondria"
},
{
"Id": 1336,
"Difficulty": 10,
"EN_US": "Quarks"
},
{
"Id": 1337,
"Difficulty": 10,
"EN_US": "Quantum Entanglement"
},
{
"Id": 1338,
"Difficulty": 10,
"EN_US": "Tax Deductions"
},
{
"Id": 1339,
"Difficulty": 10,
"EN_US": "Legal Precedent"
},
{
"Id": 1340,
"Difficulty": 10,
"EN_US": "Inner Peace"
},
{
"Id": 1341,
"Difficulty": 10,
"EN_US": "Destiny"
},
{
"Id": 1342,
"Difficulty": 10,
"EN_US": "Protagonist"
},
{
"Id": 1343,
"Difficulty": 10,
"EN_US": "Hubris"
},
{
"Id": 1344,
"Difficulty": 10,
"EN_US": "Regret"
},
{
"Id": 1345,
"Difficulty": 10,
"EN_US": "A Character Arc"
},
{
"Id": 1346,
"Difficulty": 10,
"EN_US": "Leap Year"
},
{
"Id": 1347,
"Difficulty": 10,
"EN_US": "Coincidence"
},
{
"Id": 1348,
"Difficulty": 10,
"EN_US": "Deja Vu"
},
{
"Id": 1349,
"Difficulty": 10,
"EN_US": "Moral Ambiguity"
},
{
"Id": 1350,
"Difficulty": 10,
"EN_US": "Metaphor"
},
{
"Id": 1351,
"Difficulty": 10,
"EN_US": "Secret Identity"
},
{
"Id": 1352,
"Difficulty": 10,
"EN_US": "Dehydration"
},
{
"Id": 1353,
"Difficulty": 10,
"EN_US": "Evaporation"
},
{
"Id": 1354,
"Difficulty": 10,
"EN_US": "Irony"
},
{
"Id": 1355,
"Difficulty": 10,
"EN_US": "Asbestos"
},
{
"Id": 1356,
"Difficulty": 10,
"EN_US": "Idiom"
},
{
"Id": 1357,
"Difficulty": 10,
"EN_US": "Consciousness"
},
{
"Id": 1358,
"Difficulty": 10,
"EN_US": "Conscience"
},
{
"Id": 1359,
"Difficulty": 10,
"EN_US": "The Heebie-Jeebies"
},
{
"Id": 1360,
"Difficulty": 10,
"EN_US": "Meringue"
},
{
"Id": 1361,
"Difficulty": 10,
"EN_US": "Possibility"
},
{
"Id": 1362,
"Difficulty": 10,
"EN_US": "The big bang"
},
{
"Id": 1363,
"Difficulty": 10,
"EN_US": "Emojis"
},
{
"Id": 1364,
"Difficulty": 10,
"EN_US": "The Emoji Movie"
},
{
"Id": 1365,
"Difficulty": 10,
"EN_US": "Existentialism"
},
{
"Id": 1366,
"Difficulty": 10,
"EN_US": "Philosophiæ Naturalis"
},
{
"Id": 1367,
"Difficulty": 10,
"EN_US": "Being a Screenmode Player"
},
{
"Id": 1368,
"Difficulty": 10,
"EN_US": "Self Expression"
},
{
"Id": 1369,
"Difficulty": 10,
"EN_US": "Inside-out"
},
{
"Id": 1370,
"Difficulty": 10,
"EN_US": "Expired"
},
{
"Id": 1371,
"Difficulty": 10,
"EN_US": "Excessive"
},
{
"Id": 1372,
"Difficulty": 10,
"EN_US": "Not Quite Enough"
},
{
"Id": 1373,
"Difficulty": 10,
"EN_US": "Unnecessary"
},
{
"Id": 1374,
"Difficulty": 10,
"EN_US": "Preternatural"
},
{
"Id": 1375,
"Difficulty": 10,
"EN_US": "By any means necessary"
},
{
"Id": 1376,
"Difficulty": 10,
"EN_US": "The Letter of the Law"
},
{
"Id": 1377,
"Difficulty": 10,
"EN_US": "Honor amongst thieves"
},
{
"Id": 1378,
"Difficulty": 10,
"EN_US": "Heavy-handed"
},
{
"Id": 1379,
"Difficulty": 10,
"EN_US": "Tongue-in-cheek"
},
{
"Id": 1380,
"Difficulty": 10,
"EN_US": "Taciturn"
},
{
"Id": 1381,
"Difficulty": 10,
"EN_US": "Legit"
},
{
"Id": 1382,
"Difficulty": 10,
"EN_US": "Prank"
},
{
"Id": 1383,
"Difficulty": 10,
"EN_US": "Appeal Process"
},
{
"Id": 1384,
"Difficulty": 10,
"EN_US": "Well-meaning"
},
{
"Id": 1385,
"Difficulty": 10,
"EN_US": "Bureaucracy"
},
{
"Id": 1386,
"Difficulty": 10,
"EN_US": "Nondescript"
},
{
"Id": 1387,
"Difficulty": 10,
"EN_US": "Remarkable"
},
{
"Id": 1388,
"Difficulty": 10,
"EN_US": "Discrepancy"
},
{
"Id": 1389,
"Difficulty": 10,
"EN_US": "Resolution"
},
{
"Id": 1390,
"Difficulty": 10,
"EN_US": "Return Policy"
},
{
"Id": 1391,
"Difficulty": 10,
"EN_US": "Losing Tracking"
},
{
"Id": 1392,
"Difficulty": 10,
"EN_US": "Adjusting Headset"
},
{
"Id": 1393,
"Difficulty": 10,
"EN_US": "Looking at your watch"
},
{
"Id": 1395,
"Difficulty": 10,
"EN_US": "That One Thing"
},
{
"Id": 1396,
"Difficulty": 10,
"EN_US": "5 Seconds of Silence"
},
{
"Id": 1397,
"Difficulty": 10,
"EN_US": "Cacophony"
},
{
"Id": 1398,
"Difficulty": 10,
"EN_US": "Exorcism"
},
{
"Id": 1399,
"Difficulty": 10,
"EN_US": "Mimicry"
},
{
"Id": 1400,
"Difficulty": 10,
"EN_US": "Benjamin Button"
},
{
"Id": 1401,
"Difficulty": 10,
"EN_US": "Frozen Fire"
},
{
"Id": 1402,
"Difficulty": 10,
"EN_US": "The Weekend"
},
{
"Id": 1403,
"Difficulty": 10,
"EN_US": "Transparent Tree"
},
{
"Id": 1405,
"Difficulty": 10,
"EN_US": "Movie Set"
},
{
"Id": 1406,
"Difficulty": 10,
"EN_US": "Buying a House"
},
{
"Id": 1407,
"Difficulty": 10,
"EN_US": "Tell No One"
},
{
"Id": 1408,
"Difficulty": 10,
"EN_US": "Geocaching"
},
{
"Id": 1409,
"Difficulty": 10,
"EN_US": "Inspiration"
},
{
"Id": 1410,
"Difficulty": 10,
"EN_US": "Nostalgia"
},
{
"Id": 1411,
"Difficulty": 10,
"EN_US": "Macro Economics"
},
{
"Id": 1412,
"Difficulty": 10,
"EN_US": "Asparagus Fern"
},
{
"Id": 1413,
"Difficulty": 10,
"EN_US": "Best Case Scenario"
},
{
"Id": 1414,
"Difficulty": 10,
"EN_US": "Influencer"
},
{
"Id": 1415,
"Difficulty": 10,
"EN_US": "Rabies"
},
{
"Id": 1417,
"Difficulty": 10,
"EN_US": "Hydro Power Plant"
},
{
"Id": 1418,
"Difficulty": 10,
"EN_US": "Snuggie"
},
{
"Id": 1420,
"Difficulty": 10,
"EN_US": "Pedicure"
},
{
"Id": 1421,
"Difficulty": 10,
"EN_US": "Cellar Door"
},
{
"Id": 1422,
"Difficulty": 10,
"EN_US": "Tofu"
},
{
"Id": 1423,
"Difficulty": 10,
"EN_US": "Half Sphere"
},
{
"Id": 1424,
"Difficulty": 10,
"EN_US": "Void"
},
{
"Id": 1425,
"Difficulty": 10,
"EN_US": "Skim Milk"
},
{
"Id": 1426,
"Difficulty": 10,
"EN_US": "Quetzacoatl"
},
{
"Id": 1427,
"Difficulty": 10,
"EN_US": "Event Horizon"
},
{
"Id": 1428,
"Difficulty": 10,
"EN_US": "Mortician"
},
{
"Id": 1429,
"Difficulty": 10,
"EN_US": "Trade Embargo"
},
{
"Id": 1430,
"Difficulty": 10,
"EN_US": "Employee Discount"
},
{
"Id": 1431,
"Difficulty": 10,
"EN_US": "Haha"
},
{
"Id": 1432,
"Difficulty": 10,
"EN_US": "Foreboding"
},
{
"Id": 1433,
"Difficulty": 10,
"EN_US": "Curbside Pickup"
},
{
"Id": 1434,
"Difficulty": 10,
"EN_US": "Nail Polish Remover"
},
{
"Id": 1435,
"Difficulty": 10,
"EN_US": "Gauze"
},
{
"Id": 1436,
"Difficulty": 10,
"EN_US": "Cotton Ball"
},
{
"Id": 1437,
"Difficulty": 10,
"EN_US": "Social Media"
},
{
"Id": 1438,
"Difficulty": 10,
"EN_US": "Family Time"
},
{
"Id": 1439,
"Difficulty": 10,
"EN_US": "Maintenance"
},
{
"Id": 1440,
"Difficulty": 10,
"EN_US": "Can You Hear Me?"
},
{
"Id": 1441,
"Difficulty": 10,
"EN_US": "Calligraphy"
},
{
"Id": 1442,
"Difficulty": 10,
"EN_US": "Operator"
},
{
"Id": 1443,
"Difficulty": 10,
"EN_US": "Efficiency"
},
{
"Id": 1444,
"Difficulty": 10,
"EN_US": "Metamorphosis"
},
{
"Id": 1446,
"Difficulty": 10,
"EN_US": "Rotary Phone"
},
{
"Id": 1447,
"Difficulty": 10,
"EN_US": "Zero Degrees Kelvin"
},
{
"Id": 1448,
"Difficulty": 10,
"EN_US": "Kevin"
},
{
"Id": 1449,
"Difficulty": 10,
"EN_US": "Instant Oatmeal"
},
{
"Id": 1450,
"Difficulty": 10,
"EN_US": "Census"
},
{
"Id": 1451,
"Difficulty": 10,
"EN_US": "Too Much Caffeine"
},
{
"Id": 1452,
"Difficulty": 10,
"EN_US": "Dad Joke"
},
{
"Id": 1453,
"Difficulty": 10,
"EN_US": "Another New Streaming Service"
},
{
"Id": 1454,
"Difficulty": 10,
"EN_US": "Mad with Power"
},
{
"Id": 1455,
"Difficulty": 10,
"EN_US": "Creation"
},
{
"Id": 1456,
"Difficulty": 10,
"EN_US": "Karen"
},
{
"Id": 1457,
"Difficulty": 10,
"EN_US": "Lore"
},
{
"Id": 1458,
"Difficulty": 10,
"EN_US": "Hype Train"
},
{
"Id": 1459,
"Difficulty": 10,
"EN_US": "Rec Room Plus"
},
{
"Id": 1460,
"Difficulty": 10,
"EN_US": "Curiosity"
},
{
"Id": 1461,
"Difficulty": 10,
"EN_US": "Imagination"
},
{
"Id": 1463,
"Difficulty": 10,
"EN_US": "Gravity"
},
{
"Id": 1464,
"Difficulty": 10,
"EN_US": "Difficulty"
},
{
"Id": 1465,
"Difficulty": 10,
"EN_US": "Animal Crossing"
},
{
"Id": 1466,
"Difficulty": 10,
"EN_US": "Voice-Activated"
},
{
"Id": 1467,
"Difficulty": 10,
"EN_US": "Not Playing Basketball"
},
{
"Id": 1468,
"Difficulty": 10,
"EN_US": "Calculus"
},
{
"Id": 1469,
"Difficulty": 10,
"EN_US": "Estimation"
},
{
"Id": 1470,
"Difficulty": 10,
"EN_US": "AI"
},
{
"Id": 1471,
"Difficulty": 10,
"EN_US": "Preferential Treatment"
},
{
"Id": 1472,
"Difficulty": 10,
"EN_US": "Using The Maker Pen"
},
{
"Id": 1473,
"Difficulty": 10,
"EN_US": "Osmosis"
},
{
"Id": 1474,
"Difficulty": 10,
"EN_US": "Terminal Velocity"
},
{
"Id": 1475,
"Difficulty": 10,
"EN_US": "The Current Score"
},
{
"Id": 1476,
"Difficulty": 10,
"EN_US": "Flat Soda"
},
{
"Id": 1477,
"Difficulty": 10,
"EN_US": "Dark Chocolate"
},
{
"Id": 1478,
"Difficulty": 10,
"EN_US": "Meta"
},
{
"Id": 1481,
"Difficulty": 10,
"EN_US": "Harmony"
},
{
"Id": 1482,
"Difficulty": 10,
"EN_US": "Okay Boomer"
},
{
"Id": 1483,
"Difficulty": 10,
"EN_US": "Ghosted"
},
{
"Id": 1484,
"Difficulty": 10,
"EN_US": "I Forgot"
},
{
"Id": 1485,
"Difficulty": 10,
"EN_US": "Orientation"
},
{
"Id": 1486,
"Difficulty": 10,
"EN_US": "Proverb"
},
{
"Id": 1488,
"Difficulty": 10,
"EN_US": "Inviting All Your Friends"
},
{
"Id": 1489,
"Difficulty": 10,
"EN_US": "No Peeking!"
},
{
"Id": 1490,
"Difficulty": 10,
"EN_US": "Stop Pointing"
},
{
"Id": 1491,
"Difficulty": 10,
"EN_US": "Feeling like you're going to sneeze"
},
{
"Id": 1492,
"Difficulty": 10,
"EN_US": "Mandela Effect"
},
{
"Id": 1493,
"Difficulty": 10,
"EN_US": "Sus(picious)"
},
{
"Id": 1494,
"Difficulty": 10,
"EN_US": "Charades"
},
{
"Id": 1495,
"Difficulty": 10,
"EN_US": "Creating an Invention"
},
{
"Id": 1496,
"Difficulty": 10,
"EN_US": "The Aether"
},
{
"Id": 1497,
"Difficulty": 10,
"EN_US": "The Multiverse"
},
{
"Id": 1498,
"Difficulty": 10,
"EN_US": "The Answer to Life"
},
{
"Id": 1500,
"Difficulty": 10,
"EN_US": "The Invisible Man"
},
{
"Id": 1501,
"Difficulty": 10,
"EN_US": "Meme"
},
{
"Id": 1502,
"Difficulty": 10,
"EN_US": "Poggers"
},
{
"Id": 1503,
"Difficulty": 10,
"EN_US": "Rickroll"
},
{
"Id": 1504,
"Difficulty": 10,
"EN_US": "Air"
},
{
"Id": 1505,
"Difficulty": 10,
"EN_US": "Why"
},
{
"Id": 1506,
"Difficulty": 10,
"EN_US": "Sandbox Machine"
},
{
"Id": 1507,
"Difficulty": 10,
"EN_US": "Archaeornithomimus"
},
{
"Id": 1508,
"Difficulty": 10,
"EN_US": "Thermodynamics"
},
{
"Id": 1509,
"Difficulty": 10,
"EN_US": "The Void"
},
{
"Id": 1510,
"Difficulty": 10,
"EN_US": "Hydrogen Peroxide"
},
{
"Id": 1511,
"Difficulty": 10,
"EN_US": "Hello"
},
{
"Id": 1512,
"Difficulty": 10,
"EN_US": "Crescendo"
},
{
"Id": 1513,
"Difficulty": 10,
"EN_US": "Iridocyclitis"
},
{
"Id": 1514,
"Difficulty": 10,
"EN_US": "Harambe"
},
{
"Id": 1515,
"Difficulty": 10,
"EN_US": "Cheese Balls"
},
{
"Id": 1516,
"Difficulty": 10,
"EN_US": "Worcestershire Sauce"
},
{
"Id": 1517,
"Difficulty": 10,
"EN_US": "Neutron Star"
},
{
"Id": 1518,
"Difficulty": 10,
"EN_US": "Cthulhu"
},
{
"Id": 1519,
"Difficulty": 10,
"EN_US": "Non-Euclidean"
},
{
"Id": 1520,
"Difficulty": 10,
"EN_US": "Paradox"
},
{
"Id": 1521,
"Difficulty": 10,
"EN_US": "Damage"
},
{
"Id": 1522,
"Difficulty": 10,
"EN_US": "Fractal"
},
{
"Id": 1523,
"Difficulty": 10,
"EN_US": "Bamboozled"
},
{
"Id": 1524,
"Difficulty": 10,
"EN_US": "Supernova"
},
{
"Id": 1525,
"Difficulty": 10,
"EN_US": "Corrupted"
},
{
"Id": 1526,
"Difficulty": 10,
"EN_US": "February"
},
{
"Id": 1527,
"Difficulty": 10,
"EN_US": "Apocalyptic"
},
{
"Id": 1528,
"Difficulty": 10,
"EN_US": "Mortality"
},
{
"Id": 1529,
"Difficulty": 10,
"EN_US": "Random"
},
{
"Id": 1530,
"Difficulty": 10,
"EN_US": "Oxymoron"
},
{
"Id": 1531,
"Difficulty": 10,
"EN_US": "Extravaganza"
},
{
"Id": 1532,
"Difficulty": 10,
"EN_US": "Antimatter"
},
{
"Id": 1533,
"Difficulty": 10,
"EN_US": "Hypothesize"
},
{
"Id": 1534,
"Difficulty": 10,
"EN_US": "The Spanish Inquisition"
},
{
"Id": 1535,
"Difficulty": 10,
"EN_US": "Soon tm"
},
{
"Id": 1536,
"Difficulty": 10,
"EN_US": "Antidisestablishmentarianism"
},
{
"Id": 1537,
"Difficulty": 10,
"EN_US": "The Meaning Of Life"
},
{
"Id": 1538,
"Difficulty": 10,
"EN_US": "Pi"
},
{
"Id": 1539,
"Difficulty": 10,
"EN_US": "VR Headset"
},
{
"Id": 1540,
"Difficulty": 10,
"EN_US": "The Forbidden One"
},
{
"Id": 1541,
"Difficulty": 10,
"EN_US": "Charades Card"
},
{
"Id": 1542,
"Difficulty": 10,
"EN_US": "Hawaiian Pizza"
},
{
"Id": 1543,
"Difficulty": 10,
"EN_US": "Mona Lisa"
},
{
"Id": 1544,
"Difficulty": 10,
"EN_US": "Talk to the hand"
},
{
"Id": 1545,
"Difficulty": 10,
"EN_US": "VHS player"
},
{
"Id": 1546,
"Difficulty": 10,
"EN_US": "Color Blindness"
},
{
"Id": 1547,
"Difficulty": 10,
"EN_US": "The High Ground"
},
{
"Id": 1548,
"Difficulty": 10,
"EN_US": "Bedrock"
},
{
"Id": 1549,
"Difficulty": 10,
"EN_US": "Alphabet Soup"
},
{
"Id": 1550,
"Difficulty": 10,
"EN_US": "Quadrilateral"
},
{
"Id": 1551,
"Difficulty": 10,
"EN_US": "Something"
},
{
"Id": 1552,
"Difficulty": 10,
"EN_US": "Disappointment"
},
{
"Id": 1553,
"Difficulty": 10,
"EN_US": "Bamboozle"
},
{
"Id": 1554,
"Difficulty": 10,
"EN_US": "Axolotl"
},
{
"Id": 1555,
"Difficulty": 10,
"EN_US": "Procrastination"
},
{
"Id": 1556,
"Difficulty": 10,
"EN_US": "Doing a Little Trolling"
},
{
"Id": 1557,
"Difficulty": 10,
"EN_US": "Retro Encabulator"
},
{
"Id": 1579,
"Difficulty": 10,
"EN_US": "Sound"
},
{
"Id": 1580,
"Difficulty": 10,
"EN_US": "Ranger Roy"
},
{
"Id": 1581,
"Difficulty": 10,
"EN_US": "Redacted"
},
{
"Id": 1582,
"Difficulty": 10,
"EN_US": "Rec Center"
},
{
"Id": 1583,
"Difficulty": 10,
"EN_US": "Suspicious"
},
{
"Id": 1584,
"Difficulty": 10,
"EN_US": "Announcement"
},
{
"Id": 1585,
"Difficulty": 10,
"EN_US": "Anarchy"
},
{
"Id": 1586,
"Difficulty": 10,
"EN_US": "Anything"
},
{
"Id": 1587,
"Difficulty": 10,
"EN_US": "Slow Breathing"
},
{
"Id": 1588,
"Difficulty": 10,
"EN_US": "Chemicals"
},
{
"Id": 1589,
"Difficulty": 10,
"EN_US": "Carpool"
},
{
"Id": 1590,
"Difficulty": 10,
"EN_US": "Cinematic"
},
{
"Id": 1591,
"Difficulty": 10,
"EN_US": "Coffee Beans"
},
{
"Id": 1592,
"Difficulty": 10,
"EN_US": "Confetti"
},
{
"Id": 1593,
"Difficulty": 10,
"EN_US": "Curling"
},
{
"Id": 1594,
"Difficulty": 10,
"EN_US": "Digestive System"
},
{
"Id": 1595,
"Difficulty": 10,
"EN_US": "Dignity"
},
{
"Id": 1596,
"Difficulty": 10,
"EN_US": "Dodecahedron"
},
{
"Id": 1597,
"Difficulty": 10,
"EN_US": "Dino Nuggets"
},
{
"Id": 1598,
"Difficulty": 10,
"EN_US": "Flabbergasted"
},
{
"Id": 1599,
"Difficulty": 10,
"EN_US": "Fog"
},
{
"Id": 1600,
"Difficulty": 10,
"EN_US": "Garlic Bread"
},
{
"Id": 1601,
"Difficulty": 10,
"EN_US": "GIF"
},
{
"Id": 1602,
"Difficulty": 10,
"EN_US": "Hydraulic Press"
},
{
"Id": 1603,
"Difficulty": 10,
"EN_US": "Hydrophobic Water"
},
{
"Id": 1605,
"Difficulty": 10,
"EN_US": "Immortality"
},
{
"Id": 1606,
"Difficulty": 10,
"EN_US": "Imposter"
},
{
"Id": 1607,
"Difficulty": 10,
"EN_US": "Infinite"
},
{
"Id": 1608,
"Difficulty": 10,
"EN_US": "Invisible Cloak"
},
{
"Id": 1609,
"Difficulty": 10,
"EN_US": "Joking"
},
{
"Id": 1610,
"Difficulty": 10,
"EN_US": "Lackluster"
},
{
"Id": 1611,
"Difficulty": 10,
"EN_US": "Lamborghini"
},
{
"Id": 1612,
"Difficulty": 10,
"EN_US": "Mist"
},
{
"Id": 1613,
"Difficulty": 10,
"EN_US": "Negligible"
},
{
"Id": 1614,
"Difficulty": 10,
"EN_US": "Nurture"
},
{
"Id": 1615,
"Difficulty": 10,
"EN_US": "Orange Cube"
},
{
"Id": 1616,
"Difficulty": 10,
"EN_US": "Over Thinking"
},
{
"Id": 1617,
"Difficulty": 10,
"EN_US": "Particle"
},
{
"Id": 1618,
"Difficulty": 10,
"EN_US": "Philosophy"
},
{
"Id": 1619,
"Difficulty": 10,
"EN_US": "Full Body Costume"
},
{
"Id": 1620,
"Difficulty": 10,
"EN_US": "Purgatory"
},
{
"Id": 1621,
"Difficulty": 10,
"EN_US": "Scheme"
},
{
"Id": 1622,
"Difficulty": 10,
"EN_US": "Quaternion"
},
{
"Id": 1623,
"Difficulty": 10,
"EN_US": "Rally Fast"
},
{
"Id": 1624,
"Difficulty": 10,
"EN_US": "Rick Roll"
},
{
"Id": 1625,
"Difficulty": 10,
"EN_US": "Battle Royale"
},
{
"Id": 1626,
"Difficulty": 10,
"EN_US": "Taxes"
},
{
"Id": 1627,
"Difficulty": 10,
"EN_US": "College"
},
{
"Id": 1630,
"Difficulty": 10,
"EN_US": "Contes"
},
{
"Id": 1631,
"Difficulty": 10,
"EN_US": "Hatsune Miku"
},
{
"Id": 1632,
"Difficulty": 10,
"EN_US": "Qualms"
},
{
"Id": 1633,
"Difficulty": 10,
"EN_US": "Opposite Day"
},
{
"Id": 1634,
"Difficulty": 10,
"EN_US": "Placeholder Text"
},
{
"Id": 1635,
"Difficulty": 10,
"EN_US": "Standing Still"
},
{
"Id": 1636,
"Difficulty": 10,
"EN_US": "Insubstantial"
},
{
"Id": 1637,
"Difficulty": 10,
"EN_US": "Superlimnal"
},
{
"Id": 1638,
"Difficulty": 10,
"EN_US": "Vegemite"
},
{
"Id": 1639,
"Difficulty": 10,
"EN_US": "The Sweet Smell of Flowers in Spring"
},
{
"Id": 1640,
"Difficulty": 10,
"EN_US": "The 1962 World's Fair"
},
{
"Id": 1641,
"Difficulty": 10,
"EN_US": "Underwater Basket Weaving"
},
{
"Id": 1642,
"Difficulty": 10,
"EN_US": "A Random Scribble"
},
{
"Id": 1643,
"Difficulty": 10,
"EN_US": "A lizard with 7 legs"
},
{
"Id": 1644,
"Difficulty": 10,
"EN_US": "The missing piece of a jigsaw puzzle"
},
{
"Id": 1645,
"Difficulty": 10,
"EN_US": "Gesticulating Wildly"
},
{
"Id": 1646,
"Difficulty": 10,
"EN_US": "It's Morbin' Time"
},
{
"Id": 1647,
"Difficulty": 10,
"EN_US": "Low Sodium Soy Sauce"
},
{
"Id": 1648,
"Difficulty": 10,
"EN_US": "I Can't Believe It Is Butter"
},
{
"Id": 1649,
"Difficulty": 10,
"EN_US": "Lentils"
},
{
"Id": 1650,
"Difficulty": 10,
"EN_US": "Coverage"
},
{
"Id": 1651,
"Difficulty": 10,
"EN_US": "Defenestration"
},
{
"Id": 1652,
"Difficulty": 10,
"EN_US": "Late-night Talk Show Host"
},
{
"Id": 1653,
"Difficulty": 10,
"EN_US": "1000 Jumping Jacks"
},
{
"Id": 1655,
"Difficulty": 10,
"EN_US": "Send help, I'm stuck in a charades card factory!"
},
{
"Id": 1656,
"Difficulty": 10,
"EN_US": "The entire history of Rome"
},
{
"Id": 1657,
"Difficulty": 10,
"EN_US": "Anti-Taco Legislation"
},
{
"Id": 1658,
"Difficulty": 10,
"EN_US": "Extra Crispy"
},
{
"Id": 1659,
"Difficulty": 10,
"EN_US": "Cumulonimbus"
},
{
"Id": 1660,
"Difficulty": 10,
"EN_US": "(This card intentionally left blank)"
},
{
"Id": 1661,
"Difficulty": 10,
"EN_US": "Choreography"
},
{
"Id": 1662,
"Difficulty": 10,
"EN_US": "Staring Contest"
},
{
"Id": 1663,
"Difficulty": 10,
"EN_US": "FOV"
},
{
"Id": 1664,
"Difficulty": 10,
"EN_US": "Haute Couture"
},
{
"Id": 1665,
"Difficulty": 10,
"EN_US": "Sous Vide"
},
{
"Id": 1666,
"Difficulty": 10,
"EN_US": "Callouses"
},
{
"Id": 1667,
"Difficulty": 10,
"EN_US": "Shenanigans"
},
{
"Id": 1668,
"Difficulty": 10,
"EN_US": "Duct tape fixes everything"
},
{
"Id": 1669,
"Difficulty": 10,
"EN_US": "Chest High Wall"
},
{
"Id": 1670,
"Difficulty": 10,
"EN_US": "Pressing the button but nobody has guessed the word"
},
{
"Id": 1671,
"Difficulty": 10,
"EN_US": "A Single Paintball"
},
{
"Id": 1672,
"Difficulty": 10,
"EN_US": "Six fingers on your right hand"
},
{
"Id": 1673,
"Difficulty": 10,
"EN_US": "Manual Transmission"
},
{
"Id": 1674,
"Difficulty": 10,
"EN_US": "Palindrome"
},
{
"Id": 1675,
"Difficulty": 10,
"EN_US": "Banana For Scale"
},
{
"Id": 1676,
"Difficulty": 10,
"EN_US": "Theory of Relativity"
},
{
"Id": 1677,
"Difficulty": 10,
"EN_US": "Xenoanthropology"
},
{
"Id": 1678,
"Difficulty": 10,
"EN_US": "A.I."
},
{
"Id": 1680,
"Difficulty": 10,
"EN_US": "No Limbs"
},
{
"Id": 1681,
"Difficulty": 10,
"EN_US": "Oxygenation"
},
{
"Id": 1682,
"Difficulty": 10,
"EN_US": "Rooms 2.0"
},
{
"Id": 1683,
"Difficulty": 10,
"EN_US": "Z-Fighting"
},
{
"Id": 1684,
"Difficulty": 10,
"EN_US": "Discombobulate"
},
{
"Id": 1685,
"Difficulty": 10,
"EN_US": "Amnesia"
},
{
"Id": 1686,
"Difficulty": 10,
"EN_US": "Mewing"
},
{
"Id": 1687,
"Difficulty": 10,
"EN_US": "Chupacabra"
},
{
"Id": 1688,
"Difficulty": 10,
"EN_US": "Sasquatch"
},
{
"Id": 1689,
"Difficulty": 10,
"EN_US": "Ambidextrous"
},
{
"Id": 1758,
"Difficulty": 10,
"EN_US": "Entropy"
},
{
"Id": 1759,
"Difficulty": 10,
"EN_US": "Free will"
},
{
"Id": 1760,
"Difficulty": 10,
"EN_US": "Collapse"
},
{
"Id": 1761,
"Difficulty": 10,
"EN_US": "Recursion"
},
{
"Id": 1762,
"Difficulty": 10,
"EN_US": "Impending Doom"
},
{
"Id": 1763,
"Difficulty": 10,
"EN_US": "Wireless"
},
{
"Id": 1764,
"Difficulty": 10,
"EN_US": "Waiting"
},
{
"Id": 1765,
"Difficulty": 10,
"EN_US": "Nutrition"
},
{
"Id": 1766,
"Difficulty": 10,
"EN_US": "Mirror Image"
},
{
"Id": 1767,
"Difficulty": 10,
"EN_US": "Vintage"
},
{
"Id": 1768,
"Difficulty": 10,
"EN_US": "Doomscrolling"
},
{
"Id": 1769,
"Difficulty": 10,
"EN_US": "Tinnitus"
},
{
"Id": 1770,
"Difficulty": 10,
"EN_US": "Subtext"
},
{
"Id": 1771,
"Difficulty": 10,
"EN_US": "Lost in translation"
},
{
"Id": 1772,
"Difficulty": 10,
"EN_US": "July 17th"
},
{
"Id": 1773,
"Difficulty": 10,
"EN_US": "Indigo"
},
{
"Id": 1774,
"Difficulty": 10,
"EN_US": "Waving your arms frantically"
},
{
"Id": 1775,
"Difficulty": 10,
"EN_US": "A squiggle"
},
{
"Id": 1776,
"Difficulty": 10,
"EN_US": "I Give Up"
},
{
"Id": 1777,
"Difficulty": 10,
"EN_US": "Exactly 9 spoons"
},
{
"Id": 1778,
"Difficulty": 10,
"EN_US": "The Andromeda Galaxy"
},
{
"Id": 1779,
"Difficulty": 10,
"EN_US": "Cumulonimbus Cloud"
},
{
"Id": 1780,
"Difficulty": 10,
"EN_US": "Copyright Infringement"
},
{
"Id": 1781,
"Difficulty": 10,
"EN_US": "Inserting a USB the wrong way"
},
{
"Id": 1782,
"Difficulty": 10,
"EN_US": "Rec Room"
},
{
"Id": 1783,
"Difficulty": 10,
"EN_US": "Wasabi"
},
{
"Id": 1784,
"Difficulty": 10,
"EN_US": "Staring at the card"
},
{
"Id": 1785,
"Difficulty": 10,
"EN_US": "Synced Lists"
}
]
+24 -3
View File
@@ -925,7 +925,7 @@
},
{
"Key": "Statsig.Enabled",
"Value": "true",
"Value": "false",
"ActiveExperiments": null,
"StartTime": null,
"EndTime": null
@@ -3992,7 +3992,7 @@
{
"Key": "Econ.Store.Categories",
"Value": "{\"Categories\":[{\"Name\":\"Featured\",\"Depth\":0,\"Icon\":1,\"DiscoveryPageSource\":\"StoreFeatured\",\"ClickAction\":1},{\"Name\":\"Appearance\",\"Depth\":0,\"Icon\":2,\"DiscoveryPageSource\":\"StoreClothing\",\"ClickAction\":1},{\"Name\":\"Clothing\",\"Depth\":1,\"DiscoveryPageSource\":\"StoreAppearance\",\"ClickAction\":1},{\"Name\":\"Tops\",\"Depth\":2,\"ClickAction\":3,\"Filters\":512,\"StoreCarouselType\":8,\"SortCriteria\":1},{\"Name\":\"Bottoms\",\"Depth\":2,\"ClickAction\":3,\"Filters\":8388608,\"StoreCarouselType\":17,\"SortCriteria\":1},{\"Name\":\"Footwear\",\"Depth\":2,\"ClickAction\":3,\"Filters\":16777216,\"StoreCarouselType\":18,\"SortCriteria\":1},{\"Name\":\"Accessories\",\"Depth\":1,\"DiscoveryPageSource\":\"StoreAccessories\",\"ClickAction\":1},{\"Name\":\"Headwear\",\"Depth\":2,\"ClickAction\":3,\"Filters\":64,\"StoreCarouselType\":7,\"SortCriteria\":1},{\"Name\":\"Eyewear\",\"Depth\":2,\"ClickAction\":3,\"Filters\":256,\"StoreCarouselType\":19,\"SortCriteria\":1},{\"Name\":\"Earwear\",\"Depth\":2,\"ClickAction\":3,\"Filters\":33554432,\"StoreCarouselType\":20,\"SortCriteria\":1},{\"Name\":\"Neckwear\",\"Depth\":2,\"ClickAction\":3,\"Filters\":67108864,\"StoreCarouselType\":21,\"SortCriteria\":1},{\"Name\":\"Shoulders & Back\",\"Depth\":2,\"ClickAction\":3,\"Filters\":134217728,\"StoreCarouselType\":22,\"SortCriteria\":1},{\"Name\":\"Waist\",\"Depth\":2,\"ClickAction\":3,\"Filters\":268435456,\"StoreCarouselType\":23,\"SortCriteria\":1},{\"Name\":\"Hands\",\"Depth\":2,\"ClickAction\":3,\"Filters\":1024,\"StoreCarouselType\":10,\"SortCriteria\":1},{\"Name\":\"Styling\",\"Depth\":1,\"DiscoveryPageSource\":\"StoreStyling\",\"ClickAction\":1},{\"Name\":\"Hairstyles\",\"Depth\":2,\"ClickAction\":3,\"Filters\":128,\"StoreCarouselType\":24,\"SortCriteria\":1},{\"Name\":\"Facial Hair\",\"Depth\":2,\"ClickAction\":3,\"Filters\":536870912,\"StoreCarouselType\":25,\"SortCriteria\":1},{\"Name\":\"Hair Dye\",\"Depth\":2,\"ClickAction\":3,\"Filters\":131072,\"StoreCarouselType\":6,\"SortCriteria\":1},{\"Name\":\"Equipment Skins\",\"Depth\":2,\"ClickAction\":3,\"Filters\":262144,\"StoreCarouselType\":5,\"SortCriteria\":1},{\"Name\":\"Inventions\",\"Depth\":0,\"Icon\":3,\"DiscoveryPageSource\":\"StoreInventions\",\"ClickAction\":1,\"Filters\":0,\"StoreCarouselType\":0},{\"Name\":\"Consumables\",\"Depth\":0,\"Icon\":4,\"DiscoveryPageSource\":\"StoreConsumables\",\"ClickAction\":1,\"Filters\":0,\"StoreCarouselType\":0}]}",
"ActiveExperiments": "revenue_2025q1_appearance_improve_conversion\r\nrevenue_2025q3_trending_by_payersegment",
"ActiveExperiments": "null",
"StartTime": null,
"EndTime": null
},
@@ -4614,7 +4614,7 @@
},
{
"Key": "RoomieAI.System.Enabled",
"Value": "True",
"Value": "False",
"ActiveExperiments": null,
"StartTime": null,
"EndTime": null
@@ -6053,5 +6053,26 @@
"ActiveExperiments": null,
"StartTime": null,
"EndTime": null
},
{
"Key": "BootSequence.AutoCreateAccount",
"Value": "false",
"ActiveExperiments": null,
"StartTime": null,
"EndTime": null
},
{
"Key": "DataCollection.Enabled",
"Value": "false",
"ActiveExperiments": null,
"StartTime": null,
"EndTime": null
},
{
"Key": "Backtrace.SampleRate",
"Value": "0",
"ActiveExperiments": null,
"StartTime": null,
"EndTime": null
}
]
+20
View File
@@ -2710,5 +2710,25 @@
"Key": "WatchHomeCarouselOrdering",
"StartTime": null,
"Value": "rooms_continueplaying=23;rooms_friendsplaying=22;foryou=21;rooms_hot=20;rooms_rro=19;items_featured=8;items_featuredcustom=7;inventions_featured=6;inventions_featureddormskins=5"
},
{
"Key": "BootSequence.AutoCreateAccount",
"Value": "false",
"StartTime": null,
"EndTime": null
},
{
"Key": "DataCollection.Enabled",
"Value": "false",
"ActiveExperiments": null,
"StartTime": null,
"EndTime": null
},
{
"Key": "Backtrace.SampleRate",
"Value": "0",
"ActiveExperiments": null,
"StartTime": null,
"EndTime": null
}
]
+15
View File
@@ -25,6 +25,13 @@ export default defineConfig({
// worker pushed (type + payload). GET the DO for the most recent one,
// GET /all for the whole list (friend-graph changes notify both players),
// DELETE to reset it between assertions.
//
// The ephemeral sends land in the same list, tagged `ephemeral`:
// notifyPlayerEphemeral carries `playerId` like the durable send,
// notifyPlayersEphemeral carries `playerIds` for the whole batch. Durable
// and ephemeral differ in whether an offline recipient gets the frame
// later, which is a thing worth asserting (a cheer's effect is broadcast to
// a room this way, a vote-to-kick is put to each player in it).
script: `
import { DurableObject } from 'cloudflare:workers'
export class NotificationsHub extends DurableObject {
@@ -33,6 +40,14 @@ export default defineConfig({
this.sent.push({ playerId, notificationType, data })
return { delivered: 0, queued: true }
}
async notifyPlayerEphemeral(playerId, notificationType, data) {
this.sent.push({ playerId, ephemeral: true, notificationType, data })
return { delivered: 0 }
}
async notifyPlayersEphemeral(playerIds, notificationType, data) {
this.sent.push({ playerIds, ephemeral: true, notificationType, data })
return { delivered: 0 }
}
async broadcast() { return { delivered: 0 } }
async fetch(request) {
if (request.method === 'DELETE') {
+5 -1
View File
@@ -74,6 +74,10 @@
"vars": {
"ENVIRONMENT": "development", // overridden during deployment
"SENTRY_RELEASE": "unknown", // overridden during deployment
"DOMAIN": "rec.example.com" // base domain; overridden during deployment
"DOMAIN": "rec.example.com", // base domain; overridden during deployment
// Per-file ceiling for saved images and custom-avatar-item files. Multipart parsing
// happens first, but this prevents oversized files from being copied into another
// ArrayBuffer and persisted to R2. Invalid/non-positive values fall back to 64 MiB.
"RECFLARE_MAX_API_UPLOAD_BYTES": "67108864"
}
}
+2 -2
View File
@@ -21,7 +21,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -33,6 +33,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+88 -48
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 {
countAccountsBySignupIp,
@@ -70,19 +71,16 @@ const TOKEN_SCOPE =
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
/**
* The `error_description` a banned account's grant is refused with. A fixed sentence,
* never interpolated with the expiry, because `www`'s shared auth-messages table keys on
* this exact string to put a real sentence in front of a player anything varying would
* fall through to the generic "you could not be signed in". Keep the two in sync.
*/
const BANNED_DESCRIPTION = 'this account is banned'
/**
* The refusal when it is not THIS account that is banned but one it shares an identity
* with (see bans-db's linked arms). Deliberately a different, vaguer sentence: the
* account being refused may be an innocent housemate of a banned player, so telling them
* "this account is banned" would be a lie, and naming the account we matched them to
* would hand out somebody else's moderation record.
* The `error_description` a grant is refused with when the caller's account is not itself
* banned but shares an identity with one that is (see bans-db's linked arms). A fixed
* sentence, because `www`'s shared auth-messages table keys on this exact string to put a
* real sentence in front of a player anything varying would fall through to the generic
* "you could not be signed in". Keep the two in sync. Deliberately vague: the account
* being refused may be an innocent housemate of a banned player, so telling them "this
* account is banned" would be a lie, and naming the account we matched them to would hand
* out somebody else's moderation record.
*
* A DIRECTLY banned account is not refused here at all see the token grant.
*/
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
@@ -607,19 +605,22 @@ const app = new Hono<App>()
'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.',
'',
'**Bans.** Once the grant has resolved an account, a BANNED account is refused a',
'token at all (`invalid_grant`) — every grant, including a refresh. A ban is a',
'`report` row with `banned` set (the `api` worker owns that table); it lifts on its',
'own when `ban_expires` passes, and never if that is null.',
'**Bans.** A BANNED account still gets a token — every grant, including a refresh.',
'A ban is a `report` row with `banned` set (the `api` worker owns that table); it',
'lifts on its own when `ban_expires` passes, and never if that is null. The token',
'is what lets the client reach `api`s `/api/PlayerReporting/v1/moderationBlockDetails`',
'and show the player the block screen that explains the ban; the ban itself is',
'enforced by `match`, which refuses every matchmake for a banned player, so a token',
'gets them as far as that screen and no further.',
'',
'The refusal follows the player, not just the account: it also catches an account',
'that shares a PROVEN platform identity (a `platform_account` link) or an IP',
'(`signupIp`/`lastLoginIp`, or the address this request came from) with a banned',
'one, and a `create_account` carrying either is refused BEFORE it mints anything.',
'Those two arms are the operators `BAN_EVASION_MATCH` knob (`ip`, `platform`, or',
'`off`); the ban on the account itself is always enforced. A linked match answers a',
'deliberately vaguer description than a direct one — the account refused may belong',
'to a housemate of the banned player rather than to them.',
'What IS refused here (`invalid_grant`) is ban EVASION: an account that shares a',
'PROVEN platform identity (a `platform_account` link) or an IP (`signupIp`/',
'`lastLoginIp`, or the address this request came from) with a banned one, and a',
'`create_account` carrying either, which is refused BEFORE it mints anything. Such',
'an account has no ban of its own for the block screen to describe, so there is',
'nothing to let it in for. Those two arms are the operators `BAN_EVASION_MATCH`',
'knob (`ip`, `platform`, or `off`). The description is deliberately vague — the',
'account refused may belong to a housemate of the banned player rather than to them.',
].join('\n'),
requestBody: form(
TokenRequest,
@@ -632,7 +633,7 @@ const app = new Hono<App>()
[
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
'invalid/expired refresh token, a missing account identifier, a signup cap reached,',
'or a banned account',
'or an account sharing a banned ones device or network',
].join(' ')
),
500: json(
@@ -788,9 +789,9 @@ const app = new Hono<App>()
// via create_account or /account/me/changepassword.
let accountId: string
if (grantType === 'create_account') {
// A banned player's next move is a new account, so the ban is 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
// 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
// shared check below would) still refuses the token, but leaves the account
// row behind and burns a slot off both signup caps, so the evader gets to keep
// making them.
@@ -986,14 +987,19 @@ const app = new Hono<App>()
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
}
// A banned player gets no token — and with no token every other worker is shut to
// them, so this is the outer wall of a ban; matchmaking's refusal is the inner
// one, which still has to exist because a token issued before the ban stays valid
// until it expires.
// A DIRECTLY banned account still gets its token. The client needs one to reach
// `api`'s moderationBlockDetails, which is where the player is TOLD they are banned
// (category, time left, "Rule violation") — refused here, they would only ever see
// a failed sign-in. The ban is enforced by matchmaking instead, which refuses every
// matchmake for a banned player, so the token gets them as far as the block screen
// and no further. Logged, so the operator can see a banned player signing in.
//
// Checked once here, after the grant has resolved an account, so it covers every
// grant: password, cached_login and a refresh_token redeemed by a client that has
// been running since before the ban. Deliberately AFTER the credential checks —
// Ban EVASION is still refused here: an account that merely shares a device or
// network with a banned one has no ban of its own for that screen to describe, so
// there is nothing to let it in for — and letting it in is exactly what the evader
// wants. Checked once here, after the grant has resolved an account, so it covers
// every grant: password, cached_login and a refresh_token redeemed by a client that
// has been running since before the ban. Deliberately AFTER the credential checks —
// a wrong password is still "invalid account_id or password", so this can't be
// used to probe whether an account exists or is banned without knowing it.
//
@@ -1006,8 +1012,8 @@ const app = new Hono<App>()
identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId },
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
})
if (ban) {
logger.info('token refused: player banned', {
if (ban && ban.via !== 'account') {
logger.info('token refused: ban evasion', {
accountId,
grantType,
via: ban.via,
@@ -1015,13 +1021,15 @@ const app = new Hono<App>()
reportId: ban.ban.id,
banExpires: ban.ban.ban_expires,
})
return c.json(
{
error: 'invalid_grant',
error_description: ban.via === 'account' ? BANNED_DESCRIPTION : BLOCKED_DESCRIPTION,
},
400
)
return c.json({ error: 'invalid_grant', error_description: BLOCKED_DESCRIPTION }, 400)
}
if (ban) {
logger.info('token issued to banned account', {
accountId,
grantType,
reportId: ban.ban.id,
banExpires: ban.ban.ban_expires,
})
}
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
@@ -1039,8 +1047,9 @@ const app = new Hono<App>()
// Stamp the account's elevated roles into the token's `role` claim so the client
// authorizes developer/moderator powers from the token itself (not just the
// /role/* lookups). One read of the just-resolved account; roles thus refresh on
// every login and every refresh_token grant.
// /role/* lookups), and its Plus flag into `rn.plus`. One read of the just-resolved
// account serves both; they thus refresh on every login and every refresh_token
// grant.
const roleAccount = await getAccount(c.env.DB, Number(accountId))
// A refresh grant posts no platform of its own, so the identity comes off the
// account — the same read, and the only place the bound identity is authoritative.
@@ -1055,7 +1064,13 @@ const app = new Hono<App>()
jwtSecret,
accountRoles(roleAccount),
accountPrivileges(roleAccount),
version
version,
// Rec Room Plus, off the same account read as the roles above — `econ` decides the
// CampusCard and the subscriber discount from this claim alone, so it never has to
// load the account. It therefore refreshes on every login and every refresh_token
// grant, and only then: a player who claims Plus on the website keeps a token that
// says otherwise until they sign in again.
roleAccount?.hasPlus === true
)
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
// grant_type=refresh_token). A refresh grant thus rotates its token.
@@ -1178,6 +1193,31 @@ const app = new Hono<App>()
return c.json(account.isModerator === true)
})
// @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
// is stored — Meta's nonce validation (meta-nonce.ts) is what actually proves a login,
// so this value is not security-relevant to the server. Revisit once the client's use
// of it is seen.
.get(
'/oculus/nonce',
describeRoute({
tags: ['Account'],
summary: 'A fresh nonce for the Oculus login flow',
description: [
'Mints a random 64-char hex nonce and returns it as a bare JSON string. Not stored',
'and not verified later — a best guess at the shape the client wants.',
].join(' '),
responses: { 200: json(z.string(), 'The nonce') },
}),
(c) => {
const bytes = crypto.getRandomValues(new Uint8Array(32))
const nonce = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
logger.info('oculus nonce issued')
return c.json(nonce)
}
)
// 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(
+8 -17
View File
@@ -1,6 +1,8 @@
import { resolver } from 'hono-openapi'
import { z } from 'zod'
import { PlatformType } from '@repo/domain/src/enums'
import type { OpenAPIV3_1 } from 'openapi-types'
/**
@@ -49,24 +51,13 @@ export function form(schema: z.ZodType, description: string): OpenAPIV3_1.Reques
}
/**
* PlatformType, the client's platform enum. Declaration order is wire order, and is
* the single source for the schema and description below. The `platform` form field
* is posted as the integer; the token's `platform` claim carries the name.
* PlatformType, the client's platform enum the single source for the schema and
* description below. It lives in `@repo/domain` rather than here because the link table
* (`platform-db`) and the website's benefits claim both need the values, and neither has
* any business importing this module's zod/hono-openapi dependencies. Re-exported so
* `import { PlatformType } from './openapi'` keeps working alongside the schemas.
*/
export const PlatformType = {
All: -1,
Steam: 0,
Oculus: 1,
PlayStation: 2,
Xbox: 3,
RecNet: 4,
IOS: 5,
GooglePlay: 6,
Standalone: 7,
Pico: 8,
} as const
export type PlatformType = (typeof PlatformType)[keyof typeof PlatformType]
export { PlatformType } from '@repo/domain/src/enums'
/**
* A PlatformType by value. Only Steam and Oculus (Meta) can actually be verified
+53 -2
View File
@@ -19,8 +19,18 @@
* A link is only ever written from a VERIFIED identity (a Steam-signed ticket or a
* Meta-validated nonce). It is what turns "this platform user" into "may enter this
* account with no password", so an unproven `platform_id` must never reach it.
*
* Not every link is a LOGIN, though. The table is the account's set of external
* identities, and some are stored for what they entitle the player to rather than for
* entry: `PlatformType.Discord` (101) is written by the website's benefits claim, from an
* OAuth2 code exchange Discord itself vouched for. Nobody signs in with it
* `verifyPlatformProof` answers `unsupported` for anything but Steam and Meta, so a
* `cached_login` naming platform 101 is refused and the picker must not offer it
* either. See {@link CACHED_LOGIN_PLATFORMS}, which is what keeps those two in step.
*/
import { PlatformType } from '@repo/domain/src/enums'
/** Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill). */
export const PLATFORM_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS platform_account (
@@ -64,6 +74,38 @@ export const PLATFORM_BACKFILL_SQL = `INSERT OR IGNORE INTO platform_account (ac
WHERE json_extract(data, '$.platformId') IS NOT NULL
AND json_extract(data, '$.platformId') <> ''`
/**
* The platforms a cached login can actually be redeemed for the ones
* `verifyPlatformProof` can prove, which is Steam (a Steam-signed ticket) and Meta/Oculus
* (a Meta-validated nonce).
*
* This is the picker's filter, and it exists to keep a promise the picker's own API
* documentation makes: "an entry here is always redeemable by a `cached_login` grant
* (both read the same table)". Once the table began holding identities that are NOT
* credentials Discord, from the website's benefits claim listing every row would have
* broken that promise in two ways at once. The client would be offered an account it can
* never log into (the grant refuses platform 101 outright), and, worse, the picker is
* PUBLIC and unauthenticated: `GET /cachedlogin/forplatformid/101/<snowflake>` would have
* told anyone which RecFlare account a given Discord user owns, and the bulk route would
* have done it for a list of them at once. A Discord id is trivially readable by anyone in
* a shared server, so that is a deanonymisation of every player who claimed benefits.
*
* Adding a platform here means asserting `verifyPlatformProof` can prove it. Filtering
* happens in the two picker reads only {@link isPlatformIdentityLinked},
* {@link countAccountsForPlatformIdentity} and {@link getLinksForAccount} deliberately see
* every link, because they answer "is this identity taken / whose is it", which is exactly
* the question the benefits claim's once-only guard asks about a Discord id.
*/
export const CACHED_LOGIN_PLATFORMS: readonly number[] = [PlatformType.Steam, PlatformType.Oculus]
/**
* `IN (…)` fragment for the allowlist, so the filter is applied by the query rather than
* in JS. Placeholders are numbered from ?2 because the one caller binds the platform id
* as ?1 explicit indices rather than bare `?`, which SQLite would number by position
* and quietly renumber the moment another parameter is added ahead of it.
*/
const CACHED_LOGIN_FILTER = `platform IN (${CACHED_LOGIN_PLATFORMS.map((_, i) => `?${i + 2}`).join(', ')})`
/** One account ↔ platform identity link. */
export interface PlatformLink {
accountId: number
@@ -119,6 +161,9 @@ export async function getLinksForPlatformIdentity(
platformId: string
): Promise<PlatformLink[]> {
if (platformId === '') return []
// Asking about a platform nobody can log in from yields nothing at all, rather than a
// list the grant would refuse — see CACHED_LOGIN_PLATFORMS.
if (!CACHED_LOGIN_PLATFORMS.includes(platform)) return []
const { results } = await db
.prepare(
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
@@ -138,9 +183,15 @@ export async function getLinksForPlatformId(
platformId: string
): Promise<PlatformLink[]> {
if (platformId === '') return []
// Matches on any platform a cached login can be redeemed for — but only those. This is
// the route a bare id takes, so without the filter a Discord snowflake posted here
// would resolve its account even though naming platform 101 explicitly would not.
const { results } = await db
.prepare(`${SELECT_LINK} WHERE platform_id = ?1 ORDER BY linked_at, account_id`)
.bind(platformId)
.prepare(
`${SELECT_LINK} WHERE platform_id = ?1 AND ${CACHED_LOGIN_FILTER}
ORDER BY linked_at, account_id`
)
.bind(platformId, ...CACHED_LOGIN_PLATFORMS)
.all<LinkRow>()
return results
}
+37 -6
View File
@@ -47,24 +47,55 @@ export async function issueRefreshToken(db: D1Database, accountId: number): Prom
return token
}
/**
* Attempts per redemption. D1 occasionally answers a perfectly good statement with
* `D1_ERROR: internal error` a storage-side hiccup carrying a support reference, not a
* verdict on the query. Unretried, one of those logs a player out: the grant throws, the
* shared error handler turns it into a 500, and the client falls back to the login
* screen with a refresh token it never got to spend.
*
* Deliberately small, like the Meta nonce retry next door: a refresh blocks the client on
* a loading screen, so two quick retries ride out a blip and a longer outage fails fast
* rather than hanging.
*/
const MAX_ATTEMPTS = 3
/**
* Redeem a refresh token: if it exists and hasn't expired, delete it (single-use
* rotation) and return the account it logs in; otherwise return null. The delete is
* atomic (`DELETE ... RETURNING`), so a token can't be redeemed twice a
* concurrent second attempt finds no row. An expired token is deleted and rejected.
*
* A D1 error is retried (see {@link MAX_ATTEMPTS}). That is safe precisely BECAUSE the
* statement is atomic and single-use: an attempt that actually committed before failing
* to answer leaves no row, so the retry returns null and the player re-logs in exactly
* what a replayed token does. There is no interleaving in which retrying redeems one
* token twice, and none in which it lands worse than the 500 it replaces.
*
* Only the D1 call is retried; the hashing around it is pure.
*/
export async function consumeRefreshToken(
db: D1Database,
token: string
): Promise<number | null> {
export async function consumeRefreshToken(db: D1Database, token: string): Promise<number | null> {
const now = Math.floor(Date.now() / 1000)
const row = await db
const statement = db
.prepare(
`DELETE FROM refresh_tokens WHERE token_hash = ?1
RETURNING account_id AS accountId, expires_at AS expiresAt`
)
.bind(await hashToken(token))
.first<{ accountId: number; expiresAt: number }>()
let row: { accountId: number; expiresAt: number } | null = null
for (let attempt = 1; ; attempt++) {
try {
row = await statement.first<{ accountId: number; expiresAt: number }>()
break
} catch (err) {
// The last attempt rethrows: a D1 outage is a 500, not a silent "bad token" that
// would tell the player to log in again over something that isn't their fault.
if (attempt === MAX_ATTEMPTS) throw err
await new Promise((resolve) => setTimeout(resolve, attempt * attempt * 250))
}
}
if (!row || row.expiresAt < now) return null
return row.accountId
}
+180 -45
View File
@@ -21,13 +21,15 @@ import {
createReport,
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
} from '../../../../api/src/reports-db'
import { PlatformType } from '../../openapi'
import {
countAccountsForPlatformIdentity,
getLinksForAccount,
linkPlatformIdentity,
PLATFORM_BACKFILL_SQL,
PLATFORM_SCHEMA_DDL,
} from '../../platform-db'
import { REFRESH_SCHEMA_DDL } from '../../refresh-db'
import { consumeRefreshToken, issueRefreshToken, REFRESH_SCHEMA_DDL } from '../../refresh-db'
import type { Env } from '../../context'
@@ -87,8 +89,8 @@ beforeAll(async () => {
IsDorm: false,
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
})
// Report table (owned by the api worker) — a banned account is refused a token, and
// a ban is a report row with `banned` set.
// 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()
})
@@ -421,6 +423,52 @@ describe('auth worker routes', () => {
])
})
// A Discord link is an external identity, not a credential — `www`'s benefits claim
// writes one so a claimed Discord user can't claim again on a second account. It must
// stay invisible to BOTH picker routes, for two independent reasons:
//
// - Every entry the picker lists is promised to be redeemable by a `cached_login`
// grant, and that grant refuses platform 101 outright (verifyPlatformProof answers
// `unsupported`). Listing one offers the client an account it can never log into.
// - These routes are PUBLIC and unauthenticated, and a Discord snowflake is readable by
// anyone sharing a server with its owner. Answering here would turn the login picker
// into a lookup from "Discord user" to "their RecFlare account", for every player who
// ever claimed benefits.
//
// The bare-id route is checked too, and it is the easier one to miss: it matches on ANY
// platform, so it would resolve the snowflake even though naming 101 explicitly did not.
test('never lists a Discord link in either cached-login picker', async () => {
const discordId = '308994132968210433'
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 31399, username: 'DiscordClaimer', hasPlus: true }))
.run()
await linkPlatformIdentity(env.DB, 31399, PlatformType.Discord, discordId)
// Named explicitly…
const named = await exports.default.fetch(
`${ORIGIN}/cachedlogin/forplatformid/${PlatformType.Discord}/${discordId}`
)
expect(named.status).toBe(200)
expect(await named.json()).toEqual([])
// …and via the bare id, which matches across platforms.
const bare = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/any/${discordId}`)
expect(await bare.json()).toEqual([])
// …and through the bulk friends-resolution route, which takes bare ids only.
const bulk = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformids`, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ id: discordId }).toString(),
})
expect(await bulk.json()).toEqual([])
// The link is still really there — this is a filtered READ, not a failed write.
await expect(
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, discordId)
).resolves.toBe(1)
})
// The 20250424.01 build POSTs the picker lookup with a platform-attestation form body
// instead of GETting it. Nothing reads that body yet, so both methods must answer the
// same list — otherwise the newer client's login screen comes up empty.
@@ -577,6 +625,9 @@ describe('auth worker routes', () => {
expect(payload.role).not.toContain('junior')
// 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
// byte-for-byte what it was before `rn.plus` existed.
expect(payload['rn.plus']).toBeUndefined()
expect(payload.scope).toContain('rn.api')
})
@@ -615,6 +666,64 @@ describe('auth worker routes', () => {
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator']))
})
// Rec Room Plus rides on the token as `rn.plus`, stamped from `account.hasPlus` — which
// the website's Discord benefits claim sets. `econ` decides the CampusCard and the
// subscriber discount from this claim ALONE and never reads the account, so if this
// stops being stamped, Plus silently stops existing for everyone.
//
// It is a CLAIM, not a scope: `scope` is a fixed list the client parses, and this is
// ours. And it is not a role — the `developer` role does not confer Plus.
test('POST /connect/token stamps rn.plus for a hasPlus account', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 93,
username: 'PlusPlayer',
passwordHash: await hashPassword(LOGIN_PASSWORD),
hasPlus: true,
})
)
.run()
const payload = await tokenFor(`account_id=93&password=${LOGIN_PASSWORD}`)
expect(payload['rn.plus']).toBe(true)
expect(payload.scope).not.toContain('rn.plus')
// Plus is not an elevated role, and does not come with one.
expect(payload.role).not.toContain('developer')
})
// The flag is read at LOGIN, so signing in again is what activates it — the website's
// claim page and `runx admin grant-plus` both say so, and this is the mechanism behind it.
//
// This also pins that `hasPlus` stands ALONE: the flag is set here by raw SQL, exactly as
// `runx admin grant-plus` sets it, with no Discord app configured, no OAuth exchange and
// no `platform_account` link anywhere. An operator must be able to grant Plus outright.
test('rn.plus refreshes on the next login after hasPlus is set, with no Discord link', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId: 94,
username: 'LateClaimer',
passwordHash: await hashPassword(LOGIN_PASSWORD),
})
)
.run()
// Before claiming: no Plus.
expect((await tokenFor(`account_id=94&password=${LOGIN_PASSWORD}`))['rn.plus']).toBeUndefined()
// The website's claim writes the flag…
await env.DB.prepare(
"UPDATE account SET data = json_set(data, '$.hasPlus', json('true')) WHERE account_id = 94"
).run()
// …and the NEXT token carries it. The one already in the player's hands does not,
// which is exactly why they have to sign in again.
expect((await tokenFor(`account_id=94&password=${LOGIN_PASSWORD}`))['rn.plus']).toBe(true)
// Nothing linked a Discord identity to this account, and Plus does not care.
const links = await getLinksForAccount(env.DB, 94)
expect(links.filter((l) => l.platform === PlatformType.Discord)).toEqual([])
})
test('POST /connect/token stamps the junior role for an isJunior account', async () => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(
@@ -1083,6 +1192,44 @@ describe('auth worker routes', () => {
expect(reuse.json.error).toBe('invalid_grant')
})
/**
* A D1 that throws the storage-side `internal error` on its first `n` reads and then
* behaves. Only what `consumeRefreshToken` touches is wrapped prepare/bind/first.
*/
const flakyDb = (failures: number): D1Database => {
let remaining = failures
const wrap = (stmt: D1PreparedStatement): D1PreparedStatement =>
({
bind: (...values: unknown[]) => wrap(stmt.bind(...values)),
first: async <T>() => {
if (remaining > 0) {
remaining--
throw new Error('D1_ERROR: internal error; reference = testref0000')
}
return stmt.first<T>()
},
}) as unknown as D1PreparedStatement
return { prepare: (sql: string) => wrap(env.DB.prepare(sql)) } as unknown as D1Database
}
test('consumeRefreshToken rides out a transient D1 error rather than logging the player out', async () => {
const token = await issueRefreshToken(env.DB, 77)
// Two hiccups, then the real thing — the redemption still succeeds, so the player
// keeps their session instead of being bounced to the login screen by a 500.
expect(await consumeRefreshToken(flakyDb(2), token)).toBe(77)
// And it was genuinely consumed: the retry didn't leave the row behind.
expect(await consumeRefreshToken(env.DB, token)).toBeNull()
})
test('consumeRefreshToken rethrows once the retries are spent, never a silent null', async () => {
const token = await issueRefreshToken(env.DB, 77)
// A real D1 outage has to surface as a 500. Answering null would tell the player
// their token was bad and make them log in again over something that isn't theirs.
await expect(consumeRefreshToken(flakyDb(99), token)).rejects.toThrow('internal error')
// The token survived, so a later attempt still works.
expect(await consumeRefreshToken(env.DB, token)).toBe(77)
})
test('POST /connect/token 400s on an unknown refresh_token', async () => {
const res = await postToken('grant_type=refresh_token&refresh_token=NOPE-1')
expect(res.status).toBe(400)
@@ -1209,6 +1356,7 @@ describe('auth worker routes', () => {
expect([...documented].sort()).toEqual([
'GET /cachedlogin/forplatformid/{platform}/{id}',
'GET /eac/challenge',
'GET /oculus/nonce',
'GET /privileges/me/restrictions',
'GET /role/developer/{id}',
'GET /role/moderator/{id}',
@@ -1288,38 +1436,35 @@ describe('CORS', () => {
})
})
// A banned account is refused a token at all — the outer wall of a ban, since with no
// token every other worker is shut to it. The ban is a `report` row with `banned` set
// (the api worker owns that table); matchmaking enforces the same ban on tokens issued
// before it was handed down.
// A banned account is still issued a token: the game client needs one to reach the api
// worker's moderationBlockDetails, which is where the player is shown WHY they are
// blocked. The ban is a `report` row with `banned` set (the api worker owns that table)
// and is enforced by matchmaking, which refuses every matchmake for a banned player — so
// the token gets them to the block screen and no further.
describe('banned accounts', () => {
test('POST /connect/token refuses a password grant from a banned account', async () => {
test('POST /connect/token issues a token to a banned account', async () => {
await seedAccount(6101, 'BannedPlayer')
await banAccount(6101)
const res = await postToken(`account_id=6101&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
// The exact sentence www's shared auth-messages table keys on to put a real
// message in front of the player — changing it silently downgrades that to the
// generic "you could not be signed in".
expect(res.json.error_description).toBe('this account is banned')
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('6101')
})
test('POST /connect/token refuses a username login from a banned account', async () => {
test('POST /connect/token issues a token to a banned account logging in by username', async () => {
await seedAccount(6102, 'BannedByName')
await banAccount(6102)
const res = await postToken(
`grant_type=password&username=BannedByName&password=${LOGIN_PASSWORD}`
)
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('this account is banned')
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('6102')
})
// A client that was already signed in when the ban landed still holds a valid refresh
// token; redeeming it must not renew the session.
test('POST /connect/token refuses to refresh a banned accounts session', async () => {
// A client that was already signed in when the ban landed refreshes as normal — its
// next matchmake is what refuses it, and moderationBlockDetails says why.
test('POST /connect/token refreshes a banned accounts session', async () => {
await seedAccount(6103, 'BannedLater')
const login = await postToken(`account_id=6103&password=${LOGIN_PASSWORD}`)
expect(login.status).toBe(200)
@@ -1329,13 +1474,12 @@ describe('banned accounts', () => {
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
)
expect(refreshed.status).toBe(400)
expect(refreshed.json.error_description).toBe('this account is banned')
expect(refreshed.status).toBe(200)
expect(decodePayload(refreshed.json.access_token as string).sub).toBe('6103')
})
// The ban check runs AFTER the credential check, so a wrong password on a banned
// account still answers the ordinary bad-credential refusal — it can't be used to
// find out whether an account exists or is banned without knowing its password.
// A ban does not loosen the credential check: a wrong password on a banned account is
// the ordinary bad-credential refusal.
test('a wrong password on a banned account is still a credential refusal', async () => {
await seedAccount(6104, 'BannedWrongPw')
await banAccount(6104)
@@ -1345,23 +1489,13 @@ describe('banned accounts', () => {
expect(res.json.error_description).toBe('invalid account_id or password')
})
// A timed ban lifts itself when its expiry passes; nothing clears the flag.
test('an expired ban lets the account sign in again', async () => {
await seedAccount(6105, 'ServedTime')
await banAccount(6105, '2020-01-01T00:00:00.000Z')
const res = await postToken(`account_id=6105&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('6105')
})
test('a ban that has not expired yet still refuses the login', async () => {
test('a ban that has not expired yet still issues a token', async () => {
await seedAccount(6106, 'StillServing')
await banAccount(6106, new Date(Date.now() + 3_600_000).toISOString())
const res = await postToken(`account_id=6106&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('this account is banned')
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('6106')
})
// A report is not a ban until a moderator converts it.
@@ -1386,8 +1520,10 @@ describe('banned accounts', () => {
// The ban follows the player past the account it was written on: a login from an account
// that shares a proven platform identity or an IP with a banned one is refused, and a
// signup carrying either is refused before it mints anything. See the api worker's
// bans-db.ts for the arms and the BAN_EVASION_MATCH knob.
// signup carrying either is refused before it mints anything. Unlike the banned account
// itself, such an account has no ban of its own for the block screen to describe, so
// there is nothing to let it in for. See the api worker's bans-db.ts for the arms and
// the BAN_EVASION_MATCH knob.
describe('ban evasion at the token endpoint', () => {
/** Seed a loginable account carrying the IPs it signed up / last logged in from. */
const account = async (id: number, name: string, ips: Record<string, string> = {}) => {
@@ -1467,7 +1603,7 @@ describe('ban evasion at the token endpoint', () => {
})
// The knob an operator reaches for when the IP arm locks out real players.
test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the direct ban', async () => {
test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the platform one', async () => {
const original = env.BAN_EVASION_MATCH
await account(6320, 'KnobBanned', { signupIp: '203.0.113.50' })
await linkPlatformIdentity(env.DB, 6320, 0, 'steam-knobevader')
@@ -1485,10 +1621,9 @@ describe('ban evasion at the token endpoint', () => {
env.BAN_EVASION_MATCH = 'off'
expect((await login(6322)).status).toBe(200)
// The banned account itself is refused whatever the knob says.
const banned = await login(6320)
expect(banned.status).toBe(400)
expect(banned.json.error_description).toBe('this account is banned')
// The banned account itself signs in whatever the knob says — its ban is
// enforced at matchmake, and the knob only governs the linked arms.
expect((await login(6320)).status).toBe(200)
} finally {
env.BAN_EVASION_MATCH = original
}
+2 -2
View File
@@ -16,7 +16,7 @@
},
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"hono": "4.12.27",
"hono": "4.13.5",
"workers-tagged-logger": "1.0.1"
},
"devDependencies": {
@@ -25,6 +25,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+2 -2
View File
@@ -19,7 +19,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -31,6 +31,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+4 -4
View File
@@ -6,7 +6,7 @@
"RoomNames": [],
"Context": 0,
"InputType": 0,
"Visibility": 0,
"Visibility": 1,
"AllowCycling": true,
"RestrictToNewUsers": false,
"ImageName": "tip.jpg",
@@ -34,7 +34,7 @@
"RoomNames": [],
"Context": 0,
"InputType": 0,
"Visibility": 0,
"Visibility": 1,
"AllowCycling": true,
"RestrictToNewUsers": false,
"ImageName": "tip.jpg",
@@ -48,7 +48,7 @@
"RoomNames": [],
"Context": 0,
"InputType": 0,
"Visibility": 0,
"Visibility": 1,
"AllowCycling": true,
"RestrictToNewUsers": false,
"ImageName": "tip.jpg",
@@ -139,4 +139,4 @@
"PlatformMask": 239,
"CreatedAt": "2019-02-28T18:21:25Z"
}
]
]
+12
View File
@@ -0,0 +1,12 @@
-- Give a thread its kind. `chat_thread_type` is the client's ChatThreadType enum
-- (0 Player · 1 Club · 2 Party), which 0002 left off because every thread this server
-- served was a plain player conversation and src/thread-db.ts answered a constant 0.
-- Party threads (`POST /thread/party`) are type 2, so the value now varies per row and
-- has to be stored. Generated from src/thread-db.ts (THREAD_SCHEMA_DDL) — keep in sync.
--
-- Defaulted 0 (Player), which is what every existing row is: the column is backfilled by
-- the default, not by an UPDATE.
--
-- No index: it is read alongside the thread row that is already being fetched by primary
-- key or by the membership join, never selected on.
ALTER TABLE message_thread ADD COLUMN chat_thread_type INTEGER NOT NULL DEFAULT 0;
+3 -2
View File
@@ -16,11 +16,12 @@
"test": "run-vitest"
},
"dependencies": {
"@2toad/profanity": "3.3.0",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -32,6 +33,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+431 -36
View File
@@ -5,6 +5,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { censorSwears } from '../../api/src/sanitize'
import { NotificationType } from '../../notify/src/notification-types'
import { getThreadMessages } from './message-db'
import {
@@ -15,6 +16,7 @@ import {
ChatResult,
ChatThreadDto,
ChatThreadWithMessagesDto,
CreatePartyChatResponse,
CreateThreadRequest,
CreateThreadResponse,
FavoriteThreadRequest,
@@ -22,8 +24,8 @@ import {
json,
messageCountParam,
NOT_A_MEMBER_RESPONSE,
PartyChatThread,
PartyInviteSettings,
PartyThread,
RenameThreadRequest,
SendMessageRequest,
SendMessageResponse,
@@ -35,11 +37,16 @@ import {
} from './openapi'
import {
addThreadMember,
ChatThreadType,
createThread,
getOrCreateThreadWithMembers,
getThreadForPlayer,
getThreadMemberIds,
getPartyThreadForPlayer,
getThreadMeta,
getThreadsForPlayer,
isThreadMember,
joinedChatContents,
leftChatContents,
markThreadRead,
postMessage,
@@ -53,6 +60,7 @@ import {
import type { Context } from 'hono'
import type { App, Env } from './context'
import type { ChatMessage } from './message-db'
import type { ChatThread } from './thread-db'
/**
* Resolve the account id from a Bearer token. Returns `null` when the header is
@@ -106,6 +114,25 @@ const CHAT_PLAYER_ALREADY_ON_THREAD = 4
*/
const PARTY_INVITE_LIFETIME_MINUTES = 60
/**
* Whether a party is still open to newcomers `GET /thread/party` joins the caller only
* inside this window, measured from the thread's `created_at`.
*
* It is the invite lifetime above, deliberately the same number rather than a second one:
* a player joins a party by holding its id in `LatestPartyChat`, which is what an invite
* puts there, so the join is the redemption of that invite and can't outlive it. A party
* older than the window still belongs to the people already on it this gates JOINING,
* not reading, so nobody's own party expires out from under them.
*
* Fails CLOSED on a `created_at` that won't parse: no timestamp, no join. Nothing writes
* one that can't, and the alternative is an unbounded join window on a corrupt row.
*/
function isPartyJoinable(createdAt: string, now = Date.now()): boolean {
const opened = Date.parse(createdAt)
if (Number.isNaN(opened)) return false
return now - opened <= PARTY_INVITE_LIFETIME_MINUTES * 60_000
}
/**
* Who may start a chat with a player the client's `ChatPrivacy` enum, served numerically
* like every other enum on this build. `Friends` is what a fresh account reports, and what
@@ -133,6 +160,20 @@ const CHAT_PRIVACY_NAMES = ['Friends', 'Favorites', 'NoOne'] as const
const DM_PRIVACY_KEY = 'directMessagePrivacySetting'
const GROUP_PRIVACY_KEY = 'groupChatPrivacySetting'
/**
* Where a player's CURRENT party lives: the thread id of the party they most recently
* opened, written by `POST /thread/party` and read back by the GET on the same path.
*
* It is a player setting rather than a column because the party is a property of the
* PLAYER, not of the thread "which party am I in" has one answer per person, and a
* player is in exactly one at a time. The settings bag is already read and written per
* player here, the same way the two privacy settings are.
*
* Nothing clears it: a party the player has left, or one that no longer exists, is
* filtered out on the read instead, which also covers an id written by something else.
*/
const LATEST_PARTY_CHAT_KEY = 'LatestPartyChat'
/**
* A `ChatPrivacy` out of whatever was stored or posted the member name as the client
* sends it (case-insensitively), or the ordinal as the GET serves it, since a value that
@@ -199,16 +240,87 @@ async function writeChatPrivacy(
accountId: number,
settings: Partial<Record<typeof DM_PRIVACY_KEY | typeof GROUP_PRIVACY_KEY, ChatPrivacyValue>>
): Promise<void> {
const merged: Record<string, string> = { ...(await getPlayerSettings(env, accountId)) }
const patch: Record<string, string> = {}
for (const [key, value] of Object.entries(settings)) {
if (value !== undefined) merged[key] = CHAT_PRIVACY_NAMES[value]
if (value !== undefined) patch[key] = CHAT_PRIVACY_NAMES[value]
}
await mergePlayerSettings(env, accountId, patch)
}
/**
* 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.
*/
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))
}
/**
* The thread id in the player's `LatestPartyChat` setting, or null when they have no party
* nothing stored, or something stored that isn't a positive integer (the bag's values are
* strings, and this one could have been written by hand).
*/
async function readLatestPartyChatId(env: Env, accountId: number): Promise<number | null> {
const stored = (await getPlayerSettings(env, accountId)) ?? {}
const id = Number.parseInt(String(stored[LATEST_PARTY_CHAT_KEY] ?? ''), 10)
return Number.isNaN(id) || id <= 0 ? null : id
}
/** The hub is a single global Durable Object instance, as every worker addresses it. */
const HUB_INSTANCE = 'global'
/**
* Push a thread's most recent message to everyone on it. This is how a NEW thread
* announces itself.
*
* The client has exactly two chat channels, `ChatMessageReceived` and `PlayerLeftChat`,
* and both carry a MESSAGE: there is no "a thread was opened" or "you were added" frame to
* send. So a conversation someone gains access to stays invisible on their client until a
* message arrives on it which is why `/thread/withmembers` used to go unnoticed until the
* sender typed something, the thread having been created with only its "started a chat"
* notice and that notice never having left the database.
*
* Sending the notice fixes that without inventing anything: the message being pushed is one
* that genuinely exists on the thread. A no-op for a thread with nothing in it.
*/
async function pushThreadLatestMessage(c: Context<App>, chatThreadId: number): Promise<void> {
const [latest] = await getThreadMessages(c.env.DB, chatThreadId, { limit: 1 })
if (latest === undefined) return
await pushChatMessage(c, latest)
}
/**
* Announce that a player is now on a thread: post the `Player <@U…> joined` notice and push
* it to the whole thread.
*
* The exact shape of the leave route's goodbye, and for the same reasons. Everyone is told,
* not just the player who joined: a roster change is the thread's business the others
* need to know who they are talking to and there is no roster channel to say it on, so
* the notice is the message AND the signal. The new member is a member by the time this
* runs, so the same push is what puts the conversation on their screen.
*
* Call it AFTER the membership row exists, or the joiner is left out of the fan-out.
*/
async function announceJoin(
c: Context<App>,
chatThreadId: number,
playerId: number
): Promise<void> {
const notice = await postMessage(c.env.DB, {
chatThreadId,
senderPlayerId: SYSTEM_SENDER_ID,
contents: joinedChatContents(playerId),
})
await pushChatMessage(c, notice)
}
/**
* Push ChatMessageReceived to everyone in the thread once a message lands, so the
* conversation updates live instead of on the next poll.
@@ -238,15 +350,108 @@ async function pushChatMessage(c: Context<App>, message: ChatMessage): Promise<v
}
}
/**
* The message envelope with the player's own words masked, as it will be stored.
*
* The same filter `api`'s `POST /api/sanitize/v1` runs, applied again here because
* nothing obliges the client to have called it: a message posted straight to this
* endpoint would otherwise reach every member of the thread unfiltered.
*
* Only `Data` the text the player typed is censored. `Type`, `Version`, the `Blocks`
* array and whatever else the client packs alongside are copied through untouched: the
* rest of the envelope is the client's own business and this server doesn't know what
* most of it means. The
* mask is one character per character, so lengths (and therefore the envelope) survive
* intact, and a Version 2 `Data` keeps its `<=>` prefix the marker isn't a word, so
* whole-word matching never reaches it.
*
* Contents that aren't a JSON object, or whose `Data` isn't a string, are censored
* whole: a hand-written `messageContents=hi` is plain text with nothing in it to
* preserve. Text with nothing to object to comes back as the very bytes that were sent,
* which is the common case the envelope is only rebuilt when something was masked.
*
* Blocked characters are deliberately NOT stripped the way `PreRemoveBlockedCharacters`
* strips them: chat carries emoji, and the format characters that rule removes include
* the zero-width joiners holding a multi-person emoji together.
*/
function censorContents(contents: string): string {
let envelope: unknown
try {
envelope = JSON.parse(contents)
} catch {
return censorSwears(contents)
}
if (typeof envelope !== 'object' || envelope === null || Array.isArray(envelope)) {
return censorSwears(contents)
}
const fields = envelope as Record<string, unknown>
const data = fields.Data
if (typeof data !== 'string') return contents
const censored = censorSwears(data)
return censored === data ? contents : JSON.stringify({ ...fields, Data: censored })
}
/**
* A stored message in the PascalCase shape the client's SendMessage handler reads back, as
* distinct from the camelCase {@link ChatMessage} the thread payloads carry. Same six
* fields; the client reads them in two places under two spellings.
*
* `Contents` goes out exactly as stored, which is what makes the envelope discipline matter:
* the client parses it into `MessageJson` in a post-deserialize hook, and anything that
* isn't an escaped `{ Type, Version, Data }` with a non-null `Data` leaves that null. The
* hook only logs, so the client then throws on the null instead of showing the message.
*/
function toSentChatMessage(message: ChatMessage) {
return {
ChatMessageId: message.chatMessageId,
ChatThreadId: message.chatThreadId,
SenderPlayerId: message.senderPlayerId,
TimeSent: message.timeSent,
Contents: message.contents,
ModerationState: message.moderationState,
}
}
/**
* A thread in the PascalCase shape `POST /thread/party` answers the client's
* CreatePartyChat formatter, which is its own and reads none of the camelCase keys the
* thread payloads carry. Ten wire keys; see {@link PartyChatThread} for why the CLR
* type's other three never appear.
*
* `Messages` and `LatestMessage` are both present here, unlike the camelCase pair which
* carries one or the other, and `ChatThreadName` goes out NULL when unnamed rather than
* as the empty string the camelCase projections must send.
*/
function toPartyChatThread(thread: ChatThread, messages: ChatMessage[]) {
return {
ChatThreadId: thread.chatThreadId,
ChatThreadType: thread.chatThreadType,
LastReadMessageId: thread.lastReadMessageId,
Messages: messages.map(toSentChatMessage),
LatestMessage: thread.latestMessage === null ? null : toSentChatMessage(thread.latestMessage),
PlayerIds: thread.playerIds,
// This formatter takes the null; only the camelCase projections have to send ''.
ChatThreadName: thread.chatThreadName === '' ? null : thread.chatThreadName,
SnoozedUntil: thread.snoozedUntil,
IsFavorited: thread.isFavorited,
// No thread on this table carries a club — club chat lives in the `clubs` worker.
ClubId: null,
}
}
/**
* Send a message to a thread that already exists every message after the one that
* opened the conversation. `/thread/18` is what the client posts; `/thread/18/message` is
* the same call under the reference's other spelling, so both routes land here.
*
* Answers `{chatResult, chatThread}` the whole thread with its messages, not just the
* message that was sent, so the client re-renders the conversation from one response.
* Blank or missing contents stores nothing and reports invalid-arguments, still with the
* thread attached, rather than an error status.
* Answers the message just posted (`ChatMessage`/`ChatResult`, which is what the client's
* own send handler reads) AND the whole thread with its messages (`chatResult`/`chatThread`,
* which the conversation re-renders from). Blank or missing contents stores nothing and
* reports invalid-arguments, still with the thread attached, rather than an error status
* and with a NULL `ChatMessage`, which is safe because the client only dereferences it on
* result 0.
*/
async function sendToThread(c: Context<App>) {
const id = await authedId(c)
@@ -255,14 +460,19 @@ async function sendToThread(c: Context<App>) {
const chatThreadId = Number.parseInt(c.req.param('id') ?? '', 10)
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) return c.notFound()
// Stored exactly as sent: the envelope carries its own Type/Version and may hold
// fields we know nothing about (the client sends Version 2 with a `<=>` prefix in
// Data, and a `Blocks` array alongside it), so nothing here parses or rewrites it.
// Stored as sent but for the profanity mask: the envelope carries its own Type/Version
// and may hold fields we know nothing about (the client sends Version 2 with a `<=>`
// prefix in Data, and a `Blocks` array alongside it), so `censorContents` rewrites the
// player's `Data` and nothing else.
const contents = (await formField(c, 'messageContents'))?.trim()
const posted =
contents === undefined || contents === ''
? null
: await postMessage(c.env.DB, { chatThreadId, senderPlayerId: id, contents })
: await postMessage(c.env.DB, {
chatThreadId,
senderPlayerId: id,
contents: censorContents(contents),
})
if (posted !== null) {
await pushChatMessage(c, posted)
// Sending is reading: the reference answers with `lastReadMessageId` already at the
@@ -271,8 +481,16 @@ async function sendToThread(c: Context<App>) {
}
const thread = await threadWithMessages(c, chatThreadId, id, DEFAULT_THREAD_MESSAGE_COUNT)
const chatResult = posted === null ? CHAT_INVALID_ARGUMENTS : CHAT_SUCCESS
// Both spellings, because the client reads this response in two places. Its SendMessage
// handler dereferences `ChatMessage` the moment `ChatResult` is 0, so a success answered
// without one throws inside the client — the lowercase pair alone left that null. The
// thread stays for the conversation re-render. One value, serialized twice, so the two
// result keys can never disagree.
return c.json({
chatResult: posted === null ? CHAT_INVALID_ARGUMENTS : CHAT_SUCCESS,
ChatMessage: posted === null ? null : toSentChatMessage(posted),
ChatResult: chatResult,
chatResult,
chatThread: thread,
})
}
@@ -400,9 +618,17 @@ function sendToThreadRoute(spelling: string) {
tags: ['Messages'],
summary: `Send a message to an existing thread (${spelling})`,
description: [
'Every message after the one that opened the conversation. Answers',
'`{ chatResult, chatThread }` — the WHOLE thread with its messages, not just the message',
'that was sent, so the client re-renders the conversation from one response. Blank or',
'Every message after the one that opened the conversation. Answers FOUR keys in two',
'spellings: `ChatMessage`/`ChatResult`, which is what the clients own send handler reads',
'— it dereferences `ChatMessage` as soon as `ChatResult` is 0, so a success without one',
'throws inside the client — plus `chatResult`/`chatThread`, the WHOLE thread with its',
'messages, which the conversation re-renders from. The two result keys are one value',
'serialized twice. `ChatMessage.Contents` is the stored envelope verbatim, and it MUST',
'parse to `{ Type, Version, Data }` with a non-null `Data`: the client parses it into',
'`MessageJson` in a post-deserialize hook that only logs on failure, then dereferences the',
'null. The envelopes',
'`Data` goes through the same profanity filter `api`s `POST /api/sanitize/v1` runs, masked',
'one `*` per character; every other field is stored as sent. Blank or',
'missing `messageContents` stores nothing and reports invalid-arguments (1), still with',
'the thread attached, rather than an error status. Sending is reading: the senders own',
'`lastReadMessageId` comes back already at the message just posted. Pushes',
@@ -481,9 +707,11 @@ const app = new Hono<App>()
// players already share rather than opening a second one.
//
// `messageContents` is the same envelope a message carries
// (`{"Type":0,"Version":1,"Data":"…"}`) and is stored verbatim, unparsed. The client
// also sends it blank, right after /thread/withmembers: that opens the thread without
// posting an empty message, and reports invalid-arguments the way the reference does.
// (`{"Type":0,"Version":1,"Data":"…"}`), stored as sent but for the profanity mask
// `censorContents` puts over `Data` — the same filter the later messages go through,
// since the first one is no different. The client also sends it blank, right after
// /thread/withmembers: that opens the thread without posting an empty message, and
// reports invalid-arguments the way the reference does.
.post(
'/thread',
describeRoute({
@@ -517,13 +745,22 @@ const app = new Hono<App>()
const members = [...new Set([id, ...(await memberIds(c))])]
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
const { chatThreadId, created } = await getOrCreateThreadWithMembers(c.env.DB, members, id)
// A thread nobody has been told about is a thread nobody sees. Push its opening
// notice the moment it exists, rather than leaving the conversation to surface on
// whatever message happens to follow — there may not be one: this route is also
// called with an empty `messageContents`.
if (created) await pushThreadLatestMessage(c, chatThreadId)
const contents = (await formField(c, 'messageContents'))?.trim()
const posted =
contents === undefined || contents === ''
? null
: await postMessage(c.env.DB, { chatThreadId, senderPlayerId: id, contents })
: await postMessage(c.env.DB, {
chatThreadId,
senderPlayerId: id,
contents: censorContents(contents),
})
if (posted !== null) {
await pushChatMessage(c, posted)
await markThreadRead(c.env.DB, chatThreadId, id, posted.chatMessageId)
@@ -569,21 +806,74 @@ const app = new Hono<App>()
}
)
// The party thread (`/thread/party?maxCount=1&mode=0`). STUB: the response shape is
// unknown — it hasn't been observed off a live client — so this answers an empty
// object, which parses as "no party" rather than failing the client's deserializer the
// way a 404 or a bare array would. `maxCount` and `mode` are accepted and ignored.
// Replace the body once the real shape is captured.
// The caller's current party (`/thread/party?maxCount=1&mode=0`) — the client's
// GetPartyChat.
//
// TWO paths, cheapest first:
//
// 1. ALREADY IN A PARTY — one D1 query (`getPartyThreadForPlayer`: the membership join,
// filtered to party threads, newest first) answers it outright. This is the common
// case, every read after the first, and it touches the settings KV not at all.
// 2. NOT IN ONE YET — only then is the caller's `LatestPartyChat` player setting read.
// The POST below writes that key for the player who OPENED the party; the client
// writes it (through `playersettings`) for a player pulled into someone else's. Such
// a player holds the key but no membership row — nothing has added them — so a
// membership-only read answered them "no party", which is the bug the join fixes.
// The read puts them on the thread and then serves it: `GET /thread/party` is how
// you enter a party, not merely how you look at one.
//
// Reaching path 2 means the caller is in NO party, so they cannot already be a member of
// the thread the key names — which is why the join here needs no membership check of its
// own, and why the age gate below can be unconditional.
//
// On path 2 the thread is checked to exist, to be a party, and to be YOUNGER THAN THE
// INVITE LIFETIME before anyone is added to it, so a key naming a DM, a thread that is
// gone, or an hours-old party can't produce a membership row in it.
//
// The age check gates JOINING only — path 1 never reaches it, so a party keeps being
// served to the players already on it however old it is, rather than going dark on them
// after an hour. `LatestPartyChat` is the caller's own player setting, which the client
// can PUT to anything through the `playersettings` worker, so the key IS the invite here
// and the window is what keeps it from being a permanent one. Tighten further (an invite
// the party actually issued) if parties ever need to be closed outright.
//
// SAME PATH, DIFFERENT BODY from the POST: this one is the BARE thread, with no
// `{ ChatThread, ChatResult }` wrapper around it. Same ten-key PascalCase projection
// inside, so the two share `toPartyChatThread` — but nothing else, which is why these
// are two handlers rather than one verb-agnostic one.
//
// No party answers `{}`: the client parses that as a thread with everything at its
// default, which reads as "no party", where a 404 or a null body would fail its
// deserializer.
//
// `maxCount` and `mode` are accepted and ignored. `maxCount=1` is most likely the
// number of party chats wanted, which is already what a single-thread body serves; if
// it turns out to size `Messages` instead, ignoring it only ever serves MORE history
// than asked for, where guessing wrong the other way would truncate the party's
// messages to one. `mode` is unknown.
.get(
'/thread/party',
describeRoute({
tags: ['Threads'],
summary: 'The callers party thread (stub)',
summary: 'The callers current party thread (GetPartyChat)',
description: [
'STUB — the response shape has not been observed off a live client, so this answers an',
'empty object `{}`, which parses as "no party" rather than failing the clients',
'deserializer the way a 404 or a bare array would. `maxCount` and `mode` are accepted and',
'ignored. Replace the body once the real shape is captured.',
'The party the caller is currently in: the newest party thread they are a member of,',
'answered from a single query. Failing that, the thread named by their own',
'`LatestPartyChat` player setting — which `POST /thread/party` writes for the player',
'who opened the party and the client writes for a player who joins someone elses.',
'',
'That second path JOINS: a caller who is not on any party yet is ADDED to the thread',
'their key names and then served it, which is how a player pulled into someone elses',
'party enters it — they hold the key but no membership row, and a membership-only read',
'answers them "no party". The thread must exist, be a party, and be younger than the',
'60-minute invite lifetime before anyone is added, so a key naming a DM, a deleted',
'thread or a stale party answers `{}` and writes nothing. The age gate is on JOINING',
'only — a player already on a party is served it however old it is. A join posts a',
'"Player <@U…> joined" notice and pushes it to the party, so the people already in it',
'see who arrived.',
'',
'The BARE thread, unlike the POST on the same path, which wraps the same projection in',
'`{ ChatThread, ChatResult }`. `maxCount` and `mode` are accepted and ignored.',
].join(' '),
security: AUTHED,
parameters: [
@@ -591,26 +881,121 @@ const app = new Hono<App>()
name: 'maxCount',
in: 'query',
required: false,
description: 'Page size the client sends (1). Ignored by the stub',
description: 'Page size the client sends (1). Accepted and ignored',
schema: { type: 'integer' },
},
{
name: 'mode',
in: 'query',
required: false,
description: 'Unknown mode selector the client sends (0). Ignored by the stub',
description: 'Unknown mode selector the client sends (0). Accepted and ignored',
schema: { type: 'integer' },
},
],
responses: {
200: json(PartyThread, 'Always `{}` — the stub carries no party'),
200: json(PartyChatThread, 'The callers party, or `{}` when they have none'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
return c.json({})
// Path 1: already in a party. One query, no settings read.
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({})
// 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({})
await addThreadMember(c.env.DB, chatThreadId, id)
// The same announcement the add-member route makes: the party learns someone
// walked in. It is also usually a party's FIRST message — one opens empty.
await announceJoin(c, chatThreadId, id)
thread = await getThreadForPlayer(c.env.DB, chatThreadId, id)
if (thread === null) throw new Error(`party thread ${chatThreadId} vanished after join`)
}
const messages = await getThreadMessages(c.env.DB, thread.chatThreadId, {
limit: DEFAULT_THREAD_MESSAGE_COUNT,
})
return c.json(toPartyChatThread(thread, messages))
}
)
// Open a party — the client's CreatePartyChat. A thread of type 2
// (`ChatThreadType.Party`) holding only the caller, which the client then fills by
// inviting people onto it (`POST /thread/{id}/member/{playerId}`). The one place a
// thread is opened with a single member: every other create refuses a roster of just
// yourself, because a DM with nobody in it is a mistake, whereas a party you are so far
// the only member of is exactly what starting one looks like.
//
// Takes NO query params and NO body — the client posts to the bare path.
//
// Always a NEW party, never a fetch-or-create: a party is a session, not a standing
// conversation with a set of people, so resolving to the one you left this morning
// would hand the invitees its history.
//
// It opens EMPTY — no system "started a chat" notice, unlike every other new thread
// here. The observed response carries `Messages: []` with a null `LatestMessage`, so a
// notice would be a message the reference doesn't post.
//
// The body is the PascalCase `{ ChatThread, ChatResult }` wrapper, bare — no
// `{ success, error, value }` envelope — and the thread inside it is its own
// projection: ten keys, both `Messages` and `LatestMessage`, a null `ChatThreadName`,
// and a `ClubId` that exists nowhere else. See `toPartyChatThread`.
.post(
'/thread/party',
describeRoute({
tags: ['Threads'],
summary: 'Open a party thread for the caller (CreatePartyChat)',
description: [
'The clients CreatePartyChat. Opens a thread of type 2 (Party) whose only member is',
'the caller — the client fills it by inviting players on afterwards. No query params',
'and no body. Always a new party, never a fetch-or-create: a party is a session rather',
'than a standing conversation, so an old one would hand the invitees its history. The',
'only create that accepts a roster of just the caller, and the only one that opens with',
'no messages at all — no “started a chat” notice, matching the observed',
'`Messages: []`. Records the new thread as the callers `LatestPartyChat` player',
'setting, which is where `GET /thread/party` looks for it. Answers the bare PascalCase',
'`{ ChatThread, ChatResult }` wrapper, whose thread is a projection of its own — not the',
'camelCase shape the other thread routes serve.',
].join(' '),
security: AUTHED,
responses: {
200: json(CreatePartyChatResponse, 'The new party thread, empty, with ChatResult 0'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.body(null, 401)
const chatThreadId = await createThread(c.env.DB, [id], null, undefined, ChatThreadType.Party)
// This is what makes the party findable: the GET resolves the caller's current
// party through this key and nothing else. Written before the response, so a
// client that opens the party and immediately re-reads it can't miss it.
await mergePlayerSettings(c.env, id, { [LATEST_PARTY_CHAT_KEY]: String(chatThreadId) })
const thread = await getThreadForPlayer(c.env.DB, chatThreadId, id)
if (thread === null) throw new Error(`party thread ${chatThreadId} vanished after creation`)
// Read the messages back rather than assuming []: the party is empty as it is
// created, but the projection shouldn't be the thing that says so.
const messages = await getThreadMessages(c.env.DB, chatThreadId, {
limit: DEFAULT_THREAD_MESSAGE_COUNT,
})
return c.json({
ChatThread: toPartyChatThread(thread, messages),
ChatResult: CHAT_SUCCESS,
})
}
)
@@ -793,7 +1178,11 @@ const app = new Hono<App>()
// rather than a lonely thread.
if (members.length < 2 || members.length > MAX_THREAD_MEMBERS) return c.body(null, 400)
const chatThreadId = await getOrCreateThreadWithMembers(c.env.DB, members, id)
const { chatThreadId, created } = await getOrCreateThreadWithMembers(c.env.DB, members, id)
// The reported bug: this opened the thread silently, so the other player saw
// nothing until the first message landed. The opening notice is what tells them.
if (created) await pushThreadLatestMessage(c, chatThreadId)
const limit = await formMessageCount(c, DEFAULT_THREAD_MESSAGE_COUNT)
const thread = await threadWithMessages(c, chatThreadId, id, limit)
if (thread === null) throw new Error(`thread ${chatThreadId} vanished after creation`)
@@ -1008,7 +1397,10 @@ const app = new Hono<App>()
'youre part of. Answers a bare ChatResult rather than an HTTP status, as the reference',
'does: 3 when the caller isnt a member (which doubles as "no such thread", keeping a',
'threads existence private), 4 when the target is already on it, 0 on success.',
'Idempotent — re-adding an existing member changes nothing.',
'Idempotent — re-adding an existing member changes nothing. On success a',
'"Player <@U…> joined" system notice is posted and pushed to the whole thread: the',
'existing members because the roster changed, the new one because that push is what',
'puts the conversation on their screen.',
].join(' '),
{
parameters: [
@@ -1038,6 +1430,9 @@ const app = new Hono<App>()
}
await addThreadMember(c.env.DB, chatThreadId, playerId)
// Everyone hears about it — the existing members because the roster changed under
// them, the new one because this is what puts the conversation on their screen.
await announceJoin(c, chatThreadId, playerId)
return c.json(CHAT_SUCCESS)
}
)
+3 -2
View File
@@ -7,8 +7,9 @@
* `contents` is the client's envelope, e.g.
* `{"Type":0,"Version":1,"Data":"This is jordanparki7 from your Oculus friends."}`,
* where `Type` selects how the client renders `Data` (plain text, an invite, an image
* ) and `Version` versions that encoding. It is stored verbatim and served back
* untouched, so new message types need no schema change here.
* ) and `Version` versions that encoding. It is served back exactly as stored, and the
* writer (`chat.app.ts`) rewrites nothing in it but the profanity mask over `Data`, so
* new message types need no schema change here.
*
* `chatMessageId` is server-assigned and unique across all threads (AUTOINCREMENT), the
* way the client expects to be able to reference a message by id alone.
+108 -14
View File
@@ -62,8 +62,9 @@ export const NOT_A_MEMBER_RESPONSE = {
/**
* A chat message as stored and served (see message-db.ts). `contents` is the client's own
* envelope (`{"Type":0,"Version":1,"Data":"hello"}`) stored verbatim and served back
* untouched, so new message types need no schema change. A `senderPlayerId` of -5 is the
* envelope (`{"Type":0,"Version":1,"Data":"hello"}`) served back exactly as it was
* stored, and stored as it was sent but for the profanity mask over `Data`, so new
* message types need no schema change. A `senderPlayerId` of -5 is the
* system pseudo-player the "started a chat" / "left" notices are posted as.
*/
export const ChatMessageDto = z.object({
@@ -85,7 +86,9 @@ const threadBase = {
chatThreadName: z
.string()
.describe('Empty for DMs and unnamed groups — never null (the client dereferences it)'),
chatThreadType: z.int().describe('Always 0 — the only type the reference serves'),
chatThreadType: z
.int()
.describe('The ChatThreadType enum, numeric: 0 Player (DMs and groups) · 1 Club · 2 Party'),
snoozedUntil: z.string().nullable().describe('An instant, or null when not snoozed'),
isFavorited: z.boolean(),
}
@@ -145,12 +148,57 @@ export const CreateThreadResponse = z.object({
})
/**
* `POST /thread/:id` and `/thread/:id/message` the whole thread with its messages, not
* just the message that was sent, so the client re-renders the conversation from one
* response.
* The message the SEND answers with, in the PascalCase spelling the client's SendMessage
* handler reads. Six keys the CLR type has thirteen fields, but the rest are filled in
* after deserialization; notably `MessageJson` is NOT a wire key, it is parsed out of
* `Contents` by a post-deserialize hook.
*
* That hook is why `Contents` must be an escaped JSON envelope with a non-null `Data`
* (`"{\"Type\":0,\"Version\":1,\"Data\":\"hello\"}"`): plain text, an empty string, a
* nested object instead of a string, or `Data: null` all leave `MessageJson` null, the hook
* only LOGS the failure, and the handler then dereferences it a null-reference exception
* with no other symptom.
*
* Deliberately not {@link ChatMessageDto}, which is the camelCase shape the thread payloads
* carry. Same six fields, two spellings, because the client reads them in two places.
*/
export const SentChatMessage = z.object({
ChatMessageId: z.int(),
ChatThreadId: z.int(),
SenderPlayerId: z.int(),
TimeSent: z.string().describe('ISO-8601 UTC instant'),
Contents: z
.string()
.describe(
'The escaped envelope — must parse to `{ Type, Version, Data }` with a non-null Data'
),
ModerationState: z
.int()
.describe('0 Active · 11 Junior_Pending · 100/101/102 Moderation_* · 255 MarkedForDelete'),
})
/**
* `POST /thread/:id` and `/thread/:id/message`.
*
* FOUR keys, in two spellings, because the client reads this response two ways and neither
* can be dropped:
*
* - `ChatMessage` / `ChatResult` are what the SendMessage handler itself reads. On
* `ChatResult == 0` it dereferences `ChatMessage` immediately, so a success answered
* without one is a null-reference exception in the client. That is what a response of
* only the lowercase pair caused.
* - `chatResult` / `chatThread` carry the WHOLE thread with its messages, which is what the
* conversation re-renders from.
*
* The two result keys are the same number by construction they are one value serialized
* twice so a case-insensitive decoder reading either lands on the same answer.
*/
export const SendMessageResponse = z.object({
chatResult: ChatResult,
ChatMessage: SentChatMessage.nullable().describe(
'The message just posted; null only when nothing was posted (ChatResult ≠ 0)'
),
ChatResult: ChatResult,
chatResult: ChatResult.describe('The same value as `ChatResult` — see above'),
chatThread: ChatThreadWithMessagesDto.nullable(),
})
@@ -187,11 +235,56 @@ export const ChatPrivacySettings = z.object({
})
/**
* `GET /thread/party` STUB. The real shape hasn't been observed off a live client, so
* the route answers an empty object and this schema says so rather than guessing at
* fields. Fill both in together once the real response is captured.
* A thread in the PascalCase spelling the two `/thread/party` routes serve the client's
* CreatePartyChat and GetPartyChat the THIRD projection of a thread in this worker, and deliberately not
* unified with the two camelCase ones ({@link ChatThreadDto}, {@link
* ChatThreadWithMessagesDto}): the client has a separate formatter for this response, and
* a camelCase body decodes to a thread with every field at its default.
*
* Ten wire keys, off the client's own formatter. Its CLR type declares thirteen fields:
* two are `[IgnoreDataMember]` and one is a plain field rather than an auto-property, so
* none of the three ever serialises don't add them back.
*
* Differences from the camelCase DTOs beyond the casing:
* - `Messages` and `LatestMessage` are BOTH present, where the camelCase pair carries one
* or the other. A party opens empty, so they come back `[]` and null.
* - `ChatThreadName` is NULL for an unnamed thread, not the empty string the camelCase
* projections have to send (the client dereferences that one unchecked; this formatter
* takes the null).
* - `ClubId` exists only here null for a party, and for everything this worker serves:
* club chat lives in the `clubs` worker and nothing on this table carries a club.
*
* `GET /thread/party` serves this BARE; the POST wraps it in {@link
* CreatePartyChatResponse}. The GET also answers `{}` for a caller with no party, which
* decodes to a thread with every field at its default the client reads that as no party,
* where a 404 or a null body would fail its deserializer.
*/
export const PartyThread = z.object({}).describe('Stub — always empty; the real shape is unknown')
export const PartyChatThread = z.object({
ChatThreadId: z.int(),
ChatThreadType: z.int().describe('The ChatThreadType enum: 0 Player · 1 Club · 2 Party'),
LastReadMessageId: z.int().describe('0 for a party that was just opened'),
Messages: z
.array(SentChatMessage)
.describe('Empty for a party just opened — nothing is posted into it'),
LatestMessage: SentChatMessage.nullable().describe('Null while the thread has no messages'),
PlayerIds: z.array(z.int()).describe('Just the caller, until players are invited on'),
ChatThreadName: z.string().nullable().describe('NULL when unnamed — not the empty string'),
SnoozedUntil: z.string().nullable().describe('An instant, or null when not snoozed'),
IsFavorited: z.boolean(),
ClubId: z.int().nullable().describe('Always null here — this worker serves no club threads'),
})
/**
* `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.
*
* `ChatResult` is the same twenty-member enum {@link ChatResult} records, served
* numerically; the create either works or fails the request, so it is always 0 here.
*/
export const CreatePartyChatResponse = z.object({
ChatThread: PartyChatThread,
ChatResult: ChatResult,
})
/** `GET /` — the liveness probe. */
export const ServiceStatus = z.object({
@@ -214,8 +307,8 @@ export const CreateThreadRequest = z.object({
.optional()
.describe(
[
'The client envelope, stored verbatim and unparsed. Blank/absent opens the thread',
'without posting a message and reports chatResult 1',
'The client envelope, stored as sent but for the profanity mask over its `Data`.',
'Blank/absent opens the thread without posting a message and reports chatResult 1',
].join(' ')
),
})
@@ -238,7 +331,8 @@ export const SendMessageRequest = z.object({
.string()
.describe(
[
'The client envelope (Type/Version/Data), stored verbatim. Blank or missing stores',
'The client envelope (Type/Version/Data). Stored as sent except for `Data`, which',
'comes back with any profanity masked one `*` per character. Blank or missing stores',
'nothing and reports chatResult 1, still with the thread attached',
].join(' ')
),
+698 -6
View File
@@ -12,7 +12,10 @@ import {
SCHEMA_DDL,
} from '../../message-db'
import {
addThreadMember,
ChatThreadType,
createThread,
joinedChatContents,
findThreadWithMembers,
getThreadForPlayer,
getThreadsForPlayer,
@@ -394,8 +397,107 @@ describe('GET /settings/partyinvite', () => {
})
})
// The GET serves the caller's CURRENT party — the thread named by their `LatestPartyChat`
// player setting — as the BARE thread, where the POST wraps the same projection.
describe('GET /thread/party', () => {
it('answers an empty object', async () => {
async function settings(playerId: number): Promise<Record<string, string>> {
return (
(await env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
`player:${playerId}`,
'json'
)) ?? {}
)
}
it('answers the party named by LatestPartyChat, bare — no ChatResult wrapper', async () => {
const caller = 883201
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`, {
headers: await bearer(caller),
})
expect(res.status).toBe(200)
// The whole body is the thread: no `ChatThread`/`ChatResult` keys around it.
expect(await res.json()).toEqual({
ChatThreadId: ChatThread.ChatThreadId,
ChatThreadType: ChatThreadType.Party,
LastReadMessageId: 0,
Messages: [],
LatestMessage: null,
PlayerIds: [caller],
ChatThreadName: null,
SnoozedUntil: null,
IsFavorited: false,
ClubId: null,
})
})
it('POST records the thread id in the callers LatestPartyChat setting', async () => {
const caller = 883202
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${caller}`,
JSON.stringify({ OobeState: 'Complete' })
)
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
// Merged, not overwritten: the bag holds every other setting the player has.
expect(await settings(caller)).toEqual({
OobeState: 'Complete',
LatestPartyChat: String(ChatThread.ChatThreadId),
})
})
it('follows the setting to the newest party after a second one is opened', async () => {
const caller = 883203
await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller) })
const second = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await second.json()) as { ChatThread: { ChatThreadId: number } }
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
const body = (await res.json()) as { ChatThreadId: number }
expect(body.ChatThreadId).toBe(ChatThread.ChatThreadId)
})
it('carries the partys members and messages once it has them', async () => {
const caller = 883204
const guest = 883205
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await addThreadMember(env.DB, ChatThread.ChatThreadId, guest)
const posted = await postMessage(env.DB, {
chatThreadId: ChatThread.ChatThreadId,
senderPlayerId: caller,
contents: '{"Type":0,"Version":1,"Data":"regroup at the bridge"}',
})
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
const body = (await res.json()) as {
PlayerIds: number[]
Messages: Array<{ ChatMessageId: number; Contents: string }>
LatestMessage: { ChatMessageId: number } | null
}
expect(body.PlayerIds).toEqual([caller, guest])
// PascalCase messages here too — the camelCase spelling is the other projections'.
expect(body.Messages).toHaveLength(1)
expect(body.Messages[0]?.ChatMessageId).toBe(posted.chatMessageId)
expect(body.LatestMessage?.ChatMessageId).toBe(posted.chatMessageId)
})
it('answers {} for a player who has never opened one', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`, {
headers: await bearer(883001),
})
@@ -403,12 +505,339 @@ describe('GET /thread/party', () => {
expect(await res.json()).toEqual({})
})
it('JOINS a caller who holds the key but isnt on the thread yet', async () => {
// How a player enters someone else's party: the client points their
// LatestPartyChat at it, and they have no membership row until this read.
const host = 883210
const guest = 883211
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${guest}`,
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
)
expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, guest)).toBe(false)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) })
const body = (await res.json()) as { ChatThreadId: number; PlayerIds: number[] }
// They're on the thread now, and the body they get back says so.
expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, guest)).toBe(true)
expect(body.ChatThreadId).toBe(ChatThread.ChatThreadId)
expect(body.PlayerIds).toEqual([host, guest])
// The host sees them too — one thread, one roster.
expect((await getThreadForPlayer(env.DB, ChatThread.ChatThreadId, host))?.playerIds).toEqual([
host,
guest,
])
})
it('re-joining is a no-op — the roster doesnt grow on every read', async () => {
const caller = 883212
await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller) })
for (let i = 0; i < 3; i++) {
await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
}
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(((await res.json()) as { PlayerIds: number[] }).PlayerIds).toEqual([caller])
})
it('re-joins a caller who left, while their key still names the party', async () => {
// Leaving doesn't clear the key, so the next read walks them back in. Ending a
// party is what drops the key (`match`'s POST /player/logout).
const caller = 883206
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await removeThreadMember(env.DB, ChatThread.ChatThreadId, caller)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe(
ChatThread.ChatThreadId
)
expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, caller)).toBe(true)
})
it('serves the party from D1 alone, with no LatestPartyChat key at all', async () => {
// The fast path: a player already on a party is answered from the membership join,
// so the settings KV is never read. Proven by deleting the key the POST wrote.
const caller = 883230
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await env.RECFLARE_PLAYER_SETTINGS.delete(`player:${caller}`)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe(
ChatThread.ChatThreadId
)
})
it('serves the NEWEST party when the caller is a member of several', async () => {
// Membership outlives a party — nothing removes the row when one ends — so the
// party you are in is the most recent one you are on.
const caller = 883231
await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST', headers: await bearer(caller) })
const second = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await second.json()) as { ChatThread: { ChatThreadId: number } }
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe(
ChatThread.ChatThreadId
)
})
it('prefers the party the caller is ON over one their key merely names', async () => {
// The consequence of checking D1 first: while a membership row survives, a key
// pointing somewhere else is not consulted. Switching parties means leaving the old
// one (DELETE /thread/{id}/leave), not just repointing the key.
const caller = 883232
const host = 883233
const mine = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const other = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const own = (await mine.json()) as { ChatThread: { ChatThreadId: number } }
const theirs = (await other.json()) as { ChatThread: { ChatThreadId: number } }
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${caller}`,
JSON.stringify({ LatestPartyChat: String(theirs.ChatThread.ChatThreadId) })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe(
own.ChatThread.ChatThreadId
)
expect(await isThreadMember(env.DB, theirs.ChatThread.ChatThreadId, caller)).toBe(false)
})
/** Age a party by rewriting its `created_at` — what the join window is measured from. */
async function openedMinutesAgo(chatThreadId: number, minutes: number): Promise<void> {
await env.DB.prepare('UPDATE message_thread SET created_at = ?2 WHERE chat_thread_id = ?1')
.bind(chatThreadId, new Date(Date.now() - minutes * 60_000).toISOString())
.run()
}
it('refuses to join a party older than the 60-minute invite lifetime', async () => {
const host = 883220
const latecomer = 883221
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await openedMinutesAgo(ChatThread.ChatThreadId, 61)
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${latecomer}`,
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(latecomer) })
expect(await res.json()).toEqual({})
// Refused, not quietly joined: no membership row was written.
expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, latecomer)).toBe(false)
})
it('still joins a party inside the window', async () => {
const host = 883222
const guest = 883223
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await openedMinutesAgo(ChatThread.ChatThreadId, 59)
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${guest}`,
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) })
expect(((await res.json()) as { PlayerIds: number[] }).PlayerIds).toEqual([host, guest])
})
it('keeps serving an aged party to the players already on it', async () => {
// The window gates JOINING only — a party doesn't go dark on its own members.
const host = 883224
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await openedMinutesAgo(ChatThread.ChatThreadId, 240)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(host) })
expect(((await res.json()) as { ChatThreadId: number }).ChatThreadId).toBe(
ChatThread.ChatThreadId
)
})
it('refuses to join a party whose created_at wont parse', async () => {
// Fails closed: no timestamp, no join.
const host = 883225
const stranger = 883226
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await env.DB.prepare('UPDATE message_thread SET created_at = ?2 WHERE chat_thread_id = ?1')
.bind(ChatThread.ChatThreadId, 'not-a-timestamp')
.run()
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${stranger}`,
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(stranger) })
expect(await res.json()).toEqual({})
expect(await isThreadMember(env.DB, ChatThread.ChatThreadId, stranger)).toBe(false)
})
it('answers {} when the setting names a thread that isnt a party, joining nobody', async () => {
// The type check runs BEFORE the join, so a key pointed at someone else's DM can't
// put the caller in it.
const caller = 883207
const dm = await createThread(env.DB, [883208, 883213])
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${caller}`,
JSON.stringify({ LatestPartyChat: String(dm) })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(await res.json()).toEqual({})
expect(await isThreadMember(env.DB, dm, caller)).toBe(false)
})
it('answers {} when the setting names a thread that doesnt exist', async () => {
const caller = 883214
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${caller}`,
JSON.stringify({ LatestPartyChat: '99999' })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(await res.json()).toEqual({})
expect(await isThreadMember(env.DB, 99999, caller)).toBe(false)
})
it('answers {} for an unparseable stored id', async () => {
const caller = 883209
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${caller}`,
JSON.stringify({ LatestPartyChat: 'not-an-id' })
)
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(caller) })
expect(await res.json()).toEqual({})
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/party?maxCount=1&mode=0`)
expect(res.status).toBe(401)
})
})
// The POST on the same path is not a stub: it opens a real thread, of type Party, with
// only the caller on it — and answers the client's own PascalCase CreatePartyChat shape,
// which is neither of the two camelCase thread projections.
describe('POST /thread/party', () => {
it('answers the PascalCase { ChatThread, ChatResult } wrapper for an empty party', async () => {
const caller = 883101
const res = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
expect(res.status).toBe(200)
const body = (await res.json()) as { ChatThread: { ChatThreadId: number }; ChatResult: number }
// Every wire key, exactly — a party opens empty, unnamed and with no club, and the
// client reads none of the camelCase spellings the other thread routes serve.
expect(body).toEqual({
ChatThread: {
ChatThreadId: body.ChatThread.ChatThreadId,
ChatThreadType: ChatThreadType.Party,
LastReadMessageId: 0,
Messages: [],
LatestMessage: null,
PlayerIds: [caller],
ChatThreadName: null,
SnoozedUntil: null,
IsFavorited: false,
ClubId: null,
},
ChatResult: 0,
})
// And it's a real thread: it reads back through the normal thread routes.
const stored = await getThreadForPlayer(env.DB, body.ChatThread.ChatThreadId, caller)
expect(stored?.chatThreadType).toBe(ChatThreadType.Party)
expect(stored?.playerIds).toEqual([caller])
})
it('posts no “started a chat” notice into the party', async () => {
const caller = 883105
const res = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await res.json()) as { ChatThread: { ChatThreadId: number } }
expect(await getThreadMessages(env.DB, ChatThread.ChatThreadId)).toEqual([])
})
it('opens a NEW party every call rather than resolving to the last one', async () => {
const caller = 883102
const first = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const second = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const a = (await first.json()) as { ChatThread: { ChatThreadId: number } }
const b = (await second.json()) as { ChatThread: { ChatThreadId: number } }
expect(b.ChatThread.ChatThreadId).not.toBe(a.ChatThread.ChatThreadId)
})
it('keeps party threads out of the DM fetch-or-create', async () => {
// A party the caller invited someone onto has the same roster as their DM would.
const caller = 883103
const other = 883104
const res = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(caller),
})
const { ChatThread } = (await res.json()) as { ChatThread: { ChatThreadId: number } }
await addThreadMember(env.DB, ChatThread.ChatThreadId, other)
const dm = await SELF.fetch(`${ORIGIN}/thread/withmembers`, {
method: 'POST',
headers: { ...(await bearer(caller)), 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ ids: String(other) }),
})
const opened = (await dm.json()) as { chatThreadId: number; chatThreadType: number }
expect(opened.chatThreadId).not.toBe(ChatThread.ChatThreadId)
expect(opened.chatThreadType).toBe(ChatThreadType.Player)
})
it('401s without a token', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/party`, { method: 'POST' })
expect(res.status).toBe(401)
})
})
describe('GET /thread/chatPrivacySetting', () => {
it('reports Friends for both settings by default, keyed to the caller', async () => {
const res = await SELF.fetch(`${ORIGIN}/thread/chatPrivacySetting`, {
@@ -793,6 +1222,25 @@ describe('POST /thread', () => {
expect(await getThreadMessages(env.DB, body.chatThread.chatThreadId)).toHaveLength(2)
})
// The first message goes through the same profanity filter every later one does.
it('masks profanity in the first message', async () => {
const caller = 884010
const contents = '{"Type":0,"Version":1,"Data":"fuck this"}'
const res = await createViaPost(
caller,
`ids=884011&messageContents=${encodeURIComponent(contents)}`
)
const body = (await res.json()) as {
chatThread: { chatThreadId: number; latestMessage: { contents: string } | null }
chatResult: number
}
expect(body.chatResult).toBe(0)
expect(body.chatThread.latestMessage?.contents).toBe(
'{"Type":0,"Version":1,"Data":"**** this"}'
)
})
// Sending to people you already have a thread with appends to it, rather than
// stranding the message in a second conversation.
it('appends to the existing thread with the same members', async () => {
@@ -972,11 +1420,28 @@ describe('ChatMessageReceived push', () => {
}
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe(
true
)
expect(sent[0]!.data).toEqual({
// TWO waves over one channel, because that channel is all the client has: the
// thread's opening notice, which is what makes the new conversation appear at all,
// and then the message itself. Each goes to all three members.
const notice = sent.filter((n) => (n.data as { senderPlayerId: number }).senderPlayerId === -5)
const message = sent.filter(
(n) => (n.data as { senderPlayerId: number }).senderPlayerId === caller
)
for (const wave of [notice, message]) {
expect(wave.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886002, 886003])
}
expect(notice[0]!.data).toEqual({
chatMessageId: expect.any(Number),
chatThreadId: chatThread.chatThreadId,
senderPlayerId: SYSTEM_SENDER_ID,
timeSent: expect.any(String),
contents: startedChatContents(caller),
moderationState: 0,
})
expect(message[0]!.data).toEqual({
chatMessageId: chatThread.latestMessage.chatMessageId,
chatThreadId: chatThread.chatThreadId,
senderPlayerId: caller,
@@ -986,8 +1451,144 @@ describe('ChatMessageReceived push', () => {
})
})
it('pushes nothing when there is no message to push', async () => {
await send(886004, 'ids=886005&messageContents=')
it('pushes the opening notice even when no message is sent', async () => {
// The thread is real whether or not anything was said in it, and a thread nobody
// was told about is one nobody sees — the client has no "thread opened" channel.
const caller = 886004
await send(caller, 'ids=886005&messageContents=')
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, 886005])
expect((sent[0]!.data as { senderPlayerId: number }).senderPlayerId).toBe(SYSTEM_SENDER_ID)
expect((sent[0]!.data as { contents: string }).contents).toBe(startedChatContents(caller))
})
it('pushes nothing when the thread already existed and nothing was said', async () => {
// Second call on the same pair: no new thread, no message — nothing to announce.
await send(886006, 'ids=886007&messageContents=')
await hub.getByName('global').takeSent()
await send(886006, 'ids=886007&messageContents=')
expect(await hub.getByName('global').takeSent()).toEqual([])
})
async function withMembers(caller: number, body: string): Promise<Response> {
return SELF.fetch(`${ORIGIN}/thread/withmembers`, {
method: 'POST',
headers: {
...(await bearer(caller)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
}
it('POST /thread/withmembers announces the thread it opens', async () => {
// The reported bug: this opened the conversation silently, so the other player saw
// nothing until the first message arrived.
const caller = 886010
const other = 886011
const res = await withMembers(caller, `ids=${other}`)
const { chatThreadId } = (await res.json()) as { chatThreadId: number }
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, other])
expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe(
true
)
expect(sent[0]!.data).toEqual({
chatMessageId: expect.any(Number),
chatThreadId,
senderPlayerId: SYSTEM_SENDER_ID,
timeSent: expect.any(String),
contents: startedChatContents(caller),
moderationState: 0,
})
})
it('POST /thread/withmembers pushes nothing when it resolves to an existing thread', async () => {
// Fetch-or-create: the second call opens nothing, so there is nothing to announce
// — re-announcing would ping both players every time the screen is opened.
const caller = 886012
await withMembers(caller, 'ids=886013')
await hub.getByName('global').takeSent()
await withMembers(caller, 'ids=886013')
expect(await hub.getByName('global').takeSent()).toEqual([])
})
it('adding a member announces the join to the WHOLE thread', async () => {
const caller = 886014
const existing = 886015
const added = 886016
const res = await withMembers(caller, `ids=${existing}`)
const { chatThreadId } = (await res.json()) as { chatThreadId: number }
await hub.getByName('global').takeSent()
await SELF.fetch(`${ORIGIN}/thread/${chatThreadId}/member/${added}`, {
method: 'POST',
headers: await bearer(caller),
})
// Everyone: the two who were already there because the roster changed under them,
// and the new member because this is what puts the thread on their screen.
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([caller, existing, added])
expect(sent.every((n) => n.notificationType === NotificationType.ChatMessageReceived)).toBe(
true
)
expect(sent[0]!.data).toMatchObject({
chatThreadId,
senderPlayerId: SYSTEM_SENDER_ID,
contents: joinedChatContents(added),
})
// The notice is a real message on the thread, not just a frame — the mirror of the
// "left" one, and it is the thread's newest.
const [newest] = await getThreadMessages(env.DB, chatThreadId, { limit: 1 })
expect(newest?.contents).toBe(joinedChatContents(added))
})
it('joining a party announces it to the party', async () => {
const host = 886017
const guest = 886018
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${guest}`,
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
)
await hub.getByName('global').takeSent()
await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) })
// The host hears about it too — that is the point of announcing a join.
const sent = await hub.getByName('global').takeSent()
expect(sent.map((n) => n.playerId).sort((a, b) => a - b)).toEqual([host, guest])
expect(sent[0]!.data).toMatchObject({
chatThreadId: ChatThread.ChatThreadId,
senderPlayerId: SYSTEM_SENDER_ID,
contents: joinedChatContents(guest),
})
})
it('announces a party join once, not on every subsequent read', async () => {
// The join happens once; reading your own party afterwards is not a roster change.
const host = 886019
const guest = 886020
const created = await SELF.fetch(`${ORIGIN}/thread/party`, {
method: 'POST',
headers: await bearer(host),
})
const { ChatThread } = (await created.json()) as { ChatThread: { ChatThreadId: number } }
await env.RECFLARE_PLAYER_SETTINGS.put(
`player:${guest}`,
JSON.stringify({ LatestPartyChat: String(ChatThread.ChatThreadId) })
)
await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) })
await hub.getByName('global').takeSent()
await SELF.fetch(`${ORIGIN}/thread/party`, { headers: await bearer(guest) })
expect(await hub.getByName('global').takeSent()).toEqual([])
})
})
@@ -1185,6 +1786,39 @@ describe('POST /thread/:id', () => {
expect(await getThreadMessages(env.DB, chatThreadId)).toEqual(body.chatThread.messages)
})
it('answers the PascalCase ChatMessage the clients send handler dereferences', async () => {
const caller = 889020
const chatThreadId = await createThread(env.DB, [caller, 889021], null, caller)
const res = await send(caller, `/thread/${chatThreadId}`)
const body = (await res.json()) as {
ChatMessage: Record<string, unknown> | null
ChatResult: number
chatResult: number
chatThread: { messages: ChatMessage[] }
}
// The handler reads `ChatMessage` the moment `ChatResult` is 0 — a success answered
// without one is a null-reference exception inside the client, not a failed parse.
expect(body.ChatResult).toBe(0)
const posted = body.chatThread.messages[0]!
expect(body.ChatMessage).toEqual({
ChatMessageId: posted.chatMessageId,
ChatThreadId: chatThreadId,
SenderPlayerId: caller,
TimeSent: posted.timeSent,
// The envelope verbatim. The client parses this into `MessageJson` in a
// post-deserialize hook that only LOGS on failure and then dereferences the null,
// so `Contents` has to stay a `{ Type, Version, Data }` string with a non-null Data.
Contents: CONTENTS,
ModerationState: 0,
})
// The two result keys are one value serialized twice, so a decoder reading either
// spelling lands on the same answer.
expect(body.chatResult).toBe(body.ChatResult)
})
it('accepts the /thread/:id/message spelling too', async () => {
const caller = 889003
const chatThreadId = await createThread(env.DB, [caller, 889004], null, caller)
@@ -1219,13 +1853,70 @@ describe('POST /thread/:id', () => {
const res = await send(caller, `/thread/${chatThreadId}`, ' ')
expect(res.status).toBe(200)
const body = (await res.json()) as { chatResult: number; chatThread: { messages: unknown[] } }
const body = (await res.json()) as {
ChatMessage: unknown
ChatResult: number
chatResult: number
chatThread: { messages: unknown[] }
}
expect(body.chatResult).toBe(1)
expect(body.ChatResult).toBe(1)
// Nothing was posted, so there is no message to carry — and a null is safe here
// precisely because the client only dereferences `ChatMessage` on result 0.
expect(body.ChatMessage).toBeNull()
// The thread still comes back — only the opening notice is in it.
expect(body.chatThread.messages).toHaveLength(1)
expect(await getThreadMessages(env.DB, chatThreadId)).toHaveLength(1)
})
// The same filter api's POST /api/sanitize/v1 runs — the client isn't obliged to have
// called it, so a message posted straight here must not reach the thread unfiltered.
it('masks profanity in the envelopes Data and leaves the rest of it alone', async () => {
const caller = 889012
const chatThreadId = await createThread(env.DB, [caller, 889013], null, caller)
const res = await send(
caller,
`/thread/${chatThreadId}`,
'{"Type":0,"Version":2,"Data":"<=>what the fuck man","Blocks":[]}'
)
const body = (await res.json()) as {
chatResult: number
chatThread: { messages: ChatMessage[] }
}
expect(body.chatResult).toBe(0)
// One `*` per character, so the word keeps its length; Type/Version/Blocks and the
// Version 2 `<=>` marker come through untouched.
expect(body.chatThread.messages[0]!.contents).toBe(
'{"Type":0,"Version":2,"Data":"<=>what the **** man","Blocks":[]}'
)
// Masked in the row too, not just in the response.
expect((await getThreadMessages(env.DB, chatThreadId))[0]!.contents).toBe(
body.chatThread.messages[0]!.contents
)
})
// Nothing to object to must come back as the very bytes that were sent — the envelope
// is only rebuilt when something was actually masked.
it('stores a clean envelope byte-for-byte', async () => {
const caller = 889014
const chatThreadId = await createThread(env.DB, [caller, 889015], null, caller)
const contents = '{"Type":0,"Version":2,"Data":"Grape Escape","Blocks":[{"Id":"x"}]}'
await send(caller, `/thread/${chatThreadId}`, contents)
expect((await getThreadMessages(env.DB, chatThreadId))[0]!.contents).toBe(contents)
})
// Contents that aren't an envelope are plain text with nothing in them to preserve.
it('censors contents that arent a JSON envelope whole', async () => {
const caller = 889016
const chatThreadId = await createThread(env.DB, [caller, 889017], null, caller)
await send(caller, `/thread/${chatThreadId}`, 'fuck off')
expect((await getThreadMessages(env.DB, chatThreadId))[0]!.contents).toBe('**** off')
})
it('is gated on membership and auth', async () => {
const chatThreadId = await createThread(env.DB, [889009, 889010], null, 889009)
expect((await send(889011, `/thread/${chatThreadId}`)).status).toBe(404)
@@ -1520,6 +2211,7 @@ describe('openapi', () => {
'GET /thread/{id}',
'GET /thread/{id}/message',
'POST /thread',
'POST /thread/party',
'POST /thread/withmembers',
'POST /thread/{id}',
'POST /thread/{id}/favorite',
+145 -52
View File
@@ -31,6 +31,7 @@ export const THREAD_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS message_thread (
chat_thread_id INTEGER PRIMARY KEY AUTOINCREMENT,
chat_thread_name TEXT,
chat_thread_type INTEGER NOT NULL DEFAULT 0,
latest_message_id INTEGER,
created_at TEXT NOT NULL
)`,
@@ -70,23 +71,36 @@ export interface ChatThread {
*/
chatThreadName: string
/**
* Which kind of conversation this is. Every thread the reference serves here comes back
* as 0, and nothing in the worker distinguishes DMs from groups, so it's a constant
* but the field itself has to be present: the client deserializes it as a non-nullable
* int and drops the whole response when it's missing.
* Which kind of conversation this is the client's `ChatThreadType`: 0 Player,
* 1 Club, 2 Party. Player covers both DMs and group chats; nothing here distinguishes
* the two. The field has to be present whatever its value: the client deserializes it
* as a non-nullable int and drops the whole response when it's missing.
*/
chatThreadType: number
chatThreadType: ChatThreadTypeValue
snoozedUntil: string | null
isFavorited: boolean
}
/** The only thread type the reference ever serves. See `ChatThread.chatThreadType`. */
const CHAT_THREAD_TYPE_DEFAULT = 0
/**
* The client's `ChatThreadType` enum, stored on the thread and served numerically like
* every other enum on this build. A plain conversation DM or group is `Player`;
* `Party` is what `POST /thread/party` opens. Nothing here serves `Club` yet: club chat
* lives in the `clubs` worker, and the member is here so a stored 1 renders as itself
* rather than being read as a player thread.
*/
export const ChatThreadType = {
Player: 0,
Club: 1,
Party: 2,
} as const
export type ChatThreadTypeValue = (typeof ChatThreadType)[keyof typeof ChatThreadType]
/** The joined row backing a rendered thread, before it's shaped for the client. */
interface ThreadRow {
chat_thread_id: number
chat_thread_name: string | null
chat_thread_type: number
player_ids: string | null
last_read_message_id: number | null
snoozed_until: string | null
@@ -119,30 +133,24 @@ function toThread(row: ThreadRow): ChatThread {
lastReadMessageId: row.last_read_message_id ?? 0,
// Null in the column means "unnamed"; the client dereferences it unchecked.
chatThreadName: row.chat_thread_name ?? '',
chatThreadType: CHAT_THREAD_TYPE_DEFAULT,
chatThreadType: row.chat_thread_type as ChatThreadTypeValue,
snoozedUntil: row.snoozed_until,
isFavorited: row.is_favorited !== 0,
}
}
/**
* The thread list as it renders for one player, newest conversation first the
* `?MessageCount=N` page of the thread endpoint.
*
* Reads only threads the player is a member of, so the membership join is the
* authorization check as well as the query. The inner ordered subquery around
* The shared projection behind every rendered thread the list, the single read and the
* party read. Membership LEADS the join, so it is the authorization check as well as the
* query: a row only exists for a thread `me` is in. The inner ordered subquery around
* group_concat is what makes `playerIds` come back sorted rather than in row order.
*
* Callers append their own WHERE (and ORDER/LIMIT) and bind `?1` onwards from there.
*/
export async function getThreadsForPlayer(
db: D1Database,
playerId: number,
{ limit = 50 }: { limit?: number } = {}
): Promise<ChatThread[]> {
const { results } = await db
.prepare(
`SELECT
const THREAD_SELECT = `SELECT
t.chat_thread_id,
t.chat_thread_name,
t.chat_thread_type,
(SELECT group_concat(player_id) FROM
(SELECT player_id FROM thread_member WHERE chat_thread_id = t.chat_thread_id
ORDER BY player_id)) AS player_ids,
@@ -157,7 +165,22 @@ export async function getThreadsForPlayer(
msg.moderation_state AS msg_moderation_state
FROM thread_member me
JOIN message_thread t ON t.chat_thread_id = me.chat_thread_id
LEFT JOIN message msg ON msg.chat_message_id = t.latest_message_id
LEFT JOIN message msg ON msg.chat_message_id = t.latest_message_id`
/**
* The thread list as it renders for one player, newest conversation first the
* `?MessageCount=N` page of the thread endpoint.
*
* Membership scopes the query see {@link THREAD_SELECT}.
*/
export async function getThreadsForPlayer(
db: D1Database,
playerId: number,
{ limit = 50 }: { limit?: number } = {}
): Promise<ChatThread[]> {
const { results } = await db
.prepare(
`${THREAD_SELECT}
WHERE me.player_id = ?1
ORDER BY t.latest_message_id DESC
LIMIT ?2`
@@ -175,24 +198,7 @@ export async function getThreadForPlayer(
): Promise<ChatThread | null> {
const row = await db
.prepare(
`SELECT
t.chat_thread_id,
t.chat_thread_name,
(SELECT group_concat(player_id) FROM
(SELECT player_id FROM thread_member WHERE chat_thread_id = t.chat_thread_id
ORDER BY player_id)) AS player_ids,
me.last_read_message_id,
me.snoozed_until,
me.is_favorited,
msg.chat_message_id AS msg_chat_message_id,
msg.chat_thread_id AS msg_chat_thread_id,
msg.sender_player_id AS msg_sender_player_id,
msg.time_sent AS msg_time_sent,
msg.contents AS msg_contents,
msg.moderation_state AS msg_moderation_state
FROM thread_member me
JOIN message_thread t ON t.chat_thread_id = me.chat_thread_id
LEFT JOIN message msg ON msg.chat_message_id = t.latest_message_id
`${THREAD_SELECT}
WHERE me.chat_thread_id = ?1 AND me.player_id = ?2`
)
.bind(chatThreadId, playerId)
@@ -200,6 +206,65 @@ export async function getThreadForPlayer(
return row === null ? null : toThread(row)
}
/**
* The party a player is already in, or null the newest party thread carrying a
* membership row for them, rendered exactly as {@link getThreadForPlayer} renders one.
*
* The fast path behind `GET /thread/party`: a player already on a party is answered from
* ONE D1 query, with no player-settings read at all. `LatestPartyChat` is consulted only
* when this comes back null that is, only for a player who has yet to JOIN a party.
*
* Newest first (by thread id, which is monotonic) because a player can hold membership in
* parties they never formally left: the one they are in is the most recent one they are on.
*/
export async function getPartyThreadForPlayer(
db: D1Database,
playerId: number
): Promise<ChatThread | null> {
const row = await db
.prepare(
`${THREAD_SELECT}
WHERE me.player_id = ?1 AND t.chat_thread_type = ?2
ORDER BY t.chat_thread_id DESC
LIMIT 1`
)
.bind(playerId, ChatThreadType.Party)
.first<ThreadRow>()
return row === null ? null : toThread(row)
}
/** What a thread IS, without any of what's in it. See {@link getThreadMeta}. */
export interface ThreadMeta {
chatThreadType: ChatThreadTypeValue
/** ISO-8601 UTC, as `created_at` stores it. */
createdAt: string
}
/**
* A thread's kind and age, or null when there is no such thread the one read here that
* does NOT go through membership.
*
* It exists for the party join (`GET /thread/party`), which has to know a thread is real,
* is a party, and is still young enough to join BEFORE it puts the caller on it; every
* other read is membership-scoped, and a caller joining a party is by definition not a
* member yet. It answers these two fields and nothing else no name, no roster, no
* messages precisely so it can't become a way to read a thread you aren't in.
*/
export async function getThreadMeta(
db: D1Database,
chatThreadId: number
): Promise<ThreadMeta | null> {
const row = await db
.prepare(
'SELECT chat_thread_type, created_at FROM message_thread WHERE chat_thread_id = ?1'
)
.bind(chatThreadId)
.first<{ chat_thread_type: number; created_at: string }>()
return row === null
? null
: { chatThreadType: row.chat_thread_type as ChatThreadTypeValue, createdAt: row.created_at }
}
/**
* Whether a player may read or post to a thread. Every thread-scoped route gates on
* this before touching messages.
@@ -243,6 +308,19 @@ export function leftChatContents(playerId: number): string {
return JSON.stringify({ Type: 0, Version: 1, Data: `Player <@U${playerId}> left` })
}
/**
* The counterpart notice when someone is pulled onto a thread or walks into a party:
* `Player <@U14922080> joined`. Same `<@U…>` mention token as the other two.
*
* It carries the roster change as a MESSAGE because that is the only way to carry one: the
* client has no join/leave channel, only `ChatMessageReceived` and `PlayerLeftChat`, both
* of which take a message. So the notice is both what the thread shows and what tells
* everyone the new member's client included that the roster moved.
*/
export function joinedChatContents(playerId: number): string {
return JSON.stringify({ Type: 0, Version: 1, Data: `Player <@U${playerId}> joined` })
}
/**
* Rename a thread. An empty name clears it back to unnamed, which renders as the member
* list rather than a blank title.
@@ -269,19 +347,24 @@ export async function setThreadName(
*
* Every call opens a *distinct* thread, even for a member set that already has one
* threads are not keyed by their membership, and the same pair may hold several.
*
* `type` is the thread's kind and defaults to `Player`, which covers DMs and group chats
* alike; a party opens as `Party`.
*/
export async function createThread(
db: D1Database,
playerIds: number[],
name: string | null = null,
startedBy?: number
startedBy?: number,
type: ChatThreadTypeValue = ChatThreadType.Player
): Promise<number> {
const row = await db
.prepare(
`INSERT INTO message_thread (chat_thread_name, created_at) VALUES (?1, ?2)
`INSERT INTO message_thread (chat_thread_name, chat_thread_type, created_at)
VALUES (?1, ?2, ?3)
RETURNING chat_thread_id`
)
.bind(name, new Date().toISOString())
.bind(name, type, new Date().toISOString())
.first<{ chat_thread_id: number }>()
if (row === null) throw new Error('failed to create chat thread')
@@ -322,27 +405,33 @@ export async function createThread(
* thread is gone are ignored rather than resolved to: matching one would hand back an id
* that nothing else in the worker can render, and since the oldest match wins it
* would keep winning on every subsequent call.
*
* Matching is also scoped to one `type`: a party whose roster happens to be the people
* you are opening a DM with is a different conversation, and handing it back would drop
* the DM into the party.
*/
export async function findThreadWithMembers(
db: D1Database,
playerIds: number[]
playerIds: number[],
type: ChatThreadTypeValue = ChatThreadType.Player
): Promise<number | null> {
const members = [...new Set(playerIds)]
if (members.length === 0) return null
// ?1 is the member count; ?2… are the ids themselves.
const placeholders = members.map((_, i) => `?${i + 2}`).join(', ')
// ?1 is the member count, ?2 the thread type; ?3… are the ids themselves.
const placeholders = members.map((_, i) => `?${i + 3}`).join(', ')
const row = await db
.prepare(
`SELECT m.chat_thread_id FROM thread_member m
JOIN message_thread t ON t.chat_thread_id = m.chat_thread_id
WHERE t.chat_thread_type = ?2
GROUP BY m.chat_thread_id
HAVING COUNT(*) = ?1
AND COUNT(CASE WHEN m.player_id IN (${placeholders}) THEN 1 END) = ?1
ORDER BY m.chat_thread_id
LIMIT 1`
)
.bind(members.length, ...members)
.bind(members.length, type, ...members)
.first<{ chat_thread_id: number }>()
return row?.chat_thread_id ?? null
}
@@ -352,16 +441,20 @@ export async function findThreadWithMembers(
* simultaneous first-messages to the same set can still race into two threads; the
* oldest-match rule in `findThreadWithMembers` means both parties converge on one of
* them afterwards.
*
* `created` says which happened. The caller needs it: a thread that was just opened has to
* be PUSHED to its members, or it sits on the server unseen until somebody posts to it
* the client has no "you were added to a thread" channel, so the opening notice going out
* over the socket is the only thing that makes a new conversation appear.
*/
export async function getOrCreateThreadWithMembers(
db: D1Database,
playerIds: number[],
startedBy: number
): Promise<number> {
return (
(await findThreadWithMembers(db, playerIds)) ??
(await createThread(db, playerIds, null, startedBy))
)
): Promise<{ chatThreadId: number; created: boolean }> {
const existing = await findThreadWithMembers(db, playerIds)
if (existing !== null) return { chatThreadId: existing, created: false }
return { chatThreadId: await createThread(db, playerIds, null, startedBy), created: true }
}
/** Everyone in a thread, ordered by id — the fan-out list for a push notification. */
+2 -2
View File
@@ -21,7 +21,7 @@
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -33,6 +33,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+2 -2
View File
@@ -18,7 +18,7 @@
"@repo/hono-helpers": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -30,6 +30,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+20
View File
@@ -114,6 +114,26 @@ const app = new Hono<App>()
(c) => c.json(catalog)
)
// Pending purchases the client asks the server to reconcile — platform transactions
// it started but never saw finish. Nothing is ever recorded here, so there is never
// anything pending and the answer is always an empty list. Accepts GET or POST since
// the client may use either.
.on(
['GET', 'POST'],
'/purchase/v1/cleanuppending',
describeRoute({
tags: ['Purchase'],
summary: 'Reconcile pending purchases (no-op)',
description: [
'Always `[]` — no purchase is ever recorded, so nothing can be left pending. A 404',
'here makes the client treat the call as an error, so the empty list is served',
'instead. Accepts GET or POST.',
].join(' '),
responses: { 200: json(JsonArray, 'Always empty (nothing pending)') },
}),
(c) => c.json([])
)
// Current purchase campaigns (limited-time offers/promos). None exist, and
// an empty list is the client's "no active campaigns" state.
.get(
@@ -43,6 +43,14 @@ describe('commerce endpoints', () => {
expect(skus[0]).toHaveProperty('skuId')
})
it('GET|POST /purchase/v1/cleanuppending returns []', async () => {
for (const method of ['GET', 'POST']) {
const res = await SELF.fetch(`${ORIGIN}/purchase/v1/cleanuppending`, { method })
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
}
})
it('GET /purchasecampaign/allcurrent/v2 returns []', async () => {
const res = await SELF.fetch(`${ORIGIN}/purchasecampaign/allcurrent/v2`)
expect(res.status).toBe(200)
@@ -78,9 +86,11 @@ describe('commerce endpoints', () => {
expect([...documented].sort()).toEqual([
'GET /',
'GET /api/catalog/v1/all',
'GET /purchase/v1/cleanuppending',
'GET /purchase/v1/hasspentmoney',
'GET /purchasecampaign/allcurrent/v2',
'GET /reminder/currentTokenBundles/v2',
'POST /purchase/v1/cleanuppending',
'POST /purchase/v1/initiatepurchase',
])
+3
View File
@@ -5,6 +5,9 @@
"compatibility_date": "2026-06-16",
"compatibility_flags": ["nodejs_compat"],
"upload_source_maps": true,
"cache": {
"enabled": true
},
"observability": {
"logs": {
"enabled": true,
+2 -2
View File
@@ -16,7 +16,7 @@
},
"dependencies": {
"@repo/hono-helpers": "workspace:*",
"hono": "4.12.27",
"hono": "4.13.5",
"workers-tagged-logger": "1.0.1"
},
"devDependencies": {
@@ -25,6 +25,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+2 -2
View File
@@ -18,7 +18,7 @@
"@repo/hono-helpers": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -30,6 +30,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+66 -6
View File
@@ -4,17 +4,23 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { DiscoverySections, json, PAGE_SOURCE_PARAM, ServiceStatus } from './openapi'
import { fetchPageSource } from './page-sources'
import {
DiscoverySections,
json,
PAGE_SOURCE_PARAM,
SECTION_IDS_PARAM,
ServiceStatus,
} from './openapi'
import { fetchPageSource, readSections, SECTIONS_CATALOGUE } from './page-sources'
import type { App } from './context'
/**
* Discovery Worker. Serves the layout of the client's discovery pages which carousels a
* page shows and in what order out of `static/`, one file per page source, through the
* ASSETS binding (see `page-sources.ts`). It does not serve the carousels' CONTENTS: each
* section names a client-side feed the client resolves against the `rooms`/`api` workers
* itself.
* ASSETS binding (see `page-sources.ts`), plus `sections.json`, the id-keyed catalogue the
* bulk lookup filters. It does not serve the carousels' CONTENTS: each section names a
* client-side feed the client resolves against the `rooms`/`api` workers itself.
*
* Unauthenticated: every client gets the same layout, and the client fetches this before
* anything player-specific.
@@ -45,11 +51,62 @@ const app = new Hono<App>()
(c) => c.json({ service: 'discovery', status: 'ok' })
)
// A set of sections looked up by id, out of the catalogue in `static/sections.json`.
//
// This is the id-keyed counterpart to the page-source route: a page source hands back a
// whole page's rows in draw order, while this hands back exactly the rows asked for,
// which is how the client refreshes sections it already knows the ids of without
// re-fetching every page they came from.
//
// The reference reads its catalogue file and filters it, so the failure modes are the
// file's, not the query's: an id matching nothing is simply absent from the answer
// rather than an error, and a query naming NO ids answers `[]` rather than the whole
// catalogue — the client asks for nothing when it wants nothing. Only a missing
// catalogue file is a 404.
.get(
'/sections/bulk',
describeRoute({
tags: ['Discovery', '2025'],
summary: 'Look up sections by id',
description: [
'The sections named by the repeated `?id=` query, drawn from the catalogue in',
'`static/sections.json` — the union of the rows the page sources are built from.',
'',
'The answer is that file FILTERED, which fixes the edges: rows come back in the',
'catalogues order rather than the querys, an id that matches nothing is left out',
'instead of erroring, and repeating an id still yields it once. A query with no `id`',
'at all answers `[]`. Rows are served exactly as stored, so a field this service',
'doesnt model survives the round trip.',
'',
'Same section shape as `/sections/pagesource/{type}`: a section NAMES a feed',
'(`source`/`sourceMetadata`) that the client resolves itself. Nothing here is',
'player-specific, so there is no auth.',
].join('\n'),
parameters: [SECTION_IDS_PARAM],
responses: {
200: json(DiscoverySections, 'The requested sections, in catalogue order'),
404: { description: 'The catalogue file is not published' },
},
}),
async (c) => {
// `queries` and not `query`: the ids arrive as a repeated parameter, and `query`
// would collapse them to the first one and silently drop the rest of the page.
const ids = c.req.queries('id')
if (ids === undefined || ids.length === 0) return c.json([])
const sections = await readSections(c, SECTIONS_CATALOGUE)
if (sections === null) return c.notFound()
const wanted = new Set(ids)
return c.json(sections.filter((s) => typeof s.id === 'string' && wanted.has(s.id)))
}
)
// One discovery page's section layout, served verbatim from `static/<type>.json`.
.get(
'/sections/pagesource/:type',
describeRoute({
tags: ['Discovery'],
tags: ['Discovery', '2025'],
summary: 'Section layout for a page source',
description: [
'The sections of one discovery page, in the order the client draws them. `{type}` IS',
@@ -104,6 +161,9 @@ app.get(
'than anything the code enumerates. Nothing is editable at runtime and every client',
'gets the same answer, so the routes are unauthenticated.',
'',
'Sections can also be fetched by id rather than by page: `/sections/bulk` filters',
'`static/sections.json`, the catalogue those layouts draw their rows from.',
'',
'A section names a feed rather than carrying its contents: the client resolves the',
'rooms, items and accounts behind each carousel against the `rooms` and `api` workers',
'itself.',
+30 -2
View File
@@ -30,15 +30,42 @@ export const PAGE_SOURCE_PARAM: OpenAPIV3_1.ParameterObject = {
description: [
'The page source — `WatchHome`, `PlayHighlight`, `CommunityBoard`, `PlayMenuTabs`,',
'`PlayCategories`, `StoreCategories`, `StoreFeatured`, `StoreClothing`,',
'`StoreConsumables`, `bulk` at the time of writing. It names a file in `static/`',
'`StoreConsumables` at the time of writing. It names a file in `static/`',
'(`<type>.json`) and is matched exactly, case included, so the set is whatever is',
'published rather than anything this worker enumerates.',
'',
'`sections` is a file in `static/` too but is not one of these: it is the id-keyed',
'catalogue `/sections/bulk` filters, not a page anything draws.',
].join(' '),
// Deliberately not an `enum`: the accepted values are the published files, and a spec
// that froze today's list would be wrong the moment one is added.
schema: { type: 'string', example: 'WatchHome' },
}
/**
* The repeated `?id=` query the bulk lookup selects on. Repetition, not a delimiter: the
* client sends `?id=A&id=B&id=C`, so this is `explode: true` form style rather than a
* single comma-joined value.
*/
export const SECTION_IDS_PARAM: OpenAPIV3_1.ParameterObject = {
name: 'id',
in: 'query',
required: false,
description: [
'A section id to look up, repeated once per section wanted. Ids that match nothing are',
'skipped rather than erroring, and repeating one still yields it once — the answer is',
'the catalogue filtered, so it can only ever be a subset of it. Omitting the parameter',
'entirely answers `[]`.',
].join(' '),
style: 'form',
explode: true,
schema: { type: 'array', items: { type: 'string' } },
example: [
'Rooms_New_PlayHighlight_TabsTest_Explore',
'RoomCategories_MoodPlaylists_FeelingLucky',
],
}
// ---- Response schemas ------------------------------------------------------
/** `GET /` — the liveness probe body. */
@@ -82,7 +109,8 @@ export const DiscoverySection = z.object({
})
/**
* `GET /sections/pagesource/{type}` a page's sections, in the order they are drawn.
* A list of sections `GET /sections/pagesource/{type}` in the order a page draws them,
* or `GET /sections/bulk` in the catalogue's order.
*
* The DTO accepts anything (its validator is a no-op), but the STORE page builder is much
* stricter and drops a section it doesn't like SILENTLY no error reaches the client, the
+35
View File
@@ -35,3 +35,38 @@ export async function fetchPageSource(c: Context<App>, type: string): Promise<Re
const res = await c.env.ASSETS.fetch(new Request(new URL(`/${type}.json`, c.req.url), c.req.raw))
return res.ok || res.status === 304 ? res : null
}
/**
* The file holding every section the client can ask for by id the union of the rows the
* page sources draw from, which `/sections/bulk` filters. It is a plain file in `static/`
* like the page layouts, but it is NOT a page source: nothing draws it as a page, so it is
* deliberately not reachable through `/sections/pagesource/:type` (`SAFE_NAME` would let it
* through; the route simply isn't what asks for it).
*/
export const SECTIONS_CATALOGUE = 'sections'
/**
* One row of a section file, as it is stored. Read as a bare record rather than a typed
* section because the rows are served back UNCHANGED only `id` is ever looked at, and a
* field this worker doesn't model has to survive the round trip rather than be dropped by
* a projection.
*/
export type SectionRow = Record<string, unknown>
/**
* Read and parse `static/<name>.json`. `null` when no such file is published.
*
* Unlike `fetchPageSource` this does NOT forward the caller's request. The body is needed
* here to filter, and forwarding would let a caller whose `If-None-Match` happens to match
* the FILE's etag get a bodiless 304 wrong for a response that is a subset of the file
* rather than the file itself.
*/
export async function readSections(c: Context<App>, name: string): Promise<SectionRow[] | null> {
if (!SAFE_NAME.test(name)) return null
const res = await c.env.ASSETS.fetch(new URL(`/${name}.json`, c.req.url))
if (!res.ok) return null
const rows: unknown = await res.json()
return Array.isArray(rows) ? (rows as SectionRow[]) : null
}
+133 -40
View File
@@ -12,16 +12,17 @@ const PAGE_SOURCES = [
'StoreFeatured',
'StoreClothing',
'StoreConsumables',
'bulk',
]
interface Section {
id: string
sectionType: number
sectionSubType: string
source: string
sourceMetadata: string | null
displayMetadata: string | null
/**
* A section as published. The reference captures are camelCase and the hand-authored
* store pages PascalCase; the client's decoder is case-insensitive, so both are served
* as-is and the tests read either spelling.
*/
type Section = Record<string, unknown>
function field(section: Section, name: string): unknown {
return section[name] ?? section[name[0].toUpperCase() + name.slice(1)]
}
/** Fetch a page source and return its parsed body. */
@@ -45,16 +46,17 @@ describe('GET /sections/pagesource/:type', () => {
// is reachable without the worker knowing its name.
it.each(PAGE_SOURCES)('serves %s', async (type) => {
const sections = await pageSource(type)
expect(sections.length).toBeGreaterThan(0)
expect(Array.isArray(sections)).toBe(true)
for (const section of sections) {
expect(typeof section.id).toBe('string')
expect(typeof section.sectionType).toBe('number')
expect(typeof section.source).toBe('string')
expect(typeof field(section, 'id')).toBe('string')
expect(typeof field(section, 'sectionType')).toBe('number')
expect(typeof field(section, 'source')).toBe('string')
// An embedded JSON *string* the client parses itself, not an object — or null,
// which several store and play-highlight sections use.
if (section.displayMetadata !== null) {
expect(typeof section.displayMetadata).toBe('string')
expect(() => JSON.parse(section.displayMetadata as string)).not.toThrow()
const display = field(section, 'displayMetadata')
if (display !== null && display !== undefined) {
expect(typeof display).toBe('string')
expect(() => JSON.parse(display as string)).not.toThrow()
}
}
})
@@ -82,35 +84,17 @@ describe('GET /sections/pagesource/:type', () => {
// that this builder would drop — that is the reference's data, not a mistake to fix here.
it('StoreCategories only carries sections the store page builder keeps', async () => {
for (const section of await pageSource('StoreCategories')) {
expect([4, 13]).toContain(section.sectionType)
expect(section.displayMetadata).toBeTruthy()
expect(() => JSON.parse(section.displayMetadata as string)).not.toThrow()
if (section.sectionType === 13) {
expect(['CuratedList', 'PageSource']).toContain(section.source)
expect(section.sourceMetadata).toBeTruthy()
expect([4, 13]).toContain(field(section, 'sectionType'))
const display = field(section, 'displayMetadata')
expect(display).toBeTruthy()
expect(() => JSON.parse(display as string)).not.toThrow()
if (field(section, 'sectionType') === 13) {
expect(['CuratedList', 'PageSource']).toContain(field(section, 'source'))
expect(field(section, 'sourceMetadata')).toBeTruthy()
}
}
})
it('serves the StoreCategories page', async () => {
const sections = await pageSource('StoreCategories')
expect(sections[0]).toEqual({
id: 'store-featured',
// StoreItemsSection: a store CATEGORY is drawn as the product carousel.
sectionType: 4,
sectionSubType: 'StoreCategory_Featured',
source: 'CuratedList',
// The curated list the `lists` worker serves from /curatedlists/bulk.
sourceMetadata: '17859340',
displayMetadata: expect.stringContaining('"DisplayTitle":"Featured"'),
})
// displayMetadata must be non-empty and parse, or the builder drops the section.
const display = JSON.parse(sections[0].displayMetadata as string) as {
categoryUriNames: string
}
expect(display.categoryUriNames).toBe('featured,new')
})
// The asset manifest is case-sensitive and there is no index to fold case against, so
// the name has to match the file exactly.
it('404s a name whose case does not match the file', async () => {
@@ -152,12 +136,121 @@ describe('GET /sections/pagesource/:type', () => {
})
})
describe('GET /sections/bulk', () => {
/** Fetch a set of section ids and return the parsed rows. */
async function bulk(ids: string[]) {
const query = ids.map((id) => `id=${encodeURIComponent(id)}`).join('&')
const res = await SELF.fetch(`https://discovery.example.com/sections/bulk?${query}`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('application/json')
return (await res.json()) as Section[]
}
// The ids arrive as a REPEATED parameter, not a delimited one. Reading only the first
// would drop every row but one and leave the page nearly empty.
it('serves every id the query repeats', async () => {
const ids = [
'Rooms_New_PlayHighlight_TabsTest_Explore',
'RoomCategories_MoodPlaylists_FeelingLucky',
'Rooms_RecentlyUpdated_TabsTest_Explore',
'Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Horror_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Hangout_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Explore_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
]
const sections = await bulk(ids)
expect(sections.map((s) => s.id)).toEqual(ids)
})
it('serves the catalogue rows verbatim', async () => {
const sections = await bulk(['RoomCategories_MoodPlaylists_FeelingLucky'])
expect(sections).toEqual([
{
id: 'RoomCategories_MoodPlaylists_FeelingLucky',
// RoomCategoryListSection.
sectionType: 12,
sectionSubType: 'RoomCategories',
source: 'CuratedList',
sourceMetadata: 'RoomCategories.MoodPlaylists.AlgoEndpoint.FeelingLucky',
displayMetadata: expect.stringContaining('"DisplayTitle":"I\'m Feeling Lucky"'),
},
])
})
// The answer is the catalogue filtered, so it is ordered by the FILE and not by the
// query, and an id can only ever come back once however many times it is asked for.
it('answers in catalogue order regardless of the query order', async () => {
const sections = await bulk([
'Rooms_RecentlyUpdated_TabsTest_Explore',
'Rooms_New_PlayHighlight_TabsTest_Explore',
])
expect(sections.map((s) => s.id)).toEqual([
'Rooms_New_PlayHighlight_TabsTest_Explore',
'Rooms_RecentlyUpdated_TabsTest_Explore',
])
})
it('yields a repeated id once', async () => {
const sections = await bulk([
'Rooms_New_PlayHighlight_TabsTest_Explore',
'Rooms_New_PlayHighlight_TabsTest_Explore',
])
expect(sections.map((s) => s.id)).toEqual(['Rooms_New_PlayHighlight_TabsTest_Explore'])
})
// An unknown id is left out rather than erroring: one stale id in a page's list must not
// take the rest of the page down with it.
it('skips ids that match nothing', async () => {
const sections = await bulk(['nope', 'Rooms_MyRooms_Play', 'also-nope'])
expect(sections.map((s) => s.id)).toEqual(['Rooms_MyRooms_Play'])
})
it('answers an empty array when no id matches', async () => {
expect(await bulk(['nope'])).toEqual([])
})
// No ids asked for means nothing wanted — NOT the whole catalogue.
it('answers an empty array when the query names no ids', async () => {
const res = await SELF.fetch('https://discovery.example.com/sections/bulk')
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
// The catalogue is a file in `static/` like the layouts, so the page-source route reaches
// it too — `{type}` is the filename and nothing indexes which files are pages. Harmless
// and asserted so the overlap is a known fact rather than a surprise; the client asks for
// the catalogue through this route's `?id=` filter, never as a page.
it('is also reachable through the page-source route, unfiltered', async () => {
const res = await SELF.fetch('https://discovery.example.com/sections/pagesource/sections')
expect(res.status).toBe(200)
expect((await res.json()) as Section[]).toHaveLength(14)
})
// The response is a SUBSET of the file, so it must never be answered with the file's
// etag — a client that cached the file would otherwise be told its copy is still good.
it('ignores a conditional request matching the catalogue file', async () => {
const sections = await bulk(['Rooms_MyRooms_Play'])
expect(sections).toHaveLength(1)
const res = await SELF.fetch(
'https://discovery.example.com/sections/bulk?id=Rooms_MyRooms_Play',
{ headers: { 'if-none-match': '"anything"' } }
)
expect(res.status).toBe(200)
expect((await res.json()) as Section[]).toHaveLength(1)
})
})
describe('GET /openapi.json', () => {
it('generates a spec with no dangling $refs', async () => {
const res = await SELF.fetch('https://discovery.example.com/openapi.json')
expect(res.status).toBe(200)
const spec = (await res.json()) as { paths: Record<string, unknown> }
expect(Object.keys(spec.paths)).toContain('/sections/pagesource/{type}')
expect(Object.keys(spec.paths)).toContain('/sections/bulk')
expect(JSON.stringify(spec).match(/\$ref/g)).toBeNull()
})
})
+1 -9
View File
@@ -1,12 +1,4 @@
[
{
"id": "Rooms_RoomBanner_SketchyShowdown",
"sectionType": 6,
"sectionSubType": "RoomBanner",
"source": "2477031627165896495",
"sourceMetadata": "2477031627165896495",
"displayMetadata": null
},
{
"id": "Rooms_ForYou_PlayHighlight",
"sectionType": 0,
@@ -60,7 +52,7 @@
"sectionType": 13,
"sectionSubType": "featured_creator",
"source": "PlayerCreatedRooms",
"sourceMetadata": "1",
"sourceMetadata": "2",
"displayMetadata": "{\"DisplayTitle\":\"Featured Creator\", \"descriptionText\":\"Making moments you can play again and again!\",\"itemCount\":\"4\", \"unsupportedPlatforms\":[\"Switch\"]}"
},
{
+1 -1
View File
@@ -23,4 +23,4 @@
"sourceMetadata": "Discovery.PageSource.PlayLibrary",
"displayMetadata": "{\"DisplayTitle\":\"Library\", \"unsupportedPlatforms\":[]}"
}
]
]
+69 -5
View File
@@ -1,10 +1,74 @@
[
{
"id": "store-featured",
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Headwear",
"sectionType": 4,
"sectionSubType": "StoreCategory_Featured",
"source": "CuratedList",
"sourceMetadata": "17859340",
"displayMetadata": "{\"DisplayTitle\":\"Featured\",\"itemCount\":20,\"cardStyle\":\"wide\",\"categoryUriNames\":\"featured,new\"}"
"sectionSubType": "Items_Headwear",
"source": "AlgorithmicList",
"sourceMetadata": "headwearitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Headwear\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 7,\r\n \"categoryUriNames\": \"HatsItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Tops",
"sectionType": 4,
"sectionSubType": "Items_Tops",
"source": "AlgorithmicList",
"sourceMetadata": "topsitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Tops\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 8,\r\n \"categoryUriNames\": \"TorsoItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Bottoms",
"sectionType": 4,
"sectionSubType": "Items_Bottoms",
"source": "AlgorithmicList",
"sourceMetadata": "bottomsitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Bottoms\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 17,\r\n \"categoryUriNames\": \"BottomsItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Footwear",
"sectionType": 4,
"sectionSubType": "Items_Footwear",
"source": "AlgorithmicList",
"sourceMetadata": "footwearitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Footwear\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 18,\r\n \"categoryUriNames\": \"ShoesItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Waist",
"sectionType": 4,
"sectionSubType": "Items_Waist",
"source": "AlgorithmicList",
"sourceMetadata": "waistitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Waist\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 23,\r\n \"categoryUriNames\": \"WaistItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Hands",
"sectionType": 4,
"sectionSubType": "Items_Hands",
"source": "AlgorithmicList",
"sourceMetadata": "handsitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Hands\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 10,\r\n \"categoryUriNames\": \"GlovesItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Shoulder",
"sectionType": 4,
"sectionSubType": "Items_Shoulder",
"source": "AlgorithmicList",
"sourceMetadata": "shoulderitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Shoulders & Back\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 22,\r\n \"categoryUriNames\": \"ShoulderItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Hairstyles",
"sectionType": 4,
"sectionSubType": "Items_Hairstyles",
"source": "AlgorithmicList",
"sourceMetadata": "hairitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Hairstyles\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 24,\r\n \"categoryUriNames\": \"HeadHairItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_FacialHair",
"sectionType": 4,
"sectionSubType": "Items_FacialHair",
"source": "AlgorithmicList",
"sourceMetadata": "facialhairitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Facial Hair\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 25,\r\n \"categoryUriNames\": \"FacialHairItems\",\r\n \"itemCount\": 5\r\n}"
}
]
+1 -106
View File
@@ -1,106 +1 @@
[
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_New",
"sectionType": 4,
"sectionSubType": "Items_NewlyReleased",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "newitems",
"displayMetadata": "{\r\n\t\"DisplayTitle\":\"New\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Headwear",
"sectionType": 4,
"sectionSubType": "Items_Headwear",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "headwearitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Headwear\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 7,\r\n \"categoryUriNames\": \"HatsItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Tops",
"sectionType": 4,
"sectionSubType": "Items_Tops",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "topsitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Tops\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 8,\r\n \"categoryUriNames\": \"TorsoItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Bottoms",
"sectionType": 4,
"sectionSubType": "Items_Bottoms",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "bottomsitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Bottoms\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 17,\r\n \"categoryUriNames\": \"BottomsItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Footwear",
"sectionType": 4,
"sectionSubType": "Items_Footwear",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "footwearitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Footwear\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 18,\r\n \"categoryUriNames\": \"ShoesItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Waist",
"sectionType": 4,
"sectionSubType": "Items_Waist",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "waistitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Waist\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 23,\r\n \"categoryUriNames\": \"WaistItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Hands",
"sectionType": 4,
"sectionSubType": "Items_Hands",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "handsitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Hands\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 10,\r\n \"categoryUriNames\": \"GlovesItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Shoulder",
"sectionType": 4,
"sectionSubType": "Items_Shoulder",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "shoulderitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Shoulders & Back\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 22,\r\n \"categoryUriNames\": \"ShoulderItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Skins",
"sectionType": 4,
"sectionSubType": "Items_Skins",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "skinsitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Skins\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 5,\r\n \"categoryUriNames\": \"SkinsItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Accessories",
"sectionType": 4,
"sectionSubType": "Items_Accessories",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "accessoriesitems",
"displayMetadata": "{\r\n\t\"DisplayTitle\":\"Accessories\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Hairstyles",
"sectionType": 4,
"sectionSubType": "Items_Hairstyles",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "hairitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Hairstyles\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 24,\r\n \"categoryUriNames\": \"HeadHairItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_FacialHair",
"sectionType": 4,
"sectionSubType": "Items_FacialHair",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "facialhairitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Facial Hair\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 25,\r\n \"categoryUriNames\": \"FacialHairItems\",\r\n \"itemCount\": 5\r\n}"
},
{
"id": "CustomAvatarItemCarousel_AlgorithmicList_Trending",
"sectionType": 8,
"sectionSubType": "CustomAvatarItems_Trending",
"source": "AlgorithmicList",
"sourceMetadata": "Trending",
"displayMetadata": "{\r\n\"DisplayTitle\":\"Trending Custom Shirts\",\r\n \"itemCount\": 5\r\n}"
}
]
[]
+8 -73
View File
@@ -1,74 +1,9 @@
[
{
"id": "StoreItemCarousel_Storefront_PopUpShop_18",
"sectionType": 4,
"sectionSubType": "Storefront_PopUpShop_18",
"source": "Storefront",
"sourceMetadata": "PopUpShop_18",
"displayMetadata": null
},
{
"id": "StoreItemCarousel_Storefront_PopUpShop_14",
"sectionType": 4,
"sectionSubType": "Storefront_PopUpShop_14",
"source": "Storefront",
"sourceMetadata": "PopUpShop_14",
"displayMetadata": "{\r\n \"itemCount\": 5, \r\n \"numRows\":\"2\"\r\n}"
},
{
"id": "StoreItemCarousel_Store_FoodItems",
"sectionType": 4,
"sectionSubType": "Store_FoodItems",
"source": "Store",
"sourceMetadata": "FoodItems",
"displayMetadata": "{\r\n \"itemCount\": 5, \r\n \"numRows\":\"2\"\r\n}"
},
{
"id": "StoreItemCarousel_Storefront_PopUpShop_12",
"sectionType": 4,
"sectionSubType": "Storefront_PopUpShop_12",
"source": "Storefront",
"sourceMetadata": "PopUpShop_12",
"displayMetadata": null
},
{
"id": "StoreItemCarousel_Storefront_PopUpShop_16",
"sectionType": 4,
"sectionSubType": "Storefront_PopUpShop_16",
"source": "Storefront",
"sourceMetadata": "PopUpShop_16",
"displayMetadata": null
},
{
"id": "StoreItemCarousel_Storefront_PopUpShop_17",
"sectionType": 4,
"sectionSubType": "Storefront_PopUpShop_17",
"source": "Storefront",
"sourceMetadata": "PopUpShop_17",
"displayMetadata": null
},
{
"id": "StoreItemCarousel_Store_Potions",
"sectionType": 4,
"sectionSubType": "Store_Potions",
"source": "UnifiedCuratedList",
"sourceMetadata": "StoreItemList_Potions",
"displayMetadata": "{\r\n \"itemCount\": 5,\r\n \"DisplayTitle\": \"Potions\"\r\n}"
},
{
"id": "StoreItemCarousel_Store_RandomBoxesItems",
"sectionType": 4,
"sectionSubType": "Store_RandomBoxesItems",
"source": "Store",
"sourceMetadata": "RandomBoxesItems",
"displayMetadata": "{\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_Store_KOIconsItems",
"sectionType": 4,
"sectionSubType": "Store_KOIconsItems",
"source": "Store",
"sourceMetadata": "KOIconsItems",
"displayMetadata": "{\r\n \"itemCount\": 5\r\n}"
}
]
{
"Id": "store-consumables-all",
"SectionType": 4,
"Source": "UnifiedAlgorithmicList",
"SourceMetadata": "consumableitems",
"DisplayMetadata": "{\"DisplayTitle\":\"Consumables\",\"numRows\":2,\"itemCount\":20,\"minItemsToShowSection\":1,\"horizontalScrollEnabled\":true}"
}
]
+15 -105
View File
@@ -1,106 +1,16 @@
[
{
"id": "StoreItemCarousel_UnifiedCuratedList_InternalMedieval",
"sectionType": 4,
"sectionSubType": "MedeivalItems",
"source": "UnifiedCuratedList",
"sourceMetadata": "Internal_Medieval_Items",
"displayMetadata": "{\r\n\"DisplayTitle\":\"FairyTale Fashions\"\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_UGCMedievalCarousel",
"sectionType": 4,
"sectionSubType": "Generic_MedievalCarousel",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "summerpartycarousel",
"displayMetadata": "{\r\n\"DisplayTitle\":\"Medieval Masterpieces from the Community\"\r\n}"
},
{
"id": "StoreItemCarousel_Storefront_PopUpShop_5",
"sectionType": 4,
"sectionSubType": "Storefront_PopUpShop5",
"source": "Storefront",
"sourceMetadata": "PopUpShop_5",
"displayMetadata": "{\r\n \"itemCount\": 5\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Trending2Row",
"sectionType": 4,
"sectionSubType": "StoreTrending",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "trendingitems",
"displayMetadata": "{\r\n\"DisplayTitle\":\"Trending\",\r\n\"numRows\":\"2\"\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_NewItemsFromFeaturedCreators",
"sectionType": 4,
"sectionSubType": "StoreNewItemsFromFeaturedCreators",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "newitemsfromcreators",
"displayMetadata": "{\r\n\"DisplayTitle\":\"Featured Creators\",\"numRows\":\"2\"\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_ItemsForYou2Row",
"sectionType": 4,
"sectionSubType": "Items_ForYou",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "itemsforyou",
"displayMetadata": "{\r\n\t\"DisplayTitle\":\"Recommended For You\",\"numRows\":\"2\"\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_BestSellers",
"sectionType": 4,
"sectionSubType": "BestSellingItems",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "bestsellingitems",
"displayMetadata": "{\r\n\"DisplayTitle\":\"Best Sellers\",\"numRows\":\"2\"\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedCuratedList_RoomieItems",
"sectionType": 4,
"sectionSubType": "RoomieItems",
"source": "UnifiedCuratedList",
"sourceMetadata": "StoreItems_Roomie_Collection",
"displayMetadata": "{\r\n\"DisplayTitle\":\"Stuff for Roomie\"\r\n}"
},
{
"id": "StoreItemCarousel_Storefront_PopUpShop_17",
"sectionType": 4,
"sectionSubType": "Storefront_PopUpShop_17",
"source": "Storefront",
"sourceMetadata": "PopUpShop_17",
"displayMetadata": null
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_WishlistCarousel",
"sectionType": 4,
"sectionSubType": "StoreWishlistCarousel",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "wishlistcarousel",
"displayMetadata": "{\r\n\"DisplayTitle\":\"Most Wishlisted\"\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_NewSetsForDisplay",
"sectionType": 4,
"sectionSubType": "NewSetsFromCreators",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "featuredsetscarousel",
"displayMetadata": "{\r\n\"DisplayTitle\":\"Featured Sets\"\r\n}"
},
{
"id": "CustomAvatarItemCarousel_CuratedList_StoreItemList_WeeklyMashup_FantasyBackpack",
"sectionType": 8,
"sectionSubType": "CuratedList_StoreItemList_WeeklyMashup_FantasyBackpack",
"source": "CuratedList",
"sourceMetadata": "CustomAvatarItemCarousel_CuratedList_StoreItemList_WeeklyMashup_FantasyBackpack",
"displayMetadata": "{\r\n\t\"DisplayTitle\":\"Fantasy Backpack\"\r\n}"
},
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_MostGiftedItems",
"sectionType": 4,
"sectionSubType": "StoreMostGiftedItems",
"source": "UnifiedAlgorithmicList",
"sourceMetadata": "mostgifteditems",
"displayMetadata": "{\r\n\"DisplayTitle\":\"Good for Gifts\"\r\n}"
}
]
{
"Id": "store-featured-hot",
"SectionType": 4,
"Source": "UnifiedAlgorithmicList",
"SourceMetadata": "hotitems",
"DisplayMetadata": "{\"DisplayTitle\":\"Hot Right Now\",\"numRows\":1,\"itemCount\":12,\"minItemsToShowSection\":1,\"horizontalScrollEnabled\":true}"
},
{
"Id": "store-featured-new",
"SectionType": 4,
"Source": "UnifiedAlgorithmicList",
"SourceMetadata": "newitems",
"DisplayMetadata": "{\"DisplayTitle\":\"New This Week\",\"numRows\":1,\"itemCount\":12,\"minItemsToShowSection\":1,\"horizontalScrollEnabled\":true}"
}
]
+10
View File
@@ -0,0 +1,10 @@
[
{
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Skins",
"sectionType": 4,
"sectionSubType": "Items_Skins",
"source": "AlgorithmicList",
"sourceMetadata": "skinsitems",
"displayMetadata": "{\r\n \"DisplayTitle\":\"Skins\",\r\n \"categoryDepth\":2,\r\n \"categoryCarouselType\": 5,\r\n \"categoryUriNames\": \"SkinsItems\",\r\n \"itemCount\": 5\r\n}"
}
]
-114
View File
@@ -1,114 +0,0 @@
[
{
"id": "Rooms_New_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_New",
"source": "Hot",
"sourceMetadata": "new",
"displayMetadata": "{\"DisplayTitle\":\"New\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\",\"unsupportedPlatforms\":[\"Switch\"]}"
},
{
"id": "RoomCategories_MoodPlaylists_FeelingLucky",
"sectionType": 12,
"sectionSubType": "RoomCategories",
"source": "CuratedList",
"sourceMetadata": "RoomCategories.MoodPlaylists.AlgoEndpoint.FeelingLucky",
"displayMetadata": "{\"DisplayTitle\":\"I'm Feeling Lucky\",\"unsupportedPlatforms\":[\"Switch\",\"Pico\",\"Oculus\"], \"unsupportedInteractionCategories\":[\"VR\"]}"
},
{
"id": "Rooms_RecentlyUpdated_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_RecentlyUpdated",
"source": "CarouselEndpoint",
"sourceMetadata": "recentlyupdated",
"displayMetadata": "{\"DisplayTitle\":\"Recently Updated\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Battle",
"source": "CarouselEndpoint",
"sourceMetadata": "battle_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Battle\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Quests",
"source": "CarouselEndpoint",
"sourceMetadata": "quests_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Quests\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Roleplay",
"source": "CarouselEndpoint",
"sourceMetadata": "roleplay_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Roleplay\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Horror_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Horror",
"source": "CarouselEndpoint",
"sourceMetadata": "horror_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Horror\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Hangout_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Hangout",
"source": "CarouselEndpoint",
"sourceMetadata": "hangout_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Hangout\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Casual",
"source": "CarouselEndpoint",
"sourceMetadata": "casual_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Casual\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Explore_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Exploration",
"source": "CarouselEndpoint",
"sourceMetadata": "explore_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Explore\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_ContinuePlaying_PlayLibrary",
"sectionType": 0,
"sectionSubType": "Rooms_ContinuePlaying",
"source": "Recent",
"sourceMetadata": null,
"displayMetadata": "{\"DisplayTitle\":\"Continue Playing\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_SavedForLater_PlayHighlight",
"sectionType": 0,
"sectionSubType": "Rooms_SavedForLater",
"source": "MyPlaylistByName",
"sourceMetadata": "__SavedForLater_Rooms",
"displayMetadata": "{\"DisplayTitle\":\"Saved for Later\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\", \"minItemsToShowSection\": 1}"
},
{
"id": "Rooms_Favorites_PlayLibrary",
"sectionType": 0,
"sectionSubType": "Rooms_PlayerFavorites",
"source": "MyFavorites",
"sourceMetadata": null,
"displayMetadata": "{\"DisplayTitle\":\"Favorites\",\"supportsDedupe\":\"false\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_MyRooms_Play",
"sectionType": 0,
"sectionSubType": "myrooms",
"source": "MyCreatedRooms",
"sourceMetadata": null,
"displayMetadata": "{\"DisplayTitle\":\"My Rooms\"}"
}
]
+114
View File
@@ -0,0 +1,114 @@
[
{
"id": "Rooms_New_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_New",
"source": "Hot",
"sourceMetadata": "new",
"displayMetadata": "{\"DisplayTitle\":\"New\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\",\"unsupportedPlatforms\":[\"Switch\"]}"
},
{
"id": "RoomCategories_MoodPlaylists_FeelingLucky",
"sectionType": 12,
"sectionSubType": "RoomCategories",
"source": "CuratedList",
"sourceMetadata": "RoomCategories.MoodPlaylists.AlgoEndpoint.FeelingLucky",
"displayMetadata": "{\"DisplayTitle\":\"I'm Feeling Lucky\",\"unsupportedPlatforms\":[\"Switch\",\"Pico\",\"Oculus\"], \"unsupportedInteractionCategories\":[\"VR\"]}"
},
{
"id": "Rooms_ContinuePlaying_PlayLibrary",
"sectionType": 0,
"sectionSubType": "Rooms_ContinuePlaying",
"source": "Recent",
"sourceMetadata": null,
"displayMetadata": "{\"DisplayTitle\":\"Continue Playing\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_RecentlyUpdated_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_RecentlyUpdated",
"source": "CarouselEndpoint",
"sourceMetadata": "recentlyupdated",
"displayMetadata": "{\"DisplayTitle\":\"Recently Updated\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Battle",
"source": "CarouselEndpoint",
"sourceMetadata": "battle_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Battle\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Quests",
"source": "CarouselEndpoint",
"sourceMetadata": "quests_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Quests\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Roleplay",
"source": "CarouselEndpoint",
"sourceMetadata": "roleplay_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Roleplay\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Horror_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Horror",
"source": "CarouselEndpoint",
"sourceMetadata": "horror_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Horror\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Hangout_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Hangout",
"source": "CarouselEndpoint",
"sourceMetadata": "hangout_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Hangout\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Casual",
"source": "CarouselEndpoint",
"sourceMetadata": "casual_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Casual\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Explore_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Exploration",
"source": "CarouselEndpoint",
"sourceMetadata": "explore_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Explore\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_SavedForLater_PlayHighlight",
"sectionType": 0,
"sectionSubType": "Rooms_SavedForLater",
"source": "MyPlaylistByName",
"sourceMetadata": "__SavedForLater_Rooms",
"displayMetadata": "{\"DisplayTitle\":\"Saved for Later\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\", \"minItemsToShowSection\": 1}"
},
{
"id": "Rooms_Favorites_PlayLibrary",
"sectionType": 0,
"sectionSubType": "Rooms_PlayerFavorites",
"source": "MyFavorites",
"sourceMetadata": null,
"displayMetadata": "{\"DisplayTitle\":\"Favorites\",\"supportsDedupe\":\"false\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_MyRooms_Play",
"sectionType": 0,
"sectionSubType": "myrooms",
"source": "MyCreatedRooms",
"sourceMetadata": null,
"displayMetadata": "{\"DisplayTitle\":\"My Rooms\"}"
}
]
+3
View File
@@ -16,6 +16,9 @@
// nothing documents and the client never asks for. With it, the Worker sees every
// request and the files are reachable only through the binding, i.e. only under
// `/sections/pagesource/:type`.
"cache": {
"enabled": true
},
"assets": {
"binding": "ASSETS",
"directory": "./static",
+110 -61
View File
@@ -220,49 +220,78 @@ parses it to finish the action, so a bare 200 reads as a failure and the item ne
finishes unlocking. Deletes are scoped to the caller, so an unauthenticated or
mismatched call is a harmless no-op (opening _another_ player's box is a 403).
## Weekly challenge (`static/weekly-challenge.json`)
## Weekly challenge (`src/challenge-rotation.ts`)
Served by `GET /api/challenge/v2/getCurrent` (with each challenge's per-player `Complete`
stamped in — see Progress below). The server never evaluates the rules: the client reads
the rule tree in each challenge's `Config`, watches its own gameplay, and posts the tree
back to `/api/challenge/v2/updateProgress` with its verdict. So this file is the entire
definition of a week's challenges — ids, display strings, matching rules and the reward
preview.
and `Config` stamped in — see Progress below). The server never evaluates the rules: the
client reads the rule tree in each challenge's `Config`, watches its own gameplay, and posts
the tree back to `/api/challenge/v2/updateProgress` with its verdict. So a rotation is the
entire definition of a week's challenges — ids, display strings, matching rules and the
reward preview.
**Rotations are generated from the calendar week, not authored.** `buildRotation(now)` in
`src/challenge-rotation.ts` derives everything from the week index: five challenges drawn
from a pool of rooms crossed with the challenge kinds each room supports, the week's window,
and a `ChallengeMapId` of `1000 + weekIndex`. It is a pure function of which week it is, and
that is load-bearing rather than tidy — `challenge_status` rows are scoped by
`ChallengeMapId` and the gift threshold counts completions against `Challenges`, so two
callers who disagreed about what the week holds would disagree about who had finished it.
Selection runs off a seeded PRNG (mulberry32 over the week index); nothing calls
`Math.random()`.
The week rolls at **Wednesday 21:00 UTC**, the boundary both captured rotations sit on,
counted from an epoch of 2020-01-01. Each week publishes five challenges, no room twice and
at most two of any one kind, so a week is never five variations of "finish some games".
| Kind | Asks for | Target | Rooms |
| ------- | ---------------------------------------- | ------ | --------------------------------------- |
| `games` | Finished games in one room | 5 | Head-to-head and score-based rooms |
| `win` | One finished game, won (or quest closed) | 1 | Quests, plus rooms with a real opponent |
| `ai` | Enemies defeated in one room | 10 | Quests — the rooms with enemies in them |
`static/weekly-challenge.json` still ships and still **wins**: a non-empty `Challenges` array
there pins the week to that hand-authored rotation and skips generation entirely, which is
how a debug or event week gets served without a code change. Empty (as shipped), it supplies
only what generation doesn't own — `CompletedRequired`, `FallbackGiftName` and
`ChallengeThemeString`, plus the `Gift` block used as a fallback if the catalog can't be read.
Everything below was read off reference data (one captured live rotation), not a spec.
Field meanings marked _(inferred)_ are read from how the values line up with the strings
the client renders; the rest are pinned by the data itself. The file itself is edited
freely as rotations change — the examples here are the captured week, so expect the shipped
rotation to differ.
Field meanings marked _(inferred)_ are read from how the values line up with the strings the
client renders; the rest are pinned by the data itself. The field notes describe both the
generated rotation and the pinned file, since they are the same shape on the wire.
### Top level
| Field | Example | Notes |
| ---------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `ChallengeMapId` | `17` | Id of the rotation as a whole ("map" of challenges). Echoed back on `updateProgress`; bump it when you publish a new week. |
| `CompletedRequired` | `false` | _(inferred)_ All-or-nothing: `true` makes the `Gift` need every challenge, `false` the three-of-five threshold below. |
| `StartAt` / `EndAt` | `2026-03-25T21:00:00` | The window, 7 days apart, **no timezone suffix** — unlike `ServerTime`. Treat as UTC. |
| `ServerTime` | `2026-03-31T14:42:54.2754728Z` | .NET round-trip timestamp (7-digit fraction, `Z`). The client dates the countdown off this, so it is **frozen** — see below. |
| `Challenges` | array | The week's challenges, rendered in order. |
| `Gift` | object | The reward preview for finishing the set. |
| `FallbackGiftName` | `"4-Star Box"` | Shown when the client can't resolve `Gift` into a name. |
| `ChallengeThemeString` | a designer quote | Free text carried through from the captured rotation; a theme note, not a rendered UI string as far as we can tell. |
| Field | Example | Notes |
| ---------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ChallengeMapId` | `1347` | Id of the rotation as a whole ("map" of challenges). Echoed back on `updateProgress`. Generated as `1000 + weekIndex`; the four-digit floor keeps it clear of hand-authored ids (17, 19), which would otherwise read a player's old rows as progress against a different set. |
| `CompletedRequired` | `false` | _(inferred)_ All-or-nothing: `true` makes the `Gift` need every challenge, `false` the three-of-five threshold below. |
| `StartAt` / `EndAt` | `2026-03-25T21:00:00` | The window, 7 days apart, **no timezone suffix** — unlike `ServerTime`. Treat as UTC. |
| `ServerTime` | `2026-03-31T14:42:54.2754728Z` | .NET round-trip timestamp (7-digit fraction, `Z`). The client dates the countdown off this. Generated rotations send the REAL clock — see below. |
| `Challenges` | array | The week's challenges, rendered in order. |
| `Gift` | object | The reward preview for finishing the set. |
| `FallbackGiftName` | `"4-Star Box"` | Shown when the client can't resolve `Gift` into a name. |
| `ChallengeThemeString` | a designer quote | Free text carried through from the captured rotation; a theme note, not a rendered UI string as far as we can tell. |
**The frozen clock:** `ServerTime` (Mar 31) sits _inside_ `StartAt``EndAt` (Mar 25 → Apr 1),
about a day before the end, and the file is static — so the client always sees an active
rotation with a ~1-day countdown rather than an expired one. If you edit the window, move
`ServerTime` inside the new one too, or the challenges may render as already over.
**The clock:** a generated rotation's window is genuinely the current week, so `ServerTime`
is simply now and the countdown the client draws is real — the challenges expire on Wednesday
at 21:00 UTC and the next week's set replaces them.
That is the thing generation fixes. A **pinned** rotation is static, so its `ServerTime` has
to be _frozen_ inside `StartAt``EndAt` — a day before the end is the captured shape (Mar 31
inside Mar 25 → Apr 1) — or the client renders the week as already over. Move the window on a
pinned rotation and you must move `ServerTime` into it too.
### A challenge entry
| Field | Notes |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ChallengeId` | Unique within the rotation, not sequential (`37, 38, 44, 49, 63`). Posted back on `updateProgress`. |
| `Name` | Internal slug, never displayed — and **not authoritative**: `63` is named `Complete3SpillwayGames` but its `Config` and description are Clearcut. Trust `Config`, not the name. |
| `Config` | The rule tree, as an **escaped JSON string** (not a nested object). See below. |
| `Description` | The one-line goal, e.g. `"Complete 10 games in ^Paintball"`. |
| `Tooltip` | The longer hint under it. |
| `Complete` | Per-player state, so always `false` in the file`getCurrent` overwrites it per caller from `challenge_status`. |
| Field | Notes |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ChallengeId` | Unique within the rotation, not sequential (`37, 38, 44, 49, 63`). Posted back on `updateProgress`. Generated ids are the index of the (room, kind) pair in the generator's candidate list, so id 12 is always the same challenge — append rooms, never insert. |
| `Name` | Internal slug, never displayed — and **not authoritative**: `63` is named `Complete3SpillwayGames` but its `Config` and description are Clearcut. Trust `Config`, not the name. |
| `Config` | The rule tree, as an **escaped JSON string** (not a nested object). See below. |
| `Description` | The one-line goal, e.g. `"Complete 10 games in ^Paintball"`. |
| `Tooltip` | The longer hint under it. |
| `Complete` | Per-player state, so always `false` as generated`getCurrent` overwrites it per caller from `challenge_status`, along with `Config`. |
`^Token` in `Description`/`Tooltip` is a client-side room link: the client resolves the
token to a room and renders a tappable name. Subrooms use a dotted path
@@ -279,10 +308,10 @@ scene allow-list (`ct: 7`, subroom `UnitySceneId`s) or a session variable (`ct:
`won`). The server never evaluates any of it — the client does, and posts the tree back with
its own count written in.
**Reading or writing one? See `.agents/weekly-challenge-config/SKILL.md`** — the full
grammar, the two idioms the file uses, how to resolve a scene guid to a room, the shared
scenes that make a challenge complete in more rooms than you meant (`Soccer` and `Stadium`
are one scene), and an authoring checklist.
**Reading or writing one? See `.agents/skills/weekly-challenge-config/SKILL.md`** — the full
node-type enum, the event types and session variables a predicate can match, the named scene
constants (including the shared scenes that make a challenge complete in more rooms than you
meant — `Soccer` and `Stadium` are one scene), the three idioms, and an authoring checklist.
### The `Gift` block
@@ -292,21 +321,29 @@ of avatar-item guids, `AvatarItemType`, `ConsumableItemDesc`, `EquipmentPrefabNa
**renamed**: a storefront's `Context`/`Rarity` are `GiftContext`/`GiftRarity` here. Don't
feed one shape to the other's reader.
`EquipmentModificationGuid` is the Rec Room packed guid — 22-char URL-safe base64 of the 16
guid bytes in .NET little-endian order, padding stripped (`g5u0weNLmkCLeUXFUVn74Q`
`c1b49b83-4be3-409a-8b79-45c55159fbe1`). The reward is identified by prefab + that guid,
_not_ by `GiftDropId`: this block's `GiftDropId` is `3994`, while the same skin sells in
`sf3.json` as `2121` ("Camera Skin (Comic)").
**Weekly rewards are equipment**, so a generated week rolls one from sf3 — every item there
carrying an `EquipmentModificationGuid` (187 of its 1161), drawn with the week's own seed.
Drawing from the live catalog rather than a copied list is what lets the grant path resolve
the pick back to the entry selling it, so the player receives a properly named item; the
block a generated week emits carries that entry's `GiftDropId` and rarity.
`EquipmentModificationGuid` is sometimes the Rec Room packed guid — 22-char URL-safe base64
of the 16 guid bytes in .NET little-endian order, padding stripped (`g5u0weNLmkCLeUXFUVn74Q`
`c1b49b83-4be3-409a-8b79-45c55159fbe1`) — and sometimes a plain guid; sf3 carries both forms
and they are matched as opaque strings, never converted. The reward is identified by prefab +
that guid, _not_ by `GiftDropId`: the captured block's `GiftDropId` is `3994`, while the same
skin sells in `sf3.json` as `2121` ("Camera Skin (Comic)").
**Granted when the set is finished** — see below. The grant path is `buyItem`'s, so the
block is translated into a storefront gift-drop first (`toChallengeGiftDrop`); the renamed
`GiftContext`/`GiftRarity` are exactly what that translation is for.
The block carries no display strings and a `GiftRarity` of `0` for an item that sells at
rarity `5`, so both are taken from the catalog entry selling the same item (matched on
equipment guid / avatar desc) — the reward reads as "Camera Skin (Comic)", not as the box it
might have arrived in. An explicit `FriendlyName`/`Tooltip` on the block wins over the
catalog if a rotation we publish sets them; neither is present in the captured one.
The block carries no display strings (and the captured one carries a `GiftRarity` of `0` for
an item that sells at rarity `5`), so both are taken from the catalog entry selling the same
item, matched on equipment guid / avatar desc — the reward reads as "Camera Skin (Comic)",
not as the box it might have arrived in. An explicit `FriendlyName`/`Tooltip` on the block
wins over the catalog if a pinned rotation sets them; neither is present in the captured one
or in a generated block.
**`FallbackGiftName` is the other half of the reward, not just a label.** "4-Star Box" is
what the player gets _instead_ when they already own the item — the real game phrased it
@@ -318,8 +355,8 @@ consolation tier with no code change; a name that doesn't parse falls back to 4
There is no claim endpoint and the client never asks: the reward is handed out from the
`updateProgress` call that reaches the threshold. Every completing report on the **live**
rotation re-reads the caller's completions and, once enough of `weekly-challenge.json`'s
challenges are there, grants the `Gift` the way a purchase grants a drop — the item into
rotation re-reads the caller's completions and, once enough of the week's own challenges are
there, grants the `Gift` the way a purchase grants a drop — the item into
`inventory`/`equipment`/`consumable`, plus a gift box (message
`Weekly challenge complete!`) the player finds in `GET /api/avatar/v2/gifts`.
@@ -369,29 +406,41 @@ the block empty and naming the tier.
### Progress (`challenge_status`)
`POST /api/challenge/v2/updateProgress` (auth-gated) upserts one row per (account,
challenge) into `challenge_status`, and `getCurrent` reads them back to stamp `Complete`.
The body is `{ ChallengeMapId, ChallengeId, Config, Complete }` with the ids as **strings**
and `Complete` as .NET's `"True"`/`"False"` — capitalized, so `Boolean(body.Complete)` reads
challenge) into `challenge_status`, and `getCurrent` reads them back to stamp each
challenge's `Complete` **and `Config`**. The body is
`{ ChallengeMapId, ChallengeId, Config, Complete }` with the ids as **strings** and
`Complete` as .NET's `"True"`/`"False"` — capitalized, so `Boolean(body.Complete)` reads
"not complete" as complete (`parseBool` handles both spellings and a real JSON `true`).
Only the completion is stored. `Config` is the catalog's own rule tree plus the client's
running count, so a per-player copy would just be a staler duplicate of static data — it is
echoed back untouched but never persisted. The response is the four posted fields, except
`Complete` is the **stored** value rather than the posted one, because:
**The client does the evaluating, and `Config` is its scratchpad.** It walks the rule tree
locally and posts that tree back with its own progress written into the nodes — `cc` on a
counter is the running count, `c` marks a satisfied node — so the posted tree is per-player
state, not a copy of the catalog, and it is stored. `getCurrent` then serves the static
challenge with the stored `Config` and `Complete` overwritten onto it; serving the pristine
authored tree instead (what this used to do) threw away partial progress on every login. The
server still evaluates none of the tree.
The response is the four posted fields, except `Complete` and `Config` are the **stored**
values rather than the posted ones, because:
- **Completion latches within a rotation.** The client reports repeatedly, and a later
report saying "not complete" (a fresh session, a retry arriving out of order) must not
un-finish something already finished.
- **A report carrying no `Config` keeps the stored tree.** `config` itself doesn't latch —
it's a tally, so the newest tree wins — but a report without one is missing data, not a
reset, and must not blank the progress.
- **A new rotation resets the row.** Challenge ids are only unique within a rotation, so
the same id in a later week would otherwise start out already complete. A report whose
`ChallengeMapId` differs from the stored one replaces the row instead of latching; reads
are scoped to the rotation for the same reason.
the same id in a later week would otherwise start out already complete, and half-counted.
A report whose `ChallengeMapId` differs from the stored one replaces the row instead of
latching; reads are scoped to the rotation for the same reason.
`getCurrent`'s auth is **optional** — an unauthenticated caller gets the static rotation
with every `Complete` false rather than a 401, since the rotation is public and a failure
on this route can stall the client's load. The overlay rebuilds the response object rather
with every `Complete` false and every `Config` as authored, rather than a 401, since the
rotation is public and a failure on this route can stall the client's load. A challenge the
caller has never reported keeps its authored `Config` too (a stored `NULL`), since a client
handed a null tree has nothing to evaluate. The overlay rebuilds the response object rather
than stamping the imported JSON in place: that import is module state shared across every
request an isolate serves, so mutating it would leak one player's completions to the next
request an isolate serves, so mutating it would leak one player's progress to the next
caller.
## Game rewards (`reward_status`)
+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 the generated
// catalog migration as a STRING and diff it against the captures it was generated from —
// it is counted, never executed.
declare module '*.sql?raw' {
const content: string
export default content
}
@@ -0,0 +1,16 @@
-- Store the `Config` rule tree the client posts with each weekly-challenge progress report.
--
-- Migration 0009 kept only the completion flag, on the grounds that the tree is the
-- challenge's definition (static/weekly-challenge.json) and therefore identical for every
-- player. That is only true of the tree the SERVER publishes: the client posts it back with
-- its own progress written into the nodes — `cc` on a counter is the running count, `c`
-- marks a satisfied node — so the posted copy is per-player state, and dropping it threw
-- away the only record of how far along a player was. The client does the evaluating; this
-- is where the partial progress it reports has to live between sessions.
--
-- Nullable, and NULL is meaningful: no report has been stored for that challenge yet (or a
-- report arrived without a `Config`), so `/api/challenge/v2/getCurrent` serves the static
-- tree for it unchanged. Kept in sync with CHALLENGE_STATUS_SCHEMA_DDL in
-- src/challenge-db.ts.
ALTER TABLE challenge_status ADD COLUMN config TEXT;
+59
View File
@@ -0,0 +1,59 @@
-- The item catalog: every avatar item and every equipment skin the game knows about.
--
-- STRUCTURE ONLY. This migration holds no rows: the catalog's CONTENTS are loaded by
-- `runx catalog load [--remote]` from apps/econ/static/db/avatar-items.json and
-- apps/econ/static/db/skins.json.
--
-- That split is deliberate. The item list changes as the game's does, and that is not a
-- schema change -- putting rows here would mean a migration and a deploy per refresh, an
-- ever-growing pile of near-identical data migrations, and no way to reload without writing
-- another one. Versioning the structure and reloading the contents on demand also makes a
-- refresh a readable `git diff` of the JSON rather than of 700KB of generated SQL.
--
-- ONE KEY spans both kinds: `item_key` is an avatar item's `AvatarItemDesc` and a skin's
-- `ModificationGuid`. Neither repeats, the two never collide, and no item has both -- so the
-- catalog is keyed the same way the INVENTORY is, and resolving what a player owns is a
-- lookup on this column rather than a join keyed on which kind of thing it turned out to be.
--
-- Not every key is a GUID: 191 skins and 109 avatar items carry the short alpha-string ids
-- the game used before it moved to GUIDs. The column is TEXT and compared as text; do not add
-- a uuid-shaped constraint, and do not try to parse one.
--
-- `avatar_item_id` is carried as DATA ONLY and is deliberately not indexed. It cannot key
-- anything: it is missing from 22 avatar items (the permanent hair dyes have no id at all)
-- and repeated on 9 more -- id 9503 alone is shared by five unrelated developer items.
--
-- `tooltip` is deliberately nullable, on both kinds: the capture carries NULL and "" as
-- different values and the client's DTOs serve the difference through. Do not default it.
--
-- `Favorited` from skins.json is NOT loaded: it is a player's flag, not a property of the
-- skin, and the capture recorded one account's. It is projected as false and overwritten
-- from the player's own `equipment` row -- see src/catalog-db.ts.
CREATE TABLE IF NOT EXISTS catalog (
item_key TEXT PRIMARY KEY,
kind TEXT NOT NULL,
friendly_name TEXT NOT NULL,
tooltip TEXT,
rarity INTEGER NOT NULL DEFAULT 0,
platform_mask INTEGER NOT NULL DEFAULT -1,
thumbnail_image TEXT,
avatar_item_type INTEGER,
avatar_item_id INTEGER,
is_base_avatar_item INTEGER,
tag_list TEXT,
created_at TEXT,
prefab_name TEXT,
unlocked_level INTEGER
);
-- The search index. Folded, because a name search is case-insensitive and SQLite's LIKE only
-- folds ASCII -- which these names are not all of.
CREATE INDEX IF NOT EXISTS idx_catalog_name ON catalog (kind, lower(friendly_name));
-- Every skin of one prefab, which is how a skin picker is filled.
CREATE INDEX IF NOT EXISTS idx_catalog_prefab
ON catalog (prefab_name) WHERE prefab_name IS NOT NULL;
-- Seasonal rows ('halloween', 'music', ...): a handful of tags over 3000-odd rows.
CREATE INDEX IF NOT EXISTS idx_catalog_tag ON catalog (tag_list) WHERE tag_list IS NOT NULL;
+33
View File
@@ -0,0 +1,33 @@
-- A small numeric id for a catalog row, for the places that need to name an item as a NUMBER
-- rather than as its `item_key` (a comma-laden `AvatarItemDesc` or a 22-to-36 character guid,
-- neither of which belongs in a URL or a compact index).
--
-- It is a LOAD-ORDER SURROGATE and nothing more. `runx catalog load` assigns it, so it is
-- stable only until the next load: a `--replace` renumbers everything from 1, and a merge
-- renumbers whatever the captures mention. Nothing may store it, reference it across a load,
-- or treat it as an item's identity -- `item_key` is the identity, and it is what the
-- inventory stores. Anything durable that points at a catalog row must point with the key.
--
-- Unique, because a numeric handle that names two rows is useless as a handle. The loader
-- numbers the captures from 10000 upward and pushes anything already in the table but absent
-- from them above that range, so the numbering stays collision-free through a merge as well as
-- a replace.
--
-- It starts at 10000 rather than 1 because a generated storefront lists a row under this very
-- number as its `PurchasableItemId`, and every captured storefront's own ids are 2764 or below.
-- Numbering from 1 would have collided with sf3's head-on, so one id would mean two different
-- items depending on which storefront the client read it from.
--
-- Nullable, because that is the state between "the row exists" and "the load has numbered
-- it": the loader clears the column first so a merge cannot collide with stale numbers, and
-- fills it as it goes. A NULL here after a load means the load did not finish -- the CLI
-- verifies row counts and probe keys for exactly that reason. SQLite treats NULLs as distinct
-- for uniqueness, so any number of un-numbered rows can coexist.
--
-- Its own migration rather than folded into 0015, because 0015 is already applied: an edit
-- there would never re-run on a database that has recorded it.
ALTER TABLE catalog ADD COLUMN catalog_id INTEGER;
CREATE UNIQUE INDEX IF NOT EXISTS idx_catalog_id
ON catalog (catalog_id) WHERE catalog_id IS NOT NULL;
+3 -2
View File
@@ -16,12 +16,13 @@
"test": "run-vitest"
},
"dependencies": {
"@2toad/profanity": "3.3.0",
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@repo/jwt": "workspace:*",
"@standard-community/standard-json": "0.3.5",
"@standard-community/standard-openapi": "0.2.9",
"hono": "4.12.27",
"hono": "4.13.5",
"hono-openapi": "1.3.1",
"openapi-types": "12.1.3",
"workers-tagged-logger": "1.0.1",
@@ -33,6 +34,6 @@
"@repo/typescript-config": "workspace:*",
"@types/node": "26.0.1",
"vitest": "4.1.9",
"wrangler": "4.105.0"
"wrangler": "4.128.0"
}
}
+354
View File
@@ -0,0 +1,354 @@
import { CatalogKind } from './catalog-load'
import type { CatalogKindValue } from './catalog-load'
/**
* The item CATALOG on the shared `recflare` D1 database every avatar item and every
* equipment skin the game knows about, one row each, loaded once by migration rather than
* written at runtime. Nothing here is per-player: ownership lives in `inventory` and
* `equipment`, and this table only says what a thing IS.
*
* It exists to be QUERIED. The catalogs were previously reachable only by parsing a whole
* storefront JSON per request (`sf{N}.json`, 1161 items in sf3 alone) and by scanning the
* bundled `default-avatar-items.json`, which means every lookup pays for the whole file and
* nothing can be searched by name or filtered by tag at all. A row with indexes answers those
* in one statement.
*
* BOTH kinds share one table because they share most of a record a display name, a tooltip, a
* rarity, a platform mask, a thumbnail and because the interesting queries ("what is called
* X", "what is this thing the player owns") run across both. `kind` discriminates, and the
* columns each kind alone carries are nullable and empty on the other. Read a row through
* {@link toCatalogAvatarItem}/{@link toCatalogSkin} rather than serving it raw: the client's
* two DTOs share no key order and differ in what they omit.
*
* Where the rows come from: `static/db/avatar-items.json` and `static/db/skins.json`, both
* captured from the reference and loaded by `runx catalog load`.
*
* The migration builds the TABLE ONLY it holds no rows. The catalog changes as the game's
* item list changes, and that is not a schema change: a migration per refresh would mean a
* deploy per refresh, an ever-growing pile of near-identical data migrations, and no way to
* reload without inventing a new one. So the structure is versioned and the contents are
* (re)loaded on demand, which also makes a refresh a `git diff` of the JSON rather than of
* 700KB of generated SQL.
*
* This worker (`econ`) owns the table and its migration.
*/
// The discriminator and the JSON→row mapping live in `catalog-load.ts`, which deliberately
// touches no Workers types: `runx catalog load` (a plain Node CLI in @repo/tools) imports it,
// and importing this module instead would drag `D1Database` into a package that has no such
// types. Re-exported here so callers still get the whole table from one import.
export {
buildCatalogLoad,
CATALOG_INSERT_COLUMNS,
CatalogKind,
type AvatarItemCapture,
type CatalogCollision,
type CatalogKindValue,
type CatalogLoadRow,
type CatalogValue,
type SkinCapture,
} from './catalog-load'
/**
* Schema DDL (mirror of migrations/0015_catalog.sql) also builds the table in tests.
*
* ONE KEY spans both kinds. `item_key` is an avatar item's `AvatarItemDesc` and a skin's
* `ModificationGuid`: neither repeats, the two never collide, and no item has both. That is
* also how the INVENTORY identifies what a player owns, so resolving an owned thing is a lookup
* on this column not a join that first has to establish which kind of thing it is.
*
* Not every key is a GUID. 191 skins and 109 avatar items carry the short alpha-string ids the
* game used before it moved to GUIDs (`_OWVy3z6iU-M3-zbQgSLig`), so the column is TEXT and
* compared as text. Do not add a uuid-shaped constraint and do not try to parse one.
*
* `avatar_item_id` is carried as DATA ONLY and deliberately not indexed: it is missing from 22
* avatar items and repeated on 9 more (id 9503 alone covers five unrelated developer items), so
* it can neither key nor reliably find anything.
*
* `catalog_id` is the small NUMERIC handle the site uses where a key would be unwieldy, and is
* what a generated storefront lists a row under as its `PurchasableItemId`. It is assigned by
* the loader from `CATALOG_ID_BASE` (10000, clear of every captured storefront's own ids) and
* renumbered by every load, so it identifies a row only within one load see the field's own
* note. 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
* column may not be defaulted to the empty string.
*/
export const CATALOG_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS catalog (
item_key TEXT PRIMARY KEY,
catalog_id INTEGER,
kind TEXT NOT NULL,
friendly_name TEXT NOT NULL,
tooltip TEXT,
rarity INTEGER NOT NULL DEFAULT 0,
platform_mask INTEGER NOT NULL DEFAULT -1,
thumbnail_image TEXT,
avatar_item_type INTEGER,
avatar_item_id INTEGER,
is_base_avatar_item INTEGER,
tag_list TEXT,
created_at TEXT,
prefab_name TEXT,
unlocked_level INTEGER
)`,
// 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.
`CREATE INDEX IF NOT EXISTS idx_catalog_name ON catalog (kind, lower(friendly_name))`,
// Every skin of one prefab, which is how a skin picker is filled.
`CREATE INDEX IF NOT EXISTS idx_catalog_prefab ON catalog (prefab_name) WHERE prefab_name IS NOT NULL`,
// Seasonal rows ('halloween', 'music', …) — a handful of tags over 3000-odd rows, so the
// index is worth far more than its size.
`CREATE INDEX IF NOT EXISTS idx_catalog_tag ON catalog (tag_list) WHERE tag_list IS NOT NULL`,
// The numeric handle. Unique where set — a number that names two rows is useless as a handle
// — and partial, because a row is un-numbered between existing and being numbered by a load.
`CREATE UNIQUE INDEX IF NOT EXISTS idx_catalog_id ON catalog (catalog_id) WHERE catalog_id IS NOT NULL`,
]
/** A catalog row exactly as stored — snake_case, both kinds' columns, most of them null. */
export interface CatalogRow {
item_key: string
/**
* A small numeric handle for the row for the surfaces that need to name an item as a
* number rather than as a comma-laden `AvatarItemDesc` or a guid, and the
* `PurchasableItemId` a generated storefront lists it under.
*
* A LOAD-ORDER SURROGATE, not an identity: `runx catalog load` assigns it, so it is stable
* only until the next load. Never store it, never reference it across a load, 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.
*/
catalog_id: number | null
kind: string
friendly_name: string
tooltip: string | null
rarity: number
platform_mask: number
thumbnail_image: string | null
avatar_item_type: number | null
avatar_item_id: number | null
is_base_avatar_item: number | null
tag_list: string | null
created_at: string | null
prefab_name: string | null
unlocked_level: number | null
}
/**
* The catalog's view of an avatar item the bundled `default-avatar-items.json` record plus
* the four fields that file leaves out (`AvatarItemId`, `IsBaseAvatarItem`, `CreatedAt`,
* `ThumbnailImage`), which is what `GET /api/avatar/v1/defaultunlocked` serves and what the
* store rows resolve against.
*
* `AvatarItemId` is nullable and `Tooltip` may be null; both are true of the captured data and
* the client reads them that way. Do not tighten either to keep a projection simple.
*/
export interface CatalogAvatarItem {
AvatarItemDesc: string
AvatarItemType: number
PlatformMask: number
FriendlyName: string
Tooltip: string | null
Rarity: number
TagList: string | null
AvatarItemId: number | null
IsBaseAvatarItem: boolean
CreatedAt: string | null
ThumbnailImage: string | null
}
/**
* The catalog's view of a skin, in the client's `Equipment` shape plus the two fields the
* capture carries that an owned-equipment row does not (`UnlockedLevel`, `ThumbnailImage`).
*
* `Favorited` is a PLAYER's flag, not a property of the skin, so the catalog does not store it
* every captured row said `false` because the capture belonged to one account. It is
* projected as `false` here and overwritten from the owning player's `equipment` row; storing
* it would make one player's favourites everyone's.
*/
export interface CatalogSkin {
PrefabName: string
ModificationGuid: string
UnlockedLevel: number
Favorited: boolean
PlatformMask: number
FriendlyName: string
Tooltip: string | null
Rarity: number
ThumbnailImage: string | null
}
/** Projects a row to the avatar-item record. Throws on a row of the wrong kind. */
export function toCatalogAvatarItem(row: CatalogRow): CatalogAvatarItem {
if (row.kind !== CatalogKind.AvatarItem) {
throw new Error(`catalog row ${row.item_key} is a ${row.kind}, not an avatar item`)
}
return {
// The key IS the desc — that is what makes it the key.
AvatarItemDesc: row.item_key,
AvatarItemType: row.avatar_item_type ?? 0,
PlatformMask: row.platform_mask,
FriendlyName: row.friendly_name,
Tooltip: row.tooltip,
Rarity: row.rarity,
TagList: row.tag_list,
AvatarItemId: row.avatar_item_id,
IsBaseAvatarItem: row.is_base_avatar_item === 1,
CreatedAt: row.created_at,
ThumbnailImage: row.thumbnail_image,
}
}
/** Projects a row to the skin record. Throws on a row of the wrong kind. */
export function toCatalogSkin(row: CatalogRow): CatalogSkin {
if (row.kind !== CatalogKind.Skin) {
throw new Error(`catalog row ${row.item_key} is a ${row.kind}, not a skin`)
}
return {
PrefabName: row.prefab_name ?? '',
// The key IS the guid.
ModificationGuid: row.item_key,
UnlockedLevel: row.unlocked_level ?? 0,
Favorited: false,
PlatformMask: row.platform_mask,
FriendlyName: row.friendly_name,
Tooltip: row.tooltip,
Rarity: row.rarity,
ThumbnailImage: row.thumbnail_image,
}
}
/**
* The base asset an avatar item is built on the FIRST field of its `AvatarItemDesc`, which is
* `<baseAsset>,<color>,<texture>,`. The client can only draw an item whose base asset it
* already ships with, so this is the field that decides whether a store row renders anything
* (see the Generic-row notes in the `lists` worker).
*/
export function baseAsset(desc: string): string {
return desc.split(',')[0] ?? ''
}
/**
* One catalog row by its key, WHICHEVER kind it is. This is the lookup the inventory wants: a
* player's owned things are ids of exactly this shape, and the row that comes back says which
* kind it turned out to be.
*/
export async function getCatalogItem(db: D1Database, itemKey: string): Promise<CatalogRow | null> {
return await db
.prepare('SELECT * FROM catalog WHERE item_key = ?1')
.bind(itemKey)
.first<CatalogRow>()
}
/**
* One catalog row by its numeric handle.
*
* Only meaningful WITHIN a load: the number is reassigned every time the catalog is loaded, so
* a caller holding one from before a reload will get a different item or nothing at all. Fine
* for a request that looked the number up moments ago; never for anything stored.
*/
export async function getCatalogItemById(
db: D1Database,
catalogId: number
): Promise<CatalogRow | null> {
return await db
.prepare('SELECT * FROM catalog WHERE catalog_id = ?1')
.bind(catalogId)
.first<CatalogRow>()
}
/**
* 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
* statement for a whole inventory, which is the point of the table.
*/
export async function getCatalogItems(db: D1Database, itemKeys: string[]): Promise<CatalogRow[]> {
if (itemKeys.length === 0) return []
const placeholders = itemKeys.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT * FROM catalog WHERE item_key IN (${placeholders})`)
.bind(...itemKeys)
.all<CatalogRow>()
const byKey = new Map(results.map((r) => [r.item_key, r]))
return itemKeys.flatMap((key) => byKey.get(key) ?? [])
}
/** One avatar item by its `AvatarItemDesc`. Null for an unknown key OR for a skin's key. */
export async function getAvatarItem(
db: D1Database,
avatarItemDesc: string
): Promise<CatalogAvatarItem | null> {
const row = await getCatalogItem(db, avatarItemDesc)
return row?.kind === CatalogKind.AvatarItem ? toCatalogAvatarItem(row) : null
}
/** One skin by its `ModificationGuid`. Null for an unknown key OR for an avatar item's key. */
export async function getSkin(
db: D1Database,
modificationGuid: string
): Promise<CatalogSkin | null> {
const row = await getCatalogItem(db, modificationGuid)
return row?.kind === CatalogKind.Skin ? toCatalogSkin(row) : null
}
/** Every skin of one prefab (`[MakerPen]`, `[QuestSword]`, …), by name. */
export async function getSkinsForPrefab(
db: D1Database,
prefabName: string
): Promise<CatalogSkin[]> {
const { results } = await db
.prepare('SELECT * FROM catalog WHERE prefab_name = ?1 ORDER BY friendly_name')
.bind(prefabName)
.all<CatalogRow>()
return results.map(toCatalogSkin)
}
/**
* Name search within one kind a case-insensitive substring match, ordered by name so paging
* is stable.
*
* Both sides are lowered so the comparison hits `idx_catalog_name`, whose second column is
* `lower(friendly_name)`: SQLite's `LIKE` folds case for ASCII only, and these names are not
* all ASCII. `%` and `_` in the needle are escaped, so a player searching for a literal
* underscore gets that rather than a wildcard.
*/
export async function searchCatalog(
db: D1Database,
kind: CatalogKindValue,
needle: string,
limit = 50
): Promise<CatalogRow[]> {
const escaped = needle.toLowerCase().replace(/[\\%_]/g, (ch) => `\\${ch}`)
const { results } = await db
.prepare(
`SELECT * FROM catalog
WHERE kind = ?1 AND lower(friendly_name) LIKE ?2 ESCAPE '\\'
ORDER BY friendly_name, item_key LIMIT ?3`
)
.bind(kind, `%${escaped}%`, limit)
.all<CatalogRow>()
return results
}
/** Every avatar item carrying a seasonal tag (`halloween`, `music`, …), by name. */
export async function getAvatarItemsByTag(
db: D1Database,
tag: string
): Promise<CatalogAvatarItem[]> {
const { results } = await db
.prepare('SELECT * FROM catalog WHERE tag_list = ?1 ORDER BY friendly_name')
.bind(tag)
.all<CatalogRow>()
return results.map(toCatalogAvatarItem)
}
/** How many rows of each kind the catalog holds — the cheap check that a load actually landed. */
export async function countCatalog(db: D1Database): Promise<Record<string, number>> {
const { results } = await db
.prepare('SELECT kind, COUNT(*) AS n FROM catalog GROUP BY kind')
.all<{ kind: string; n: number }>()
return Object.fromEntries(results.map((r) => [r.kind, r.n]))
}
+288
View File
@@ -0,0 +1,288 @@
/**
* Turning the captured JSON into `catalog` rows the loader half of the item catalog, used by
* `runx catalog load` (in @repo/tools) rather than by the worker.
*
* Separate from `catalog-db.ts` for one reason: that module types its queries with
* `D1Database`, a Workers type, and the loader runs in a plain Node CLI that has no such
* types. Everything here is pure data mapping with no imports, so both sides can use it.
* `catalog-db.ts` re-exports all of it, so nothing outside these two files needs to know.
*
* The mapping lives beside the schema it fills (rather than in the CLI) so that a column added
* to the table and a column added to the loader cannot drift apart a test pins that they
* agree, and the loader renders values POSITIONALLY, so a mismatch is a silent mis-load.
*/
/** What a catalog row IS — the discriminator, and what says which id `item_key` holds. */
export const CatalogKind = {
/** An avatar item: something worn. Its `item_key` is the `AvatarItemDesc`. */
AvatarItem: 'avatar_item',
/** An equipment skin: a re-skin of a held prefab. Its `item_key` is the `ModificationGuid`. */
Skin: 'skin',
} as const
export type CatalogKindValue = (typeof CatalogKind)[keyof typeof CatalogKind]
/**
* The capture's avatar-item record (`static/db/avatar-items.json`).
*
* Everything from `TagList` down is absent on some rows the 22 permanent hair dyes carry
* only the first six fields so those are optional rather than nullable. The distinction
* matters: a missing key and a null value both land as NULL, but only one of them is a field
* the capture actually recorded.
*/
export interface AvatarItemCapture {
AvatarItemDesc: string
AvatarItemType: number
PlatformMask: number
FriendlyName: string
Tooltip: string | null
Rarity: number
TagList?: string | null
AvatarItemId?: number
IsBaseAvatarItem?: boolean
CreatedAt?: string
ThumbnailImage?: string | null
}
/** The capture's skin record (`static/db/skins.json`). Every field is present on every row. */
export interface SkinCapture {
PrefabName: string
ModificationGuid: string
UnlockedLevel: number
Favorited: boolean
PlatformMask: number
FriendlyName: string
Tooltip: string | null
Rarity: number
ThumbnailImage: string | null
}
/**
* The first `catalog_id` a load hands out.
*
* The catalog needs ids that cannot be confused with any captured storefront's, because a
* generated storefront lists a row under its `catalog_id` DIRECTLY one number, no second
* numbering and no arithmetic between them. Every real captured `PurchasableItemId` is 2764 or
* below (one sf3 outlier at 20756767 aside), so numbering from 1 would have collided with sf3's
* own head-on and the same id would mean two different items depending on which storefront the
* client read it from. Starting at 10000 puts the whole catalog somewhere nothing else uses.
*/
export const CATALOG_ID_BASE = 10_000
/** The columns a load writes, in the order {@link toCatalogInsertRow} returns values. */
export const CATALOG_INSERT_COLUMNS = [
'item_key',
'catalog_id',
'kind',
'friendly_name',
'tooltip',
'rarity',
'platform_mask',
'thumbnail_image',
'avatar_item_type',
'avatar_item_id',
'is_base_avatar_item',
'tag_list',
'created_at',
'prefab_name',
'unlocked_level',
] as const
/** A value bound into a load's INSERT. `undefined` is a key the capture omitted. */
export type CatalogValue = string | number | boolean | null | undefined
/** One row of a load, plus enough to name it in a collision report. */
export interface CatalogLoadRow {
key: string
/** Its `catalog_id`: 1-based position in this load, also present inside `values`. */
id: number
label: string
values: CatalogValue[]
}
/** A duplicate `item_key` in the captures: which key, which row won, which was dropped. */
export interface CatalogCollision {
key: string
kept: string
dropped: string
}
function avatarItemRow(i: AvatarItemCapture): Omit<CatalogLoadRow, 'id'> {
return {
key: i.AvatarItemDesc,
label: `${i.FriendlyName} (avatar item)`,
values: [
i.AvatarItemDesc,
// Filled in by buildCatalogLoad once the de-duplicated order is known.
null,
CatalogKind.AvatarItem,
i.FriendlyName,
i.Tooltip,
i.Rarity,
i.PlatformMask,
i.ThumbnailImage,
i.AvatarItemType,
i.AvatarItemId,
i.IsBaseAvatarItem ?? false,
i.TagList,
i.CreatedAt,
null,
null,
],
}
}
function skinRow(s: SkinCapture): Omit<CatalogLoadRow, 'id'> {
return {
key: s.ModificationGuid,
label: `${s.FriendlyName} (skin, ${s.PrefabName})`,
values: [
s.ModificationGuid,
// Filled in by buildCatalogLoad once the de-duplicated order is known.
null,
CatalogKind.Skin,
s.FriendlyName,
s.Tooltip,
s.Rarity,
s.PlatformMask,
s.ThumbnailImage,
null,
null,
null,
null,
null,
s.PrefabName,
s.UnlockedLevel,
],
}
}
/**
* Turn both captures into the rows a load writes, de-duplicated on `item_key`.
*
* `item_key` is unique across BOTH kinds, so a repeat is a defect in the capture rather than
* something the table should model. First occurrence wins and the rest are RETURNED rather
* than dropped on the floor: the caller has to report them, because a collision that vanishes
* quietly is the exact failure the single key exists to prevent.
*
* Each surviving row is also numbered `catalog_id`, from {@link CATALOG_ID_BASE} upward in
* capture order, the small numeric handle the site uses in place of a comma-laden desc or a
* guid, and the `PurchasableItemId` a generated storefront lists it under. It is assigned here
* rather than by the database so the caller can render it into the same INSERT, and it is a
* LOAD-ORDER surrogate: the next load renumbers, and nothing may store it.
*/
export function buildCatalogLoad(
avatarItems: AvatarItemCapture[],
skins: SkinCapture[]
): { rows: CatalogLoadRow[]; collisions: CatalogCollision[] } {
const seen = new Map<string, string>()
const rows: CatalogLoadRow[] = []
const collisions: CatalogCollision[] = []
const idAt = CATALOG_INSERT_COLUMNS.indexOf('catalog_id')
for (const row of [...avatarItems.map(avatarItemRow), ...skins.map(skinRow)]) {
const kept = seen.get(row.key)
if (kept !== undefined) {
collisions.push({ key: row.key, kept, dropped: row.label })
continue
}
seen.set(row.key, row.label)
// Numbered AFTER de-duplication and from {@link CATALOG_ID_BASE}, so a load's ids are
// exactly BASE..BASE+rows.length-1 with no gaps — a dropped duplicate must not burn a
// number.
const id = CATALOG_ID_BASE + rows.length
row.values[idAt] = id
rows.push({ ...row, id })
}
return { rows, collisions }
}
/**
* Rarities that are NOT sold, and so never appear in a generated storefront.
*
* `-1` is the developer/unreleased tier. The items carrying it stay in the `catalog` table
* that is a record of what EXISTS but a storefront is a record of what is for SALE, and an
* item listed in one can be bought: `findStoreItem` resolves a purchase against the catalog
* file itself, so listing them at any price would put them on sale.
*
* Lives here rather than in the storefront generator because two things need the same answer:
* the generator, which omits them, and anything that hands the client a PurchasableItem id,
* which must not name one the storefront never listed. A client asked to resolve an id no
* storefront sells renders nothing, indistinguishably from an id it failed to parse.
*/
export const UNSELLABLE_RARITIES: readonly number[] = [-1]
/** Whether an item of this rarity may appear in a storefront. */
export const isSellableRarity = (rarity: number): boolean => !UNSELLABLE_RARITIES.includes(rarity)
/**
* What a catalog item costs, by rarity the pricing every surface that sells a catalog row
* must agree on.
*
* Shared rather than living in the storefront generator alone because a purchase is CHECKED
* against the price the client was shown: `priceCheck` compares the posted `RequestedPrice`
* with the catalog's, and a server that priced a buy differently from the file it listed would
* refuse every purchase as "Price has changed". One table, both sides.
*
* The tiers 0/10/30/50 were specified; 20 sits between its neighbours at 700 (sf3's own
* rarity-20 items cluster at 400-600, but 600 is taken by rarity 10 here, and two tiers sharing
* a price makes the rarity invisible). {@link UNSELLABLE_RARITIES} is priced by nothing those
* items are not sold at all.
*/
export const PRICE_BY_RARITY: Record<number, number> = {
0: 150,
10: 600,
20: 700,
30: 800,
50: 3000,
}
/**
* What a rarity absent from {@link PRICE_BY_RARITY} costs the bottom tier, never free.
*
* Only reachable if a future capture introduces a rarity nobody has priced. A floor rather than
* a skip because an unpriced item silently vanishing from the store is harder to notice than
* one that turns up cheap; a rarity meant to be unsellable belongs in
* {@link UNSELLABLE_RARITIES}, where a load reports it.
*/
export const DEFAULT_PRICE = 150
/** What one catalog row costs, in RecCenterTokens. */
export const priceForRarity = (rarity: number): number => PRICE_BY_RARITY[rarity] ?? DEFAULT_PRICE
/**
* What Rec Room Plus takes off, in percent. The same number the server's own `subscriberFloor`
* allows, which is what makes a subscriber's discounted `RequestedPrice` land inside the band
* rather than through the floor.
*/
export const SUBSCRIBER_DISCOUNT_PERCENT = 10
/** The subscriber price for a regular one. Floored, matching the server's `subscriberFloor`. */
export const subscriberPriceFor = (regular: number): number =>
Math.floor((regular * (100 - SUBSCRIBER_DISCOUNT_PERCENT)) / 100)
/**
* The last client build treated as the 2023-era one, as a build number and as the ISO date the
* item catalogue records `CreatedAt` in.
*
* One constant in two forms because two things key off the same moment: the econ worker decides
* which storefront FILE a caller is served by comparing their token's `rn.ver` to the number,
* and the storefront generator decides which ITEMS go in the 2023 file by comparing each item's
* `CreatedAt` to the date. They must not drift a build served the old store but a store built
* to a different date would sell items that build has never heard of.
*/
export const LEGACY_CLIENT_BUILD = 20_230_414
/** {@link LEGACY_CLIENT_BUILD} as `YYYY-MM-DD`, for comparing against a `CreatedAt`. */
export const LEGACY_CLIENT_BUILD_DATE = '2023-04-14'
/**
* Whether an item existed by the time of {@link LEGACY_CLIENT_BUILD} i.e. whether the 2023
* store should sell it.
*
* An item with NO `CreatedAt` is treated as too new and left out: three catalogue rows carry
* none, and there is no way to show they predate the cutoff. Excluding is the conservative
* direction the 2023 store missing three items nobody noticed is better than it offering
* something that build cannot render.
*/
export const existedByLegacyBuild = (createdAt: string | null | undefined): boolean =>
typeof createdAt === 'string' && createdAt.slice(0, 10) < LEGACY_CLIENT_BUILD_DATE
+63 -33
View File
@@ -1,36 +1,46 @@
/**
* Weekly-challenge progress on the shared `recflare` D1 database one row per
* (account, challenge), written by `POST /api/challenge/v2/updateProgress` and read back
* by `GET /api/challenge/v2/getCurrent` to stamp each challenge's per-player `Complete`.
* by `GET /api/challenge/v2/getCurrent` to stamp each challenge's per-player state.
*
* Only the completion flag is stored, not the `Config` rule tree the client posts with it.
* That tree is the challenge's DEFINITION (it comes from static/weekly-challenge.json and
* is identical for everyone), decorated with the client's running count in `cc`; the
* server evaluates none of it, so persisting a per-player copy would only be a second,
* staler copy of the catalog. See .agents/weekly-challenge-config/SKILL.md for the grammar.
* The CLIENT owns the evaluating: it walks the challenge's rule tree locally and posts the
* tree back with its own progress written into the nodes `cc` on a counter is the running
* count, `c` marks a satisfied node (see .agents/skills/weekly-challenge-config/SKILL.md for
* the grammar). So the posted `Config` is not the catalog's copy of the definition, it is
* per-player STATE, and it is stored here alongside the completion flag; the server still
* evaluates none of it. `getCurrent` serves the static challenge with the stored `Config`
* and `Complete` overwritten onto it, which is how partial progress survives a session:
* without it a player who had two of three kills started over on every login.
*
* Completion LATCHES within a rotation: the client reports progress repeatedly, and a
* report that arrives with the challenge no longer complete (a fresh session, a reordered
* retry) must not un-finish something already finished. A report carrying a different
* `ChallengeMapId` is a new rotation and REPLACES the row instead challenge ids are only
* unique within a rotation, so a challenge that returns in a later week would otherwise
* start out already complete on the old week's row.
* retry) must not un-finish something already finished. `config` does NOT latch it is the
* running tally, so the newest report wins but a report that carries none leaves the
* stored tree alone rather than blanking it. A report carrying a different `ChallengeMapId`
* is a new rotation and REPLACES the row instead challenge ids are only unique within a
* rotation, so a challenge that returns in a later week would otherwise start out already
* complete, and half-counted, on the old week's row.
*
* Finishing enough of a rotation's challenges earns its `Gift`, which is handed out from the
* same `updateProgress` call that reaches the threshold. That payout is gated by a
* second table here, `challenge_gift` one row per (account, rotation), claimed once.
*
* The `econ` worker owns both tables and their migrations
* (apps/econ/migrations/0009_challenge_status.sql, 0011_challenge_gift.sql).
* (apps/econ/migrations/0009_challenge_status.sql, 0011_challenge_gift.sql,
* 0014_challenge_status_config.sql).
*/
/** Schema DDL (mirror of migrations 0009_challenge_status.sql) — also builds the table in tests. */
/**
* Schema DDL (mirror of migrations 0009_challenge_status.sql + 0014_challenge_status_config.sql)
* also builds the table in tests.
*/
export const CHALLENGE_STATUS_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS challenge_status (
account_id INTEGER NOT NULL,
challenge_id INTEGER NOT NULL,
challenge_map_id INTEGER NOT NULL,
complete INTEGER NOT NULL,
config TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (account_id, challenge_id)
)`,
@@ -41,70 +51,90 @@ export interface ChallengeProgress {
challengeMapId: number
challengeId: number
complete: boolean
/** The client-evaluated rule tree, or null when the report carried none. */
config: string | null
}
/** What a stored row holds for one challenge, as `getCurrent` overwrites it onto the catalog. */
export interface ChallengeStatus {
complete: boolean
/** The last tree the client posted; null means it never posted one — serve the static tree. */
config: string | null
}
/**
* Record a progress report and return the completion the row now holds which is what the
* Record a progress report and return the state the row now holds which is what the
* response must echo, since it isn't always what was posted: within a rotation `complete`
* only ever goes false true (see the latching note above), so a `false` report against a
* finished challenge answers `true`.
* finished challenge answers `true`, and a report with no `Config` answers the tree already
* stored.
*
* SQLite evaluates every `DO UPDATE SET` expression against the pre-update row, so the
* `CASE` can compare the stored `challenge_map_id` with the incoming one while the same
* `CASE`s can compare the stored `challenge_map_id` with the incoming one while the same
* statement overwrites it.
*/
export async function recordChallengeProgress(
db: D1Database,
accountId: number,
progress: ChallengeProgress
): Promise<boolean> {
): Promise<ChallengeStatus> {
const row = await db
.prepare(
`INSERT INTO challenge_status (account_id, challenge_id, challenge_map_id, complete, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
`INSERT INTO challenge_status (account_id, challenge_id, challenge_map_id, complete, config, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT (account_id, challenge_id) DO UPDATE SET
complete = CASE
WHEN challenge_status.challenge_map_id = excluded.challenge_map_id
THEN MAX(challenge_status.complete, excluded.complete)
ELSE excluded.complete
END,
config = CASE
WHEN challenge_status.challenge_map_id = excluded.challenge_map_id
THEN COALESCE(excluded.config, challenge_status.config)
ELSE excluded.config
END,
challenge_map_id = excluded.challenge_map_id,
updated_at = excluded.updated_at
RETURNING complete`
RETURNING complete, config`
)
.bind(
accountId,
progress.challengeId,
progress.challengeMapId,
progress.complete ? 1 : 0,
progress.config,
new Date().toISOString()
)
.first<{ complete: number }>()
return row?.complete === 1
.first<{ complete: number; config: string | null }>()
return { complete: row?.complete === 1, config: row?.config ?? null }
}
/**
* The ids of the challenges a player has finished in one rotation. Scoped to the rotation
* so a stale row from an earlier week same challenge id, different `challenge_map_id`
* doesn't show up pre-completed before the client has reported anything against it.
* What a player has stored for one rotation's challenges, keyed by challenge id. Scoped to
* the rotation so a stale row from an earlier week same challenge id, different
* `challenge_map_id` doesn't show up pre-completed, or half-counted, before the client has
* reported anything against it.
*
* Also what earning the rotation's `Gift` is decided from: it is due once ENOUGH of the
* challenges in static/weekly-challenge.json appear here three of the five a week
* publishes, not all of them (see `CHALLENGES_REQUIRED_FOR_GIFT` in econ.app.ts).
* Read by `getCurrent` to overwrite the static rotation, and by the gift path: the `Gift` is
* due once ENOUGH of the week's own challenges are complete here three of the five a
* rotation publishes, not all of them (see `CHALLENGES_REQUIRED_FOR_GIFT` in econ.app.ts).
* The rotation itself is generated per week by src/challenge-rotation.ts.
*/
export async function getCompletedChallengeIds(
export async function getChallengeStatuses(
db: D1Database,
accountId: number,
challengeMapId: number
): Promise<Set<number>> {
): Promise<Map<number, ChallengeStatus>> {
const { results } = await db
.prepare(
`SELECT challenge_id FROM challenge_status
WHERE account_id = ?1 AND challenge_map_id = ?2 AND complete = 1`
`SELECT challenge_id, complete, config FROM challenge_status
WHERE account_id = ?1 AND challenge_map_id = ?2`
)
.bind(accountId, challengeMapId)
.all<{ challenge_id: number }>()
return new Set(results.map((r) => r.challenge_id))
.all<{ challenge_id: number; complete: number; config: string | null }>()
return new Map(
results.map((r) => [r.challenge_id, { complete: r.complete === 1, config: r.config }])
)
}
/** Schema DDL (mirror of migrations 0011_challenge_gift.sql) — also builds the table in tests. */
+658
View File
@@ -0,0 +1,658 @@
/**
* The weekly challenge rotation, generated from the calendar week rather than authored.
*
* A week's rotation is a pure function of which week it is: the same five challenges, the
* same window and the same gift for every player, recomputed identically by every isolate
* and every request. That is not a nicety `challenge_status` rows are scoped by
* `ChallengeMapId` and the gift threshold counts completions against `Challenges`, so two
* callers who disagreed about what this week holds would disagree about who has finished it.
* Everything here therefore hangs off {@link rotationIndex} and a seeded PRNG; nothing calls
* `Math.random()` or reads the clock except to work out which week it is.
*
* The client evaluates the rule trees and the server never does (see
* .agents/skills/weekly-challenge-config/SKILL.md), so a generated tree is a specification
* handed to a client that fails SILENTLY when it's malformed. Everything emitted here is
* therefore built from the three idioms that are pinned by captured live data or by a
* rotation this server has already served no lib-only node types, no invented fields.
*
* `static/weekly-challenge.json` still ships, and still wins: a non-empty `Challenges` array
* there PINS the week to that hand-authored rotation and skips generation entirely, which is
* how a debug or event rotation gets served without a code change. When it is empty the file
* supplies only the parts generation doesn't own the fallback gift name, the theme string,
* and `CompletedRequired`.
*/
import weeklyChallenge from '../static/weekly-challenge.json'
/** Rec Room's weekly reset: Wednesday 21:00 UTC, the boundary both captured rotations sit on. */
const ROTATION_EPOCH_MS = Date.UTC(2020, 0, 1, 21, 0, 0)
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
/**
* Where generated `ChallengeMapId`s start. Hand-authored rotations have used small ids (the
* captured 17, this repo's 19), and a generated id that collided with one would let a
* player's stored rows from that rotation read as progress against a completely different
* set of challenges. A four-digit floor keeps the two id spaces from ever meeting.
*/
const CHALLENGE_MAP_ID_BASE = 1000
/** How many challenges a week publishes. Five is the captured rotation's size, and what the three-of-five gift threshold is written against. */
const CHALLENGES_PER_ROTATION = 5
/**
* How many challenges of one kind a week may hold. Five slots over three kinds with a cap of
* two guarantees every kind appears, so no week is five variations of "finish some games".
*/
const MAX_PER_KIND = 2
/** `ct:6` event ids — `ChallengeEventTypes`. Only the two the captured/served trees use. */
const EVENT_GAME_END = 2
const EVENT_ELIMINATED_AI = 5
/** The targets each kind counts to. Fixed rather than rolled: a week should vary in WHAT it asks, not in how much. */
const GAMES_TARGET = 5
const AI_TARGET = 10
/** What a challenge asks for. Each maps to one proven `Config` idiom and one line of copy. */
type ChallengeKind = 'games' | 'win' | 'ai'
/**
* A room the generator may name, keyed by the scene(s) its games run in.
*
* `scenes` is what `ct:7` matches, and one scene can belong to several rooms (Soccer and
* Stadium are one scene; Dodgeball, Gym and DodgeballVR are another) `shares` records the
* rooms a challenge naming this one also completes in, which is a property of the game data,
* not something the tree can narrow. Entries are one per scene, so picking by room key also
* keeps a week from naming one scene twice.
*
* `link` is the `^Token` the client resolves into a tappable room name; `null` where the room
* name would make a doubtful token (Charades spans two scenes and starts with a digit) and
* the copy falls back to plain text, which the captured rotation also does.
*/
interface ChallengeRoom {
/** Stable key — also the slug fragment in a generated `Name`. */
key: string
/** Plain display name, used when `link` is null. */
name: string
/** The `^Token` room link, or null to write the name plainly. */
link: string | null
/** `UnitySceneId`s this room's games run in, straight from apps/rooms/static/ImportRooms.json. */
scenes: string[]
/** The kinds this room can be asked for — quests have enemies to defeat, hangouts have no games at all. */
kinds: ChallengeKind[]
/** True for the quest rooms, whose "win" is completing the quest rather than beating other players. */
quest?: boolean
/** Other rooms on the same scene, which a challenge naming this one also completes in. */
shares?: string
}
/**
* The rooms in play. Every scene id here resolves in `apps/rooms/static/ImportRooms.json`
* a challenge naming a scene this server hosts no room for can never be completed by anyone,
* and nothing server-side would report that.
*
* `kinds` is deliberately conservative. `win` reads the `won` session variable, which is
* pinned by the captured rotation for quests and is meaningful in a head-to-head game, so
* rooms where "winning" is vague (bowling, disc golf, charades, Stunt Runner) only ever ask
* for completed games. `ai` is quests only: they are the rooms with enemies in them.
*/
const CHALLENGE_ROOMS: ChallengeRoom[] = [
// Quests — win the quest, or thin out its enemies.
{
key: 'GoldenTrophy',
name: 'Quest for the Golden Trophy',
link: '^GoldenTrophy',
scenes: ['91e16e35-f48f-4700-ab8a-a1b79e50e51b'],
kinds: ['win', 'ai'],
quest: true,
},
{
key: 'Jumbotron',
name: 'The Rise of Jumbotron',
link: '^TheRiseofJumbotron',
scenes: ['acc06e66-c2d0-4361-b0cd-46246a4c455c'],
kinds: ['win', 'ai'],
quest: true,
},
{
key: 'CrimsonCauldron',
name: 'Curse of the Crimson Cauldron',
link: '^CrimsonCauldron',
scenes: ['949fa41f-4347-45c0-b7ac-489129174045'],
kinds: ['win', 'ai'],
quest: true,
},
{
key: 'IsleOfLostSkulls',
name: 'The Isle of Lost Skulls',
link: '^IsleOfLostSkulls',
scenes: ['7e01cfe0-820a-406f-b1b3-0a5bf575235c'],
kinds: ['win', 'ai'],
quest: true,
},
{
key: 'Crescendo',
name: 'Crescendo of the Blood Moon',
link: '^Crescendo',
scenes: ['49cb8993-a956-43e2-86f4-1318f279b22a'],
kinds: ['win', 'ai'],
quest: true,
},
// Head-to-head rooms — finish games, or win one.
{
key: 'Clearcut',
name: 'Paintball: Clear Cut',
link: '^Paintball.Clearcut',
scenes: ['380d18b5-de9c-49f3-80f7-f4a95c1de161'],
kinds: ['games', 'win'],
shares: 'PaintballVR/Clearcut, Clearcut/Home',
},
{
key: 'River',
name: 'Paintball: River',
link: '^Paintball.River',
scenes: ['e122fe98-e7db-49e8-a1b1-105424b6e1f0'],
kinds: ['games', 'win'],
shares: 'PaintballVR/River, River/Home',
},
{
key: 'Homestead',
name: 'Paintball: Homestead',
link: '^Paintball.Homestead',
scenes: ['a785267d-c579-42ea-be43-fec1992d1ca7'],
kinds: ['games', 'win'],
shares: 'PaintballVR/Homestead, Homestead/Home',
},
{
key: 'Quarry',
name: 'Paintball: Quarry',
link: '^Paintball.Quarry',
scenes: ['ff4c6427-7079-4f59-b22a-69b089420827'],
kinds: ['games', 'win'],
shares: 'PaintballVR/Quarry, Quarry/Home',
},
{
key: 'Spillway',
name: 'Paintball: Spillway',
link: '^Paintball.Spillway',
scenes: ['58763055-2dfb-4814-80b8-16fac5c85709'],
kinds: ['games', 'win'],
shares: 'PaintballVR/Spillway, Spillway/Home',
},
{
key: 'Dodgeball',
name: 'Dodgeball',
link: '^Dodgeball',
scenes: ['3d474b26-26f7-45e9-9a36-9b02847d5e6f'],
kinds: ['games', 'win'],
shares: 'Gym/Home, DodgeballVR/Home',
},
{
key: 'Soccer',
name: 'Soccer',
link: '^Soccer',
scenes: ['6d5eea4b-f069-4ed0-9916-0e2f07df0d03'],
kinds: ['games', 'win'],
shares: 'Stadium/Home',
},
{
key: 'Hangar',
name: 'Laser Tag: Hangar',
link: '^LaserTag.Hangar',
scenes: ['239e676c-f12f-489f-bf3a-d4c383d692c3'],
kinds: ['games', 'win'],
shares: 'Hangar/Home',
},
{
key: 'CyberJunkCity',
name: 'Laser Tag: CyberJunk City',
link: '^LaserTag.CyberJunkCity',
scenes: ['9d6456ce-6264-48b4-808d-2d96b3d91038'],
kinds: ['games', 'win'],
shares: 'LaserTagCyberJunk/Home, CyberJunkCity/Home',
},
{
key: 'Paddleball',
name: 'Paddleball',
link: '^Paddleball',
scenes: ['d89f74fa-d51e-477a-a425-025a891dd499'],
kinds: ['games', 'win'],
},
{
key: 'FrontierSolos',
name: 'Rec Royale: Solos',
link: '^RecRoyaleSolos',
scenes: ['b010171f-4875-4e89-baba-61e878cd41e1'],
kinds: ['games', 'win'],
},
{
key: 'FrontierSquads',
name: 'Rec Royale: Squads',
link: '^RecRoyaleSquads',
scenes: ['253fa009-6e65-4c90-91a1-7137a56a267f'],
kinds: ['games', 'win'],
},
// Rooms where finishing is the whole ask — "winning" one of these isn't a thing the
// `won` variable is known to report.
{
key: 'Bowling',
name: 'Bowling',
link: '^Bowling',
scenes: ['ae929543-9a07-41d5-8ee9-dbbee8c36800'],
kinds: ['games'],
shares: 'BowlingAlley/Home',
},
{
key: 'DiscGolfLake',
name: 'Disc Golf: Lake',
link: '^DiscGolfLake',
scenes: ['f6f7256c-e438-4299-b99e-d20bef8cf7e0'],
kinds: ['games'],
shares: 'Lake/Home',
},
{
key: 'DiscGolfPropulsion',
name: 'Disc Golf: Propulsion',
link: '^DiscGolfPropulsion',
scenes: ['d9378c9f-80bc-46fb-ad1e-1bed8a674f55'],
kinds: ['games'],
shares: 'PropulsionTestRange/Home',
},
{
key: 'Charades',
name: 'Charades',
link: null,
// Both charades scenes, as the captured rotation's own charades challenge does.
scenes: ['a673712c-877f-4749-b69a-4a4c6310d545', '4078dfed-24bb-4db7-863f-578ba48d726b'],
kinds: ['games'],
shares: '3DCharades/InkSpaceHome, Legacy3DCharades/Home',
},
{
key: 'StuntRunner',
name: 'Stunt Runner',
link: '^StuntRunner',
scenes: ['b7281665-a715-4051-826b-8e08e69c6172'],
kinds: ['games'],
},
]
/** One challenge as the rotation serves it — `Complete` is stamped per caller by `getCurrent`. */
export interface RotationChallenge {
ChallengeId: number
Name: string
Config: string
Description: string
Tooltip: string
Complete: boolean
}
/**
* The rotation's reward block. Same item vocabulary as a storefront gift drop, but with
* `Context`/`Rarity` spelled `GiftContext`/`GiftRarity` the two shapes are not
* interchangeable, see `toChallengeGiftDrop` in econ.app.ts.
*/
export interface ChallengeGiftBlock {
GiftDropId: number
AvatarItemDesc: string
AvatarItemType: number
ConsumableItemDesc: string
EquipmentPrefabName: string
EquipmentModificationGuid: string
StorefrontType: number
Xp: number
Level: number
GiftContext: number
GiftRarity: number
/**
* Display strings, OPTIONAL because neither the captured rotation nor a generated block
* carries them the reward's name is resolved from the catalog entry selling the same
* item, falling back to `FallbackGiftName`. A pinned rotation can set them to name its
* reward outright.
*/
FriendlyName?: string
Tooltip?: string
}
/** A week's whole rotation — the body `GET /api/challenge/v2/getCurrent` serves. */
export interface WeeklyChallengeRotation {
ChallengeMapId: number
CompletedRequired: boolean
StartAt: string
EndAt: string
ServerTime: string
Challenges: RotationChallenge[]
Gift: ChallengeGiftBlock
FallbackGiftName: string
/**
* What the week is themed on the FriendlyName of the item its `Gift` hands over, set
* by {@link withWeeklyGift} once the catalog has named the roll. The static file's value
* is only a placeholder: a generated week's reward isn't known until it is rolled. A
* PINNED rotation keeps whatever string it ships.
*/
ChallengeThemeString: string
}
/** An equipment item the weekly gift can be drawn from — one sf3 entry, trimmed to what a gift needs. */
export interface EquipmentGift {
GiftDropId: number
EquipmentPrefabName: string
EquipmentModificationGuid: string
Rarity: number
/** The catalog's display name for the item — what the week is themed on. */
FriendlyName: string
}
/** Whether the shipped file pins the week, in which case nothing here is generated. */
function pinnedRotation(): WeeklyChallengeRotation | null {
return weeklyChallenge.Challenges.length > 0 ? (weeklyChallenge as WeeklyChallengeRotation) : null
}
/**
* Which week it is: whole weeks since the epoch, so the value ticks over at Wednesday 21:00
* UTC and every caller in the same week gets the same number.
*/
export function rotationIndex(now: Date): number {
return Math.floor((now.getTime() - ROTATION_EPOCH_MS) / WEEK_MS)
}
/**
* This week's `ChallengeMapId` the identity of the rotation, and what makes a stored
* completion belong to one week rather than another. Cheap on purpose: `updateProgress` asks
* only this to decide whether a report is against the live week.
*/
export function rotationMapId(now: Date): number {
return pinnedRotation()?.ChallengeMapId ?? CHALLENGE_MAP_ID_BASE + rotationIndex(now)
}
/** The week's window, as the client's `StartAt`/`EndAt` want it: UTC, but written without a zone. */
function rotationWindow(index: number): { StartAt: string; EndAt: string } {
const start = ROTATION_EPOCH_MS + index * WEEK_MS
return {
StartAt: toLocalIsoString(new Date(start)),
EndAt: toLocalIsoString(new Date(start + WEEK_MS)),
}
}
/** `2026-08-19T21:00:00` — ISO 8601 with the milliseconds and the `Z` cut off, which is the shape the client's window fields take. */
function toLocalIsoString(at: Date): string {
return at.toISOString().slice(0, 19)
}
/**
* `2026-08-25T14:42:54.2754728Z` .NET's round-trip format, seven fractional digits. The
* client dates its countdown off this, and because a generated window is genuinely the
* current one, this is the real clock rather than the frozen timestamp a static file needs.
*/
function toDotNetString(at: Date): string {
return `${at.toISOString().slice(0, -1)}0000Z`
}
/** mulberry32 — a small deterministic PRNG. Same seed, same week, same rotation, everywhere. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0
return () => {
a = (a + 0x6d2b79f5) >>> 0
let t = a
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
/**
* Spread a week index into a seed. Adjacent weeks are adjacent integers, and feeding those
* straight in makes consecutive rotations correlate; a multiply by a large odd constant
* (Knuth's) scatters them.
*/
function seedFor(mapId: number, salt: number): number {
return Math.imul(mapId ^ salt, 2654435761) >>> 0
}
/** In-place Fisher-Yates against a seeded stream — the only place ordering comes from. */
function shuffle<T>(items: T[], random: () => number): T[] {
const out = [...items]
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1))
const a = out[i] as T
const b = out[j] as T
out[i] = b
out[j] = a
}
return out
}
/** A `ct:7` scene allow-list node. */
function sceneNode(scenes: string[]) {
return { ct: 7, vs: scenes.map((l) => ({ l })) }
}
/**
* The rule tree for one (kind, room), as an object stringified into `Config` by the caller.
* Each branch is one of the three idioms in the skill doc, with the field order the captured
* trees use:
*
* - `games` a counter over finished games in the room (`ct:1` + `GameEnd` + scene).
* - `win` one finished game in the room that the player won (`ct:0` + `GameEnd` + `won` + scene).
* - `ai` a counter over enemies defeated in the room (`ct:1` + `EliminatedAI` + scene).
*/
function configFor(kind: ChallengeKind, room: ChallengeRoom): unknown {
const scene = sceneNode(room.scenes)
switch (kind) {
case 'games':
return {
ct: 1,
ipc: false,
ctc: [{ ct: 0, ipc: false, wc: [{ ct: 6, vs: [EVENT_GAME_END] }, scene] }],
t: GAMES_TARGET,
}
case 'win':
return {
ct: 0,
ipc: false,
wc: [{ ct: 6, vs: [EVENT_GAME_END] }, { ct: 9, vs: [true], v: 'won' }, scene],
}
case 'ai':
return {
ct: 1,
ipc: false,
ctc: [{ ct: 0, ipc: false, wc: [{ ct: 6, vs: [EVENT_ELIMINATED_AI] }, scene] }],
t: AI_TARGET,
}
}
}
/**
* The copy for one (kind, room). Generated from the same two inputs as the tree, which is
* the point: the client renders these strings and evaluates the tree independently, so
* hand-written copy is free to drift into describing a challenge that doesn't exist.
*/
function copyFor(
kind: ChallengeKind,
room: ChallengeRoom
): { Description: string; Tooltip: string } {
const where = room.link ?? room.name
switch (kind) {
case 'games':
return {
Description: `Complete ${GAMES_TARGET} games in ${where}`,
Tooltip: `Play ${GAMES_TARGET} games of ${room.name} through to the end. Winning is optional.`,
}
case 'win':
return room.quest === true
? {
Description: `Complete the ${where} quest`,
Tooltip: `See ${room.name} through to a win.`,
}
: {
Description: `Win a game in ${where}`,
Tooltip: `Come out on top of a game of ${room.name}.`,
}
case 'ai':
return {
Description: `Defeat ${AI_TARGET} enemies in ${where}`,
Tooltip: `Take out ${AI_TARGET} enemies in ${room.name}. They don't have to be in one run.`,
}
}
}
/** The internal slug — never displayed, but it's what a log line or a D1 row is read against. */
function nameFor(kind: ChallengeKind, room: ChallengeRoom): string {
switch (kind) {
case 'games':
return `Complete${GAMES_TARGET}Games${room.key}`
case 'win':
return `Win${room.key}`
case 'ai':
return `Defeat${AI_TARGET}AI${room.key}`
}
}
/** One thing the generator may publish: a room crossed with a kind that room supports. */
interface Candidate {
challengeId: number
kind: ChallengeKind
room: ChallengeRoom
}
/**
* Every (room, kind) pair, in a fixed order the index in this list IS the challenge id.
*
* Deriving the id from the pair rather than from the position in a week keeps ids meaningful
* across weeks: id 12 is always "win in Dodgeball", so a `challenge_status` row that outlives
* its rotation is at worst stale, never a different challenge wearing the same id. Ids are
* only required to be unique within a rotation, which distinct pairs trivially are.
*
* Appending to `CHALLENGE_ROOMS` is safe; INSERTING into the middle renumbers everything
* after it, so add rooms at the end.
*/
const CANDIDATES: Candidate[] = CHALLENGE_ROOMS.flatMap((room) =>
room.kinds.map((kind) => ({ challengeId: 0, kind, room }))
).map((candidate, index) => ({ ...candidate, challengeId: index + 1 }))
/**
* Pick the week's challenges: shuffle every candidate, then take the first five that keep
* one room out of two slots and one kind out of three. The relaxation pass exists so the
* constraints can never under-deliver a rotation a short week would quietly lower the gift
* threshold, since it clamps to what's published.
*/
function pickChallenges(random: () => number): RotationChallenge[] {
const shuffled = shuffle(CANDIDATES, random)
const picked: Candidate[] = []
const rooms = new Set<string>()
const kinds = new Map<ChallengeKind, number>()
for (const pass of [0, 1]) {
for (const candidate of shuffled) {
if (picked.length === CHALLENGES_PER_ROTATION) break
if (rooms.has(candidate.room.key)) continue
if (pass === 0 && (kinds.get(candidate.kind) ?? 0) >= MAX_PER_KIND) continue
picked.push(candidate)
rooms.add(candidate.room.key)
kinds.set(candidate.kind, (kinds.get(candidate.kind) ?? 0) + 1)
}
}
return picked.map(({ challengeId, kind, room }) => ({
ChallengeId: challengeId,
Name: nameFor(kind, room),
Config: JSON.stringify(configFor(kind, room)),
...copyFor(kind, room),
Complete: false,
}))
}
/**
* The week's gift: one equipment item, drawn from the pool with the week's own seed.
*
* Weekly rewards are equipment the captured rotation's is a camera skin so the pool is
* every sf3 item carrying an `EquipmentModificationGuid`. Drawing from the live catalog
* rather than a copied list is what lets `toChallengeGiftDrop` resolve the pick back to the
* entry selling it and hand the player a properly named item.
*
* Null when the pool is empty (the catalog didn't load), and the caller keeps the static
* file's block so the reward preview is still something rather than nothing.
*/
function pickWeeklyGift(
mapId: number,
pool: EquipmentGift[]
): { gift: ChallengeGiftBlock; friendlyName: string } | null {
if (pool.length === 0) return null
const random = mulberry32(seedFor(mapId, 0x9e3779b9))
const gift = pool[Math.floor(random() * pool.length)] as EquipmentGift
// The name comes back alongside rather than on the block: the block is the wire shape,
// whose display strings are optional and left unset here so `toChallengeGiftDrop` keeps
// resolving them from the catalog entry that sells the item.
return {
friendlyName: gift.FriendlyName,
gift: {
GiftDropId: gift.GiftDropId,
AvatarItemDesc: '',
AvatarItemType: 0,
ConsumableItemDesc: '',
EquipmentPrefabName: gift.EquipmentPrefabName,
EquipmentModificationGuid: gift.EquipmentModificationGuid,
StorefrontType: 0,
Xp: 0,
Level: 0,
GiftContext: 0,
GiftRarity: gift.Rarity,
},
}
}
/**
* Memoised generation. The rotation is identical for every caller in a week, so it is built
* once per isolate per week rather than per request; `ServerTime` is the one field that
* moves, and it is written fresh on the way out.
*
* The cached object is never handed out directly for that reason, and callers that
* personalise it (`getCurrent` stamping per-player state) rebuild rather than mutate.
*/
let cached: WeeklyChallengeRotation | null = null
/**
* This week's rotation, with the static file's `Gift` as a placeholder callers that can
* read the catalog replace it with {@link pickWeeklyGift}. Pure apart from `now`: same week
* in, same rotation out.
*/
export function buildRotation(now: Date): WeeklyChallengeRotation {
const pinned = pinnedRotation()
if (pinned !== null) return pinned
const index = rotationIndex(now)
const mapId = CHALLENGE_MAP_ID_BASE + index
if (cached === null || cached.ChallengeMapId !== mapId) {
cached = {
ChallengeMapId: mapId,
CompletedRequired: weeklyChallenge.CompletedRequired,
...rotationWindow(index),
ServerTime: '',
Challenges: pickChallenges(mulberry32(seedFor(mapId, 0))),
Gift: weeklyChallenge.Gift as ChallengeGiftBlock,
FallbackGiftName: weeklyChallenge.FallbackGiftName,
ChallengeThemeString: weeklyChallenge.ChallengeThemeString,
}
}
return { ...cached, ServerTime: toDotNetString(now) }
}
/**
* Put the week's own reward on a rotation. Separate from {@link buildRotation} because the
* pool comes from the storefront catalog, which is an async read the cheap paths
* (`updateProgress` deciding whether a report is against the live week) have no reason to pay.
*
* A PINNED rotation is returned untouched: it ships its own `Gift`, and overwriting that with
* a rolled one would make the pin a half-pin.
*/
export function withWeeklyGift(
rotation: WeeklyChallengeRotation,
pool: EquipmentGift[]
): WeeklyChallengeRotation {
if (pinnedRotation() !== null) return rotation
const picked = pickWeeklyGift(rotation.ChallengeMapId, pool)
if (picked === null) return rotation
// The week is themed on its reward: `ChallengeThemeString` is the item's catalog name,
// which is the same string `toChallengeGiftDrop` resolves for the grant, so the heading
// and the thing handed over read as one. The static file's value is a placeholder — it
// can't name an item that is rolled per week.
return { ...rotation, Gift: picked.gift, ChallengeThemeString: picked.friendlyName }
}
+1397 -326
View File
@@ -6,6 +6,7 @@ import {
addXp,
consumeGift,
createGift,
getAccount,
getGift,
getOutfits,
getPendingGifts,
@@ -16,12 +17,17 @@ import {
setOutfit,
} from '@repo/domain'
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
import { validateAndGetAccountId, validateAndGetPlus, validateAndGetVersion } from '@repo/jwt'
// 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
// their own, and buyInvention has to read the very rows `api` writes.
import { getInventionById, toSaveResult } from '../../api/src/inventions-db'
import {
getCustomAvatarItems,
toUgcPurchasable,
UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM,
} from '../../api/src/custom-avatar-items-db'
import { getInventionById, toInventionV9, toSaveResult } from '../../api/src/inventions-db'
// The profanity filter behind `api`'s `POST /api/sanitize/v1`, imported rather than copied
// so a gift note is masked by the very same word list every other player-typed string is.
import { censorSwears } from '../../api/src/sanitize'
// The notification-type ids the hub carries, and the payload shapes recovered from the
// client's own decoder (both owned by the `notify` worker). Imported rather than copied so
// the frames this worker builds are typed by the shapes the client actually parses — a
@@ -29,11 +35,12 @@ import { getInventionById, toSaveResult } from '../../api/src/inventions-db'
import { BalanceAddType } from '../../notify/src/notification-payloads'
import { NotificationType } from '../../notify/src/notification-types'
import adCarouselItems from '../static/ad-carousel-items.json'
import avatarItemCatalog from '../static/db/avatar-items.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'
import myProgress from '../static/my-progress.json'
import weeklyChallenge from '../static/weekly-challenge.json'
import questRewards from '../static/quest-rewards.json'
import { getAvatar, setAvatar } from './avatar-db'
import {
ALL_PLATFORMS,
@@ -45,11 +52,20 @@ import {
isSpendable,
spendCurrency,
} from './balance-db'
import { getCatalogItem } 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 which ITEMS go in that file there. The two must
// name the same moment or a build gets a store built to a different cutoff.
import {
claimChallengeGift,
getCompletedChallengeIds,
recordChallengeProgress,
} from './challenge-db'
CATALOG_ID_BASE,
CatalogKind,
isSellableRarity,
LEGACY_CLIENT_BUILD,
priceForRarity,
subscriberPriceFor,
} from './catalog-load'
import { claimChallengeGift, getChallengeStatuses, recordChallengeProgress } from './challenge-db'
import { buildRotation, rotationMapId, withWeeklyGift } from './challenge-rotation'
import {
consumeConsumable,
countConsumable,
@@ -65,7 +81,9 @@ import {
BalanceEntry,
BulkPurchaseRequest,
BulkPurchaseResponse,
BuyInventionRequest,
BuyInventionResponse,
BuyInventionV3Response,
BuyItemRequest,
BuyItemResponse,
ChallengeProgressRequest,
@@ -82,10 +100,14 @@ import {
form,
GameRewardRequest,
InfluencerIdsResponse,
InfluencerTierResponse,
ItemPurchaseInfoList,
ItemPurchaseInfosRequest,
json,
JsonArray,
jsonBody,
JsonObject,
LockedItemsBulkRequest,
MakerAiFreeTrialEligibilityResponse,
OpaqueJsonBody,
OPTIONAL_AUTHED,
@@ -95,6 +117,8 @@ import {
SaveOutfitRequest,
SaveOutfitV4Response,
SubscriptionResponse,
UgcPurchasableBulkRequest,
UgcPurchasableItemList,
UNAUTHORIZED_RESPONSE,
UpdateObjectiveRequest,
UpdateObjectiveResponse,
@@ -103,16 +127,30 @@ import { claimReward } from './reward-db'
import type { Context } from 'hono'
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
import type { CustomAvatarItem } from '../../api/src/custom-avatar-items-db'
import type { SavedInvention } from '../../api/src/inventions-db'
import type {
BalanceResponsePayload,
PurchaseBalanceModificationPayload,
} from '../../notify/src/notification-payloads'
import type { Avatar } from './avatar-db'
import type { CatalogRow } from './catalog-db'
import type {
ChallengeGiftBlock,
EquipmentGift,
WeeklyChallengeRotation,
} from './challenge-rotation'
import type { ConsumeResult } from './consumables-db'
import type { App } from './context'
import type { Equipment } from './equipment-db'
import type { AvatarItem } from './inventory-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
// their own, and buyInvention has to read the very rows `api` writes.
// Custom avatar items likewise live in an `api`-owned table; the UGC-purchasable bulk
// lookup is the store's view of those rows.
/**
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
* the `econ` service (these are separate from the main `api` worker). Balances,
@@ -133,13 +171,19 @@ async function authedId(c: Context<App>): Promise<number | null> {
}
/**
* The `role` claim from a Bearer token the operator-granted roles the auth worker stamps
* from the account's flags, so a plain player's token is just `['gameClient']`. `null` when
* the request carries no valid token; an empty array means a valid token with no roles.
* Shaped to mirror {@link authedId}.
* 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.
*
* 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 authedRoles(c: Context<App>): Promise<string[] | null> {
return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
async function authedBuild(c: Context<App>): Promise<number | null> {
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
if (version === null) return null
const build = Number.parseInt(version.split('.')[0] ?? '', 10)
return Number.isInteger(build) ? build : null
}
/** Results.Unauthorized() equivalent — 401 with empty body. */
@@ -357,8 +401,40 @@ async function pushBalancePurchase(
}
}
/** The operator-granted role that comes with a complimentary subscription. */
const DEVELOPER_ROLE = 'developer'
/**
* 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.
*/
const NOT_AN_INFLUENCER = 0
/**
* Whether the caller holds a Rec Room Plus subscription the ONE definition, shared by
* `UpdateAndGetSubscription` (which reports it) and the storefront buys (which price off
* it via `SubscriberPrices`). Those two must never disagree: a subscriber whose client
* applied the discount itself and then had the buy refused as a price mismatch is exactly
* what one definition prevents.
*
* Nothing SELLS subscriptions here. Plus is `account.hasPlus`, claimed on the website by
* proving a qualifying role in the community Discord (`www` `POST /api/benefits/claim`),
* and it reaches this worker as the token's `rn.plus` claim stamped by `auth` at login
* from that flag. So this is a pure token read: no database, no binding, nothing to load.
*
* The cost is FRESHNESS, deliberately accepted. The claim is only as current as the token,
* which lasts a day and is never refreshed (see TOKEN_TTL_SECONDS), so a player who claims
* on the website has to sign in again and restart the game before Plus applies. The
* website's claim page says so.
*
* The `developer` role does NOT grant Plus. It used to, as a stand-in while nothing else
* could confer it; now that the Discord claim exists, Plus is one thing with one source.
* An operator who wants a developer to have it sets `hasPlus` on their account like
* anyone else's.
*
* Never read from the body. No token, or an invalid one, is "not subscribed".
*/
async function isSubscriber(c: Context<App>): Promise<boolean> {
return validateAndGetPlus(c.req.raw, await c.env.JWT_SECRET.get())
}
/** `SubscriptionLevel.Gold`. 1 is Platinum. */
const SUBSCRIPTION_LEVEL_GOLD = 0
@@ -377,20 +453,21 @@ const SUBSCRIPTION_PLATFORM_ALL = -1
const STUB_SUBSCRIPTION_ID = 1
/**
* The complimentary subscription a `developer` account reports Rec Room Plus, which the
* client's API calls a `CampusCard`.
* The complimentary subscription a subscriber reports Rec Room Plus, which the client's
* API calls a `CampusCard`. See `isSubscriber` for who counts as one: a `developer`, or a
* player who claimed `hasPlus` with a Discord role on the website.
*
* Nothing here sells subscriptions, so holding the role IS the subscription: it's how the
* paid-tier surfaces get exercised without a store. Every field is computed per call and
* none of it is persisted, so this is not a record of anything revoking the role revokes
* the subscription, and no expiry sweep or renewal exists.
* Nothing here sells subscriptions, so holding one of those IS the subscription. Every
* field is computed per call and none of it is persisted, so this is not a record of
* anything dropping the role or the flag drops the subscription, and no expiry sweep or
* renewal exists.
*
* `ExpirationDate` is a year out from THIS call rather than a fixed date: a hard-coded one
* lapses on a day nobody is expecting, and the client would start showing an expired
* subscription with no way to renew it. `IsAutoRenewing` tells the client the same thing.
* The dates are milliseconds-precision ISO like the rest of this worker's timestamps.
*/
function developerSubscription(accountId: number) {
function plusSubscription(accountId: number) {
const now = new Date()
// Calendar arithmetic, not now + 365 days: setUTCFullYear lands on the same date next
// year whether or not a leap day falls in between.
@@ -427,23 +504,45 @@ function toAvatarV2Dto(avatar: Avatar) {
}
/**
* The subset of a storefront catalog (`static/storefronts/sf{N}.json`) that `buyItem`
* reads: each store item carries the `GiftDrop` describing what you get and a list of
* `Prices` per currency. The catalogs hold more fields (SubscriberPrices, IsFeatured,
* ) that the purchase path doesn't need.
* A storefront catalog entry's `GiftDrop` (`static/storefronts/sf{N}.json`) what a store
* item hands over. Field-for-field the client's own `GiftDrop` class, in its declared
* order, so a name here is a name the client reads.
*
* REQUIRED vs OPTIONAL is about what this server produces, not what the client declares:
* the required eleven are the ones every drop-building helper here sets (a game reward, a
* level-up box, a challenge gift see {@link toGameRewardDrop} and friends), and they are
* the only ones the purchase and roll paths read. The rest are optional because nothing
* here synthesizes one, whether or not a captured catalog carries it `GiftDropId`,
* `Unique`, `SubscribersOnly`, `ItemSetId` and `ItemSetFriendlyName` are on all 5,875
* captured entries, while `TagList`, `CustomAvatarItemId`, `AvatarItemId`,
* `EquipmentItemId` and `ThumbnailImageName` are on none of them.
*
* The store item around it carries `Prices` per currency and optionally `SubscriberPrices`
* the discounted list a Rec Room Plus subscriber is shown and pays. Both hold more
* fields (IsFeatured, ) the purchase path doesn't need.
*/
interface StoreGiftDrop {
/**
* The drop's own id. Every captured entry has it equal to the item's
* `PurchasableItemId`, which is why the paths that need one (a weekly gift, a skin)
* take it off there instead of from here.
*/
GiftDropId?: number
FriendlyName: string
Tooltip: string
/**
* NULL on 23 captured entries the client's field is a plain string, but the catalogs
* keep null and `""` apart, so a reader passing it on has to collapse it (`?? ''`).
*/
Tooltip: string | null
/** Not on any captured entry; nothing here reads or sets one. */
TagList?: string
ConsumableItemDesc: string
AvatarItemDesc: string
/** A UGC item's guid. Not on any captured entry — the captures predate them. */
CustomAvatarItemId?: string | null
AvatarItemType: number | null
EquipmentPrefabName: string
EquipmentModificationGuid: string
Rarity: number
Context: number
Currency: number
CurrencyType: number
/**
* A QUERY drop a loot box rather than an item. Its item fields are all empty on
* purpose: what the player gets is rolled at grant time from everything of the target
@@ -452,16 +551,37 @@ interface StoreGiftDrop {
* random 4-star item that you don't have."
*/
IsQuery?: boolean
/** Whether the player may hold only one. Nothing here enforces it. */
Unique?: boolean
/** Whether only a Rec Room Plus subscriber may buy it. Nothing here enforces it. */
SubscribersOnly?: boolean
Rarity: number
CurrencyType: number
Currency: number
Context: number
/** The set the item belongs to; null on 92 captured entries. */
ItemSetId?: number | null
ItemSetFriendlyName?: string
/** Catalog ids for the item the drop carries. Not on any captured entry. */
AvatarItemId?: number | null
EquipmentItemId?: number | null
/** Not on any captured entry; the client falls back to the item's own thumbnail. */
ThumbnailImageName?: string
// ---- Not part of the client's class -------------------------------------
/**
* The rarity a query drop rolls at, when it differs from the box's own `Rarity`. The
* sf2 boxes carry both and they agree; sf3's don't carry it at all, hence the fallback
* to `Rarity`.
* to `Rarity`. The client's `GiftDrop` has no such field — it is the catalog's, and
* only this server reads it.
*/
QueryRedirectRarity?: number
/**
* XP the drop pays out. No storefront catalog sets it a bought item is an item but a
* game reward is XP in a gift box, so the box and its notification carry the amount from
* here. The XP itself is banked in `progression`, not read back off the box.
* XP the drop pays out. Ours, not the client's and not any catalog's a bought item is
* an item, but a game reward is XP in a gift box, so the box and its notification carry
* the amount from here. The XP itself is banked in `progression`, not read back off the
* box.
*/
Xp?: number
}
@@ -472,8 +592,78 @@ interface StorePrice {
interface StoreItem {
GiftDrop: StoreGiftDrop
Prices: StorePrice[]
/**
* The subscriber price list, where the catalog has one (sf300's item 2263 lists 95 tokens
* in `Prices` and 85 in here). A subscriber's client renders and posts this as
* `RequestedPrice`, so checking their buy against `Prices` alone 409s it as "Price has
* changed". Treated as a FLOOR rather than the price to expect, because the client also
* posts the FULL price for items whose two lists agree (sf3's 2208, 150/150) see
* {@link priceCheck}.
*/
SubscriberPrices?: StorePrice[] | null
PurchasableItemId: number
}
/**
* The most Rec Room Plus can take off an item, in percent of the regular price.
*
* The client applies the discount ITSELF and posts the result as `RequestedPrice`, but it
* does NOT apply it to everything: sf3's item 2208 is 150 tokens in both catalog lists and a
* subscriber's client posts 150, while sf300's 2263 is 95/85 and posts 85. Only 144 of the
* 1382 captured items carry a discounted `SubscriberPrices` at all, and whether the rest are
* genuinely full price for a subscriber or were merely captured through a non-subscriber's
* view isn't answerable from here. So the server doesn't predict the number: it accepts
* anything from the regular price down to this much off (see {@link priceCheck}) and charges
* what the client asked to pay. Deriving one exact subscriber price instead 409'd every buy
* the client priced the other way.
*/
const SUBSCRIBER_DISCOUNT_PERCENT = 10
/** The lowest a subscriber's client can render an item whose regular price is `regular`. */
function subscriberFloor(regular: number): number {
return Math.floor((regular * (100 - SUBSCRIBER_DISCOUNT_PERCENT)) / 100)
}
/**
* The outcome of confirming a client's `RequestedPrice` against the catalog: the price to
* actually charge, or why the line can't be sold.
*/
type PriceCheck =
| { charge: number }
/** The item isn't sold in the requested currency at all. */
| 'no-currency'
/** The catalog moved under a stale client, or the price was made up. */
| 'mismatch'
/**
* Confirms what the buyer's client rendered, and answers what to charge them.
*
* A non-subscriber pays the `Prices` entry, exactly. A subscriber pays whatever they asked to
* pay within a BAND: the regular price at the top (their client posts it for items it doesn't
* discount) down to the catalog's `SubscriberPrices` entry or
* {@link SUBSCRIBER_DISCOUNT_PERCENT} off, whichever is lower.
*
* Charging `RequestedPrice` rather than a server-picked end of the band keeps the debit equal
* to the price the buyer was shown. The floor is what bounds the discount: a modified client
* can shave at most {@link SUBSCRIBER_DISCOUNT_PERCENT} off, and only while subscribed.
*/
function priceCheck(
item: StoreItem,
currencyType: number,
subscriber: boolean,
requestedPrice: unknown
): PriceCheck {
const regular = item.Prices.find((p) => p.CurrencyType === currencyType)
if (regular === undefined) return 'no-currency'
if (!Number.isInteger(requestedPrice)) return 'mismatch'
const requested = requestedPrice as number
if (requested === regular.Price) return { charge: requested }
if (!subscriber) return 'mismatch'
const listed = item.SubscriberPrices?.find((p) => p.CurrencyType === currencyType)
const floor = Math.min(subscriberFloor(regular.Price), listed?.Price ?? regular.Price)
return requested >= floor && requested < regular.Price ? { charge: requested } : 'mismatch'
}
interface Storefront {
StoreItems: StoreItem[]
}
@@ -487,15 +677,74 @@ interface GiftRequest {
}
/**
* Read a storefront catalog (`sf{type}.json`) from the ASSETS binding. Null when there is
* no such storefront.
* Storefront ids that are served ANOTHER storefront's catalog, because no capture of their
* own exists yet. Placeholder: an alias here is a storefront this server hasn't got, not one
* it has decided is a duplicate, so a line should come OUT again the moment `static/storefronts`
* grows the real `sf{id}.json` the alias silently wins over a file of that name.
*
* Resolved in {@link storefrontAssetPath} rather than at the route, so an aliased storefront
* is aliased for BUYING too. Browsing and purchasing read the same catalog by id, and an
* alias applied to only the browse side would show a page of items whose every purchase
* 404s as "no such storefront".
*/
const STOREFRONT_ALIASES: Record<string, string> = {
// Empty. 1704 was here for a while, standing in for a 2025 gift-drop storefront nobody had
// captured; the items it was meant to sell turned out to belong in the general store, so
// they are in `sf3-2025.json` and served as storefront 3 — see {@link STOREFRONT_BY_BUILD}.
// That is a per-BUILD variant of one storefront rather than an alias between two ids, which
// is why nothing is listed here.
}
/**
* Storefronts that have a SECOND file for newer clients, keyed by the id the client asks for
* and naming the file a build past {@link LEGACY_CLIENT_BUILD} is served instead.
*
* `3` is the general store, and BOTH files are generated from the item catalog by
* `runx storefront build` the same store at two points in time. `sf3.json` holds what existed
* by {@link LEGACY_CLIENT_BUILD}; `sf3-2025.json` holds everything. One storefront id either
* way: the client asks for 3 in both cases and neither knows there are two files, so nothing
* about the request changes and no item is renumbered between them.
*
* Resolved in {@link storefrontAssetPath}, which BOTH the listing route and
* {@link loadStorefront} go through, so browsing and buying always read the same file. That is
* the whole reason it is not done at the route: a newer client shown the merged store and then
* charged against the captured one would have every catalog item 404 as "no such storefront".
*/
const STOREFRONT_BY_BUILD: Record<string, string> = {
'3': 'sf3-2025',
}
/**
* The ASSETS path a storefront id reads from, following any {@link STOREFRONT_ALIASES} entry
* and any {@link STOREFRONT_BY_BUILD} variant. The id arrives as a path param, so it is a
* string here rather than a number: both tables are matched on what the client asked for.
*
* `build` is the caller's `rn.ver` (see {@link authedBuild}), or null when there is no readable
* one. Null gets the captured file: an unversioned token is the OLD client, so treating "can't
* prove its version" as "newer" would swap the store out from under the build that needs it.
*/
function storefrontAssetPath(id: string, build: number | null): string {
const aliased = STOREFRONT_ALIASES[id] ?? id
const variant = STOREFRONT_BY_BUILD[aliased]
if (variant !== undefined && build !== null && build > LEGACY_CLIENT_BUILD) {
return `/${variant}.json`
}
return `/sf${aliased}.json`
}
/**
* Read a storefront catalog from the ASSETS binding WHICH file depending on the caller's
* build, see {@link storefrontAssetPath}. Null when there is no such storefront.
*
* Separate from {@link findStoreItem} so a caller resolving SEVERAL items from one
* storefront reads (and parses) it once: sf3 alone is over a thousand items, and a bulk
* purchase carries up to `BULK_PURCHASE_CAP` lines.
* storefront reads (and parses) it once: sf3 alone is over a thousand items and the merged
* sf3-2025 is four, and a bulk purchase carries up to `BULK_PURCHASE_CAP` lines.
*/
async function loadStorefront(c: Context<App>, storefrontType: number): Promise<Storefront | null> {
const res = await c.env.ASSETS.fetch(new URL(`/sf${storefrontType}.json`, c.req.url))
const build = await authedBuild(c)
const res = await c.env.ASSETS.fetch(
new URL(storefrontAssetPath(String(storefrontType), build), c.req.url)
)
if (!res.ok) return null
return (await res.json()) as Storefront
}
@@ -515,6 +764,92 @@ async function findStoreItem(
return storefront.StoreItems.find((it) => it.PurchasableItemId === purchasableItemId) ?? null
}
/**
* How one item may be bought, as `POST /api/items/purchaseInfos` answers it. The store row
* holds ids only, so everything a price tag needs comes from here.
*
* `ItemId` re-uses the request's reference verbatim camelCase members under a PascalCase
* key. It reads like a mistake and is not one: the client's decoder names the members that
* way on both legs, and PascalCasing them here loses the id.
*
* `PurchaseMethodId` names WHICH listing sells the item, and is a tagged union of the two
* kinds of id a listing can have: `Type` 1 carries a `Guid` (a UGC item, keyed by its own
* guid) and leaves `NumberId` null; a storefront's numbered `PurchasableItemId` would be the
* other side. Nothing here sells anything under a second listing, so the guid is the item's own.
*/
interface ItemPurchaseInfo {
ItemId: { itemType: number; itemId: string }
PurchaseMethodId: { Type: number; NumberId: number | null; Guid: string | null }
Prices: Array<{
CurrencyType: number
Price: number
StorefrontSaleData: {
SalePercent: number
SaleStartDate: string | null
SaleEndDate: string | null
} | null
}>
NewUntil: string | null
AvailableAt: string | null
AvailableUntil: string | null
CanBeGifted: boolean
CanApplySubscriberDiscount: boolean
SubscribersOnly: boolean
IsFeatured: boolean
}
/** The `PurchaseMethodId.Type` that carries a `Guid` rather than a `NumberId`. */
const PURCHASE_METHOD_TYPE_GUID = 1
/**
* The purchase-info projection of a custom avatar item.
*
* The price is in `RecCenterTokens` because that is what a UGC item costs: the creation UI's
* floor (`api`'s `/api/customAvatarItems/v1/minPriceForPublicItem`) is a token price, and the
* `price` column it writes is the same number. It must NOT be a room currency those are
* scoped to a room this endpoint knows nothing about, and the client holds no balance to pay
* one with, so the item would draw a price it can never meet.
*
* The rest is what the row can honestly say:
* - `AvailableAt` is the item's creation the moment it began being sellable. There is no
* scheduled listing here, so `AvailableUntil` is null: on sale until the creator pulls it.
* - `NewUntil` is null rather than derived from `CreatedAt`: nothing has ever defined how long
* new lasts here, and guessing draws the pip on items that are not.
* - `StorefrontSaleData` is a zero-percent sale rather than null, since nothing discounts UGC
* items yet and a present-but-empty sale is the shape the client always gets to read.
* - `SubscribersOnly`/`CanApplySubscriberDiscount` are false: subscriber pricing is a
* storefront-catalog feature (`sf{N}.json`'s `SubscriberPrices`) and no UGC item has one.
* - `IsFeatured` is the row's own flag, the same one the featured feed reads.
*
* `CanBeGifted` is true because the reference let players gift UGC items but nothing here
* buys a custom avatar item yet, gift or otherwise, so the button it draws leads nowhere until
* that exists. It is the flag to flip if a dead gift button is worse than a missing one.
*/
function toItemPurchaseInfo(item: CustomAvatarItem): ItemPurchaseInfo {
return {
ItemId: { itemType: UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM, itemId: item.CustomAvatarItemId },
PurchaseMethodId: {
Type: PURCHASE_METHOD_TYPE_GUID,
NumberId: null,
Guid: item.CustomAvatarItemId,
},
Prices: [
{
CurrencyType: CurrencyType.RecCenterTokens,
Price: item.Price,
StorefrontSaleData: { SalePercent: 0, SaleStartDate: null, SaleEndDate: null },
},
],
NewUntil: null,
AvailableAt: item.CreatedAt,
AvailableUntil: null,
CanBeGifted: true,
CanApplySubscriberDiscount: false,
SubscribersOnly: false,
IsFeatured: item.IsFeatured,
}
}
/** Build the owned avatar-item DTO granted into the buyer's inventory from a gift-drop. */
function toAvatarItem(giftDrop: StoreGiftDrop): AvatarItem {
return {
@@ -522,7 +857,7 @@ function toAvatarItem(giftDrop: StoreGiftDrop): AvatarItem {
AvatarItemDesc: giftDrop.AvatarItemDesc,
PlatformMask: -1,
FriendlyName: giftDrop.FriendlyName,
Tooltip: giftDrop.Tooltip,
Tooltip: giftDrop.Tooltip ?? '',
Rarity: giftDrop.Rarity,
}
}
@@ -533,7 +868,7 @@ function toEquipment(giftDrop: StoreGiftDrop): Equipment {
ModificationGuid: giftDrop.EquipmentModificationGuid,
PrefabName: giftDrop.EquipmentPrefabName,
FriendlyName: giftDrop.FriendlyName,
Tooltip: giftDrop.Tooltip,
Tooltip: giftDrop.Tooltip ?? '',
Rarity: giftDrop.Rarity,
PlatformMask: -1,
Favorited: false,
@@ -546,15 +881,72 @@ const CONSUMABLE_GRANT_COUNT = 1
/** The "Coach" system account — the sender a self-buy or anonymous gift is attributed to. */
const COACH_ACCOUNT_ID = 1
/** Build the stored gift-box content (the client's rendered "gift box") from a gift-drop. */
/** What a box says when the buyer wrote nothing — a self-purchase, or a gift sent bare. */
const DEFAULT_GIFT_MESSAGE = 'A gift for you <3'
/**
* The most a gift note may carry the same 150 the client's own input field stops typing at,
* so a longer one is a client that ignored its own limit rather than a longer note.
*/
const MAX_GIFT_MESSAGE_LENGTH = 150
/**
* The note a gift box carries: capped at {@link MAX_GIFT_MESSAGE_LENGTH}, then masked the way
* every other string a player typed is.
*
* The buyer writes this and someone ELSE reads it off the box, out of the hub frame, and
* for as long as the box goes unopened so a gift is a way to put text in front of a player
* who never chose to hear from you. That is why both rules are re-applied here: nothing
* obliges a client to have called `POST /api/sanitize/v1` first, or to have honoured its own
* character limit, and this is the last point before the note is stored. `chat` censors its
* messages again for the same reason.
*
* Trimming and masking (rather than refusing) matches the rest of this server: the purchase
* goes through, the swear comes out as asterisks, the overrun is dropped, and the buyer is
* never told their gift was rejected. Blocked characters are deliberately left alone, as in
* chat a note is emoji-carrying text, and stripping format characters would break the
* joiners inside a multi-person emoji.
*
* The cap is applied FIRST so what gets filtered is what gets stored: cutting a word in half
* can leave a swear where there wasn't one ("assassin" ending as "ass"), and cutting after
* the mask would leave a half-masked word instead. Both counts are UTF-16 units, as the
* client's are a trailing lone surrogate is dropped rather than stored as half a character.
*/
function giftMessage(gift: GiftRequest | null): string {
if (typeof gift?.Message !== 'string') return DEFAULT_GIFT_MESSAGE
return censorSwears(truncateGiftMessage(gift.Message))
}
/** `message` cut to the cap, never through the middle of a surrogate pair. */
function truncateGiftMessage(message: string): string {
if (message.length <= MAX_GIFT_MESSAGE_LENGTH) return message
const cut = message.slice(0, MAX_GIFT_MESSAGE_LENGTH)
const last = cut.charCodeAt(cut.length - 1)
// A high surrogate at the end lost its partner to the cut, and alone it is not a
// character at all — the client would draw the replacement glyph for it.
return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut
}
/**
* Build the stored gift-box content (the client's rendered "gift box") from a gift-drop.
*
* `fromPlayerId` and `giftContext` are stamped on because the box outlives the request that
* made it: a gift's receiver may well be offline and meets it in `GET /api/avatar/v2/gifts`,
* with nothing but the row to say who sent it or why. They default to Coach and the drop's
* own context a box the server handed over on nobody's behalf.
*/
function toGiftContent(
giftDrop: StoreGiftDrop,
message: string,
consumableCount: number,
consumableMappingId = 0,
consumablePreExistingCount = 0
consumablePreExistingCount = 0,
fromPlayerId = COACH_ACCOUNT_ID,
giftContext: number | null = null
): GiftContent {
return {
FromPlayerId: fromPlayerId,
GiftContext: giftContext ?? giftDrop.Context,
ConsumableItemDesc: giftDrop.ConsumableItemDesc,
ConsumableCount: consumableCount,
ConsumableMappingId: consumableMappingId,
@@ -598,7 +990,8 @@ async function pushGiftReceived(
accountId: number,
gift: GrantedGift,
message: string,
fromPlayerId: number
fromPlayerId: number,
giftContext: number | null = null
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
@@ -620,7 +1013,7 @@ async function pushGiftReceived(
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: ALL_PLATFORMS,
GiftContext: gift.drop.Context,
GiftContext: giftContext ?? gift.drop.Context,
GiftRarity: gift.drop.Rarity,
Message: message,
}
@@ -670,10 +1063,111 @@ async function pushProgressionUpdate(
*/
const ROLL_STOREFRONT_TYPE = 3
/** Every item in the roll catalog, or `[]` if it can't be read (a roll then yields nothing). */
/**
* Every item a roll or a weekly gift may draw, or `[]` if it can't be read (a roll then yields
* nothing).
*
* The storefront PLUS every equipment skin, which no storefront sells: skins are awarded from
* weekly challenges rather than bought, so they were taken out of sf3 and the weekly gift pool
* is exactly the equipment in this list, which would otherwise be empty. They come from the
* `catalog` table, whose skins are the same rows `static/db/skins.json` holds.
*
* Being in this list does NOT make an item purchasable. `findStoreItem` and the bulk bag resolve
* a purchase against the storefront file, never against this.
*/
async function loadRollCatalog(c: Context<App>): Promise<StoreItem[]> {
const storefront = await loadStorefront(c, ROLL_STOREFRONT_TYPE)
return storefront?.StoreItems ?? []
const { results } = await c.env.DB.prepare(
`SELECT * FROM catalog WHERE kind = ?1 AND catalog_id IS NOT NULL`
)
.bind(CatalogKind.Skin)
.all<CatalogRow>()
return [...(storefront?.StoreItems ?? []), ...results.map(toSkinStoreItem)]
}
/**
* One catalog skin as a STORE ITEM, so the roll catalog and the weekly gift pool can read it the
* same way they read a storefront entry.
*
* Keyed the way a gift-drop keys equipment (`EquipmentPrefabName` + `EquipmentModificationGuid`)
* rather than as an avatar item that guid is what marks an entry as equipment, and what the
* gift pool filters on. Priced at zero: nothing sells these, and a price here would be a number
* no surface ever shows.
*/
function toSkinStoreItem(row: CatalogRow): StoreItem {
return {
GiftDrop: {
FriendlyName: row.friendly_name,
Tooltip: row.tooltip ?? '',
ConsumableItemDesc: '',
AvatarItemDesc: '',
AvatarItemType: 0,
EquipmentPrefabName: row.prefab_name ?? '',
EquipmentModificationGuid: row.item_key,
Rarity: row.rarity,
Context: 0,
Currency: 0,
CurrencyType: 0,
},
Prices: [],
PurchasableItemId: row.catalog_id as number,
}
}
/**
* Equipment prefabs a weekly gift is never drawn from, matched on the prefix of
* `EquipmentPrefabName`.
*
* `[Sandbox_D4]` `[Sandbox_D20]` are the sandbox dice 24 skins across six prefabs, a sixth
* of the whole pool. Theming a week on "Sandbox D8 (Pewter)" spends the week's headline reward
* on a die recolour, so they are excluded and the pool is the 248 that remain.
*/
const WEEKLY_GIFT_EXCLUDED_PREFABS = ['[Sandbox_']
/**
* The equipment a weekly challenge gift can be drawn from: every roll-catalog item carrying an
* `EquipmentModificationGuid`, less {@link WEEKLY_GIFT_EXCLUDED_PREFABS}. Weekly rewards are
* equipment the captured rotation's is a camera skin and that guid is exactly what marks an
* entry as equipment.
*
* The pool comes from the catalog's SKINS now rather than from sf3, which no longer sells
* equipment at all: skins are awarded here, not bought. See {@link loadRollCatalog}.
*
* `GiftDropId` comes off `PurchasableItemId`, which for a skin is its `catalog_id`.
*/
function toEquipmentGiftPool(catalog: StoreItem[]): EquipmentGift[] {
return catalog
.filter(
(item) =>
item.GiftDrop.EquipmentModificationGuid !== '' &&
!WEEKLY_GIFT_EXCLUDED_PREFABS.some((prefix) =>
item.GiftDrop.EquipmentPrefabName.startsWith(prefix)
)
)
.map((item) => ({
GiftDropId: item.PurchasableItemId,
EquipmentPrefabName: item.GiftDrop.EquipmentPrefabName,
EquipmentModificationGuid: item.GiftDrop.EquipmentModificationGuid,
Rarity: item.GiftDrop.Rarity,
// Carried so the rotation can theme the week on the item it rolled; the grant path
// resolves the same name from this entry when it hands the item over.
FriendlyName: item.GiftDrop.FriendlyName,
}))
}
/**
* The same pool, memoised for the life of the isolate. `getCurrent` needs it on every call
* just to show the week's reward, and sf3 is a megabyte and a half of JSON to fetch and parse
* but it is a bundled asset, so it cannot change under a running isolate and a deploy
* builds new ones. A failed read is deliberately NOT cached: it would pin an empty pool (and
* so the static fallback gift) until the next deploy.
*/
let cachedGiftPool: EquipmentGift[] | null = null
async function loadEquipmentGiftPool(c: Context<App>): Promise<EquipmentGift[]> {
if (cachedGiftPool !== null) return cachedGiftPool
const pool = toEquipmentGiftPool(await loadRollCatalog(c))
if (pool.length > 0) cachedGiftPool = pool
return pool
}
/**
@@ -793,6 +1287,13 @@ interface GrantOptions extends RollOptions {
* it is saying its own UI announces the items.
*/
skipGiftBox?: boolean
/**
* Who the box says it is from, and why it exists a purchase gifted to another player
* carries the buyer (or Coach, when they sent it anonymously) and the `Gift` block's
* `GiftContext`. Default: Coach and the drop's own context, i.e. a box from the server.
*/
fromPlayerId?: number
giftContext?: number | null
}
/**
@@ -879,7 +1380,15 @@ async function grantGiftDrop(
const { id } = await createGift(
db,
accountId,
toGiftContent(giftDrop, message, consumableCount, consumableMappingId, consumablePreExisting)
toGiftContent(
giftDrop,
message,
consumableCount,
consumableMappingId,
consumablePreExisting,
options.fromPlayerId,
options.giftContext
)
)
return { id, drop: giftDrop }
}
@@ -1067,20 +1576,85 @@ function toPurchaseMethodId(raw: Partial<PurchaseMethodId> | null | undefined):
}
}
/**
* Catalog rows as STORE ITEMS, so a bag can be resolved against the `catalog` table the same
* way it is resolved against an `sf{N}.json` file.
*
* The generated storefront (`sf3-2025.json`) is built from these very rows with this very
* pricing, so an item bought here costs exactly what that file lists it at. That is not a
* nicety: `priceCheck` refuses a line whose posted `RequestedPrice` doesn't match, so two
* pricings would 409 every purchase the client made from the page it was shown.
*
* Mostly redundant now that the merged store carries every sellable AVATAR ITEM a newer
* build's bag resolves those straight out of the file. What it still reaches that the file does
* not is SKINS, which the generator leaves out, keyed the way a gift-drop keys equipment
* (`EquipmentPrefabName` +
* `EquipmentModificationGuid`) rather than as an avatar item which is what lets a skin be
* bought at all, since no generated storefront file lists one.
*
* {@link isSellableRarity} is applied here as well as in the generator: the developer tier is
* absent from the file, and resolving a bag straight off the table would otherwise sell items
* the store never offered.
*/
async function catalogStoreItems(db: D1Database, catalogIds: number[]): Promise<StoreItem[]> {
if (catalogIds.length === 0) return []
const placeholders = catalogIds.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT * FROM catalog WHERE catalog_id IN (${placeholders})`)
.bind(...catalogIds)
.all<CatalogRow>()
return results
.filter(
(row) =>
row.catalog_id !== null &&
row.kind === CatalogKind.AvatarItem &&
isSellableRarity(row.rarity)
)
.map((row) => {
const price = priceForRarity(row.rarity)
return {
GiftDrop: {
FriendlyName: row.friendly_name,
// The client's field is a string; the catalog keeps NULL and "" apart.
Tooltip: row.tooltip ?? '',
ConsumableItemDesc: '',
// `item_key` IS the `AvatarItemDesc` for an avatar item — that is what makes it the
// key. The equipment fields stay empty: only avatar items reach here.
AvatarItemDesc: row.item_key,
AvatarItemType: row.avatar_item_type ?? 0,
EquipmentPrefabName: '',
EquipmentModificationGuid: '',
Rarity: row.rarity,
Context: 0,
Currency: 0,
CurrencyType: 0,
},
Prices: [{ CurrencyType: CurrencyType.RecCenterTokens, Price: price }],
SubscriberPrices: [
{ CurrencyType: CurrencyType.RecCenterTokens, Price: subscriberPriceFor(price) },
],
PurchasableItemId: row.catalog_id as number,
}
})
}
/**
* Resolve one line against the bag's catalog: what it wants, how many, and at what price.
* Returns the failure with the `UpdateResponse` its entry will carry instead when the
* line can't be bought.
*
* Pure the catalog is passed in so the whole bag resolves from ONE storefront read.
* The price check is buyItem's, per line: `RequestedPrice` is the UNIT price the client
* rendered, and a mismatch means the catalog moved under a stale client rather than that
* the player agreed to today's price.
* Pure the catalog and the buyer's subscriber status are passed in so the whole bag
* resolves from ONE storefront read and ONE token read. The price check is buyItem's, per
* line: `RequestedPrice` is the UNIT price the client rendered (for a subscriber, anywhere in
* the discount band see {@link priceCheck}), and a mismatch means the catalog moved under a
* stale client rather than that the player agreed to today's price.
*/
function resolveBulkLine(
line: PurchaseItemRequest,
storefront: Storefront | null,
currencyType: number
currencyType: number,
subscriber: boolean
): BulkPurchaseLine | BulkLineFailure {
const method = toPurchaseMethodId(line.ItemPurchaseMethodId)
// Guid-keyed ids name UGC / custom avatar items, which no catalog here sells. Failing the
@@ -1129,30 +1703,25 @@ function resolveBulkLine(
error: 'This item can only be bought once per line',
}
}
const price = item.Prices.find((p) => p.CurrencyType === currencyType)
if (price === undefined) {
const checked = priceCheck(item, currencyType, subscriber, line.RequestedPrice)
if (checked === 'no-currency') {
return {
method,
code: UpdateResponse.NoItemAvailable,
error: 'Currency type not available for this item',
}
}
if (!Number.isInteger(line.RequestedPrice)) {
if (checked === 'mismatch') {
return {
method,
code: UpdateResponse.RequestedPriceDoesNotMatch,
error: 'RequestedPrice is required',
}
}
if (line.RequestedPrice !== price.Price) {
return {
method,
code: UpdateResponse.RequestedPriceDoesNotMatch,
error: 'Price has changed',
error: !Number.isInteger(line.RequestedPrice)
? 'RequestedPrice is required'
: 'Price has changed',
}
}
const gift = typeof line.Gift === 'object' && line.Gift !== null ? line.Gift : null
return { method, item, price: price.Price, count, gift }
return { method, item, price: checked.charge, count, gift }
}
/** Whether a resolved line is buyable or is already a failure. */
@@ -1204,6 +1773,87 @@ function toGameRewardDrop(): StoreGiftDrop {
}
}
/**
* One row of `static/quest-rewards.json`: the reward table of the live game's activities,
* keyed by the `giftContext` the client posts with a game-reward ask (`Dodgeball`,
* `Quest_Goblin_S`, `Paintball_Dam`, ). Each row is the gift-drop as the game's own reward
* server shaped it a comma-laden `AvatarItemDesc` (the catalog's `item_key`), or for the
* Laser Tag entry a currency payout with `GiftRarity` and the activity's own `Context`
* (8000 for dodgeball, 4003 for the goblin quest's S rank) spelled the way the client's box
* reads them. Untyped fields (`Id`, `Level`, `Message`) are carried but unused.
*/
interface QuestReward {
AvatarItemDesc: string
ConsumableItemDesc: string
EquipmentPrefabName: string
EquipmentModificationGuid: string
CurrencyType: number
Currency: number
Xp: number
GiftRarity: number
Context: number
}
const QUEST_REWARDS: Record<string, QuestReward[]> = questRewards
/**
* The reward an activity pays, when `giftContext` names an entry in `quest-rewards.json`:
* one row drawn at random from that key's list, among the rows the player DOESN'T ALREADY
* OWN the table is "what this activity can give you", and handing over a duplicate gives
* nothing (the inventory is a set). A currency row is never "owned", so it always stays in
* the pool.
*
* Null for a context the table doesn't know (or one whose every reward the player already
* has), which the caller pays as the plain XP box the cooldown key is the same string
* either way, so an unknown or exhausted context is still rate-limited.
*/
async function pickQuestReward(
db: D1Database,
accountId: number,
giftContext: string
): Promise<QuestReward | null> {
if (!Object.hasOwn(QUEST_REWARDS, giftContext)) return null
const rows = QUEST_REWARDS[giftContext] ?? []
if (rows.length === 0) return null
const ownedItems = new Set((await getInventory(db, accountId)).map((i) => i.AvatarItemDesc))
const ownedGuids = new Set((await getEquipment(db, accountId)).map((e) => e.ModificationGuid))
const pool = rows.filter(
(r) =>
!(r.AvatarItemDesc !== '' && ownedItems.has(r.AvatarItemDesc)) &&
!(r.EquipmentModificationGuid !== '' && ownedGuids.has(r.EquipmentModificationGuid))
)
if (pool.length === 0) {
logger.info('quest rewards exhausted for player', { accountId, giftContext })
return null
}
return pool[Math.floor(Math.random() * pool.length)] ?? null
}
/**
* A quest reward as the gift-drop `grantGiftDrop` hands over. The item fields come off the
* 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.
*/
function toQuestRewardDrop(reward: QuestReward, catalog: CatalogRow | null): StoreGiftDrop {
return {
FriendlyName: catalog?.friendly_name ?? '',
Tooltip: catalog?.tooltip ?? '',
ConsumableItemDesc: reward.ConsumableItemDesc,
AvatarItemDesc: reward.AvatarItemDesc,
AvatarItemType: catalog?.avatar_item_type ?? null,
EquipmentPrefabName: reward.EquipmentPrefabName,
EquipmentModificationGuid: reward.EquipmentModificationGuid,
Rarity: reward.GiftRarity,
Context: reward.Context,
Currency: reward.Currency,
CurrencyType: reward.CurrencyType,
Xp: GAME_REWARD_XP,
}
}
/**
* The box a CLOTHING level-up hands over: a query drop at the level's own tier, rolled from
* AVATAR ITEMS only. The published table calls these levels "N-Star Clothing", so the prize
@@ -1285,30 +1935,6 @@ async function grantLevelUpGifts(
}
}
/**
* The rotation's reward, as static/weekly-challenge.json writes it. Same item vocabulary as
* a storefront `GiftDrop` but with `Context`/`Rarity` spelled `GiftContext`/`GiftRarity`,
* so it has to be translated before the grant path can read it (see
* {@link toChallengeGiftDrop}).
*
* `FriendlyName`/`Tooltip` are OPTIONAL because the captured rotation has neither the
* client resolves the reward's name from the item itself, falling back to
* `FallbackGiftName`. A rotation we publish can carry them to name the granted item
* properly without a code change.
*/
interface ChallengeGift {
AvatarItemDesc: string
AvatarItemType: number
ConsumableItemDesc: string
EquipmentPrefabName: string
EquipmentModificationGuid: string
GiftContext: number
GiftRarity: number
Xp: number
FriendlyName?: string
Tooltip?: string
}
/** The message on the gift box the weekly reward arrives in. */
const CHALLENGE_GIFT_MESSAGE = 'Weekly challenge complete!'
@@ -1330,8 +1956,8 @@ const DEFAULT_FALLBACK_STARS = 4
* it is what the client renders when the gift resolves to a box rather than a named item
* so a rotation can retune the tier by renaming it, with no code change.
*/
function fallbackGiftRarity(): number {
const stars = Number(/^(\d+)-star/i.exec(weeklyChallenge.FallbackGiftName)?.[1])
function fallbackGiftRarity(rotation: WeeklyChallengeRotation): number {
const stars = Number(/^(\d+)-star/i.exec(rotation.FallbackGiftName)?.[1])
return STAR_RARITY[stars - 1] ?? STAR_RARITY[DEFAULT_FALLBACK_STARS - 1] ?? 0
}
@@ -1346,8 +1972,11 @@ function fallbackGiftRarity(): number {
* selling the same item, so the granted item reads as itself "Camera Skin (Comic)" rather
* than the name of the box it might have arrived in.
*/
function toChallengeGiftDrop(catalog: StoreItem[]): StoreGiftDrop {
const gift = weeklyChallenge.Gift as ChallengeGift
function toChallengeGiftDrop(
rotation: WeeklyChallengeRotation,
catalog: StoreItem[]
): StoreGiftDrop {
const gift: ChallengeGiftBlock = rotation.Gift
const sold = catalog.find(
({ GiftDrop: drop }) =>
(gift.EquipmentModificationGuid !== '' &&
@@ -1355,7 +1984,7 @@ function toChallengeGiftDrop(catalog: StoreItem[]): StoreGiftDrop {
(gift.AvatarItemDesc !== '' && drop.AvatarItemDesc === gift.AvatarItemDesc)
)?.GiftDrop
return {
FriendlyName: gift.FriendlyName ?? sold?.FriendlyName ?? weeklyChallenge.FallbackGiftName,
FriendlyName: gift.FriendlyName ?? sold?.FriendlyName ?? rotation.FallbackGiftName,
Tooltip: gift.Tooltip ?? sold?.Tooltip ?? '',
ConsumableItemDesc: gift.ConsumableItemDesc,
AvatarItemDesc: gift.AvatarItemDesc,
@@ -1376,17 +2005,17 @@ function toChallengeGiftDrop(catalog: StoreItem[]): StoreGiftDrop {
* it. Handed over instead of the rotation's item when that item would be a duplicate, which
* is what the fallback name is for the reward reads "the Camera Skin, or a 4-Star Box".
*/
function toChallengeFallbackDrop(): StoreGiftDrop {
function toChallengeFallbackDrop(rotation: WeeklyChallengeRotation): StoreGiftDrop {
return {
FriendlyName: weeklyChallenge.FallbackGiftName,
FriendlyName: rotation.FallbackGiftName,
Tooltip: '',
ConsumableItemDesc: '',
AvatarItemDesc: '',
AvatarItemType: null,
EquipmentPrefabName: '',
EquipmentModificationGuid: '',
Rarity: fallbackGiftRarity(),
Context: (weeklyChallenge.Gift as ChallengeGift).GiftContext,
Rarity: fallbackGiftRarity(rotation),
Context: rotation.Gift.GiftContext,
Currency: 0,
CurrencyType: 0,
IsQuery: true,
@@ -1406,11 +2035,9 @@ const CHALLENGES_REQUIRED_FOR_GIFT = 3
* all-or-nothing when it's true the reading its name and the partial default suggest
* and a rotation shorter than the threshold can only ever ask for what it publishes.
*/
function challengesRequiredForGift(): number {
const published = weeklyChallenge.Challenges.length
return weeklyChallenge.CompletedRequired
? published
: Math.min(CHALLENGES_REQUIRED_FOR_GIFT, published)
function challengesRequiredForGift(rotation: WeeklyChallengeRotation): number {
const published = rotation.Challenges.length
return rotation.CompletedRequired ? published : Math.min(CHALLENGES_REQUIRED_FOR_GIFT, published)
}
/**
@@ -1436,25 +2063,27 @@ function challengesRequiredForGift(): number {
* would otherwise meet without playing.
*/
async function awardChallengeGift(c: Context<App>, accountId: number): Promise<void> {
const rotation = buildRotation(new Date())
try {
if (weeklyChallenge.Challenges.length === 0) return
const complete = await getCompletedChallengeIds(
c.env.DB,
accountId,
weeklyChallenge.ChallengeMapId
)
const done = weeklyChallenge.Challenges.filter((ch) => complete.has(ch.ChallengeId)).length
if (done < challengesRequiredForGift()) return
if (rotation.Challenges.length === 0) return
const statuses = await getChallengeStatuses(c.env.DB, accountId, rotation.ChallengeMapId)
const done = rotation.Challenges.filter(
(ch) => statuses.get(ch.ChallengeId)?.complete === true
).length
if (done < challengesRequiredForGift(rotation)) return
// Claim first: this is what stops the next report paying out a second time.
const claimed = await claimChallengeGift(c.env.DB, accountId, weeklyChallenge.ChallengeMapId)
const claimed = await claimChallengeGift(c.env.DB, accountId, rotation.ChallengeMapId)
if (!claimed) return
// Only now is the catalog worth reading: it names the week's reward and is what the
// grant path rolls a duplicate's replacement from.
const catalog = await loadRollCatalog(c)
const reward = toChallengeGiftDrop(catalog)
const week = withWeeklyGift(rotation, toEquipmentGiftPool(catalog))
const reward = toChallengeGiftDrop(week, catalog)
const duplicate = await ownsGiftDrop(c.env.DB, accountId, reward)
const granted = await grantGiftDrop(
c,
accountId,
duplicate ? toChallengeFallbackDrop() : reward,
duplicate ? toChallengeFallbackDrop(week) : reward,
CHALLENGE_GIFT_MESSAGE,
{ rollCatalog: catalog }
)
@@ -1465,7 +2094,7 @@ async function awardChallengeGift(c: Context<App>, accountId: number): Promise<v
await pushGiftReceived(c, accountId, granted, CHALLENGE_GIFT_MESSAGE, COACH_ACCOUNT_ID)
logger.info('weekly challenge gift granted', {
accountId,
challengeMapId: weeklyChallenge.ChallengeMapId,
challengeMapId: rotation.ChallengeMapId,
giftId: granted.id,
fallbackRoll: duplicate,
challengesComplete: done,
@@ -1473,7 +2102,7 @@ async function awardChallengeGift(c: Context<App>, accountId: number): Promise<v
} catch (err) {
logger.error('failed to grant weekly challenge gift', {
accountId,
challengeMapId: weeklyChallenge.ChallengeMapId,
challengeMapId: rotation.ChallengeMapId,
error: err instanceof Error ? err.message : String(err),
})
}
@@ -1513,6 +2142,120 @@ function listRoute(summary: string, description: string, auth = false) {
})
}
/**
* A completed invention purchase: the invention that changed hands and the buyer's
* RESULTING token balance (not the change see the envelopes both routes build from it).
*/
interface SettledInventionPurchase {
invention: SavedInvention
balance: number
}
/**
* Settle an invention purchase the whole of buyInvention except the envelope it is
* announced in, shared by the `v2` GET and the `v3` POST. Every refusal is a `Response`
* (the `{ error }` body both routes answer with); a sale is the bought invention and the
* buyer's resulting balance, which each route then wraps in ITS OWN shape the two
* clients want different ones, so the money is shared and the projection is not.
*
* A priced invention is settled player-to-player: the buyer is debited its `Price` in
* RecCenterTokens and the CREATOR is credited the same amount no house cut, so the
* tokens are moved rather than minted or burned. A free invention (`Price` 0) skips the
* money entirely: nothing is debited and nobody is paid. The stored price is confirmed
* against the price the client rendered first, so a stale or tampered client cant buy
* at a price the creator no longer offers (409), and an unaffordable one is a 400
* the same "Insufficient balance" buyItem answers with.
*
* 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 inventions `NumDownloads` counter is deliberately NOT
* bumped: that column lives on the `invention` table the `api` worker owns, and this
* worker only reads it.
*/
async function settleInventionPurchase(
c: Context<App>,
id: number,
inventionId: number,
requestedPrice: number
): Promise<SettledInventionPurchase | Response> {
const invention = await getInventionById(c.env.DB, inventionId)
if (invention === null) return c.json({ error: 'Invention not found' }, 404)
// An unpublished invention is a draft: it isn't on sale, not even for free.
if (!invention.IsPublished) return c.json({ error: 'Invention is not for sale' }, 403)
if (invention.CreatorPlayerId === id) {
return c.json({ error: 'Cannot buy your own invention' }, 400)
}
if (await ownsInvention(c.env.DB, id, inventionId)) {
return c.json({ error: 'Already owned' }, 409)
}
// The price the client rendered must still be the stored one: a mismatch is a stale
// catalog or a tampered request, never a sale.
if (invention.Price !== requestedPrice) {
return c.json({ error: 'Price has changed' }, 409)
}
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
// Inventions are priced in RecCenterTokens only — the store shows no other currency
// for them, and `Price` carries no currency of its own to pick a different one from.
const price = invention.Price
if (price > 0) {
// Debit the buyer atomically; false means they couldn't afford it and nothing
// changed, so no ownership is recorded and the creator is not paid.
const paid = await spendCurrency(
c.env.DB,
id,
CurrencyType.RecCenterTokens,
price,
startingTokens
)
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
}
// Grant before paying out: these are three separate D1 writes with no transaction
// 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)
if (price > 0) {
// Seed the creator's signup grant BEFORE crediting them: `creditCurrency` upserts
// the balance row, and `ensureStartingBalances` is an INSERT OR IGNORE, so a
// creator who had never touched their balance would otherwise have the row created
// here and lose their starting tokens forever.
await ensureStartingBalances(c.env.DB, invention.CreatorPlayerId, startingTokens)
const creatorBalance = await creditCurrency(
c.env.DB,
invention.CreatorPlayerId,
CurrencyType.RecCenterTokens,
price,
startingTokens
)
// The creator is a different, probably-online player with no response to read:
// push the sale so it lands on their shown balance without a re-fetch. The frame
// carries their resulting TOTAL (what `creditCurrency` returns), not the payout —
// sending the payout would set their whole balance to it. A plain update rather
// than a purchase frame: they sold, they didn't buy. Best-effort, as everywhere.
await pushBalanceUpdate(
c,
invention.CreatorPlayerId,
CurrencyType.RecCenterTokens,
creatorBalance
)
}
// Unlike buyItem — whose `Balance` is the change applied — the reference server
// answers this one with the RESULTING total (a first read seeds the buyer's starting
// grant, as everywhere else). The buyer's frame carries that same total, so the body
// and the push land the client on one number.
const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens)
// A free invention moved nothing, so there is no purchase to report.
if (price > 0) {
await pushBalancePurchase(c, id, CurrencyType.RecCenterTokens, -price, balance)
}
return { invention, balance }
}
// strict: false so trailing-slash routes (e.g. `/gifts/consume/`, which the client
// posts with a trailing slash) match either form. Mirrors the `api` worker.
const app = new Hono<App>({ strict: false })
@@ -1529,6 +2272,66 @@ const app = new Hono<App>({ strict: false })
.onError(withOnError())
.notFound(withNotFound())
// A batch lookup of LOCKED avatar items — the client posts the descs it wants the locked
// state for and expects the matching item records back, as a BARE ARRAY.
//
// Filtered by exact `AvatarItemDesc`, matching the reference implementation: it walks its
// own item list and keeps the entries whose desc appears in the posted set. Two consequences
// of copying that shape rather than the obvious one:
//
// - Order is the CATALOGUE's, not the request's, because the filter iterates the catalogue.
// A caller must not read the response positionally against what it asked for.
// - The match is the WHOLE desc, not the base asset. `<base>,,,` and `<base>,<colour>,` are
// different items and only the one asked for comes back.
//
// An EMPTY or absent list means everything, again as the reference does — that is its "give
// me the catalogue" case rather than a degenerate "match nothing".
//
// Unknown descs are simply absent from the response; a miss is not an error. Nothing records
// a LOCK yet, so what comes back is the item records rather than a genuine locked answer.
//
// POST only, despite the reference declaring it `[HttpGet]` with a `[FromBody]` parameter —
// a combination the fetch standard forbids, so a GET could never carry the descs it needs.
//
// NOTE: the `api` worker has a route of this same path that answers `[]`. The client asks
// THIS host, so that one is unreached; they must be reconciled before either is taken for
// real behaviour.
.post(
'/api/avatar/v1/lockeditems/bulk',
describeRoute({
tags: ['Avatar'],
summary: 'Locked avatar items in bulk',
description: [
'Resolves `AvatarItemDescriptions` against the bundled item catalogue and answers the',
'matching records as a bare array. The match is on the WHOLE `AvatarItemDesc`, so a',
'colourway is not found by its base asset alone.',
'An empty or absent list answers the WHOLE catalogue, which is the references own',
'behaviour rather than a degenerate empty match.',
'Results come back in CATALOGUE order, not request order — the filter walks the',
'catalogue — so the response must not be read positionally. Unknown descs are simply',
'absent; a miss is not an error.',
'Nothing records a LOCK yet, so what comes back is the item records rather than a',
'genuine locked/unlocked answer.',
].join(' '),
requestBody: jsonBody(LockedItemsBulkRequest, 'The descs to resolve'),
responses: { 200: json(JsonArray, 'The matching items, in catalogue order') },
}),
async (c) => {
const body = (await c.req.json().catch(() => null)) as {
AvatarItemDescriptions?: unknown
} | null
const requested = Array.isArray(body?.AvatarItemDescriptions)
? body.AvatarItemDescriptions.filter((d): d is string => typeof d === 'string')
: []
if (requested.length === 0) return c.json(avatarItemCatalog)
// A Set rather than `Array.includes` per item: the client posts hundreds of descs
// against a catalogue of thousands, and the reference's nested scan is quadratic.
const wanted = new Set(requested)
return c.json(avatarItemCatalog.filter((item) => wanted.has(item.AvatarItemDesc)))
}
)
// Default-unlocked avatar items, served from the bundled static JSON.
.get(
'/api/avatar/v1/defaultunlocked',
@@ -2048,11 +2851,17 @@ const app = new Hono<App>({ strict: false })
}
)
// Favourite/un-favourite owned equipment. [Authorize]. The client PUTs the entries
// it wants changed (one request can carry several) and reads nothing back. Only
// Favourite/un-favourite owned equipment. [Authorize]. The client sends the entries it
// wants changed (one request can carry several) and reads nothing back. Only
// `Favorited` is written — the rest of each entry is the client echoing what it was
// served, and a guid the caller doesn't own matches no row and is dropped.
.put(
//
// PUT or POST: the client uses both spellings for this one call, with an identical body
// either way, so they are the same route rather than two handlers. A 404 on the POST
// leaves the star drawn on the item the client already redrew, and the favourite
// silently doesn't stick.
.on(
['PUT', 'POST'],
'/api/equipment/v1/update',
describeRoute({
tags: ['Equipment'],
@@ -2060,7 +2869,8 @@ const app = new Hono<App>({ strict: false })
description: [
'Applies the posted `Favorited` flags to the callers owned equipment, matched by',
'`ModificationGuid`. Everything else in each entry is ignored, and a guid the caller',
'doesnt own is silently skipped. Empty body on success.',
'doesnt own is silently skipped. Empty body on success. Accepts PUT or POST — the',
'client uses both, with the same body.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(EquipmentUpdateRequest, 'The entries to update'),
@@ -2184,6 +2994,93 @@ const app = new Hono<App>({ strict: false })
(c) => c.json([])
)
// Bulk lookup of UGC purchasables by `{ itemType, itemId }`. Only custom avatar items
// (type 3) exist to resolve; they come off the api-owned `custom_avatar_item` table.
.post(
'/api/ugcPurchasables/v1/items/bulk',
describeRoute({
tags: ['Rooms'],
summary: 'Look up UGC purchasables by id',
description:
'Resolves `Ids[]` (`{ itemType, itemId }`) against the `custom_avatar_item` table and ' +
'answers the store-facing `UgcPurchasableItem` view of each, in request order. ' +
'Only `itemType` 3 (custom avatar item) is served; other types and unknown ids are ' +
'dropped. `RoomId` is echoed onto every item — what the client wants it for is ' +
'not yet known. `PurchaseCurrencyId` is null until a currency exists.',
security: AUTHED,
requestBody: jsonBody(UgcPurchasableBulkRequest, 'The room and the ids to resolve'),
responses: {
200: json(UgcPurchasableItemList, 'The resolved items (unknown ids omitted)'),
400: json(ErrorResponse, 'Malformed body'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (!body || !Array.isArray(body.Ids)) return c.json({ error: 'Ids is required' }, 400)
const roomId = typeof body.RoomId === 'number' ? body.RoomId : 0
const ids = (body.Ids as unknown[]).flatMap((ref) => {
if (!ref || typeof ref !== 'object') return []
const { itemType, itemId } = ref as Record<string, unknown>
return itemType === UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM && typeof itemId === 'string'
? [itemId]
: []
})
const items = await getCustomAvatarItems(c.env.DB, ids)
return c.json(items.map((item) => toUgcPurchasable(item, roomId)))
}
)
// How the items in a store row may be BOUGHT — the counterpart of the bulk lookup above.
// The row itself carries only ids; the client asks this for the price tag, the sale
// banner, the “new” pip and whether the gift button is drawn. It answers one entry per
// RESOLVED id, in request order, dropping ids it doesn't know exactly as the bulk lookup
// does — an item with no purchase info renders as not-for-sale rather than at price zero.
//
// Two shapes meet in one object here and neither may be tidied into the other: the
// request's `{ itemType, itemId }` reference is camelCase, and the response nests THAT
// object, members unchanged, under a PascalCase `ItemId` beside PascalCase siblings.
.post(
'/api/items/purchaseInfos',
describeRoute({
tags: ['Storefront'],
summary: 'Purchase info for a bag of items',
description: [
'Resolves `Ids[]` (`{ itemType, itemId }`) against the `custom_avatar_item` table and',
'answers how each may be bought: its price in RecCenterTokens, its availability window',
'and the flags the store row draws. Only `itemType` 3 (custom avatar item) is served;',
'other types and unknown ids are dropped, so the response is one entry per RESOLVED',
'id in request order — never a positional match for `Ids[]`.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(ItemPurchaseInfosRequest, 'The ids to price'),
responses: {
200: json(ItemPurchaseInfoList, 'The resolved items purchase info (unknown ids omitted)'),
400: json(ErrorResponse, 'Malformed body'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (!body || !Array.isArray(body.Ids)) return c.json({ error: 'Ids is required' }, 400)
const ids = (body.Ids as unknown[]).flatMap((ref) => {
if (!ref || typeof ref !== 'object') return []
const { itemType, itemId } = ref as Record<string, unknown>
return itemType === UGC_ITEM_TYPE_CUSTOM_AVATAR_ITEM && typeof itemId === 'string'
? [itemId]
: []
})
const items = await getCustomAvatarItems(c.env.DB, ids)
return c.json(items.map(toItemPurchaseInfo))
}
)
// 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.
@@ -2299,13 +3196,25 @@ const app = new Hono<App>({ strict: false })
)
// Gift-drop storefront. Serves `static/storefronts/sf{id}.json` for the requested
// storefront id via the ASSETS binding; 404s when no such catalog exists.
// storefront id via the ASSETS binding; 404s when no such catalog exists. A few ids are
// stand-ins for another storefront's catalog (see `STOREFRONT_ALIASES`) — resolved through
// the same helper `buyItem` uses, so an aliased storefront can be bought from as well as
// browsed.
.get(
'/api/storefronts/v3/giftdropstore/:id',
describeRoute({
tags: ['Storefront'],
summary: 'Gift-drop storefront catalog',
description: 'Serves the `sf{id}.json` catalog via the ASSETS binding. 404 when none exists.',
description: [
'Serves the `sf{id}.json` catalog via the ASSETS binding. 404 when none exists. An id',
'with no capture of its own may stand in for another storefronts catalog (see',
'`STOREFRONT_ALIASES`, currently empty), and such an alias applies to purchases from',
'that storefront too, not just to this listing. Which FILE a storefront reads from can',
'also depend on the callers build (`rn.ver`): storefront `3` serves the captured',
'`sf3.json` to builds up to 20230414 and the merged `sf3-2025.json` — that same store',
'plus every sellable row of the item catalog — to later ones. The id does not change,',
'and the same resolution applies to purchases, so what is browsed is what is charged.',
].join(' '),
parameters: [
{
name: 'id',
@@ -2322,7 +3231,10 @@ const app = new Hono<App>({ strict: false })
}),
async (c) => {
const id = c.req.param('id')
const res = await c.env.ASSETS.fetch(new URL(`/sf${id}.json`, c.req.url))
// The same resolution `loadStorefront` uses, so what is browsed is what a purchase is
// checked against — see `storefrontAssetPath`.
const path = storefrontAssetPath(id, await authedBuild(c))
const res = await c.env.ASSETS.fetch(new URL(path, c.req.url))
if (!res.ok) return c.notFound()
return c.json(await res.json())
}
@@ -2347,9 +3259,14 @@ const app = new Hono<App>({ strict: false })
summary: 'Buy a storefront item',
description: [
'Looks the item up in its storefront catalog, confirms the clients `RequestedPrice`',
'still matches, debits the buyer atomically, grants the item (into the inventory or',
'consumable table), and returns a gift box. A `Gift` block routes the item to another',
'player, but the caller always pays. `Balance` in the response is the CHANGE (negated',
'still matches the `Prices` entry — a Rec Room Plus subscriber (the same `rn.plus`',
'check as `UpdateAndGetSubscription`) may pay anywhere from that down to 10% off, since their',
'client applies the discount itself and not to every item — debits the buyer atomically,',
'grants the item (into the inventory or',
'consumable table), and returns a gift box. A `Gift` block routes the item — and its',
'box — to the player it names, who is handed it over the hub as',
'`GiftPackageReceivedImmediate`; the caller always pays, and `Anonymous` hides them',
'from the box rather than withholding it. `Balance` in the response is the CHANGE (negated',
'price), not the new total. Pushes a StorefrontBalancePurchase socket frame that SETS the',
'buyers account-wide bucket to the RESULTING total, so the frame, this body and a',
'`GET /balance` re-fetch all agree (`Delta` there is display-only).',
@@ -2360,7 +3277,7 @@ const app = new Hono<App>({ strict: false })
200: json(BuyItemResponse, 'The purchase result (gift box + balance change)'),
400: json(ErrorResponse, 'Invalid body, unavailable currency, or insufficient balance'),
401: UNAUTHORIZED_RESPONSE,
404: json(ErrorResponse, 'No such item'),
404: json(ErrorResponse, 'No such item, or a `Gift` naming a player that does not exist'),
409: json(ErrorResponse, 'The price has changed since the client rendered it'),
},
}),
@@ -2394,13 +3311,21 @@ const app = new Hono<App>({ strict: false })
const item = await findStoreItem(c, storefrontType as number, purchasableItemId as number)
if (item === null) return c.json({ error: 'Item not found' }, 404)
const price = item.Prices.find((p) => p.CurrencyType === currencyType)
if (price === undefined) {
// A subscriber's client prices the item itself and posts the result, so the check is a
// band rather than one number — see `priceCheck`. `charge` is what they asked to pay.
const checked = priceCheck(
item,
currencyType as number,
await isSubscriber(c),
requestedPrice
)
if (checked === 'no-currency') {
return c.json({ error: 'Currency type not available for this item' }, 400)
}
if (price.Price !== requestedPrice) {
if (checked === 'mismatch') {
return c.json({ error: 'Price has changed' }, 409)
}
const price = checked.charge
// The item's currency must be an account balance we can debit (RecCenterTokens et al),
// not a room-scoped or non-spendable currency.
if (!isSpendable(currencyType as number)) {
@@ -2414,18 +3339,20 @@ const app = new Hono<App>({ strict: false })
// A named (non-anonymous) gift shows the sender; a self-purchase or an anonymous gift
// is attributed to the "Coach" system account (id 1), never a null/0 sender.
const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID
const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3'
const message = giftMessage(gift)
const giftContext = Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null
// A gift is paid for here and granted THERE, so an id that names nobody would take the
// buyer's tokens and strand the box on an account that will never read it. The client
// only offers players it just looked up, so this is a tampered or stale id — refuse it
// before charging rather than after.
if (receiverId !== id && (await getAccount(c.env.DB, receiverId)) === null) {
return c.json({ error: 'No such player to gift to' }, 404)
}
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
// Debit the buyer atomically; a false return means they couldn't afford it and
// nothing changed, so no item is granted.
const paid = await spendCurrency(
c.env.DB,
id,
currencyType as number,
price.Price,
startingTokens
)
const paid = await spendCurrency(c.env.DB, id, currencyType as number, price, startingTokens)
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
// Grant the item to the recipient, with the gift box that renders it. A box (an
@@ -2433,7 +3360,18 @@ const app = new Hono<App>({ strict: false })
// `granted.drop` is what the roll landed on — the response has to describe THAT, not
// the box, or a query purchase answers with every item field empty and the client
// draws an empty box.
const granted = await grantGiftDrop(c, receiverId, item.GiftDrop, message)
const granted = await grantGiftDrop(c, receiverId, item.GiftDrop, message, {
fromPlayerId,
giftContext,
})
// The buyer reads their own box out of the response below, but a gift's receiver has
// no response to read — they may not even be online. Hand them the box the way every
// other server-handed box arrives, so it pops in front of them instead of waiting for
// their client's next `GET /api/avatar/v2/gifts`.
if (receiverId !== id) {
await pushGiftReceived(c, receiverId, granted, message, fromPlayerId, giftContext)
}
// Push the spend to the buyer (`id` — the caller is who was charged) so their client
// updates without waiting for a `GET /balance` re-fetch. StorefrontBalancePurchase
@@ -2441,7 +3379,7 @@ const app = new Hono<App>({ strict: false })
// with both the response body below and any re-fetch instead of compounding with them
// — see the frame rule above pushBalanceUpdate. Best-effort.
const newBalance = await getBalance(c.env.DB, id, currencyType as number, startingTokens)
await pushBalancePurchase(c, id, currencyType as number, -price.Price, newBalance)
await pushBalancePurchase(c, id, currencyType as number, -price, newBalance)
// The response mirrors a captured real buyItem: `Balance` is the change applied (the
// negated price), not the resulting balance (the client reads its new total from
@@ -2450,17 +3388,10 @@ const app = new Hono<App>({ strict: false })
BalanceUpdates: [
{
UpdateResponse: 0,
Data: [
toBalanceUpdateData(
granted,
fromPlayerId,
message,
Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null
),
],
Data: [toBalanceUpdateData(granted, fromPlayerId, message, giftContext)],
},
],
Balance: -price.Price,
Balance: -price,
CurrencyType: currencyType,
BalanceType: ALL_PLATFORMS,
})
@@ -2505,6 +3436,7 @@ const app = new Hono<App>({ strict: false })
200: json(BulkPurchaseResponse, 'The bags result, or `Success: false` if nothing sold'),
400: json(BulkPurchaseResponse, 'A request that could not be evaluated at all'),
401: UNAUTHORIZED_RESPONSE,
404: json(BulkPurchaseResponse, 'A line gifts to a player that does not exist'),
},
}),
async (c) => {
@@ -2515,7 +3447,7 @@ const app = new Hono<App>({ strict: false })
// this shape never has to special-case one. A null `Value` is legal here (the client's
// validator only cascades into a non-null one), and it is the honest answer: nothing
// was bought, so there is no balance to report and nothing to render.
const refuse = (error: string, status: 200 | 400 = 200) =>
const refuse = (error: string, status: 200 | 400 | 404 = 200) =>
c.json({ Success: false, Error: error, error_id: null, Value: null }, status)
const body = (await c.req.json().catch(() => null)) as {
@@ -2547,8 +3479,33 @@ const app = new Hono<App>({ strict: false })
// One catalog read for the bag; every line resolves against it in memory.
const storefront = await loadStorefront(c, storefrontType as number)
// Past LEGACY_CLIENT_BUILD the bag may also name CATALOG rows — the ids the generated
// storefront and the discovery rows hand out (10000 and up) — so those are looked up in
// the `catalog` table and appended. One extra query for the whole bag.
//
// Appended rather than replacing the file: the two id spaces do not overlap
// (`CATALOG_ID_BASE` is above every captured id), so a bag may mix them and a newer
// client buying from a captured storefront still works. An older build is not offered
// catalog ids anywhere, so it is left resolving exactly what it always did.
const build = await authedBuild(c)
const catalogItems =
build !== null && build > LEGACY_CLIENT_BUILD
? await catalogStoreItems(
c.env.DB,
lines.flatMap((line) => {
const numberId = toPurchaseMethodId(line.ItemPurchaseMethodId).NumberId
return numberId !== null && numberId >= CATALOG_ID_BASE ? [numberId] : []
})
)
: []
const bagCatalog: Storefront | null =
catalogItems.length === 0
? storefront
: { StoreItems: [...(storefront?.StoreItems ?? []), ...catalogItems] }
const subscriber = await isSubscriber(c)
const resolved = lines.map((line) =>
resolveBulkLine(line, storefront, currencyType as number)
resolveBulkLine(line, bagCatalog, currencyType as number, subscriber)
)
const buyable = resolved.filter(isBulkLine)
@@ -2561,6 +3518,20 @@ const app = new Hono<App>({ strict: false })
const firstFailure = resolved.find((line): line is BulkLineFailure => !isBulkLine(line))
if (!allowPartial && firstFailure !== undefined) return refuse(firstFailure.error)
// Same as buyItem: a line gifting to an id that names nobody would charge the buyer and
// strand the box. One lookup per DISTINCT recipient, and the whole bag refuses — a bad
// recipient is a malformed request, not a line that merely didn't fit.
const recipients = new Set<number>()
for (const line of buyable) {
const to = line.gift?.ToPlayerId
if (Number.isInteger(to) && to !== id) recipients.add(to as number)
}
for (const to of recipients) {
if ((await getAccount(c.env.DB, to)) === null) {
return refuse('No such player to gift to', 404)
}
}
// Decide what the balance covers BEFORE spending: lines are taken in request order
// while they fit, so a bag that overruns still buys the items the player put in first.
// The read is only for choosing; the single spend below is what actually settles, and
@@ -2610,9 +3581,16 @@ const app = new Hono<App>({ strict: false })
// player while the caller pays, a named gift shows the sender, and a self-buy or an
// anonymous gift is attributed to the "Coach" system account.
const gift = line.gift
const receiverId = Number.isInteger(gift?.ToPlayerId) ? (gift?.ToPlayerId as number) : id
// Annotated: without it the inference of this handler's own type runs through the
// hub call below and back, and tsc gives up on the initializer (TS7022).
const receiverId: number = Number.isInteger(gift?.ToPlayerId)
? (gift?.ToPlayerId as number)
: id
const fromPlayerId = gift !== null && gift.Anonymous !== true ? id : COACH_ACCOUNT_ID
const message = typeof gift?.Message === 'string' ? gift.Message : 'A gift for you <3'
const message = giftMessage(gift)
const giftContext = Number.isInteger(gift?.GiftContext)
? (gift?.GiftContext as number)
: null
// One box per requested item, holding all `count` copies — the wire has one
// `GiftPackage` per entry, and only a consumable can be asked for more than once
// (`resolveBulkLine` refuses a bigger count on anything owned once).
@@ -2620,20 +3598,22 @@ const app = new Hono<App>({ strict: false })
rollCatalog,
skipGiftBox,
copies: line.count,
fromPlayerId,
giftContext,
})
// The bag's own response carries only the buyer's boxes, so a gifted line is
// announced to its receiver the same way buyItem's is. `BypassGiftPackages` skipped
// the box entirely, and there is nothing to announce.
if (receiverId !== id && !skipGiftBox) {
await pushGiftReceived(c, receiverId, granted, message, fromPlayerId, giftContext)
}
packages.set(
line,
// Null under `BypassGiftPackages`, which is the flag asking for exactly that —
// the item is granted either way.
skipGiftBox
? null
: toGiftPackage(
granted,
receiverId,
fromPlayerId,
message,
Number.isInteger(gift?.GiftContext) ? (gift?.GiftContext as number) : null
)
: toGiftPackage(granted, receiverId, fromPlayerId, message, giftContext)
)
}
@@ -2690,22 +3670,11 @@ const app = new Hono<App>({ strict: false })
}
)
// Buy an invention. [Authorize]. A GET, despite being a purchase — the client sends
// `?inventionId=…&requestedPrice=…` with no body, so that's what we answer.
//
// A priced invention is settled player-to-player: the buyer is debited its `Price` in
// RecCenterTokens and the CREATOR is credited the same amount — no house cut, so the
// tokens are moved rather than minted or burned. A free invention (`Price` 0) skips the
// money entirely: nothing is debited and nobody is paid. The stored price is confirmed
// against the price the client rendered first, so a stale or tampered client can't buy
// at a price the creator no longer offers (409), and an unaffordable one is a 400 —
// the same "Insufficient balance" buyItem answers with.
//
// 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.
// Buy an invention. [Authorize]. A GET, despite being a purchase — the 2023 client sends
// `?inventionId=…&requestedPrice=…` with no body, so thats what we answer, in the v6 save
// envelope that build reads. The 2025 build posts to `v3/buyInvention` below and wants a
// different envelope back; the two share {@link settleInventionPurchase}, which is where
// the money and the rules live, and build their own bodies from what it returns.
.get(
'/api/storefronts/v2/buyInvention',
describeRoute({
@@ -2760,91 +3729,116 @@ const app = new Hono<App>({ strict: false })
// 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 invention = await getInventionById(c.env.DB, inventionId)
if (invention === null) return c.json({ error: 'Invention not found' }, 404)
// An unpublished invention is a draft: it isn't on sale, not even for free.
if (!invention.IsPublished) return c.json({ error: 'Invention is not for sale' }, 403)
if (invention.CreatorPlayerId === id) {
return c.json({ error: 'Cannot buy your own invention' }, 400)
}
if (await ownsInvention(c.env.DB, id, inventionId)) {
return c.json({ error: 'Already owned' }, 409)
}
const settled = await settleInventionPurchase(c, id, inventionId, requestedPrice)
if (settled instanceof Response) return settled
// The price the client rendered must still be the stored one: a mismatch is a stale
// catalog or a tampered request, never a sale.
if (invention.Price !== requestedPrice) {
return c.json({ error: 'Price has changed' }, 409)
}
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
// Inventions are priced in RecCenterTokens only — the store shows no other currency
// for them, and `Price` carries no currency of its own to pick a different one from.
const price = invention.Price
if (price > 0) {
// Debit the buyer atomically; false means they couldn't afford it and nothing
// changed, so no ownership is recorded and the creator is not paid.
const paid = await spendCurrency(
c.env.DB,
id,
CurrencyType.RecCenterTokens,
price,
startingTokens
)
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
}
// Grant before paying out: these are three separate D1 writes with no transaction
// 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)
if (price > 0) {
// Seed the creator's signup grant BEFORE crediting them: `creditCurrency` upserts
// the balance row, and `ensureStartingBalances` is an INSERT OR IGNORE, so a
// creator who had never touched their balance would otherwise have the row created
// here and lose their starting tokens forever.
await ensureStartingBalances(c.env.DB, invention.CreatorPlayerId, startingTokens)
const creatorBalance = await creditCurrency(
c.env.DB,
invention.CreatorPlayerId,
CurrencyType.RecCenterTokens,
price,
startingTokens
)
// The creator is a different, probably-online player with no response to read:
// push the sale so it lands on their shown balance without a re-fetch. The frame
// carries their resulting TOTAL (what `creditCurrency` returns), not the payout —
// sending the payout would set their whole balance to it. A plain update rather
// than a purchase frame: they sold, they didn't buy. Best-effort, as everywhere.
await pushBalanceUpdate(
c,
invention.CreatorPlayerId,
CurrencyType.RecCenterTokens,
creatorBalance
)
}
// Unlike buyItem — whose `Balance` is the change applied — the reference server
// answers this one with the RESULTING total (a first read seeds the buyer's starting
// grant, as everywhere else). The buyer's frame carries that same total, so the body
// and the push land the client on one number.
const balance = await getBalance(c.env.DB, id, CurrencyType.RecCenterTokens, startingTokens)
// A free invention moved nothing, so there is no purchase to report.
if (price > 0) {
await pushBalancePurchase(c, id, CurrencyType.RecCenterTokens, -price, balance)
}
return c.json({
BalanceUpdateResponse: {
Balance: balance,
Balance: settled.balance,
BalanceType: ALL_PLATFORMS,
CurrencyType: CurrencyType.RecCenterTokens,
BalanceUpdates: [{ UpdateResponse: 0, Data: invention }],
BalanceUpdates: [{ UpdateResponse: 0, Data: settled.invention }],
},
// The bare `{ Status, Invention, InventionVersion }` the v6 save serves — this
// build's invention endpoints answer in it, and the client re-renders from it.
InventionResponse: toSaveResult(settled.invention),
})
}
)
// Buy an invention, the way the 2025 client asks for it. [Authorize]. A POST carrying
// `{ InventionId, RequestedPrice }` as JSON, where the v2 GET takes query params.
//
// The PURCHASE is identical — both settle through `settleInventionPurchase` — but the
// RESPONSE is not, and that is the whole reason this route exists rather than an alias:
// this build wraps the invention in the v9 save envelope and names its balance bucket
// `Platform`. See `BuyInventionV3Response`. Both routes stay served: the 2023 build still
// sends the GET, and it would not parse this body.
.post(
'/api/storefronts/v3/buyInvention',
describeRoute({
tags: ['Storefront'],
summary: 'Buy an invention (JSON body)',
description: [
'The same purchase as `GET /api/storefronts/v2/buyInvention` — confirms the clients',
'`RequestedPrice` still matches the inventions stored `Price`, debits the buyer and',
'pays the creator that price in RecCenterTokens (a free invention moves nothing),',
'records ownership in `inventory_invention`, and pushes both players a socket frame',
'carrying their RESULTING total — but answered in a DIFFERENT envelope, which is why',
'the route exists at all: `InventionResponse` is the v9 saves',
'`{ Value, Success, Error, error_id }` (its `InventionVersion` and `TagsResponse` null,',
'since a buy mints neither) and the balance half names its bucket `Platform`, not v2s',
'`BalanceType`.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(BuyInventionRequest, 'The invention id and the price rendered'),
responses: {
200: json(BuyInventionV3Response, 'The purchase result (invention + balance)'),
400: json(
ErrorResponse,
'Invalid body, missing InventionId, buying your own, or insufficient balance'
),
401: UNAUTHORIZED_RESPONSE,
403: json(ErrorResponse, 'The invention is not published, so it is not for sale'),
404: json(ErrorResponse, 'No such invention'),
409: json(ErrorResponse, 'Already owned, or the price has changed'),
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
return c.json({ error: 'Invalid request body' }, 400)
}
const inventionId = body.InventionId
if (!Number.isInteger(inventionId)) {
return c.json({ error: 'InventionId is required' }, 400)
}
// Read the same way as v2s query param: an absent or non-integer RequestedPrice is 0,
// which only matches a free invention — a priced one then fails the confirmation rather
// than selling for nothing.
const requestedPrice = Number.isInteger(body.RequestedPrice)
? (body.RequestedPrice as number)
: 0
const settled = await settleInventionPurchase(c, id, inventionId as number, requestedPrice)
if (settled instanceof Response) return settled
return c.json({
// The v9 SAVE envelope, not v6's bare `{ Status, Invention, InventionVersion }`:
// `Value` under `{ Success, Error, error_id }`, with `Invention` the client's 28-key
// `RRInvention`. A buy mints no version and takes no tags, so both of those keys are
// present and NULL — which is safe here for the same reason it is on the save: the
// client reads `Success` and `Value.Invention` and nothing else. `Value` itself must
// never be null under `Success: true` — that dereference is what crashes it.
InventionResponse: {
Value: {
Status: 0,
Invention: toInventionV9(settled.invention),
InventionVersion: null,
TagsResponse: null,
},
Success: true,
Error: null,
error_id: null,
},
// `BalanceResponseDTO`, the same one the bulk purchase answers in — so the bucket key
// is `Platform`, NOT the `BalanceType` the v2 body sends. The client's member IS named
// `BalanceType`, but it carries a [DataMember] rename to `Platform` and its decoder
// drops what it doesn't know, so spelling it `BalanceType` here would land this balance
// in bucket 0 beside the real one. `Balance` is the RESULTING total, as in v2.
BalanceUpdateResponse: {
BalanceUpdates: [{ UpdateResponse: 0, Data: toInventionV9(settled.invention) }],
Balance: settled.balance,
CurrencyType: CurrencyType.RecCenterTokens,
// The capture says 0 (SteamPurchased) because the reference server kept a wallet per
// platform. This one keeps ONE bucket and the client SUMS them, so naming 0 here
// while every socket frame names -2 is exactly the phantom second balance that
// doubled players' tokens twice before. -2, like every other surface.
Platform: ALL_PLATFORMS,
},
// The same `{ Status, Invention, InventionVersion }` envelope the invention
// save/read endpoints serve — the client re-renders the invention from it.
InventionResponse: toSaveResult(invention),
})
}
)
@@ -2857,60 +3851,76 @@ const app = new Hono<App>({ strict: false })
(c) => c.json(adCarouselItems)
)
// Current weekly challenge. The rotation itself is the bundled static JSON (its format
// is documented in the README) but each challenge's `Complete` is per-player, so the
// caller's rows from `challenge_status` are stamped over the static `false`s.
// Auth is OPTIONAL: without a valid bearer the static catalog is served unchanged
// rather than 401, since the rotation is public information and a 404/401 on this
// route can stall the client's load orchestration.
// Current weekly challenge. The rotation is GENERATED from the calendar week (see
// challenge-rotation.ts — the same five challenges, window and gift for everyone, derived
// from the week index; static/weekly-challenge.json pins it instead when it carries
// challenges), but each challenge's state is per-player, so the caller's rows from
// `challenge_status` are stamped over the week's: `Complete` over the published `false`,
// and `Config` over the published rule tree — the client evaluates that tree locally and
// reports it back with its running counts written into it (`cc`/`c`), so serving the
// pristine tree back is what makes partial progress reset every session.
// Auth is OPTIONAL: without a valid bearer the week is served unstamped rather than 401,
// since the rotation is public information and a 404/401 on this route can stall the
// client's load orchestration.
.get(
'/api/challenge/v2/getCurrent',
describeRoute({
tags: ['Econ'],
summary: 'Current weekly challenge',
description: [
'The bundled static rotation, with each challenges `Complete` stamped from the',
'callers progress rows. Auth is optional — unauthenticated callers get the static',
'catalog with every `Complete` false.',
'This weeks rotation — generated from the calendar week — with each challenges',
'`Complete` and `Config` stamped from the callers progress rows, the stored `Config`',
'carrying the clients running counts. Auth is optional: unauthenticated callers get',
'the week unstamped, every `Complete` false and every `Config` as published.',
].join(' '),
security: OPTIONAL_AUTHED,
responses: { 200: json(JsonObject, 'The current weekly challenge') },
}),
async (c) => {
const rotation = withWeeklyGift(buildRotation(new Date()), await loadEquipmentGiftPool(c))
const id = await authedId(c)
if (id === null) return c.json(weeklyChallenge)
const complete = await getCompletedChallengeIds(c.env.DB, id, weeklyChallenge.ChallengeMapId)
if (complete.size === 0) return c.json(weeklyChallenge)
// Rebuild rather than mutate: the static import is module state shared by every
// request this isolate serves, so stamping it in place would leak one player's
// completions to the next caller.
if (id === null) return c.json(rotation)
const statuses = await getChallengeStatuses(c.env.DB, id, rotation.ChallengeMapId)
if (statuses.size === 0) return c.json(rotation)
// Rebuild rather than mutate: the generated rotation is cached module state shared
// by every request this isolate serves, so stamping it in place would leak one
// player's progress to the next caller.
return c.json({
...weeklyChallenge,
Challenges: weeklyChallenge.Challenges.map((challenge) => ({
...challenge,
Complete: complete.has(challenge.ChallengeId),
})),
...rotation,
Challenges: rotation.Challenges.map((challenge) => {
const status = statuses.get(challenge.ChallengeId)
if (status === undefined) return challenge
// A row with no stored tree (never reported one) keeps the authored `Config`;
// overwriting it with null would hand the client a challenge it can't evaluate.
return {
...challenge,
Complete: status.complete,
Config: status.config ?? challenge.Config,
}
}),
})
}
)
// Report progress on a weekly challenge. [Authorize]. The client evaluates the
// challenge's rule tree locally and posts ChallengeMapId/ChallengeId, that tree in
// `Config`, and whether it now considers the challenge `Complete`. Only the
// completion is persisted (keyed by account + challenge); `Config` is the catalog's
// own definition plus the client's running count, so storing it would duplicate
// static data. Echoes the identifying fields back with the completion the row now
// holds — which is not always what was posted, since completion latches within a
// rotation.
// `Config`, and whether it now considers the challenge `Complete`. Both are persisted
// (keyed by account + challenge): the posted tree is the catalog's definition with the
// client's running counts written into it, so it is this player's progress, and
// `getCurrent` serves it back in place of the authored tree. Echoes the identifying
// fields back with the state the row now holds — which is not always what was posted,
// since completion latches within a rotation and a report with no `Config` keeps the
// stored tree.
.post(
'/api/challenge/v2/updateProgress',
describeRoute({
tags: ['Econ'],
summary: 'Report weekly-challenge progress',
description: [
'Persists the reported completion into `challenge_status`, keyed by account +',
'challenge. `Config` is accepted and echoed but not stored. Completion latches within',
'a rotation, so the echoed `Complete` is the stored value, not the posted one.',
'Persists the reported completion and rule tree into `challenge_status`, keyed by',
'account + challenge, so `getCurrent` can serve the players own progress back.',
'Completion latches within a rotation and a report carrying no `Config` keeps the',
'stored tree, so the echoed fields are the stored values, not the posted ones.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(ChallengeProgressRequest, 'Challenge ids + the evaluated rule tree'),
@@ -2932,14 +3942,16 @@ const app = new Hono<App>({ strict: false })
.catch(() => ({}) as Record<string, never>)
const challengeMapId = Number(body.ChallengeMapId) || 0
const challengeId = Number(body.ChallengeId) || 0
const config = typeof body.Config === 'string' ? body.Config : null
// Nothing to key a row on — echo the body back rather than writing a (0, 0) row.
const complete =
const stored =
challengeId === 0
? parseBool(body.Complete)
? { complete: parseBool(body.Complete), config }
: await recordChallengeProgress(c.env.DB, id, {
challengeMapId,
challengeId,
complete: parseBool(body.Complete),
config,
})
// This report may have been the last one of the set. Only a completing report on
// the LIVE rotation can be — an old rotation's set can no longer be finished, and
@@ -2947,14 +3959,14 @@ const app = new Hono<App>({ strict: false })
// The response is unchanged whether or not a gift was won: the client learns about
// the box from `GET /api/avatar/v2/gifts`, and adding a field here would be
// inventing response shape the client never sent us.
if (complete && challengeId !== 0 && challengeMapId === weeklyChallenge.ChallengeMapId) {
if (stored.complete && challengeId !== 0 && challengeMapId === rotationMapId(new Date())) {
await awardChallengeGift(c, id)
}
return c.json({
ChallengeMapId: challengeMapId,
ChallengeId: challengeId,
Config: typeof body.Config === 'string' ? body.Config : '',
Complete: complete,
Config: stored.config ?? '',
Complete: stored.complete,
})
}
)
@@ -2984,6 +3996,11 @@ const app = new Hono<App>({ strict: false })
// 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 `''`.
//
// 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.
.post(
'/api/gamerewards/v1/request',
describeRoute({
@@ -2993,8 +4010,10 @@ const app = new Hono<App>({ strict: false })
'Claims one reward of `rewardType` in `giftContext` per hour per player, recorded in',
'`reward_status`. The cooldown is per (type, activity), so a different activity is',
'owed another reward while the same one is not; an ask with no `giftContext` keys on',
'the empty context. The reward rides in a gift box, so a claim and a rejected',
'(on-cooldown) ask both answer `[]`.',
'the empty context. A `giftContext` that names an activity in `quest-rewards.json`',
'(`Dodgeball`, `Quest_Goblin_S`, …) draws one of that activitys rewards and grants it;',
'any other claim pays XP only. 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'),
@@ -3021,7 +4040,30 @@ const app = new Hono<App>({ strict: false })
// 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 granted = await grantGiftDrop(c, id, toGameRewardDrop(), message)
// 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)
// 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.
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,
id,
drop.CurrencyType,
drop.Currency,
startingTokens
)
await pushBalanceUpdate(c, id, drop.CurrencyType, balance)
}
const granted = await grantGiftDrop(c, id, drop, message)
await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID)
// Every grant moves the bar, whether or not it crossed a level.
await pushProgressionUpdate(c, id, progression)
@@ -3081,38 +4123,40 @@ const app = new Hono<App>({ strict: false })
)
// Subscription lookup (Rec Room Plus, the client's `CampusCard`). There is no store to
// buy one from, so the `developer` role stands in for a paid subscription: a developer
// reports an active Gold year, everyone else reports none. Nothing is stored — see
// `developerSubscription`.
// buy one from: Plus is claimed on the website by proving a Discord role, and reaches
// this worker as the token's `rn.plus` claim. A caller carrying it reports an active
// Gold year; everyone else reports none. Nothing about the subscription itself is
// stored, and nothing here reads the database — see `isSubscriber` and `plusSubscription`.
//
// Auth is OPTIONAL, and a missing or invalid token answers "no subscription" rather than
// 401: the client posts this while loading, so an error here can stall its load
// orchestration, and "you aren't subscribed" is the truthful answer for an anonymous
// caller anyway. The role is read from the token's `role` claim, never from the body.
// caller anyway. Never read from the body.
.post(
'/api/CampusCard/v1/UpdateAndGetSubscription',
describeRoute({
tags: ['Econ'],
summary: 'Subscription lookup',
description: [
'The callers Rec Room Plus subscription. Nothing sells subscriptions here, so the',
'operator-granted `developer` role stands in for one: a developers token reports an',
'active Gold (`Level` 0) yearly (`Period` 1) subscription on `PlatformType` -1 (All),',
'expiring a year from the call, and every other caller gets `{}`. Auth is optional —',
'a missing or invalid token reads as “not subscribed”, not 401. Nothing is persisted:',
'the role IS the subscription, so revoking it revokes this.',
'The callers Rec Room Plus subscription. Nothing sells subscriptions here: Plus is',
'claimed on the website by proving a qualifying role in the community Discord, and',
'arrives as the tokens `rn.plus` claim. A token carrying it reports an active Gold',
'(`Level` 0) yearly (`Period` 1) subscription on `PlatformType` -1 (All), expiring a',
'year from the call; every other caller gets `{}`. The `developer` role does NOT',
'confer it. Auth is optional — a missing or invalid token reads as “not subscribed”,',
'not 401. The subscription itself is not persisted, and because the claim is stamped',
'at login, a player who has just claimed must sign in again before it appears.',
].join(' '),
responses: {
200: json(SubscriptionResponse, 'The subscription, or `{}` for no subscription'),
},
}),
async (c) => {
const roles = await authedRoles(c)
if (!roles?.includes(DEVELOPER_ROLE)) return c.json({})
if (!(await isSubscriber(c))) return c.json({})
const id = await authedId(c)
if (id === null) return c.json({})
return c.json({
Subscription: developerSubscription(id),
Subscription: plusSubscription(id),
PlatformAccountSubscribedPlayerId: null,
})
}
@@ -3233,27 +4277,26 @@ const app = new Hono<App>({ strict: false })
}
)
// Whether the caller is in the influencer partner program. NOBODY is: this server runs
// no such program, and "not an influencer" is a 404 rather than a body saying so — the
// reference answers 404 with an EMPTY body typed `application/json`, which is what the
// client branches on. A 200 carrying null or `{}` is a different answer to it.
// 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.
//
// Deliberately built by hand rather than through `c.notFound()`: the worker's not-found
// handler answers its own body, and this has to be empty with that content type.
// 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` is accepted and ignored — the reference binds it and never reads it, the
// answer being the same for everyone. The token is still validated first, so an
// unauthenticated caller gets 401 rather than the 404.
// `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.
.get(
'/api/influencerpartnerprogram/influencer',
describeRoute({
tags: ['Econ'],
summary: 'The callers influencer partner program status',
summary: 'An accounts influencer partner program tier',
description: [
'Always 404 with an EMPTY body typed `application/json` — this server runs no partner',
'program, and 404 is how the reference says not an influencer”. `accountId` is',
'accepted and ignored; the answer is the same for every caller. Auth is checked first,',
'so a missing or invalid token is 401, not 404.',
'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.',
].join(' '),
security: AUTHED,
parameters: [
@@ -3261,19 +4304,47 @@ const app = new Hono<App>({ strict: false })
name: 'accountId',
in: 'query',
required: false,
description: 'The account being asked about. Accepted and ignored.',
description: 'The account being asked about. Every account answers 0.',
schema: { type: 'integer' },
},
],
responses: {
404: { description: 'Not in the partner program — always. Empty body' },
200: json(InfluencerTierResponse, 'The accounts tier — always 0'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.body('', 404, { 'Content-Type': 'application/json' })
return c.json(NOT_AN_INFLUENCER)
}
)
// 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.
//
// 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.
.get(
'/api/influencerpartnerprogram/myinfluencer',
describeRoute({
tags: ['Econ'],
summary: 'The callers influencer partner program tier',
description: [
'The callers 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.',
].join(' '),
security: AUTHED,
responses: {
200: json(InfluencerTierResponse, 'The callers tier — always 0'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(NOT_AN_INFLUENCER)
}
)
+3 -3
View File
@@ -36,7 +36,7 @@ export const EQUIPMENT_SCHEMA_DDL: string[] = [
* inconsistency to tidy up: a drop is a flat record holding avatar, consumable and
* equipment fields side by side, so it needs the prefix to disambiguate, while this
* record is all equipment. Confirmed against the live endpoint, and the entries the
* client PUTs back to `/api/equipment/v1/update` use the same unprefixed names.
* client sends back to `/api/equipment/v1/update` use the same unprefixed names.
*/
export interface Equipment extends Record<string, unknown> {
ModificationGuid: string
@@ -46,7 +46,7 @@ export interface Equipment extends Record<string, unknown> {
Rarity: number
/** Always -1 (all platforms) — we don't gate equipment per platform. */
PlatformMask: number
/** Player-set favourite flag, toggled by `PUT /api/equipment/v1/update`. */
/** Player-set favourite flag, toggled by `PUT`/`POST /api/equipment/v1/update`. */
Favorited: boolean
}
@@ -73,7 +73,7 @@ export async function grantEquipment(
.run()
}
/** One entry of the `PUT /api/equipment/v1/update` body. */
/** One entry of the `PUT`/`POST /api/equipment/v1/update` body. */
export interface EquipmentFavoriteUpdate {
ModificationGuid: string
Favorited: boolean
+178 -9
View File
@@ -116,8 +116,9 @@ export const CustomAvatarItemsResponse = z.object({
/**
* A Rec Room Plus subscription (the client calls it a `CampusCard`). Nothing here sells one,
* so this is the complimentary subscription a `developer` account reports see
* `developerSubscription` in econ.app.ts for why each field reads the way it does.
* so this is the complimentary subscription reported by a caller whose token carries
* `rn.plus` stamped from `account.hasPlus`, which the website's Discord benefits claim
* sets. See `plusSubscription` in econ.app.ts for why each field reads the way it does.
*/
export const SubscriptionDto = z.object({
SubscriptionId: z.int().describe('Placeholder — no subscription is stored'),
@@ -226,6 +227,18 @@ export const InfluencerIdsResponse = z.object({
.describe('Account ids in the partner program. Empty — no programme runs here'),
})
/**
* `GET /api/influencerpartnerprogram/influencer` and `…/myinfluencer` one account's
* standing in the partner program.
*
* 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.
*/
export const InfluencerTierResponse = z
.literal(0)
.describe('The accounts partner tier. Always 0 — nobody here is an influencer')
/**
* `GET /api/incentivizedreferrals/progress` how far the caller has got with the
* refer-a-friend rewards: how many referrals have been verified, and which rewards they
@@ -257,7 +270,9 @@ export const MakerAiFreeTrialEligibilityResponse = z
export const ChallengeProgressResponse = z.object({
ChallengeMapId: z.int(),
ChallengeId: z.int(),
Config: z.string().describe('Echoed back verbatim; not stored'),
Config: z
.string()
.describe('The STORED rule tree — a report carrying none keeps (and echoes) the last one'),
Complete: z
.boolean()
.describe('The STORED completion — latches true within a rotation, so it may differ'),
@@ -360,10 +375,24 @@ export const BulkPurchaseResponse = z.object({
})
/**
* `GET /api/storefronts/v2/buyInvention` the purchase result. Two envelopes side by
* side: the balance update (shaped like buyItem's, except `Balance` is the RESULTING
* total, not the change, and `Data` is a single invention rather than a gift-drop list)
* and the invention envelope the invention endpoints already serve.
* The JSON body `POST /api/storefronts/v3/buyInvention` takes. The same two values the v2
* GET reads off the query string (`inventionId`/`requestedPrice`), PascalCase in a body
* that is the only difference between the two routes.
*/
export const BuyInventionRequest = z.object({
InventionId: z.int().describe('The invention to buy; missing or non-integer is 400'),
RequestedPrice: z
.int()
.optional()
.describe('The price the client rendered; a mismatch is 409. Absent reads as 0'),
})
/**
* `GET /api/storefronts/v2/buyInvention` and `POST /api/storefronts/v3/buyInvention` the
* purchase result, identical for both. Two envelopes side by side: the balance update
* (shaped like buyItem's, except `Balance` is the RESULTING total, not the change, and
* `Data` is a single invention rather than a gift-drop list) and the invention envelope the
* invention endpoints already serve.
*/
export const BuyInventionResponse = z.object({
BalanceUpdateResponse: z.object({
@@ -386,7 +415,143 @@ export const BuyInventionResponse = z.object({
.describe('The same envelope `POST /api/inventions/v6/save` returns'),
})
/**
* `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
* recovered from a capture of the real response:
*
* - `InventionResponse` is the v9 SAVE envelope (`{ Value, Success, Error, error_id }`)
* rather than v6's bare `{ Status, Invention, InventionVersion }`, and its `Invention`
* is the client's 28-key `RRInvention`. A buy mints no version and takes no tags, so
* `InventionVersion` and `TagsResponse` are present and null.
* - The balance half is `BalanceResponseDTO`, so the bucket key is `Platform` the
* client's `BalanceType` member under a [DataMember] rename, the same one the bulk
* purchase answers in. v2 spells it `BalanceType`; do not unify them.
*/
export const BuyInventionV3Response = z.object({
InventionResponse: z
.object({
Value: z
.object({
Status: z.int().describe('0 on success'),
Invention: JsonObject.describe('The bought invention as the 28-key `RRInvention`'),
InventionVersion: z.null().describe('Always null — a buy mints no version'),
TagsResponse: z.null().describe('Always null — a buy takes no tags'),
})
.describe('Never null under `Success: true` — the client dereferences it unguarded'),
Success: z.boolean(),
Error: z.string().nullable().describe('Null on success — not `""`'),
error_id: z.string().nullable().describe('Always null — no error-id catalog here'),
})
.describe('The same envelope `POST /api/inventions/v9/save` returns'),
BalanceUpdateResponse: z.object({
BalanceUpdates: z.array(
z.object({
UpdateResponse: z.int(),
Data: JsonObject.describe('The bought invention, the same `RRInvention` as above'),
})
),
Balance: z.int().describe('The resulting balance — NOT the change, unlike buyItem'),
CurrencyType: z.int().describe('2 = RecCenterTokens'),
Platform: z
.int()
.describe(
'The balance bucket — the clients `BalanceType` under a [DataMember] rename. -2, ' +
'account-wide: the capture said 0 (SteamPurchased) because the reference server ' +
'kept a wallet per platform; this one keeps a single bucket, and the client SUMS them'
),
}),
})
/** buyItem / buyInvention error body (`{ error }`), returned on 400/403/404/409. */
/** The JSON body `POST /api/ugcPurchasables/v1/items/bulk` takes. */
export const UgcPurchasableBulkRequest = z.object({
RoomId: z.number().int().describe('Echoed back on each item; not otherwise used'),
Ids: z.array(
z.object({
itemType: z.number().int().describe('3 = custom avatar item (the only type served)'),
itemId: z.string().describe('The `CustomAvatarItemId`'),
})
),
})
/** The client's `UgcPurchasableItem` — a store-facing view of a custom avatar item. */
export const UgcPurchasableItemDto = z.object({
ItemType: z.number().int(),
ItemId: z.string(),
Name: z.string(),
Description: z.string(),
ImageName: z.string(),
RoomId: z.number().int(),
Price: z.number().int(),
PurchaseCurrencyId: z.string().nullable(),
CreatedAt: z.string(),
ModifiedAt: z.string(),
})
/** What the bulk lookup answers: the resolved items, unknown ids omitted. */
export const UgcPurchasableItemList = z.array(UgcPurchasableItemDto)
/**
* `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
* the client's own inconsistency: the response wraps this very object under a PascalCase
* `ItemId` key without renaming its members.
*/
export const ItemPurchaseInfosRequest = z.object({
Ids: z.array(
z.object({
itemType: z.number().int().describe('3 = custom avatar item (the only type served)'),
itemId: z.string().describe('The `CustomAvatarItemId`'),
})
),
})
/** 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'),
Price: z.number().int(),
StorefrontSaleData: z
.object({
SalePercent: z.number().int(),
SaleStartDate: z.string().nullable(),
SaleEndDate: z.string().nullable(),
})
.nullable()
.describe('Always a zero-percent sale here; nothing discounts UGC items yet'),
})
/** The client's `ItemPurchaseInfo` — how one item may be bought. */
export const ItemPurchaseInfoDto = z.object({
ItemId: z.object({ itemType: z.number().int(), itemId: z.string() }),
PurchaseMethodId: z.object({
Type: z.number().int(),
NumberId: z.number().int().nullable(),
Guid: z.string().nullable(),
}),
Prices: z.array(ItemPriceDto),
NewUntil: z.string().nullable(),
AvailableAt: z.string().nullable(),
AvailableUntil: z.string().nullable(),
CanBeGifted: z.boolean(),
CanApplySubscriberDiscount: z.boolean(),
SubscribersOnly: z.boolean(),
IsFeatured: z.boolean(),
})
/** What the purchase-info lookup answers: one entry per RESOLVED id, unknown ids omitted. */
export const ItemPurchaseInfoList = z.array(ItemPurchaseInfoDto)
/**
* `POST /api/avatar/v1/lockeditems/bulk` JSON body the descs the client wants the locked
* state for. Currently accepted and not read; see the route.
*/
export const LockedItemsBulkRequest = z.object({
AvatarItemDescriptions: z
.array(z.string())
.describe('The `AvatarItemDesc` of each item the client is about to draw'),
})
export const ErrorResponse = z.object({ error: z.string() })
// ---- Request schemas -------------------------------------------------------
@@ -472,7 +637,9 @@ export const ChallengeProgressRequest = z.object({
Config: z
.string()
.optional()
.describe('The client-evaluated rule tree, with its running count in `cc`; not stored'),
.describe(
'The client-evaluated rule tree, with its running count in `cc`; stored as the players progress'
),
Complete: z
.union([z.string(), z.boolean()])
.optional()
@@ -488,7 +655,9 @@ export const GameRewardRequest = z.object({
giftContext: z
.string()
.optional()
.describe('The activity it came from, e.g. `Soccer` — part of the cooldown key'),
.describe(
'The activity it came from, e.g. `Soccer` — part of the cooldown key. A key of `quest-rewards.json` (`Dodgeball`, `Quest_Goblin_S`, …) also picks the prize from that activitys table'
),
})
/**
+2193 -203
View File
@@ -15,13 +15,27 @@ import {
// The `invention` table belongs to the `api` worker; buyInvention reads it, so its DDL
// is built here too (see the same cross-worker import in econ.app.ts).
import {
createCustomAvatarItem,
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
} from '../../../../api/src/custom-avatar-items-db'
import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db'
// The notification-type ids the hub carries, from the worker that owns them — asserting
// against the enum rather than a copied number is what keeps these frames honest.
import { NotificationType } from '../../../../notify/src/notification-types'
// The live weekly rotation, so the challenge tests exercise whatever it currently holds
// instead of hard-coded ids from a rotation that has since been replaced.
import weeklyChallenge from '../../../static/weekly-challenge.json'
// The catalog's two migrations and the captures the loader reads, imported so the tests at the
// bottom can check the schema they build against `CATALOG_SCHEMA_DDL`. `?raw` because they are
// SQL, not modules: they are never executed here, only read.
import catalogStructureSql from '../../../migrations/0015_catalog.sql?raw'
import catalogIdSql from '../../../migrations/0016_catalog_id.sql?raw'
import avatarItemsJson from '../../../static/db/avatar-items.json'
// The merged 2025 general store, read as a FILE: which file the route serves depends on the
// caller's build, and these assertions are about the file's CONTENTS.
import carriedItems from '../../../static/db/consumables.json'
import skinsJson from '../../../static/db/skins.json'
import questRewards from '../../../static/quest-rewards.json'
import sf32025 from '../../../static/storefronts/sf3-2025.json'
import sf3 from '../../../static/storefronts/sf3.json'
import { SCHEMA_DDL } from '../../avatar-db'
import {
BALANCE_SCHEMA_DDL,
@@ -30,22 +44,99 @@ import {
getBalance,
spendCurrency,
} from '../../balance-db'
import {
baseAsset,
buildCatalogLoad,
CATALOG_INSERT_COLUMNS,
CATALOG_SCHEMA_DDL,
CatalogKind,
countCatalog,
getAvatarItem,
getAvatarItemsByTag,
getCatalogItem,
getCatalogItemById,
getCatalogItems,
getSkin,
getSkinsForPrefab,
searchCatalog,
toCatalogSkin,
} from '../../catalog-db'
import { CATALOG_ID_BASE } from '../../catalog-load'
import { CHALLENGE_GIFT_SCHEMA_DDL, CHALLENGE_STATUS_SCHEMA_DDL } from '../../challenge-db'
// The live weekly rotation, generated the same way the worker generates it, so the challenge
// tests exercise whatever this week actually holds instead of ids from a rotation that has
// since rolled over.
import { buildRotation, rotationIndex, withWeeklyGift } from '../../challenge-rotation'
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db'
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
import type { CatalogLoadRow, CatalogRow, CatalogValue } from '../../catalog-db'
import type { Env } from '../../context'
/**
* The GENERATED half of a store file the items built from the item catalog, as opposed to the
* equipment, consumables and boxes carried across from the 2023 capture.
*
* Split on membership in the CARRIED ids rather than on `CATALOG_ID_BASE`. The two happen to
* agree now that the equipment skins are gone the carried ids run 2168-2458, well below the
* base but they did not while a skin carried id 20756767, and asking the real question costs
* nothing.
*/
const capturedIds = new Set(carriedItems.map((i) => i.PurchasableItemId))
const catalogItems = () => sf32025.StoreItems.filter((i) => !capturedIds.has(i.PurchasableItemId))
/**
* An item ONLY the newer store sells created after the cutoff, so it is in sf3-2025 and not in
* sf3. The build gate is only observable through such an item: everything else is in both files
* and buys identically either way.
*/
const sf3Ids = new Set(sf3.StoreItems.map((i) => i.PurchasableItemId))
const NEWER_ONLY = (() => {
const item = sf32025.StoreItems.find((i) => !sf3Ids.has(i.PurchasableItemId))
if (item === undefined) throw new Error('sf3-2025 sells nothing sf3 does not')
return {
id: item.PurchasableItemId,
price: item.Prices[0]!.Price,
name: item.GiftDrop.FriendlyName,
}
})()
/**
* Items the generated `sf3.json` sells, resolved FROM the file rather than hardcoded.
*
* sf3 used to be a capture with its own ids (73 = "Bowtie (White)" at 450); it is now generated
* from the item catalog, so those ids are gone and the prices come from the rarity table. Looking
* them up here means a regenerate or a repriced tier cannot leave these tests asserting
* against items the store no longer sells.
*
* `atPrice` picks an AVATAR item at a given tier; the carried equipment/consumables/boxes keep
* their captured ids and are still referenced by number where a test is about one of those.
*/
const sf3AvatarAtPrice = (price: number) => {
const item = sf3.StoreItems.find(
(i) => (i.GiftDrop.AvatarItemDesc ?? '') !== '' && i.Prices[0]?.Price === price
)
if (item === undefined) throw new Error(`sf3 sells no avatar item at ${price}`)
return { id: item.PurchasableItemId, price, name: item.GiftDrop.FriendlyName }
}
/** A mid-priced item — the general "buy something" fixture. */
const SF3_ITEM = sf3AvatarAtPrice(600)
/** The cheapest tier, for the line-level price-mismatch assertions. */
const SF3_CHEAP = sf3AvatarAtPrice(150)
declare module 'cloudflare:test' {
interface ProvidedEnv extends Env {}
}
const ORIGIN = 'https://example.com'
/** This week's rotation — the same one the worker builds for these requests. */
const weekly = buildRotation(new Date())
/** The first challenge of the live rotation — the progress tests report against it. */
const CURRENT_CHALLENGE = weeklyChallenge.Challenges[0]
const CURRENT_CHALLENGE = weekly.Challenges[0]
// Build the accounts table and seed the test player (the default token's sub, 42)
// so avatar reads/writes have a row to attach to.
@@ -65,6 +156,29 @@ beforeAll(async () => {
for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of CATALOG_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// A few equipment skins, which is where the WEEKLY CHALLENGE gift pool comes from now that
// skins are awarded rather than sold and no storefront lists one. Without these the pool is
// empty and the week has nothing to be themed on.
// Skipping the two the `catalog` block seeds by hand further down — `item_key` is the primary
// key, so a second insert of either would fail rather than merge.
const catalogBlockSeeds = new Set([
'19ef59c7-f74b-4c63-935a-1d4b1abd8518',
'bfrFOdnHzEaIwHqem2dXkg',
])
for (const [i, skin] of skinsJson
.filter((sk) => !catalogBlockSeeds.has(sk.ModificationGuid))
.slice(0, 8)
.entries()) {
await env.DB.prepare(
`INSERT OR IGNORE INTO catalog
(item_key, catalog_id, kind, friendly_name, tooltip, rarity, platform_mask, prefab_name)
VALUES (?1, ?2, 'skin', ?3, '', ?4, -1, ?5)`
)
.bind(skin.ModificationGuid, 70_001 + i, skin.FriendlyName, skin.Rarity, skin.PrefabName)
.run()
}
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
.run()
@@ -167,10 +281,25 @@ function b64url(input: ArrayBuffer | string): string {
* account's flags — pass `['gameClient', 'developer']` for an elevated account; the default
* is no claim at all, which reads as no roles.
*/
async function bearer(sub = '42', roles?: string[]): Promise<Record<string, string>> {
async function bearer(
sub = '42',
roles?: string[],
/** The client build to stamp as `rn.ver` — omitted, like a token minted before the claim. */
version?: string,
/**
* Stamp `rn.plus`, as auth does for an account with `hasPlus`. This is the ONLY thing
* that makes a caller a Rec Room Plus subscriber the `developer` role does not so
* every subscriber-priced test passes it.
*/
plus = false
): Promise<Record<string, string>> {
const now = Math.floor(Date.now() / 1000)
const claims =
roles === undefined ? { sub, exp: now + 3600 } : { sub, exp: now + 3600, role: roles }
const claims: Record<string, unknown> = { sub, exp: now + 3600 }
if (roles !== undefined) claims.role = roles
if (version !== undefined) claims['rn.ver'] = version
// Omitted when false, exactly as generateToken omits it — so these tokens match the
// shape of a real non-subscriber's.
if (plus) claims['rn.plus'] = true
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
JSON.stringify(claims)
)}`
@@ -671,6 +800,356 @@ describe('econ endpoints', () => {
}
})
test('POST /api/ugcPurchasables/v1/items/bulk resolves custom avatar items, echoing RoomId', async () => {
const item = await createCustomAvatarItem(env.DB, {
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 205,
name: 'Neon Visor',
description: '',
price: 250,
baseAvatarItemId: 1,
baseAvatarItemColor: '#fff',
accessibility: 0,
designFilename: 'design_x.bin',
thumbnailImageFilename: 'thumb_x.png',
})
const res = await exports.default.fetch(`${ORIGIN}/api/ugcPurchasables/v1/items/bulk`, {
method: 'POST',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify({
RoomId: 92,
Ids: [
{ itemType: 3, itemId: item.CustomAvatarItemId },
{ itemType: 3, itemId: 'does-not-exist' },
{ itemType: 1, itemId: item.CustomAvatarItemId },
],
}),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([
{
ItemType: 3,
ItemId: item.CustomAvatarItemId,
Name: 'Neon Visor',
Description: '',
ImageName: 'thumb_x.png',
RoomId: 92,
Price: 250,
PurchaseCurrencyId: null,
CreatedAt: item.CreatedAt,
ModifiedAt: item.ModifiedAt,
},
])
})
test('POST /api/ugcPurchasables/v1/items/bulk 400s without Ids and 401s without a token', async () => {
const bad = await exports.default.fetch(`${ORIGIN}/api/ugcPurchasables/v1/items/bulk`, {
method: 'POST',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify({ RoomId: 92 }),
})
expect(bad.status).toBe(400)
const anon = await exports.default.fetch(`${ORIGIN}/api/ugcPurchasables/v1/items/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ RoomId: 92, Ids: [] }),
})
expect(anon.status).toBe(401)
})
test('bulkpurchase buys a merged-store catalog item from storefront 3', async () => {
// The exact request the client sends, verbatim: a catalog id under storefront 3, which is
// what the merged sf3-2025 lists it as. It resolves because `loadStorefront` picks the file
// by the caller's build, so what the store page offered is what the purchase is checked
// against.
const res = await exports.default.fetch(`${ORIGIN}/api/items/bulkpurchase`, {
method: 'POST',
headers: {
...((await bearer('4801', undefined, '20250718.01')) as Record<string, string>),
'Content-Type': 'application/json',
},
body: JSON.stringify({
PurchaseItemRequests: [
{
ItemPurchaseMethodId: { Type: 0, NumberId: NEWER_ONLY.id, Guid: null },
RequestedPrice: NEWER_ONLY.price,
Gift: null,
CouponConsumablePlayerMappingId: null,
DuplicateItemCount: 1,
},
],
StorefrontType: 3,
CurrencyType: 2,
BypassGiftPackages: false,
AllowPartialSuccess: true,
ShoppingBagId: null,
}),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
Success: boolean
Error: string
Value: { Balance: number } | null
}
// It used to answer `{ Success: false, Error: "Item not found" }` — storefront 3 resolved
// to the captured sf3, which has no id in the catalog range.
expect(body.Error).not.toBe('Item not found')
expect(body.Success).toBe(true)
// The price the client posts is the one the file lists, because the file it browsed and the
// purchase it made are priced from the same shared rarity table. A second pricing anywhere
// would 409 every purchase as "Price has changed".
const item = sf32025.StoreItems.find((i) => i.PurchasableItemId === NEWER_ONLY.id)
expect(item?.Prices[0]?.Price).toBe(NEWER_ONLY.price)
// The SAME request from an old build still fails: its storefront 3 is generated to the
// cutoff, and this item postdates it.
const legacy = await exports.default.fetch(`${ORIGIN}/api/items/bulkpurchase`, {
method: 'POST',
headers: {
...((await bearer('4802', undefined, '20230414')) as Record<string, string>),
'Content-Type': 'application/json',
},
body: JSON.stringify({
PurchaseItemRequests: [
{
ItemPurchaseMethodId: { Type: 0, NumberId: NEWER_ONLY.id, Guid: null },
RequestedPrice: NEWER_ONLY.price,
DuplicateItemCount: 1,
},
],
StorefrontType: 3,
CurrencyType: 2,
AllowPartialSuccess: true,
}),
})
expect(((await legacy.json()) as { Success: boolean }).Success).toBe(false)
})
test('bulkpurchase resolves catalog ids for newer builds, at the storefronts price', async () => {
// A catalog row the generated storefront would list at 600 (rarity 10), bought straight off
// the `catalog` table; plus a skin and a developer-tier row, neither of which may be.
const AVATAR_ID = 20_001
const SKIN_ID = 20_002
const DEV_ID = 20_003
await env.DB.prepare(
`INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, tooltip, rarity, platform_mask, avatar_item_type)
VALUES ('bulk-buy-desc,,,', ?1, 'avatar_item', 'Bulk Buy Hat', '', 10, -1, 0)`
)
.bind(AVATAR_ID)
.run()
await env.DB.prepare(
`INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, tooltip, rarity, platform_mask, prefab_name)
VALUES ('bulk-buy-guid', ?1, 'skin', 'Bulk Buy Skin', '', 0, -1, '[MakerPen]')`
)
.bind(SKIN_ID)
.run()
// Rarity -1 is the developer tier: in the catalog, absent from the storefront, and so not
// for sale here either — resolving straight off the table must not sell what the store
// never offered.
await env.DB.prepare(
`INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, tooltip, rarity, platform_mask, avatar_item_type)
VALUES ('bulk-buy-dev,,,', ?1, 'avatar_item', 'Bulk Buy Dev Item', '', -1, -1, 0)`
)
.bind(DEV_ID)
.run()
const buy = async (
version: string | undefined,
lines: Array<{ id: number; price: number }>,
sub = '46'
) =>
exports.default.fetch(`${ORIGIN}/api/items/bulkpurchase`, {
method: 'POST',
headers: {
...((await bearer(sub, undefined, version)) as Record<string, string>),
'Content-Type': 'application/json',
},
body: JSON.stringify({
StorefrontType: 3,
CurrencyType: 2,
AllowPartialSuccess: false,
PurchaseItemRequests: lines.map((l) => ({
ItemPurchaseMethodId: { Type: 0, NumberId: l.id, Guid: null },
RequestedPrice: l.price,
})),
}),
})
// 600 (rarity 10) is the generated storefront's own price. It MUST match: `priceCheck`
// refuses a line whose posted price differs, so a server pricing a buy differently from the
// file it listed would 409 every purchase.
const res = await buy('20250718.01', [{ id: AVATAR_ID, price: 600 }])
expect(res.status).toBe(200)
const body = (await res.json()) as { Success: boolean; Value: { Balance: number } | null }
expect(body.Success).toBe(true)
// A SKIN is refused even though the catalog holds it: skins are awarded from weekly
// challenges rather than sold, so no storefront lists one and the bag will not resolve one
// off the table either.
const skin = await buy('20250718.01', [{ id: SKIN_ID, price: 150 }])
expect(((await skin.json()) as { Success: boolean }).Success).toBe(false)
// A price the storefront does not list is refused, not quietly charged.
const wrongPrice = await buy('20250718.01', [{ id: AVATAR_ID, price: 1 }])
expect(((await wrongPrice.json()) as { Success: boolean }).Success).toBe(false)
// The developer-tier row is not for sale.
const dev = await buy('20250718.01', [{ id: DEV_ID, price: 150 }])
expect(((await dev.json()) as { Success: boolean }).Success).toBe(false)
// An OLD build is left resolving exactly what it always did — the storefront file — so a
// catalog id means nothing to it. Nothing offers those ids to that build anyway.
const legacy = await buy('20230414', [{ id: AVATAR_ID, price: 600 }])
expect(((await legacy.json()) as { Success: boolean }).Success).toBe(false)
const unversioned = await buy(undefined, [{ id: AVATAR_ID, price: 600 }])
expect(((await unversioned.json()) as { Success: boolean }).Success).toBe(false)
// A bag may MIX an item the STOREFRONT FILE lists with one resolved straight off the
// `catalog` table.
const mixed = await buy('20250718.01', [
{ id: SF3_ITEM.id, price: SF3_ITEM.price },
{ id: AVATAR_ID, price: 600 },
])
expect(((await mixed.json()) as { Success: boolean }).Success).toBe(true)
// Cleaned up: the `catalog` block below counts every row in the table, so rows left behind
// here would change what it sees.
await env.DB.prepare('DELETE FROM catalog WHERE catalog_id BETWEEN ?1 AND ?2')
.bind(AVATAR_ID, DEV_ID)
.run()
})
test('lockeditems/bulk filters by exact AvatarItemDesc, in catalogue order', async () => {
const ask = async (
descs: unknown
): Promise<Array<{ AvatarItemDesc: string; FriendlyName: string }>> => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v1/lockeditems/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(descs === undefined ? {} : { AvatarItemDescriptions: descs }),
})
expect(res.status).toBe(200)
return (await res.json()) as Array<{ AvatarItemDesc: string; FriendlyName: string }>
}
// Three real catalogue entries, deliberately asked for OUT of catalogue order.
const [first, second, third] = [
avatarItemsJson[0]!,
avatarItemsJson[40]!,
avatarItemsJson[900]!,
]
const got = await ask([third.AvatarItemDesc, first.AvatarItemDesc, second.AvatarItemDesc])
expect(got).toHaveLength(3)
// In CATALOGUE order, not request order: the reference's filter walks the catalogue, so
// the response must never be read positionally against what was asked for.
expect(got.map((i) => i.AvatarItemDesc)).toEqual([
first.AvatarItemDesc,
second.AvatarItemDesc,
third.AvatarItemDesc,
])
// The match is the WHOLE desc, not the base asset: asking for a plain base does not drag
// in every colourway built on it.
const one = await ask([first.AvatarItemDesc])
expect(one).toHaveLength(1)
expect(one[0]?.AvatarItemDesc).toBe(first.AvatarItemDesc)
// A miss is not an error: unknown descs are absent and do not cost the caller the batch.
expect(
(await ask(['no-such-desc,,,', first.AvatarItemDesc])).map((i) => i.AvatarItemDesc)
).toEqual([first.AvatarItemDesc])
expect(await ask(['no-such-desc,,,'])).toEqual([])
// EMPTY or absent means the WHOLE catalogue — the reference's "give me everything" case,
// not a degenerate match-nothing.
expect(await ask([])).toHaveLength(avatarItemsJson.length)
expect(await ask(undefined)).toHaveLength(avatarItemsJson.length)
// No auth needed, and a body that will not parse falls back to the catalogue rather than
// erroring.
const junk = await exports.default.fetch(`${ORIGIN}/api/avatar/v1/lockeditems/bulk`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: 'not json',
})
expect(junk.status).toBe(200)
expect(((await junk.json()) as unknown[]).length).toBe(avatarItemsJson.length)
})
test('POST /api/items/purchaseInfos prices custom avatar items in tokens', async () => {
const item = await createCustomAvatarItem(env.DB, {
customAvatarItemId: crypto.randomUUID(),
creatorAccountId: 206,
name: 'Chrome Jacket',
description: '',
price: 425,
baseAvatarItemId: 1,
baseAvatarItemColor: '#000',
designFilename: 'design_pi.bin',
thumbnailImageFilename: 'thumb_pi.png',
accessibility: 1,
})
const res = await exports.default.fetch(`${ORIGIN}/api/items/purchaseInfos`, {
method: 'POST',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify({
Ids: [
{ itemType: 3, itemId: item.CustomAvatarItemId },
// Dropped, both of them: an id nothing owns, and a type this doesn't serve. The
// response is one entry per RESOLVED id, so it is SHORTER than `Ids` rather than
// carrying a null in their places — the client must not read it positionally.
{ itemType: 3, itemId: crypto.randomUUID() },
{ itemType: 1, itemId: item.CustomAvatarItemId },
],
}),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([
{
// The reference echoed back verbatim: camelCase members under a PascalCase key. The
// client names them that way on both legs and PascalCasing them here loses the id.
ItemId: { itemType: 3, itemId: item.CustomAvatarItemId },
// A UGC listing is keyed by guid, so `Type` 1 and `NumberId` null. A storefront's
// numbered `PurchasableItemId` would be the other side of the union.
PurchaseMethodId: { Type: 1, NumberId: null, Guid: item.CustomAvatarItemId },
// RecCenterTokens (2) — the currency the creation UI's price floor is denominated
// in, and the one the client actually holds a balance in. A room currency (300)
// would draw a price nothing can pay.
Prices: [
{
CurrencyType: 2,
Price: 425,
StorefrontSaleData: { SalePercent: 0, SaleStartDate: null, SaleEndDate: null },
},
],
NewUntil: null,
AvailableAt: item.CreatedAt,
AvailableUntil: null,
CanBeGifted: true,
CanApplySubscriberDiscount: false,
SubscribersOnly: false,
IsFeatured: false,
},
])
})
test('POST /api/items/purchaseInfos 400s without Ids and 401s without a token', async () => {
const bad = await exports.default.fetch(`${ORIGIN}/api/items/purchaseInfos`, {
method: 'POST',
headers: { ...(await bearer()), 'content-type': 'application/json' },
body: JSON.stringify({}),
})
expect(bad.status).toBe(400)
const anon = await exports.default.fetch(`${ORIGIN}/api/items/purchaseInfos`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ Ids: [] }),
})
expect(anon.status).toBe(401)
})
test('GET /econ/roomEconConfig/:roomId echoes the room and disables sorting tabs', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/econ/roomEconConfig/92`)
expect(anon.status).toBe(401)
@@ -853,6 +1332,172 @@ describe('econ endpoints', () => {
expect(await res.json()).toBeTruthy()
})
// TEMPORARY, alongside the probe in `econ.app.ts`: the storefront ids are swapped so it can
// be seen from the client which one the 2025 store actually reads. Delete this with the
// probe.
test('storefront 3 serves sf3 to old builds and the merged sf3-2025 to newer ones', async () => {
const store = async (headers: Record<string, string>) => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v3/giftdropstore/3`, {
headers,
})
expect(res.status).toBe(200)
return (await res.json()) as { StorefrontType: number; StoreItems: unknown[] }
}
const at = async (version: string) =>
(await bearer('42', undefined, version)) as Record<string, string>
// 20230414 is GAME_VERSION — what the rest of the stack targets — so the cutoff is
// INCLUSIVE and that build keeps the captured sf3 exactly as it has always had it.
const legacy = await store(await at('20230414'))
expect(legacy.StoreItems).toHaveLength(sf3.StoreItems.length)
// A same-day rebuild sorts by its DATE, not the `.NN` suffix.
expect((await store(await at('20230414.02'))).StoreItems).toHaveLength(sf3.StoreItems.length)
// A caller with no readable build gets the captured file too: an unversioned token is the
// OLD client, so treating "can't prove its version" as "newer" would swap the store out
// from under the build that needs it.
expect((await store({})).StoreItems).toHaveLength(sf3.StoreItems.length)
expect((await store(await bearer())).StoreItems).toHaveLength(sf3.StoreItems.length)
expect((await store(await at('not-a-build'))).StoreItems).toHaveLength(sf3.StoreItems.length)
// Later builds get the merged store — bigger than either half, and still storefront 3.
for (const version of ['20230616', '20250424.01', '20250718.01']) {
const merged = await store(await at(version))
expect(merged.StorefrontType, version).toBe(3)
expect(merged.StoreItems.length, version).toBe(sf32025.StoreItems.length)
expect(merged.StoreItems.length, version).toBeGreaterThan(sf3.StoreItems.length)
}
// The id does not change and nothing is renumbered: sf3's own items are in the merged file
// unchanged, so a newer client buying one is charged the same as an older client would be.
const bowtie = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await at('20250718.01')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: SF3_ITEM.id, // an avatar item the generated sf3 sells
CurrencyType: 2,
RequestedPrice: SF3_ITEM.price,
}),
})
expect(bowtie.status).toBe(200)
// And a CATALOG item can be bought from storefront 3 by a newer build — the half a
// listing-only swap breaks. `findStoreItem` resolves the purchase through the same
// build-aware path the listing does, so an item on the page is an item that can be bought.
// From the catalog half — see `catalogItems`, which is why this is not an id comparison.
const catalogItem = sf32025.StoreItems.find((i) => i.PurchasableItemId === NEWER_ONLY.id)
expect(catalogItem).toBeDefined()
const bought = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await at('20250718.01')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: catalogItem!.PurchasableItemId,
CurrencyType: 2,
RequestedPrice: catalogItem!.Prices[0]!.Price,
}),
})
expect(bought.status).toBe(200)
// The SAME item is not for sale to an old build: it postdates the cutoff, so sf3 — which
// is generated to that date — does not list it.
const refused = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await at('20230414')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: catalogItem!.PurchasableItemId,
CurrencyType: 2,
RequestedPrice: catalogItem!.Prices[0]!.Price,
}),
})
expect(refused.status).toBe(404)
})
test('sf3 and sf3-2025 are the same store at two points in time', async () => {
// BOTH are generated from the item catalog now — sf3 is no longer a capture. They report
// the same storefront id, because they are two versions of ONE store and the client asks
// for 3 either way.
expect(sf3.StorefrontType).toBe(3)
expect(sf32025.StorefrontType).toBe(3)
// sf3 is a strict SUBSET of sf3-2025: same items, same ids, same prices — it just stops at
// the cutoff. Anything else would mean a player's store changed under them on upgrade.
const newer = new Map(sf32025.StoreItems.map((i) => [i.PurchasableItemId, i]))
for (const item of sf3.StoreItems) {
expect(newer.get(item.PurchasableItemId), String(item.PurchasableItemId)).toEqual(item)
}
expect(sf3.StoreItems.length).toBeLessThan(sf32025.StoreItems.length)
// Ids are unique within each file. The merge of carried and generated halves is only safe
// because their id spaces don't overlap, so a collision must fail rather than be resolved
// by array order.
for (const [label, file] of [
['sf3', sf3],
['sf3-2025', sf32025],
] as const) {
const ids = file.StoreItems.map((i) => i.PurchasableItemId)
expect(new Set(ids).size, label).toBe(ids.length)
}
// The discount is expressed ONLY in `SubscriberPrices`; announcing it again at the top
// level risks a client taking 10% off an already-discounted price and posting through the
// server's own subscriber floor, refused as "Price has changed".
expect(sf3.SubscriberDiscountPercent).toBe(0)
expect(sf32025.SubscriberDiscountPercent).toBe(0)
// Every GENERATED item is priced from its rarity, and rarity -1 (the developer tier) is
// excluded rather than priced — an item listed here can be bought.
const priceByRarity = new Map([
[0, 150],
[10, 600],
[20, 700],
[30, 800],
[50, 3000],
])
for (const item of catalogItems()) {
const expected = priceByRarity.get(item.GiftDrop.Rarity)
expect(expected, `rarity ${item.GiftDrop.Rarity}`).toBeDefined()
expect(item.Prices[0]).toMatchObject({ CurrencyType: 2, Price: expected })
// Floored, matching the server's own `subscriberFloor`.
expect(item.SubscriberPrices[0]).toMatchObject({
CurrencyType: 2,
Price: Math.floor((expected! * 90) / 100),
})
// `GiftDropId` echoes the id, as the capture did on all 1161 of its items.
expect(item.GiftDrop.GiftDropId).toBe(item.PurchasableItemId)
expect(item.PurchasableItemId).toBeGreaterThanOrEqual(CATALOG_ID_BASE)
}
expect(catalogItems().filter((i) => i.GiftDrop.Rarity === -1)).toEqual([])
// The CARRIED half — 30 consumables and 5 random boxes — comes from
// `static/db/consumables.json`, what survives of the 2023 capture. The item catalog does
// not model these, so they keep their own ids and prices.
const carried = sf3.StoreItems.filter((i) => capturedIds.has(i.PurchasableItemId))
expect(carried.length).toBeGreaterThan(0)
expect(carried.every((i) => (i.GiftDrop.AvatarItemDesc ?? '') === '')).toBe(true)
// And NO equipment skins anywhere in either file: they are awarded from weekly challenges,
// so a store listing one would sell something the game gives away.
for (const [label, file] of [
['sf3', sf3],
['sf3-2025', sf32025],
] as const) {
expect(
file.StoreItems.filter((i) => (i.GiftDrop.EquipmentModificationGuid ?? '') !== ''),
label
).toEqual([])
}
// An id with no storefront still 404s, and 1704 is gone — it was a stand-in for a store
// that turned out to belong inside sf3.
for (const id of [1705, 1704]) {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v3/giftdropstore/${id}`)
expect(res.status, String(id)).toBe(404)
}
})
// Item 73 in sf3.json — "Bowtie (White)", 450 RecCenterTokens (CurrencyType 2).
test('POST /api/storefronts/v2/buyItem 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
@@ -860,9 +1505,9 @@ describe('econ endpoints', () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 73,
PurchasableItemId: SF3_ITEM.id,
CurrencyType: 2,
RequestedPrice: 450,
RequestedPrice: SF3_ITEM.price,
}),
})
expect(res.status).toBe(401)
@@ -876,9 +1521,9 @@ describe('econ endpoints', () => {
headers: { ...(await bearer('20')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 73,
PurchasableItemId: SF3_ITEM.id,
CurrencyType: 2,
RequestedPrice: 450,
RequestedPrice: SF3_ITEM.price,
}),
})
expect(res.status).toBe(200)
@@ -891,7 +1536,7 @@ describe('econ endpoints', () => {
}>
}
// `Balance` is the change applied (the negated price), not the resulting total.
expect(body.Balance).toBe(-450)
expect(body.Balance).toBe(-SF3_ITEM.price)
expect(body.CurrencyType).toBe(2)
expect(body.BalanceType).toBe(-2)
const gift = body.BalanceUpdates[0].Data[0]
@@ -912,8 +1557,8 @@ describe('econ endpoints', () => {
payload: {
// 1400 = CommercePurchase; -2 = NonPurchasedNotUsableInP2P, the only bucket we use.
BalanceAddType: 1400,
Delta: -450,
Balance: 9550,
Delta: -SF3_ITEM.price,
Balance: DEFAULT_STARTING_TOKENS - SF3_ITEM.price,
Platform: -2,
CurrencyType: 2,
},
@@ -924,14 +1569,16 @@ describe('econ endpoints', () => {
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('20'),
})
expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 9550 }])
expect(await bal.json()).toEqual([
{ CurrencyType: 2, Platform: -2, Balance: DEFAULT_STARTING_TOKENS - SF3_ITEM.price },
])
// The item is now owned — it leads the v4/items list (owned items prepend the catalog).
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('20'),
})
const list = (await items.json()) as Array<{ avatarItemDesc: string; friendlyName: string }>
expect(list[0].friendlyName).toBe('Bowtie (White)')
expect(list[0].friendlyName).toBe(SF3_ITEM.name)
expect(list[0].avatarItemDesc).toBe(gift.AvatarItemDesc)
// And a pending gift box is waiting to be opened.
@@ -1026,105 +1673,72 @@ describe('econ endpoints', () => {
expect(second[0].CreatedAts).toHaveLength(2)
})
test('POST /api/storefronts/v2/buyItem grants equipment, read back by getUnlocked, no re-buy dupe', async () => {
// Item 1950 (Disc Skin (Coop)) in storefront 3 is a pure equipment drop — its
// gift-drop carries an EquipmentModificationGuid but no avatar/consumable desc.
const guid = '19ef59c7-f74b-4c63-935a-1d4b1abd8518'
const buy = async () =>
exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await bearer('31')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 1950,
CurrencyType: 2,
RequestedPrice: 3500,
}),
})
// The equipment-purchase test that lived here is gone: skins are awarded from weekly
// challenges rather than sold, so no storefront lists one and the bulk bag will not resolve
// one off the catalog either. Equipment GRANTING is still covered — the weekly challenge
// reward path grants a skin and reads it back through `getUnlocked`.
const res = await buy()
expect(res.status).toBe(200)
const body = (await res.json()) as {
Balance: number
BalanceUpdates: Array<{
Data: Array<{ Id: number; EquipmentModificationGuid: string; EquipmentPrefabName: string }>
}>
}
expect(body.Balance).toBe(-3500)
const gift = body.BalanceUpdates[0].Data[0]
expect(gift.EquipmentModificationGuid).toBe(guid)
expect(gift.EquipmentPrefabName).toBe('[DiscGolfDisc]')
const unlocked = async () => {
const r = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`, {
headers: await bearer('31'),
})
expect(r.status).toBe(200)
return (await r.json()) as Array<{
ModificationGuid: string
PrefabName: string
FriendlyName: string
PlatformMask: number
Favorited: boolean
}>
}
const first = await unlocked()
expect(first).toHaveLength(1)
// The unlocked DTO is unprefixed, unlike the gift-drop the grant came from.
expect(first[0].ModificationGuid).toBe(guid)
expect(first[0].PrefabName).toBe('[DiscGolfDisc]')
expect(first[0].FriendlyName).toBe('Disc Skin (Coop)')
expect(first[0].PlatformMask).toBe(-1)
// Equipment is not an avatar item — it does not show up in v4/items.
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('31'),
})
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.every((i) => i.friendlyName !== 'Disc Skin (Coop)')).toBe(true)
expect(first[0].Favorited).toBe(false)
// Owning equipment is boolean: re-buying upserts, it does not add a second row.
expect((await buy()).status).toBe(200)
expect(await unlocked()).toHaveLength(1)
// Favouriting sticks.
const update = async (favorited: boolean) =>
test('POST /api/equipment/v1/update favourites from the clients own body', async () => {
// The body verbatim as the client sends it — a full echo of the entry it was served,
// of which only `Favorited` is read.
const post = async (favorited: boolean, sub = '33') =>
exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
method: 'PUT',
headers: { ...(await bearer('31')), 'Content-Type': 'application/json' },
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify([
{ PrefabName: '[DiscGolfDisc]', ModificationGuid: guid, Favorited: favorited },
// A guid the caller doesn't own is silently skipped, not inserted.
{ PrefabName: '[Basketball]', ModificationGuid: 'not-owned', Favorited: true },
{
PrefabName: '[ShareCamera]',
ModificationGuid: 'g5u0weNLmkCLeUXFUVn74Q',
FriendlyName: 'Camera Skin (Comic)',
Tooltip: 'ShareCamera Comic Debug: 2121',
Rarity: 5,
Favorited: favorited,
},
]),
})
expect((await update(true)).status).toBe(200)
let after = await unlocked()
expect(after).toHaveLength(1)
expect(after[0].Favorited).toBe(true)
// …and un-favouriting flips it back.
expect((await update(false)).status).toBe(200)
after = await unlocked()
expect(after[0].Favorited).toBe(false)
// Nothing owned yet: the guid matches no row, so this is a silent no-op, not an error.
expect((await post(true)).status).toBe(200)
await grantEquipment(env.DB, 33, {
PrefabName: '[ShareCamera]',
ModificationGuid: 'g5u0weNLmkCLeUXFUVn74Q',
FriendlyName: 'Camera Skin (Comic)',
Tooltip: 'ShareCamera Comic Debug: 2121',
Rarity: 5,
PlatformMask: -1,
Favorited: false,
})
const owned = async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`, {
headers: await bearer('33'),
})
return (await res.json()) as Array<{ ModificationGuid: string; Favorited: boolean }>
}
expect((await owned())[0]?.Favorited).toBe(false)
expect((await post(true)).status).toBe(200)
expect((await owned())[0]?.Favorited).toBe(true)
expect((await post(false)).status).toBe(200)
expect((await owned())[0]?.Favorited).toBe(false)
})
test('PUT /api/equipment/v1/update 401s without a token, 400s on a non-array body', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: '[]',
})
expect(anon.status).toBe(401)
test('equipment/v1/update 401s without a token, 400s on a non-array body (PUT and POST)', async () => {
for (const method of ['PUT', 'POST'] as const) {
const anon = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
method,
headers: { 'Content-Type': 'application/json' },
body: '[]',
})
expect(anon.status).toBe(401)
const bad = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
method: 'PUT',
headers: { ...(await bearer('32')), 'Content-Type': 'application/json' },
body: '{}',
})
expect(bad.status).toBe(400)
const bad = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
method,
headers: { ...(await bearer('32')), 'Content-Type': 'application/json' },
body: '{}',
})
expect(bad.status).toBe(400)
}
})
test('POST /api/storefronts/v2/buyItem 409s when the sent price no longer matches', async () => {
@@ -1133,7 +1747,7 @@ describe('econ endpoints', () => {
headers: { ...(await bearer('21')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 73,
PurchasableItemId: SF3_ITEM.id,
CurrencyType: 2,
RequestedPrice: 1,
}),
@@ -1146,6 +1760,280 @@ describe('econ endpoints', () => {
expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 }])
})
// sf300's item 2263 is 95 tokens in `Prices` and 85 in `SubscriberPrices`. A subscriber's
// client applies the Plus discount itself, but not to every item, so the server takes any
// price in the band [85, 95] from a subscriber and charges what they asked to pay.
const buy2263 = async (headers: Record<string, string>, RequestedPrice: number) =>
exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 300,
PurchasableItemId: 2263,
CurrencyType: 2,
RequestedPrice,
CouponConsumablePlayerMappingId: null,
Gift: null,
}),
})
test('POST /api/storefronts/v2/buyItem charges a subscriber the SubscriberPrices entry', async () => {
await drainFrames()
const res = await buy2263(await bearer('322', ['gameClient'], undefined, true), 85)
expect(res.status).toBe(200)
expect(((await res.json()) as { Balance: number }).Balance).toBe(-85)
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('322'),
})
expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 - 85 }])
})
test('POST /api/storefronts/v2/buyItem takes 10% off from a subscriber when the catalog lists no discount', async () => {
// sf3's item 2184 is 95 in BOTH lists, but a subscriber's client may still post
// floor(95 * 0.9) = 85.
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: {
...(await bearer('325', ['gameClient'], undefined, true)),
'Content-Type': 'application/json',
},
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 2184,
CurrencyType: 2,
RequestedPrice: 85,
CouponConsumablePlayerMappingId: null,
Gift: null,
}),
})
expect(res.status).toBe(200)
expect(((await res.json()) as { Balance: number }).Balance).toBe(-85)
})
test('POST /api/storefronts/v2/buyItem charges a subscriber the full price when their client sends it', async () => {
// sf3's item 2208 is 150 in both lists and the live client posts 150 for a subscriber:
// not every item is discounted, so the full price has to stay buyable by a subscriber.
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: {
...(await bearer('326', ['gameClient'], undefined, true)),
'Content-Type': 'application/json',
},
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 2208,
CurrencyType: 2,
RequestedPrice: 150,
CouponConsumablePlayerMappingId: null,
Gift: null,
}),
})
expect(res.status).toBe(200)
expect(((await res.json()) as { Balance: number }).Balance).toBe(-150)
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('326'),
})
expect(await bal.json()).toEqual([{ CurrencyType: 2, Platform: -2, Balance: 10000 - 150 }])
})
test('POST /api/storefronts/v2/buyItem 409s a subscriber below the discount band', async () => {
const res = await buy2263(await bearer('323', ['gameClient'], undefined, true), 84)
expect(res.status).toBe(409)
// …and above it: a made-up price is a mismatch in either direction.
const over = await buy2263(await bearer('323', ['gameClient'], undefined, true), 96)
expect(over.status).toBe(409)
})
test('POST /api/storefronts/v2/buyItem 409s a non-subscriber posting the subscriber price', async () => {
const res = await buy2263(await bearer('324'), 85)
expect(res.status).toBe(409)
const ok = await buy2263(await bearer('324'), 95)
expect(ok.status).toBe(200)
})
/** Seed a real account row, so a gift naming this player has somewhere to land. */
const seedAccount = async (accountId: number, username: string) => {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId, username, displayName: username }))
.run()
}
/**
* Buy one sf3 item with a `Gift` block, as the client posts it. This used to buy an equipment
* skin; skins are awarded from weekly challenges rather than sold, so the store no longer
* lists one and these tests use an ordinary avatar item they are about GIFTING either way.
*/
const giftBackpack = async (sub: string, gift: Record<string, unknown> | null) =>
exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: SF3_ITEM.id,
CurrencyType: 2,
RequestedPrice: SF3_ITEM.price,
CouponConsumablePlayerMappingId: null,
Gift: gift,
}),
})
const pendingGifts = async (sub: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
headers: await bearer(sub),
})
expect(res.status).toBe(200)
return (await res.json()) as Array<Record<string, unknown>>
}
test('POST /api/storefronts/v2/buyItem charges the buyer and hands the item to the gifts receiver', async () => {
await seedAccount(205, 'GiftReceiver')
await drainFrames()
const res = await giftBackpack('330', {
ToPlayerId: 205,
Message: 'hello this is a message',
Anonymous: false,
GiftContext: 500,
})
expect(res.status).toBe(200)
// The buyer pays — `Balance` is their change — even though nothing lands on them.
expect(((await res.json()) as { Balance: number }).Balance).toBe(-SF3_ITEM.price)
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
headers: await bearer('330'),
})
expect(await bal.json()).toEqual([
{ CurrencyType: 2, Platform: -2, Balance: DEFAULT_STARTING_TOKENS - SF3_ITEM.price },
])
// The item and its box are the RECEIVER's; the buyer keeps neither. An avatar item lands
// in the inventory rather than the equipment list — `v4/items` leads with what is owned.
expect(await pendingGifts('330')).toEqual([])
const ownedBy = async (sub: string) => {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer(sub),
})
return (await res.json()) as Array<{ avatarItemDesc: string; friendlyName: string }>
}
expect((await ownedBy('330'))[0]?.friendlyName).not.toBe(SF3_ITEM.name)
expect((await ownedBy('205'))[0]?.friendlyName).toBe(SF3_ITEM.name)
const [gift, ...others] = await pendingGifts('205')
expect(others).toEqual([])
// The box outlives the request, so it carries who sent it and why — the receiver may
// only ever meet it in this list.
expect(gift?.FromPlayerId).toBe(330)
expect(gift?.GiftContext).toBe(500)
expect(gift?.Message).toBe('hello this is a message')
// The receiver has no response to read, so the box is pushed to them.
const frames = await drainFrames()
const received = frames.find(
(f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate
)
expect(received?.accountId).toBe(205)
expect(received?.payload).toMatchObject({
Id: gift?.Id,
FromPlayerId: 330,
GiftContext: 500,
Message: 'hello this is a message',
// An avatar item, so the equipment half of the drop is empty and the desc carries it.
EquipmentModificationGuid: '',
})
// …and the spend frame still goes to the BUYER, who is the one who paid.
const spend = frames.find(
(f) => f.notificationType === NotificationType.StorefrontBalancePurchase
)
expect(spend?.accountId).toBe(330)
})
test('POST /api/storefronts/v2/buyItem attributes an anonymous gift to Coach', async () => {
await seedAccount(206, 'AnonReceiver')
await drainFrames()
const res = await giftBackpack('331', {
ToPlayerId: 206,
Message: 'guess who',
Anonymous: true,
})
expect(res.status).toBe(200)
// Anonymous hides the sender from the box, it does not withhold the gift: id 1 is Coach.
const [gift] = await pendingGifts('206')
expect(gift?.FromPlayerId).toBe(1)
expect(gift?.Message).toBe('guess who')
const received = (await drainFrames()).find(
(f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate
)
expect(received?.payload).toMatchObject({ FromPlayerId: 1 })
})
test('POST /api/storefronts/v2/buyItem masks swears in the gift message', async () => {
await seedAccount(208, 'MaskedReceiver')
await drainFrames()
const res = await giftBackpack('334', {
ToPlayerId: 208,
Message: 'happy birthday you shit',
Anonymous: false,
GiftContext: 500,
})
expect(res.status).toBe(200)
// The buyer writes it and someone else reads it, so it is filtered like any other
// player-typed string — masked per character, never refused.
const masked = 'happy birthday you ****'
expect(
((await res.json()) as { BalanceUpdates: Array<{ Data: Array<{ Message: string }> }> })
.BalanceUpdates[0]?.Data[0]?.Message
).toBe(masked)
const [gift] = await pendingGifts('208')
expect(gift?.Message).toBe(masked)
const received = (await drainFrames()).find(
(f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate
)
expect(received?.payload).toMatchObject({ Message: masked })
})
test('POST /api/storefronts/v2/buyItem caps the gift message at the clients 150 characters', async () => {
await seedAccount(209, 'LongNoteReceiver')
await drainFrames()
// The client's input field stops at 150, so a 200-character note is a client that
// ignored its own limit — the overrun is dropped, the purchase still goes through.
const long = 'a'.repeat(140) + 'b'.repeat(60)
const res = await giftBackpack('335', { ToPlayerId: 209, Message: long, Anonymous: false })
expect(res.status).toBe(200)
const [gift] = await pendingGifts('209')
expect(gift?.Message).toBe(long.slice(0, 150))
expect((gift?.Message as string).length).toBe(150)
// An emoji straddling the cut is dropped whole rather than stored as half a character.
const emoji = `${'x'.repeat(149)}😀tail`
const second = await giftBackpack('336', { ToPlayerId: 209, Message: emoji, Anonymous: false })
expect(second.status).toBe(200)
const notes = (await pendingGifts('209')).map((box) => box.Message)
expect(notes).toContain('x'.repeat(149))
})
test('POST /api/storefronts/v2/buyItem 404s a gift to a player that does not exist', async () => {
await drainFrames()
const res = await giftBackpack('332', { ToPlayerId: 999999, Message: 'hi', Anonymous: false })
expect(res.status).toBe(404)
expect(await res.json()).toEqual({ error: 'No such player to gift to' })
// Refused before the debit: the buyer still has every token, and nothing was pushed.
expect(
await getBalance(env.DB, 332, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(10000)
expect(await drainFrames()).toEqual([])
})
test('POST /api/storefronts/v2/buyItem gifting to yourself is just a purchase', async () => {
await seedAccount(333, 'SelfGifter')
await drainFrames()
const res = await giftBackpack('333', { ToPlayerId: 333, Message: 'treat', Anonymous: false })
expect(res.status).toBe(200)
const [gift] = await pendingGifts('333')
expect(gift?.FromPlayerId).toBe(333)
// No hub gift frame: the buyer read the box out of the response.
expect(
(await drainFrames()).filter(
(f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate
)
).toEqual([])
})
test('POST /api/storefronts/v2/buyItem 404s for an unknown item', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
method: 'POST',
@@ -1154,7 +2042,7 @@ describe('econ endpoints', () => {
StorefrontType: 3,
PurchasableItemId: 9999999,
CurrencyType: 2,
RequestedPrice: 450,
RequestedPrice: SF3_ITEM.price,
}),
})
expect(res.status).toBe(404)
@@ -1170,9 +2058,9 @@ describe('econ endpoints', () => {
headers: { ...(await bearer('23')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 73,
PurchasableItemId: SF3_ITEM.id,
CurrencyType: 2,
RequestedPrice: 450,
RequestedPrice: SF3_ITEM.price,
}),
})
expect(res.status).toBe(400)
@@ -1243,7 +2131,7 @@ describe('econ endpoints', () => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
PurchaseItemRequests: [line(10, 200)],
PurchaseItemRequests: [line(SF3_ITEM.id, SF3_ITEM.price)],
StorefrontType: 3,
CurrencyType: 2,
}),
@@ -1253,11 +2141,15 @@ describe('econ endpoints', () => {
test('POST /api/items/bulkpurchase debits the bag once and grants every line', async () => {
// Account 90: fresh, so its first balance touch grants the 10000 default. Three donuts
// (a consumable, 100 each — consumables are the only thing that stacks) and one dress
// (an avatar item, 200) — 500 in total.
// (a consumable, 100 each — consumables are the only thing that stacks) and one avatar
// item. The prices come from the fixtures rather than being written out: sf3 is generated
// now, so a repriced rarity tier must not silently invalidate the arithmetic.
await drainFrames()
const res = await bulkPurchase('90', {
PurchaseItemRequests: [line(2182, 100, { DuplicateItemCount: 3 }), line(10, 200)],
PurchaseItemRequests: [
line(2182, 100, { DuplicateItemCount: 3 }),
line(SF3_ITEM.id, SF3_ITEM.price),
],
ShoppingBagId: 'bag-1',
})
expect(res.status).toBe(200)
@@ -1266,17 +2158,17 @@ describe('econ endpoints', () => {
expect(body.Error).toBe(null)
expect(body.error_id).toBe(null)
const value = body.Value!
// `Balance` here is the RESULTING total (10000 - 500), unlike buyItem's change. The
// `Balance` here is the RESULTING total, unlike buyItem's change. The
// bucket is -2, the one `GET /balance` reports — the reference server's 4
// (RecNetPurchased) would read as a second balance the client adds to the real one.
expect(value.Balance).toBe(9500)
expect(value.Balance).toBe(DEFAULT_STARTING_TOKENS - 300 - SF3_ITEM.price)
expect(value.CurrencyType).toBe(2)
expect(value.Platform).toBe(-2)
// ONE entry per REQUESTED item — three donuts are one line, so one entry — in order.
expect(value.BalanceUpdates).toHaveLength(2)
expect(codes(body)).toEqual([0, 0])
expect(value.BalanceUpdates.map((u) => u.Data.PurchasableItemId)).toEqual([2182, 10])
expect(value.BalanceUpdates.map((u) => u.Data.PurchasableItemId)).toEqual([2182, SF3_ITEM.id])
expect(value.BalanceUpdates.every((u) => u.Data.CustomAvatarItem === null)).toBe(true)
// The box each line produced, as `GiftPackage` carries it: 20 keys, the receiver in
// `PlayerId`, a self-buy attributed to the "Coach" account (1), and the platform MASK in
@@ -1313,15 +2205,15 @@ describe('econ endpoints', () => {
expect(value.BalanceUpdates[1].Data.GiftPackage!.AvatarItemDesc).not.toBe('')
// ONE frame for the whole bag, setting the account-wide bucket to the resulting total —
// the same 9500 the body reports, so the two agree instead of compounding.
// the same total the body reports, so the two agree instead of compounding.
expect(await drainFrames()).toEqual([
{
accountId: 90,
notificationType: NotificationType.StorefrontBalancePurchase,
payload: {
BalanceAddType: 1400,
Delta: -500,
Balance: 9500,
Delta: -(300 + SF3_ITEM.price),
Balance: DEFAULT_STARTING_TOKENS - 300 - SF3_ITEM.price,
Platform: -2,
CurrencyType: 2,
},
@@ -1329,7 +2221,7 @@ describe('econ endpoints', () => {
])
expect(
await getBalance(env.DB, 90, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(9500)
).toBe(DEFAULT_STARTING_TOKENS - 300 - SF3_ITEM.price)
// Everything landed: the dress is owned, all three donuts stacked into the one box's
// grant, and each LINE left one gift box.
@@ -1337,7 +2229,7 @@ describe('econ endpoints', () => {
headers: await bearer('90'),
})
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list[0].friendlyName).toBe('Babydoll Dress (Blue)')
expect(list[0].friendlyName).toBe(SF3_ITEM.name)
const unlocked = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, {
headers: await bearer('90'),
})
@@ -1356,30 +2248,30 @@ describe('econ endpoints', () => {
// The second line's price no longer matches the catalog (200, not 1). The bag still
// succeeds — that entry just comes back non-OK, which is what AllowPartialSuccess means.
const res = await bulkPurchase('91', {
PurchaseItemRequests: [line(10, 200), line(80, 1)],
PurchaseItemRequests: [line(SF3_ITEM.id, SF3_ITEM.price), line(SF3_CHEAP.id, 1)],
})
expect(res.status).toBe(200)
const body = (await res.json()) as BulkBody
expect(body.Success).toBe(true)
expect(body.Error).toBe(null)
expect(body.Value!.Balance).toBe(9800)
expect(body.Value!.Balance).toBe(DEFAULT_STARTING_TOKENS - SF3_ITEM.price)
// 6 = RequestedPriceDoesNotMatch. The failed line still names the item it asked for.
expect(codes(body)).toEqual([0, 6])
expect(body.Value!.BalanceUpdates[1].Data).toEqual({
GiftPackage: null,
PurchasableItemId: 80,
PurchasableItemId: SF3_CHEAP.id,
CustomAvatarItem: null,
})
expect(
await getBalance(env.DB, 91, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(9800)
).toBe(DEFAULT_STARTING_TOKENS - SF3_ITEM.price)
})
test('POST /api/items/bulkpurchase charges nothing when a line fails and partial success is off', async () => {
await drainFrames()
const res = await bulkPurchase('92', {
AllowPartialSuccess: false,
PurchaseItemRequests: [line(10, 200), line(80, 1)],
PurchaseItemRequests: [line(SF3_ITEM.id, SF3_ITEM.price), line(SF3_CHEAP.id, 1)],
})
expect(res.status).toBe(200)
const body = (await res.json()) as BulkBody
@@ -1394,38 +2286,59 @@ describe('econ endpoints', () => {
headers: await bearer('92'),
})
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.every((i) => i.friendlyName !== 'Babydoll Dress (Blue)')).toBe(true)
expect(list.every((i) => i.friendlyName !== SF3_ITEM.name)).toBe(true)
expect(await drainFrames()).toEqual([])
})
test('POST /api/items/bulkpurchase takes the lines that fit, in request order', async () => {
// Leave account 93 with 250 tokens: enough for the first 200-token line, not both.
// Leave account 93 with enough for the FIRST line and not both, whatever the tiers cost.
const fitsOne = SF3_ITEM.price + SF3_CHEAP.price - 1
await getBalance(env.DB, 93, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
expect(
await spendCurrency(env.DB, 93, CurrencyType.RecCenterTokens, 9750, DEFAULT_STARTING_TOKENS)
await spendCurrency(
env.DB,
93,
CurrencyType.RecCenterTokens,
DEFAULT_STARTING_TOKENS - fitsOne,
DEFAULT_STARTING_TOKENS
)
).toBe(true)
const res = await bulkPurchase('93', {
PurchaseItemRequests: [line(10, 200), line(80, 200)],
PurchaseItemRequests: [
line(SF3_ITEM.id, SF3_ITEM.price),
line(SF3_CHEAP.id, SF3_CHEAP.price),
],
})
const body = (await res.json()) as BulkBody
expect(body.Success).toBe(true)
expect(body.Value!.Balance).toBe(50)
expect(body.Value!.Balance).toBe(fitsOne - SF3_ITEM.price)
// 2 = NotEnoughCredit for the line the balance no longer covered.
expect(codes(body)).toEqual([0, 2])
expect(body.Value!.BalanceUpdates[1].Data.GiftPackage).toBe(null)
expect(
await getBalance(env.DB, 93, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(50)
).toBe(fitsOne - SF3_ITEM.price)
})
test('POST /api/items/bulkpurchase fails the whole bag it cannot afford when partial success is off', async () => {
// Same shaping as above: enough for one line, not both.
const affordsOne = SF3_ITEM.price + SF3_CHEAP.price - 1
await getBalance(env.DB, 94, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
expect(
await spendCurrency(env.DB, 94, CurrencyType.RecCenterTokens, 9750, DEFAULT_STARTING_TOKENS)
await spendCurrency(
env.DB,
94,
CurrencyType.RecCenterTokens,
DEFAULT_STARTING_TOKENS - affordsOne,
DEFAULT_STARTING_TOKENS
)
).toBe(true)
const res = await bulkPurchase('94', {
AllowPartialSuccess: false,
PurchaseItemRequests: [line(10, 200), line(80, 200)],
PurchaseItemRequests: [
line(SF3_ITEM.id, SF3_ITEM.price),
line(SF3_CHEAP.id, SF3_CHEAP.price),
],
})
const body = (await res.json()) as BulkBody
// Even the line that would have fitted is refused: all of it or none.
@@ -1434,21 +2347,21 @@ describe('econ endpoints', () => {
expect(body.Value).toBe(null)
expect(
await getBalance(env.DB, 94, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(250)
).toBe(affordsOne)
})
test('POST /api/items/bulkpurchase grants without gift boxes when BypassGiftPackages is set', async () => {
const res = await bulkPurchase('95', {
BypassGiftPackages: true,
PurchaseItemRequests: [line(10, 200)],
PurchaseItemRequests: [line(SF3_ITEM.id, SF3_ITEM.price)],
})
const body = (await res.json()) as BulkBody
expect(body.Success).toBe(true)
expect(body.Value!.Balance).toBe(9800)
expect(body.Value!.Balance).toBe(DEFAULT_STARTING_TOKENS - SF3_ITEM.price)
// No box was created, so there is none to hand back — the capture's null GiftPackage.
expect(body.Value!.BalanceUpdates[0]).toEqual({
UpdateResponse: 0,
Data: { GiftPackage: null, PurchasableItemId: 10, CustomAvatarItem: null },
Data: { GiftPackage: null, PurchasableItemId: SF3_ITEM.id, CustomAvatarItem: null },
})
const gifts = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
headers: await bearer('95'),
@@ -1459,7 +2372,59 @@ describe('econ endpoints', () => {
headers: await bearer('95'),
})
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list[0].friendlyName).toBe('Babydoll Dress (Blue)')
expect(list[0].friendlyName).toBe(SF3_ITEM.name)
})
test('POST /api/items/bulkpurchase routes a gifted line to its receiver', async () => {
await seedAccount(207, 'BagReceiver')
await drainFrames()
const res = await bulkPurchase('960', {
PurchaseItemRequests: [
line(SF3_ITEM.id, SF3_ITEM.price),
line(2182, 100, {
Gift: { ToPlayerId: 207, Message: 'from the bag', Anonymous: false, GiftContext: 500 },
}),
],
})
const body = (await res.json()) as BulkBody
expect(body.Success).toBe(true)
// The buyer pays for both lines; only the first one lands on them.
expect(body.Value!.Balance).toBe(DEFAULT_STARTING_TOKENS - SF3_ITEM.price - 100)
const [own, gifted] = body.Value!.BalanceUpdates
expect(own?.Data.GiftPackage).toMatchObject({ PlayerId: 960, FromPlayerId: 1 })
expect(gifted?.Data.GiftPackage).toMatchObject({
PlayerId: 207,
FromPlayerId: 960,
GiftContext: 500,
})
expect(await pendingGifts('960')).toHaveLength(1)
const [box] = await pendingGifts('207')
expect(box?.FromPlayerId).toBe(960)
expect(box?.GiftContext).toBe(500)
expect(box?.Message).toBe('from the bag')
// The bag's response is the buyer's; the receiver is told over the hub instead.
const received = (await drainFrames()).find(
(f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate
)
expect(received?.accountId).toBe(207)
expect(received?.payload).toMatchObject({ Id: box?.Id, FromPlayerId: 960, GiftContext: 500 })
})
test('POST /api/items/bulkpurchase 404s a bag gifting to a player that does not exist', async () => {
const res = await bulkPurchase('970', {
PurchaseItemRequests: [
line(SF3_ITEM.id, SF3_ITEM.price),
line(2182, 100, { Gift: { ToPlayerId: 999998, Message: 'hi', Anonymous: false } }),
],
})
expect(res.status).toBe(404)
const body = (await res.json()) as BulkBody
expect(body.Success).toBe(false)
expect(body.Error).toBe('No such player to gift to')
// The whole bag is refused before the debit, the good line included.
expect(
await getBalance(env.DB, 970, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(10000)
})
test('POST /api/items/bulkpurchase reports per line what it cannot sell', async () => {
@@ -1467,20 +2432,20 @@ describe('econ endpoints', () => {
const res = await bulkPurchase('96', {
PurchaseItemRequests: [
// A guid-keyed (UGC) item — nothing here sells one, and it has no NumberId to echo.
line(0, 200, {
line(0, SF3_ITEM.price, {
ItemPurchaseMethodId: { Type: 1, NumberId: null, Guid: 'a3f1-not-a-catalog-item' },
}),
// Nothing issues coupons, so a line claiming one is refused rather than charged full
// price for a discount it thinks it applied.
line(10, 200, { CouponConsumablePlayerMappingId: 4242 }),
line(999999, 200),
line(10, 200, { DuplicateItemCount: 0 }),
line(SF3_ITEM.id, SF3_ITEM.price, { CouponConsumablePlayerMappingId: 4242 }),
line(999999, SF3_ITEM.price),
line(SF3_ITEM.id, SF3_ITEM.price, { DuplicateItemCount: 0 }),
// An avatar item is owned once — a second copy would grant nothing and charge for it.
line(80, 200, { DuplicateItemCount: 2 }),
line(SF3_CHEAP.id, SF3_CHEAP.price, { DuplicateItemCount: 2 }),
// The catalog prices this item in RecCenterTokens only.
line(2182, 100),
// …and one that works, so the bag is a partial success rather than a refusal.
line(10, 200),
line(SF3_ITEM.id, SF3_ITEM.price),
],
CurrencyType: 2,
})
@@ -1491,15 +2456,15 @@ describe('econ endpoints', () => {
// RequestedAmountNotAllowed, 0 OK (the donuts do price in tokens), 0 OK.
expect(codes(body)).toEqual([4, 5, 4, 7, 7, 0, 0])
expect(body.Value!.BalanceUpdates[0].Data.PurchasableItemId).toBe(null)
// Only the two OK lines were charged (100 + 200).
expect(body.Value!.Balance).toBe(9700)
// Only the two OK lines were charged: the donuts and one avatar item.
expect(body.Value!.Balance).toBe(DEFAULT_STARTING_TOKENS - 100 - SF3_ITEM.price)
expect(await drainFrames()).toHaveLength(1)
})
test('POST /api/items/bulkpurchase refuses a bag where nothing sells', async () => {
const res = await bulkPurchase('97', {
CurrencyType: CurrencyType.LaserTagTickets,
PurchaseItemRequests: [line(10, 200)],
PurchaseItemRequests: [line(SF3_ITEM.id, SF3_ITEM.price)],
})
expect(res.status).toBe(200)
const body = (await res.json()) as BulkBody
@@ -1519,7 +2484,7 @@ describe('econ endpoints', () => {
// A room-scoped currency is not an account balance we can debit.
const roomCurrency = await bulkPurchase('98', {
CurrencyType: CurrencyType.RoomCurrency,
PurchaseItemRequests: [line(10, 200)],
PurchaseItemRequests: [line(SF3_ITEM.id, SF3_ITEM.price)],
})
expect(roomCurrency.status).toBe(400)
expect(((await roomCurrency.json()) as BulkBody).Error).toBe('Currency type is not spendable')
@@ -1691,6 +2656,168 @@ describe('econ endpoints', () => {
expect(await getOwnedInventionIds(env.DB, 60)).toEqual([])
})
// The 2025 client posts the same purchase as a JSON body — and wants a DIFFERENT response
// back: the v9 save envelope, and a balance bucket keyed `Platform`. The settlement is
// shared with v2, so these pin the envelope and the money moving, not the rules v2 covers.
const buyInventionV3 = async (sub: string, body: unknown) =>
exports.default.fetch(`${ORIGIN}/api/storefronts/v3/buyInvention`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
test('POST /api/storefronts/v3/buyInvention 401s without a token', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v3/buyInvention`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ InventionId: 8, RequestedPrice: 0 }),
})
expect(res.status).toBe(401)
})
test('POST /api/storefronts/v3/buyInvention answers the v9 envelope, not v2s', async () => {
const res = await buyInventionV3('55', { InventionId: 8, RequestedPrice: 0 })
expect(res.status).toBe(200)
const body = (await res.json()) as {
InventionResponse: {
Value: {
Status: number
Invention: Record<string, unknown>
InventionVersion: unknown
TagsResponse: unknown
} | null
Success: boolean
Error: string | null
error_id: string | null
}
BalanceUpdateResponse: {
Balance: number
CurrencyType: number
Platform: number
BalanceType?: number
BalanceUpdates: Array<{ UpdateResponse: number; Data: { InventionId: number } }>
}
}
// The v9 SAVE envelope: `Value` under `{ Success, Error, error_id }`. `Value` is never
// null under `Success: true` — the client dereferences `Value.Invention` unguarded.
expect(body.InventionResponse).toMatchObject({ Success: true, Error: null, error_id: null })
expect(body.InventionResponse.Value?.Status).toBe(0)
expect(body.InventionResponse.Value?.Invention.InventionId).toBe(8)
expect(body.InventionResponse.Value?.Invention.Name).toBe('Invention 8')
// The 28-key `RRInvention`, not the stored record: the version rides nowhere here, and
// `IsPublished` is a stored field this projection drops.
expect(body.InventionResponse.Value?.Invention.CurrentVersion).toBeUndefined()
expect(body.InventionResponse.Value?.Invention.IsPublished).toBeUndefined()
expect(body.InventionResponse.Value?.Invention.LatestVersionNumber).toBe(1)
// A buy mints no version and takes no tags — present and null, not absent.
expect(body.InventionResponse.Value).toHaveProperty('InventionVersion', null)
expect(body.InventionResponse.Value).toHaveProperty('TagsResponse', null)
// `BalanceResponseDTO`: the bucket key is `Platform`. Spelling it `BalanceType` (which is
// what v2 sends) would be dropped by the client's decoder and default this balance into
// bucket 0, beside the -2 the socket frames set — a phantom second balance.
expect(body.BalanceUpdateResponse.Platform).toBe(-2)
expect(body.BalanceUpdateResponse.BalanceType).toBeUndefined()
// Nothing was debited, so `Balance` is the resulting total, not a change.
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS)
expect(body.BalanceUpdateResponse.CurrencyType).toBe(CurrencyType.RecCenterTokens)
expect(body.BalanceUpdateResponse.BalanceUpdates[0].Data.InventionId).toBe(8)
expect(await getOwnedInventionIds(env.DB, 55)).toEqual([8])
// Owning it is boolean here too — the route shares v2's settlement.
expect((await buyInventionV3('55', { InventionId: 8, RequestedPrice: 0 })).status).toBe(409)
})
test('GET v2 and POST v3 buyInvention answer the SAME buy in different envelopes', async () => {
// The one thing that must not drift: two builds buying the same invention get the same
// invention back, shaped for each. v2 serves the stored record under a bare status
// envelope; v3 serves the 28-key projection under the v9 one. Don't unify them.
const v2 = (await (await buyInvention('58', 8)).json()) as {
InventionResponse: { Status: number; Invention: Record<string, unknown> }
BalanceUpdateResponse: { BalanceType: number; Platform?: number }
}
const v3 = (await (
await buyInventionV3('59', { InventionId: 8, RequestedPrice: 0 })
).json()) as {
InventionResponse: { Value: { Invention: Record<string, unknown> } | null }
BalanceUpdateResponse: { Platform: number; BalanceType?: number }
}
expect(v2.InventionResponse.Invention.InventionId).toBe(8)
expect(v3.InventionResponse.Value?.Invention.InventionId).toBe(8)
// v2 keeps the nested version; v3's projection lifts it away entirely.
expect(v2.InventionResponse.Invention.CurrentVersion).toBeDefined()
expect(v3.InventionResponse.Value?.Invention.CurrentVersion).toBeUndefined()
// The bucket is spelled differently on each, and each spells exactly one.
expect(v2.BalanceUpdateResponse).toMatchObject({ BalanceType: -2 })
expect(v2.BalanceUpdateResponse.Platform).toBeUndefined()
expect(v3.BalanceUpdateResponse).toMatchObject({ Platform: -2 })
expect(v3.BalanceUpdateResponse.BalanceType).toBeUndefined()
})
test('POST /api/storefronts/v3/buyInvention pays the creator and pushes both sides', async () => {
await drainFrames()
// Creator 999 has already been paid by the v2 tests above, so their resulting total is
// read rather than assumed — it is the payout ADDED to whatever they had.
const creatorBefore = await getBalance(
env.DB,
999,
CurrencyType.RecCenterTokens,
DEFAULT_STARTING_TOKENS
)
const res = await buyInventionV3('56', { InventionId: 9, RequestedPrice: 250 })
expect(res.status).toBe(200)
const body = (await res.json()) as { BalanceUpdateResponse: { Balance: number } }
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS - 250)
expect(
await getBalance(env.DB, 56, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(DEFAULT_STARTING_TOKENS - 250)
expect(
await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(creatorBefore + 250)
expect(await getOwnedInventionIds(env.DB, 56)).toEqual([9])
// Same two frames as the v2 buy, each carrying its player's RESULTING total into the -2
// bucket: the creator sold (a plain update), the buyer bought (a purchase frame).
expect(await drainFrames()).toEqual([
{
accountId: 999,
notificationType: NotificationType.StorefrontBalanceUpdate,
payload: {
Balance: creatorBefore + 250,
CurrencyType: CurrencyType.RecCenterTokens,
Platform: -2,
},
},
{
accountId: 56,
notificationType: NotificationType.StorefrontBalancePurchase,
payload: {
BalanceAddType: 1400,
Delta: -250,
Balance: DEFAULT_STARTING_TOKENS - 250,
Platform: -2,
CurrencyType: CurrencyType.RecCenterTokens,
},
},
])
})
test('POST /api/storefronts/v3/buyInvention rejects a stale price and a bad body', async () => {
// A body is the only difference from v2, so the price check reads it the same way: an
// absent RequestedPrice is 0, which does not match the 250-token invention 9.
expect((await buyInventionV3('57', { InventionId: 9 })).status).toBe(409)
expect((await buyInventionV3('57', { InventionId: 9, RequestedPrice: 0 })).status).toBe(409)
// No InventionId, and no body at all.
expect((await buyInventionV3('57', { RequestedPrice: 0 })).status).toBe(400)
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v3/buyInvention`, {
method: 'POST',
headers: { ...(await bearer('57')), 'Content-Type': 'application/json' },
})
expect(res.status).toBe(400)
expect(await getOwnedInventionIds(env.DB, 57)).toEqual([])
})
test('POST /api/avatar/v2/gifts/consume opens the box the way the client sends it', async () => {
// Buy an item for account 24, then consume the box the way the client does: on the
// econ host, with a form body (`Id=..&UnlockedLevel=..`).
@@ -1699,9 +2826,9 @@ describe('econ endpoints', () => {
headers: { ...(await bearer('24')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 73,
PurchasableItemId: SF3_ITEM.id,
CurrencyType: 2,
RequestedPrice: 450,
RequestedPrice: SF3_ITEM.price,
}),
})
const bought = (await buy.json()) as {
@@ -1729,7 +2856,7 @@ describe('econ endpoints', () => {
headers: await bearer('24'),
})
const list = (await items.json()) as Array<{ friendlyName: string }>
expect(list.some((i) => i.friendlyName === 'Bowtie (White)')).toBe(true)
expect(list.some((i) => i.friendlyName === SF3_ITEM.name)).toBe(true)
// Opening it again is a harmless no-op — still 200.
const again = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume/`, {
@@ -1794,9 +2921,9 @@ describe('econ endpoints', () => {
headers: { ...(await bearer('27')), 'Content-Type': 'application/json' },
body: JSON.stringify({
StorefrontType: 3,
PurchasableItemId: 73,
PurchasableItemId: SF3_ITEM.id,
CurrencyType: 2,
RequestedPrice: 450,
RequestedPrice: SF3_ITEM.price,
}),
})
const giftId = (
@@ -1834,10 +2961,129 @@ describe('econ endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
expect(res.status).toBe(200)
const body = (await res.json()) as { ChallengeMapId: number; Challenges: unknown[] }
expect(body).toHaveProperty('ChallengeMapId')
expect(Array.isArray(body.Challenges)).toBe(true)
expect(body.ChallengeMapId).toBe(weekly.ChallengeMapId)
expect(body.Challenges).toHaveLength(weekly.Challenges.length)
})
test('the rotation is a pure function of the week', async () => {
const at = new Date('2026-08-25T12:00:00Z')
// Same instant, same rotation — and any instant in the same week, too. Two players
// served different challenges for one `ChallengeMapId` would disagree about who has
// finished the week.
expect(buildRotation(at)).toEqual(buildRotation(at))
const laterSameWeek = new Date('2026-08-26T20:59:59Z')
expect(rotationIndex(laterSameWeek)).toBe(rotationIndex(at))
expect(buildRotation(laterSameWeek).Challenges).toEqual(buildRotation(at).Challenges)
// …and the week rolls at Wednesday 21:00 UTC, one map id at a time.
const nextWeek = new Date('2026-08-26T21:00:00Z')
expect(rotationIndex(nextWeek)).toBe(rotationIndex(at) + 1)
expect(buildRotation(nextWeek).ChallengeMapId).toBe(buildRotation(at).ChallengeMapId + 1)
expect(buildRotation(nextWeek).StartAt).toBe(buildRotation(at).EndAt)
})
test('the weekly gift pool leaves out the sandbox dice', async () => {
// The pool is the catalog's skins, and a sixth of them are `[Sandbox_D4]`…`[Sandbox_D20]`
// recolours. A week themed on "Sandbox D8 (Pewter)" spends its headline reward on a die, so
// those prefabs are excluded — everything else is fair game.
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
expect(res.status).toBe(200)
const week = (await res.json()) as {
ChallengeThemeString: string
Gift: { EquipmentPrefabName: string }
}
expect(week.Gift.EquipmentPrefabName.startsWith('[Sandbox_')).toBe(false)
expect(week.ChallengeThemeString).not.toMatch(/^Sandbox D/)
// It still rolls something: excluding the dice must not empty the pool, which would leave
// the week themed on nothing.
expect(week.ChallengeThemeString).not.toBe('')
})
test('the week is themed on the name of the item it rolls', async () => {
// `ChallengeThemeString` is the reward's catalog FriendlyName. The static file ships it
// empty on purpose — a generated week's gift isn't known until it is rolled — so the
// theming happens where the pick does.
const pool = [
{
GiftDropId: 11,
EquipmentPrefabName: '[ShareCamera]',
EquipmentModificationGuid: 'guid-a',
Rarity: 30,
FriendlyName: 'Camera Skin (Comic)',
},
{
GiftDropId: 12,
EquipmentPrefabName: '[Boombox]',
EquipmentModificationGuid: 'guid-b',
Rarity: 20,
FriendlyName: 'Boombox (Neon)',
},
]
const at = new Date('2026-08-25T12:00:00Z')
const themed = withWeeklyGift(buildRotation(at), pool)
const rolled = pool.find((p) => p.GiftDropId === themed.Gift.GiftDropId)
expect(rolled).toBeDefined()
expect(themed.ChallengeThemeString).toBe(rolled!.FriendlyName)
// An empty pool (the catalog didn't load) leaves the rotation as it was rather than
// theming the week on nothing.
expect(withWeeklyGift(buildRotation(at), []).ChallengeThemeString).toBe(
buildRotation(at).ChallengeThemeString
)
// And over the live catalog the route serves a real name, not the placeholder.
const served = (await (
await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
).json()) as { ChallengeThemeString: string }
expect(served.ChallengeThemeString).not.toBe('')
})
test('every generated week is five valid, distinct challenges', async () => {
// Walk two years of rotations: the pool, the constraints and the tree builders all have
// to hold for every week, not just this one.
for (let week = 0; week < 104; week++) {
const at = new Date(Date.UTC(2026, 0, 7, 21, 0, 0) + week * 7 * 24 * 60 * 60 * 1000)
const rotation = buildRotation(at)
const where = `week ${week}`
expect(rotation.Challenges, where).toHaveLength(5)
// Ids have to be unique within a rotation — `challenge_status` is keyed by them.
const ids = rotation.Challenges.map((ch) => ch.ChallengeId)
expect(new Set(ids).size, where).toBe(ids.length)
// One room per week: five ways to say "play Paintball" is not a rotation.
const scenes = rotation.Challenges.flatMap((ch) => sceneIdsOf(ch.Config))
expect(new Set(scenes).size, where).toBe(scenes.length)
for (const challenge of rotation.Challenges) {
// A malformed tree fails SILENTLY in the client — the challenge just never
// completes — so the shape is asserted here rather than discovered in game.
const tree = JSON.parse(challenge.Config) as { ct: number; t?: number }
expect([0, 1], `${where} ${challenge.Name}`).toContain(tree.ct)
if (tree.ct === 1) expect(tree.t, `${where} ${challenge.Name}`).toBeGreaterThan(0)
expect(sceneIdsOf(challenge.Config).length, `${where} ${challenge.Name}`).toBeGreaterThan(0)
// The copy is generated from the same inputs as the tree, so it can't drift — but a
// counter still has to say out loud how far it counts.
if (tree.ct === 1) expect(challenge.Description, where).toContain(String(tree.t))
expect(challenge.Tooltip.length, `${where} ${challenge.Name}`).toBeGreaterThan(0)
expect(challenge.Complete, `${where} ${challenge.Name}`).toBe(false)
}
}
})
/** Every `ct:7` scene id in a rule tree, however deep the tree nests them. */
function sceneIdsOf(config: string): string[] {
const scenes: string[] = []
const walk = (node: unknown): void => {
if (Array.isArray(node)) return node.forEach(walk)
if (node === null || typeof node !== 'object') return
const record = node as { ct?: number; vs?: Array<{ l?: string }> }
if (record.ct === 7)
for (const value of record.vs ?? []) if (value.l !== undefined) scenes.push(value.l)
for (const value of Object.values(record)) walk(value)
}
walk(JSON.parse(config))
return scenes
}
test('GET /api/storefronts/v1/adcarouselitems returns the carousel items', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v1/adcarouselitems`)
expect(res.status).toBe(200)
@@ -1860,7 +3106,7 @@ describe('econ endpoints', () => {
method: 'POST',
headers: { ...(await bearer('70')), 'Content-Type': 'application/json' },
body: JSON.stringify({
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
ChallengeMapId: String(weekly.ChallengeMapId),
ChallengeId: String(challenge.ChallengeId),
Config: challenge.Config,
// .NET's bool.ToString() — the capitalized string, which `Boolean("False")`
@@ -1870,7 +3116,7 @@ describe('econ endpoints', () => {
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
ChallengeMapId: weeklyChallenge.ChallengeMapId,
ChallengeMapId: weekly.ChallengeMapId,
ChallengeId: challenge.ChallengeId,
Config: challenge.Config,
Complete: false,
@@ -1893,7 +3139,7 @@ describe('econ endpoints', () => {
method: 'POST',
headers: { ...bearerHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
ChallengeMapId: String(weekly.ChallengeMapId),
ChallengeId: completedId,
Complete: 'True',
}),
@@ -1942,24 +3188,74 @@ describe('econ endpoints', () => {
expect(await completeOf(await post('18', 'True'))).toBe(true)
})
test('the reported Config is stored and served back over the static rule tree', async () => {
const challenge = CURRENT_CHALLENGE
const bearerHeaders = await bearer('78')
const headers = { ...bearerHeaders, 'Content-Type': 'application/json' }
// The client posts the catalog's tree with its own running count written into it —
// `cc` on the counter — which is the progress that has to survive the session.
const inProgress = challenge.Config.replace(/}$/, ',"cc":1}')
expect(inProgress).not.toBe(challenge.Config)
const post = (body: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
method: 'POST',
headers,
body: JSON.stringify({
ChallengeMapId: String(weekly.ChallengeMapId),
ChallengeId: String(challenge.ChallengeId),
...body,
}),
})
const reported = await post({ Config: inProgress, Complete: 'False' })
expect(await reported.json()).toEqual({
ChallengeMapId: weekly.ChallengeMapId,
ChallengeId: challenge.ChallengeId,
Config: inProgress,
Complete: false,
})
const configOf = async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`, {
headers: bearerHeaders,
})
const body = (await res.json()) as {
Challenges: Array<{ ChallengeId: number; Config: string }>
}
return body.Challenges.find((ch) => ch.ChallengeId === challenge.ChallengeId)?.Config
}
expect(await configOf()).toBe(inProgress)
// A report carrying no tree is not a reset — the stored progress stays, and is echoed.
const noConfig = await post({ Complete: 'False' })
expect(((await noConfig.json()) as { Config: string }).Config).toBe(inProgress)
expect(await configOf()).toBe(inProgress)
// Challenges this player never reported keep the authored tree, and so does everyone else.
const anon = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
const anonBody = (await anon.json()) as { Challenges: Array<{ Config: string }> }
expect(anonBody.Challenges.map((ch) => ch.Config)).toEqual(
weekly.Challenges.map((ch) => ch.Config)
)
})
/**
* How many of the rotation's challenges earn the gift three, unless the rotation
* publishes fewer or declares itself all-or-nothing (`CHALLENGES_REQUIRED_FOR_GIFT`).
*/
const REQUIRED_FOR_GIFT = weeklyChallenge.CompletedRequired
? weeklyChallenge.Challenges.length
: Math.min(3, weeklyChallenge.Challenges.length)
const REQUIRED_FOR_GIFT = weekly.CompletedRequired
? weekly.Challenges.length
: Math.min(3, weekly.Challenges.length)
/** Report the live rotation's challenges complete, for one player. */
async function finishTheRotation(sub: string) {
const headers = { ...(await bearer(sub)), 'Content-Type': 'application/json' }
const ids = weeklyChallenge.Challenges.map((challenge) => challenge.ChallengeId)
const ids = weekly.Challenges.map((challenge) => challenge.ChallengeId)
const report = (challengeId: number) =>
exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
method: 'POST',
headers,
body: JSON.stringify({
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
ChallengeMapId: String(weekly.ChallengeMapId),
ChallengeId: String(challengeId),
Complete: 'True',
}),
@@ -1967,6 +3263,16 @@ describe('econ endpoints', () => {
return { ids, report }
}
/**
* The reward this week advertises, read back from the route that shows it to the client.
* The gift is rolled from the storefront catalog rather than authored, so the assertion
* that matters is that the box a player receives carries what the rotation promised.
*/
async function advertisedGift() {
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
return ((await res.json()) as { Gift: Record<string, string & number> }).Gift
}
/** A player's unopened gift boxes, as the client reads them back. */
async function giftBoxes(sub: string) {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
@@ -1979,15 +3285,17 @@ describe('econ endpoints', () => {
AvatarItemDesc: string
ConsumableItemDesc: string
GiftRarity: number
GiftContext: number
}>
}
test('completing enough of the rotation grants its gift, once', async () => {
// The live rotation, so this follows whatever static/weekly-challenge.json holds.
// The live rotation, so this follows whatever this week generated.
const gift = await advertisedGift()
const { ids, report } = await finishTheRotation('74')
// The whole point of the threshold: the gift lands before the set is finished (the
// published week is five challenges for three).
expect(REQUIRED_FOR_GIFT).toBeLessThan(ids.length)
// The threshold can't ask for more than the week publishes: a five-challenge week asks
// for three, and a rotation of three or fewer asks for all of them.
expect(REQUIRED_FOR_GIFT).toBeLessThanOrEqual(ids.length)
for (const id of ids.slice(0, REQUIRED_FOR_GIFT - 1)) {
expect((await report(id)).status).toBe(200)
}
@@ -2000,7 +3308,7 @@ describe('econ endpoints', () => {
const won = await giftBoxes('74')
expect(won).toHaveLength(1)
expect(won[0]?.Message).toBe('Weekly challenge complete!')
expect(won[0]?.EquipmentModificationGuid).toBe(weeklyChallenge.Gift.EquipmentModificationGuid)
expect(won[0]?.EquipmentModificationGuid).toBe(gift.EquipmentModificationGuid)
// The client is told the moment the set is finished, rather than finding the box the
// next time it reads the gifts list. `Immediate` (31), from Coach (1).
@@ -2013,10 +3321,10 @@ describe('econ endpoints', () => {
FromGiftDropId: 0,
FromPlayerId: 1,
ConsumableItemDesc: '',
AvatarItemDesc: weeklyChallenge.Gift.AvatarItemDesc,
AvatarItemType: weeklyChallenge.Gift.AvatarItemType,
EquipmentPrefabName: weeklyChallenge.Gift.EquipmentPrefabName,
EquipmentModificationGuid: weeklyChallenge.Gift.EquipmentModificationGuid,
AvatarItemDesc: gift.AvatarItemDesc,
AvatarItemType: gift.AvatarItemType,
EquipmentPrefabName: gift.EquipmentPrefabName,
EquipmentModificationGuid: gift.EquipmentModificationGuid,
CurrencyType: 0,
Currency: 0,
Xp: 0,
@@ -2024,9 +3332,10 @@ describe('econ endpoints', () => {
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: -2,
GiftContext: weeklyChallenge.Gift.GiftContext,
// The catalog's rarity for the item, not the block's `GiftRarity` of 0.
GiftRarity: 5,
GiftContext: gift.GiftContext,
// The catalog's rarity for the item — which is also what the generated block carries,
// since the week's gift is drawn from the catalog itself.
GiftRarity: gift.GiftRarity,
Message: 'Weekly challenge complete!',
})
@@ -2035,9 +3344,7 @@ describe('econ endpoints', () => {
headers: await bearer('74'),
})
const owned = (await unlocked.json()) as Array<{ ModificationGuid: string }>
expect(owned.map((e) => e.ModificationGuid)).toContain(
weeklyChallenge.Gift.EquipmentModificationGuid
)
expect(owned.map((e) => e.ModificationGuid)).toContain(gift.EquipmentModificationGuid)
// Finishing the REST of the set, and re-reporting what's already done (which the client
// keeps doing), must not mint a second reward.
@@ -2047,10 +3354,11 @@ describe('econ endpoints', () => {
test('a player who already owns the rotations gift rolls the fallback box instead', async () => {
// Own the reward up front — the case the rotation's `FallbackGiftName` exists for.
const gift = await advertisedGift()
await grantEquipment(env.DB, 75, {
ModificationGuid: weeklyChallenge.Gift.EquipmentModificationGuid,
PrefabName: weeklyChallenge.Gift.EquipmentPrefabName,
FriendlyName: 'Camera Skin (Comic)',
ModificationGuid: gift.EquipmentModificationGuid,
PrefabName: gift.EquipmentPrefabName,
FriendlyName: 'The weeks reward, already owned',
Tooltip: '',
Rarity: 5,
PlatformMask: -1,
@@ -2069,9 +3377,7 @@ describe('econ endpoints', () => {
// Something they don't have, at the tier `FallbackGiftName` names ("4-Star Box" → 30),
// rather than a second copy of the gift.
const rolled = won[0]
expect(rolled?.EquipmentModificationGuid).not.toBe(
weeklyChallenge.Gift.EquipmentModificationGuid
)
expect(rolled?.EquipmentModificationGuid).not.toBe(gift.EquipmentModificationGuid)
expect(rolled?.GiftRarity).toBe(30)
expect(
(rolled?.AvatarItemDesc ?? '') !== '' || (rolled?.EquipmentModificationGuid ?? '') !== ''
@@ -2379,6 +3685,94 @@ describe('econ endpoints', () => {
expect(held.map((cons) => cons.ConsumableItemDesc)).toContain(consumableBox?.ConsumableItemDesc)
})
test('a giftContext naming a quest-rewards.json key pays one of that activitys rewards', async () => {
const request = async (body: string) =>
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST',
headers: {
...(await bearer('83')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
await drainFrames()
// Quest_Goblin_S: forty avatar-item rewards, all at the goblin quest's S-rank context.
const goblin = await request(
'rewardType=PostGameActivity&Message=Quest%20complete&giftContext=Quest_Goblin_S'
)
expect(goblin.status).toBe(200)
expect(await goblin.json()).toEqual([])
const boxes = await giftBoxes('83')
expect(boxes).toHaveLength(1)
const box = boxes[0]
expect(box).toMatchObject({ Xp: 5, Message: 'Quest complete', GiftContext: 4003 })
expect(box?.AvatarItemDesc).not.toBe('')
const row = questRewards.Quest_Goblin_S.find((r) => r.AvatarItemDesc === box?.AvatarItemDesc)
expect(row).toBeDefined()
expect(box?.GiftRarity).toBe(row?.GiftRarity)
// …and the item is in the inventory, not just on the box.
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
headers: await bearer('83'),
})
const owned = (await items.json()) as Array<{ avatarItemDesc: string }>
expect(owned.map((i) => i.avatarItemDesc)).toContain(box?.AvatarItemDesc)
// The box announces the activity's context, not the generic GameRewards one.
const frames = await drainFrames()
expect(frames[0]?.notificationType).toBe(NotificationType.GiftPackageReceivedImmediate)
expect(frames[0]?.payload).toMatchObject({
GiftContext: 4003,
AvatarItemDesc: box?.AvatarItemDesc,
})
// Lasertag's single reward is 50 Laser Tag tickets: credited to the balance, no item.
const before = await getBalance(
env.DB,
83,
CurrencyType.LaserTagTickets,
DEFAULT_STARTING_TOKENS
)
expect((await request('rewardType=PostGameActivity&giftContext=Lasertag')).status).toBe(200)
expect(
await getBalance(env.DB, 83, CurrencyType.LaserTagTickets, DEFAULT_STARTING_TOKENS)
).toBe(before + 50)
const ticketBox = (await giftBoxes('83'))[1]
expect(ticketBox).toMatchObject({
Currency: 50,
CurrencyType: CurrencyType.LaserTagTickets,
AvatarItemDesc: '',
GiftContext: 9000,
})
const ticketFrames = await drainFrames()
expect(ticketFrames.map((f) => f.notificationType)).toContain(
NotificationType.StorefrontBalanceUpdate
)
// An activity the table doesn't know pays the plain XP box, as before. (The LAST box:
// the two claims above also crossed level 1, and that level-up box sits in between.)
expect((await request('rewardType=PostGameActivity&giftContext=Bowling')).status).toBe(200)
const plain = (await giftBoxes('83')).at(-1)
expect(plain).toMatchObject({ Xp: 5, AvatarItemDesc: '', Currency: 0, GiftContext: 50 })
// A reward the player already owns is never drawn again: Dodgeball has three rows, so
// three claims hand over all three, and a fourth — nothing left to give — pays the
// plain XP box rather than a duplicate.
const dodgeball = questRewards.Dodgeball.map((r) => r.AvatarItemDesc)
const handed: string[] = []
for (let i = 0; i < 4; i++) {
await env.DB.prepare(
"DELETE FROM reward_status WHERE account_id = 83 AND gift_context = 'Dodgeball'"
).run()
expect((await request('rewardType=PostGameActivity&giftContext=Dodgeball')).status).toBe(200)
const latest = (await giftBoxes('83')).findLast(
(b) => b.GiftContext === 8000 || b.GiftContext === 50
)
if (i < 3) handed.push(latest?.AvatarItemDesc as string)
else expect(latest).toMatchObject({ AvatarItemDesc: '', GiftContext: 50 })
}
expect(handed.toSorted()).toEqual(dodgeball.toSorted())
})
test('POST /api/gamerewards/v1/request is 401 without a token, and ignores a typeless ask', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST',
@@ -2448,9 +3842,8 @@ describe('econ endpoints', () => {
{ headers: await bearer('207') }
)
expect(res.status).toBe(200)
// An object around the list, not a bare array. Note this is a 200 while its
// single-account sibling below answers 404 — "nobody is" is a complete answer to
// "who is?", where "are you?" is answered by the 404 itself.
// An object around the list, not a bare array — unlike its single-account siblings
// below, whose whole body is a bare tier number.
expect(await res.json()).toEqual({ InfluencerIds: [] })
})
@@ -2459,24 +3852,43 @@ describe('econ endpoints', () => {
expect(res.status).toBe(401)
})
test('GET /api/influencerpartnerprogram/influencer 404s with an empty JSON body', async () => {
test('GET /api/influencerpartnerprogram/influencer answers a bare 0', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/influencerpartnerprogram/influencer?accountId=220`,
{ headers: await bearer('206') }
)
expect(res.status).toBe(200)
// The tier is the WHOLE body — a bare number, not `{ Tier: 0 }` or a string. 0 is
// "not an influencer", which every account is here.
expect(res.headers.get('content-type')).toContain('application/json')
expect(await res.text()).toBe('0')
// Any account, the caller's own included, gets the same answer.
const self = await exports.default.fetch(
`${ORIGIN}/api/influencerpartnerprogram/influencer?accountId=206`,
{ headers: await bearer('206') }
)
// 404 IS the answer — "not an influencer" — and the body is empty, not `{}` or null,
// with the content type the reference sends.
expect(res.status).toBe(404)
expect(res.headers.get('content-type')).toContain('application/json')
expect(await res.text()).toBe('')
expect(await self.json()).toBe(0)
})
test('GET /api/influencerpartnerprogram/influencer 401s without a bearer token', async () => {
// Auth is checked before the 404, so an unauthenticated caller is told that, not that
// they aren't an influencer.
const res = await exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/influencer`)
expect(res.status).toBe(401)
expect(await res.text()).toBe('')
test('GET /api/influencerpartnerprogram/myinfluencer answers a bare 0', async () => {
// The `my` form takes the account from the token instead of a query parameter, and
// answers the same tier in the same shape.
const res = await exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/myinfluencer`, {
headers: await bearer('206'),
})
expect(res.status).toBe(200)
expect(await res.text()).toBe('0')
})
test('the influencer tier routes 401 without a bearer token', async () => {
// Auth is checked before anything is answered, so an unauthenticated caller is told
// that rather than handed a tier.
for (const path of ['influencer', 'myinfluencer']) {
const res = await exports.default.fetch(`${ORIGIN}/api/influencerpartnerprogram/${path}`)
expect(res.status, path).toBe(401)
expect(await res.text()).toBe('')
}
})
test('GET /api/makerai/checkfreetrialeligibility answers a bare false', async () => {
@@ -2506,7 +3918,7 @@ describe('econ endpoints', () => {
})
test('POST /api/CampusCard/v1/UpdateAndGetSubscription gives a developer a Gold year', async () => {
const res = await getSubscription(await bearer('205', ['gameClient', 'developer']))
const res = await getSubscription(await bearer('205', ['gameClient'], undefined, true))
expect(res.status).toBe(200)
const body = (await res.json()) as {
Subscription: Record<string, unknown>
@@ -2538,7 +3950,7 @@ describe('econ endpoints', () => {
})
test('POST /api/CampusCard/v1/UpdateAndGetSubscription is {} without the developer role', async () => {
// A plain player's token: valid, but no elevated role.
// A plain player's token: valid, no elevated role, and no `hasPlus` on the account.
expect(await (await getSubscription(await bearer('206', ['gameClient']))).json()).toEqual({})
// A token with no `role` claim at all.
expect(await (await getSubscription(await bearer('206'))).json()).toEqual({})
@@ -2548,6 +3960,56 @@ describe('econ endpoints', () => {
expect(await anon.json()).toEqual({})
})
// Plus reaches this worker as the token's `rn.plus` claim, which `auth` stamps from
// `account.hasPlus` at login. Nothing here reads the account, so this is the whole
// mechanism — and the reason a player who claims on the website has to sign in again.
//
// The token carries only `gameClient`, exactly as a game client's does.
test('POST /api/CampusCard/v1/UpdateAndGetSubscription honours the rn.plus claim', async () => {
const res = await getSubscription(await bearer('9208', ['gameClient'], undefined, true))
expect(res.status).toBe(200)
const body = (await res.json()) as { Subscription: Record<string, unknown> }
expect(body.Subscription).toMatchObject({
SubscriptionId: 1,
RecNetPlayerId: 9208,
PlatformType: -1,
Level: 0,
Period: 1,
IsAutoRenewing: true,
})
})
// The `developer` role used to BE the subscription, as a stand-in while nothing else
// could confer one. Now that Plus has a real source it is one thing with one source, and
// an elevated account is not a subscriber unless it also holds `rn.plus`. Pinned because
// nothing else would fail if the old shortcut came back: it would silently hand Plus (and
// the 10% discount) to every operator account.
test('the developer role alone is not a Rec Room Plus subscription', async () => {
const dev = await getSubscription(await bearer('9210', ['gameClient', 'developer']))
expect(dev.status).toBe(200)
expect(await dev.json()).toEqual({})
// …and it buys nothing at the subscriber price either, so the report and the buy path
// agree. 85 is the SubscriberPrices entry for sf300's 2263; 95 is the list price.
const discounted = await buy2263(await bearer('9211', ['gameClient', 'developer']), 85)
expect(discounted.status).toBe(409)
})
// Plus is priced, not just displayed: the same claim gates the subscriber discount band
// on a buy. A subscriber whose client applied the discount itself and then had the
// purchase refused as a price mismatch is exactly what one definition prevents, so the
// CampusCard report and the buy must never disagree.
test('an rn.plus token is charged the subscriber price', async () => {
const res = await buy2263(await bearer('9326', ['gameClient'], undefined, true), 85)
expect(res.status).toBe(200)
expect(((await res.json()) as { Balance: number }).Balance).toBe(-85)
// The same request without the claim is refused, so the discount really comes from
// `rn.plus` and not from the band being open to everyone.
const plain = await buy2263(await bearer('9327', ['gameClient']), 85)
expect(plain.status).toBe(409)
})
test('unknown path returns 404', async () => {
const res = await exports.default.fetch(`${ORIGIN}/nope`)
expect(res.status).toBe(404)
@@ -2592,6 +4054,7 @@ describe('econ endpoints', () => {
'GET /api/incentivizedreferrals/progress',
'GET /api/influencerpartnerprogram/influencer',
'GET /api/influencerpartnerprogram/influencers',
'GET /api/influencerpartnerprogram/myinfluencer',
'GET /api/itemWishlists/v1/wishlist/me',
'GET /api/itemWishlists/v1/wishlist/{accountId}',
'GET /api/makerai/checkfreetrialeligibility',
@@ -2618,6 +4081,7 @@ describe('econ endpoints', () => {
'GET /econ/roomOffer/room/{roomId}',
'GET /econ/roomOffer/room/{roomId}/purchaseCounts',
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
'POST /api/avatar/v1/lockeditems/bulk',
'POST /api/avatar/v2/gifts/consume',
'POST /api/avatar/v2/set',
'POST /api/avatar/v3/saved/set',
@@ -2626,11 +4090,15 @@ describe('econ endpoints', () => {
'POST /api/checklist/v1/complete',
'POST /api/checklist/v2/complete',
'POST /api/consumables/v1/consume',
'POST /api/equipment/v1/update',
'POST /api/gamerewards/v1/request',
'POST /api/items/bulkpurchase',
'POST /api/items/purchaseInfos',
'POST /api/objectives/v1/cleargroup',
'POST /api/objectives/v1/updateobjective',
'POST /api/storefronts/v2/buyItem',
'POST /api/storefronts/v3/buyInvention',
'POST /api/ugcPurchasables/v1/items/bulk',
'PUT /api/equipment/v1/update',
])
@@ -2641,3 +4109,525 @@ describe('econ endpoints', () => {
}
})
})
// The item catalog. Loaded by migration, not written at runtime, so these exercise the SHAPE of
// the table and its query helpers against a handful of hand-seeded rows; the drift test at the
// end is what pins the thousands of real ones.
describe('catalog', () => {
// One row of each kind, plus the cases that decided the schema: an avatar_item_id shared by
// two rows and absent from a third, and keys that are alpha strings rather than GUIDs.
//
// Starts from an EMPTY table: the suite-wide setup seeds skins for the weekly-challenge gift
// pool, and the exact counts and lists below are about these rows alone. This block is the
// last in the file, so clearing is safe.
beforeAll(async () => {
await env.DB.prepare('DELETE FROM catalog').run()
const insert = (row: unknown[]) =>
env.DB.prepare(
`INSERT INTO catalog (
item_key, catalog_id, kind, friendly_name, tooltip, rarity, platform_mask,
thumbnail_image, avatar_item_type, avatar_item_id, is_base_avatar_item, tag_list,
created_at, prefab_name, unlocked_level
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)`
)
.bind(...row)
.run()
// `catalog_id` is handed out by the loader as 1..N over the captures. These seeds number
// themselves the same way but from a base far above N, so a test that inserts a REAL
// capture row (which carries its own low id) cannot collide with a seed — the constraint
// under test should only ever fire on something the test meant to collide.
let nextId = 900_001
/** An avatar item: `item_key` is its `AvatarItemDesc`, the skin columns stay null. */
const avatarItem = (
desc: string,
name: string,
tooltip: string | null,
rarity: number,
type: number,
id: number | null,
tag: string | null,
createdAt: string | null,
thumb: string | null
) =>
insert([
desc,
nextId++,
'avatar_item',
name,
tooltip,
rarity,
-1,
thumb,
type,
id,
0,
tag,
createdAt,
null,
null,
])
/** A skin: `item_key` is its `ModificationGuid`, the avatar columns stay null. */
const skin = (guid: string, name: string, tooltip: string | null, prefab: string) =>
insert([
guid,
nextId++,
'skin',
name,
tooltip,
0,
-1,
'',
null,
null,
null,
null,
null,
prefab,
0,
])
await avatarItem(
'_OWVy3z6iU-M3-zbQgSLig,,,',
'Vampire Hunter Gloves (Blue)',
// NULL, not '' — the two are different values in the capture and the client's DTO serves
// the difference through.
null,
10,
0,
835,
null,
'2018-11-01T17:51:50.733Z',
'cimomkml6k4toyowd1voh7hqm.png'
)
await avatarItem(
'002a0f2f-1a24-4439-b578-470818ef8325,,,',
'Turkey Sweater',
'',
50,
0,
1570,
'thanksgiving',
'2020-10-28T00:37:27.263Z',
'4g2r02n1g5w09re7hl1ba2yyi.png'
)
// These two share avatar_item_id 9503 — the reason that column keys nothing.
await avatarItem(
'c5010738-41fa-4eca-aeac-e24adaa29789,',
'Helmet Hair',
'',
-1,
0,
9503,
null,
null,
null
)
await avatarItem(
'60067e91-18b8-43ab-ae20-a8ea74c757bf,KUAMuM41hk-YLZoqTiKncA',
'Green Cheer Sash',
'',
-1,
0,
9503,
null,
null,
null
)
// A hair dye: AvatarItemType 1, NO avatar_item_id at all, and a desc that is a bare alpha
// string with no commas. Still perfectly keyed.
await avatarItem(
'pQNfh-3DsEGWfiIls6Qf6g',
'Permanent Hair Dye (Pirate Gold)',
'',
0,
1,
null,
null,
null,
null
)
await skin('19ef59c7-f74b-4c63-935a-1d4b1abd8518', 'Disc (Coop)', '', '[DiscGolfDisc]')
// An alpha-string key, from before the game moved to GUIDs.
await skin('bfrFOdnHzEaIwHqem2dXkg', 'Confetti Gun (Gold)', null, '[PaintballGun] Confetti')
})
test('an avatar item reads back by its desc, nullable fields intact', async () => {
const item = await getAvatarItem(env.DB, '_OWVy3z6iU-M3-zbQgSLig,,,')
expect(item).toEqual({
AvatarItemDesc: '_OWVy3z6iU-M3-zbQgSLig,,,',
AvatarItemType: 0,
PlatformMask: -1,
FriendlyName: 'Vampire Hunter Gloves (Blue)',
// The one the column may never be defaulted to '' for.
Tooltip: null,
Rarity: 10,
TagList: null,
AvatarItemId: 835,
IsBaseAvatarItem: false,
CreatedAt: '2018-11-01T17:51:50.733Z',
ThumbnailImage: 'cimomkml6k4toyowd1voh7hqm.png',
})
expect(await getAvatarItem(env.DB, 'nothing-has-this-desc')).toBeNull()
})
test('a skin reads back by its guid, and an alpha-string key is just as good', async () => {
expect(await getSkin(env.DB, '19ef59c7-f74b-4c63-935a-1d4b1abd8518')).toEqual({
PrefabName: '[DiscGolfDisc]',
ModificationGuid: '19ef59c7-f74b-4c63-935a-1d4b1abd8518',
UnlockedLevel: 0,
// The catalog does not store this: it is a PLAYER's flag, and the capture recorded one
// account's. It is overwritten from the player's own `equipment` row.
Favorited: false,
PlatformMask: -1,
FriendlyName: 'Disc (Coop)',
Tooltip: '',
Rarity: 0,
ThumbnailImage: '',
})
// 191 of the skins are keyed by the short alpha-string ids the game used before GUIDs. The
// column is TEXT and compared as text, so these need no special handling — which is exactly
// why nothing here parses or validates a key's shape.
const gold = await getSkin(env.DB, 'bfrFOdnHzEaIwHqem2dXkg')
expect(gold?.FriendlyName).toBe('Confetti Gun (Gold)')
// Skins carry NULL tooltips too, so the projection must not flatten them to ''.
expect(gold?.Tooltip).toBeNull()
expect((await getSkinsForPrefab(env.DB, '[DiscGolfDisc]')).map((s) => s.FriendlyName)).toEqual([
'Disc (Coop)',
])
expect(await getSkin(env.DB, 'no-such-guid')).toBeNull()
})
test('one key spans both kinds, and asking for the wrong kind gets null, not a mangled row', async () => {
// The lookup the inventory wants: a player's owned things are ids of exactly this shape and
// the row says which kind each turned out to be. No join, no guessing.
const owned = await getCatalogItems(env.DB, [
'19ef59c7-f74b-4c63-935a-1d4b1abd8518',
'_OWVy3z6iU-M3-zbQgSLig,,,',
// Unknown keys are SKIPPED rather than left as holes, so the result is shorter than the
// input and must never be read positionally.
'nothing-owns-this',
])
expect(owned.map((r) => [r.kind, r.friendly_name])).toEqual([
['skin', 'Disc (Coop)'],
['avatar_item', 'Vampire Hunter Gloves (Blue)'],
])
expect(await getCatalogItems(env.DB, [])).toEqual([])
// A key is unique across BOTH kinds, so a typed accessor handed the other kind's key answers
// null rather than projecting a skin into an avatar item's shape.
expect(await getAvatarItem(env.DB, '19ef59c7-f74b-4c63-935a-1d4b1abd8518')).toBeNull()
expect(await getSkin(env.DB, '_OWVy3z6iU-M3-zbQgSLig,,,')).toBeNull()
expect(() => toCatalogSkin({ ...(owned[1] as CatalogRow) })).toThrow(/not a skin/)
})
test('avatar_item_id is carried as data and keys nothing', async () => {
// Two seeded rows share 9503 (five real ones do), and the hair dye has no id at all. Keying
// the table on it would have silently dropped four of those five at load time, which is why
// it is stored, not indexed, and never looked up by.
const shared = await searchCatalog(env.DB, CatalogKind.AvatarItem, 'a')
expect(shared.filter((r) => r.avatar_item_id === 9503)).toHaveLength(2)
const dye = await getAvatarItem(env.DB, 'pQNfh-3DsEGWfiIls6Qf6g')
expect(dye?.AvatarItemId).toBeNull()
expect(dye?.AvatarItemType).toBe(1)
})
test('search is case-insensitive, scoped to one kind, and takes wildcards literally', async () => {
const hits = await searchCatalog(env.DB, CatalogKind.AvatarItem, 'HAIR')
expect(hits.map((r) => r.friendly_name)).toEqual([
'Helmet Hair',
'Permanent Hair Dye (Pirate Gold)',
])
// `kind` scopes it: the same needle against skins finds nothing, so a skin search can never
// surface a wearable.
expect(await searchCatalog(env.DB, CatalogKind.Skin, 'hair')).toEqual([])
expect(
(await searchCatalog(env.DB, CatalogKind.Skin, 'disc')).map((r) => r.prefab_name)
).toEqual(['[DiscGolfDisc]'])
// A needle of LIKE metacharacters matches them literally rather than everything — the escape
// is what stops a player typing `%` from pulling the whole catalog back.
expect(await searchCatalog(env.DB, CatalogKind.AvatarItem, '%')).toEqual([])
expect(await searchCatalog(env.DB, CatalogKind.AvatarItem, '_')).toEqual([])
// A hit that really does contain the character still matches, so escaping didn't break it.
expect((await searchCatalog(env.DB, CatalogKind.AvatarItem, 'cheer')).length).toBe(1)
})
test('seasonal rows come back by tag, and counts are per kind', async () => {
expect((await getAvatarItemsByTag(env.DB, 'thanksgiving')).map((i) => i.FriendlyName)).toEqual([
'Turkey Sweater',
])
expect(await getAvatarItemsByTag(env.DB, 'halloween')).toEqual([])
expect(await countCatalog(env.DB)).toEqual({ avatar_item: 5, skin: 2 })
})
test('the key is unique across both kinds, so a collision is refused', async () => {
// A second avatar item with the same desc...
await expect(
env.DB.prepare(
`INSERT INTO catalog (item_key, kind, friendly_name, rarity, platform_mask)
VALUES ('_OWVy3z6iU-M3-zbQgSLig,,,', 'avatar_item', 'Impostor', 0, -1)`
).run()
).rejects.toThrow()
// ...a second skin with the same guid...
await expect(
env.DB.prepare(
`INSERT INTO catalog (item_key, kind, friendly_name, rarity, platform_mask, prefab_name)
VALUES ('bfrFOdnHzEaIwHqem2dXkg', 'skin', 'Impostor', 0, -1, '[PaintballGun]')`
).run()
).rejects.toThrow()
// ...and a skin claiming an avatar item's key. The kinds share ONE key space, which is what
// lets an owned id be resolved without first knowing what kind of thing it is.
await expect(
env.DB.prepare(
`INSERT INTO catalog (item_key, kind, friendly_name, rarity, platform_mask, prefab_name)
VALUES ('_OWVy3z6iU-M3-zbQgSLig,,,', 'skin', 'Impostor', 0, -1, '[MakerPen]')`
).run()
).rejects.toThrow()
expect(await countCatalog(env.DB)).toEqual({ avatar_item: 5, skin: 2 })
})
test('baseAsset takes the first field of an AvatarItemDesc', async () => {
// `<baseAsset>,<color>,<texture>,` — the base asset is what decides whether the client can
// draw an item at all, so it is read off the key rather than stored twice.
expect(baseAsset('_OWVy3z6iU-M3-zbQgSLig,,,')).toBe('_OWVy3z6iU-M3-zbQgSLig')
expect(baseAsset('60067e91-18b8-43ab-ae20-a8ea74c757bf,KUAMuM41hk-YLZoqTiKncA')).toBe(
'60067e91-18b8-43ab-ae20-a8ea74c757bf'
)
// A dye's desc is a bare alpha string with no commas at all; it is still the base asset.
expect(baseAsset('pQNfh-3DsEGWfiIls6Qf6g')).toBe('pQNfh-3DsEGWfiIls6Qf6g')
})
// The migration builds the TABLE; `runx catalog load` fills it. These two are the seam
// between them: the schema the tests build must be the schema the migration builds, and the
// loader must produce rows that schema accepts.
test('the migrations and CATALOG_SCHEMA_DDL build the same table', async () => {
// Both migrations together: 0015 builds the table, 0016 adds `catalog_id`. They are
// separate because 0015 was already applied, and an edit there would never re-run — which
// is exactly the drift this test exists to catch. `CATALOG_SCHEMA_DDL` declares the end
// state in one CREATE, so it is compared against the pair.
const migrations = `${catalogStructureSql}\n${catalogIdSql}`
// Compared on identifiers rather than text, since the two are formatted differently, and
// `catalog_id` arrives via ALTER rather than inside the CREATE.
for (const column of CATALOG_INSERT_COLUMNS) {
expect(migrations, column).toContain(column)
expect(CATALOG_SCHEMA_DDL[0], column).toContain(`\t\t${column} `)
}
expect(catalogStructureSql).toContain('item_key TEXT PRIMARY KEY')
expect(CATALOG_SCHEMA_DDL[0]).toContain('item_key TEXT PRIMARY KEY')
expect(catalogIdSql).toContain('ALTER TABLE catalog ADD COLUMN catalog_id INTEGER')
for (const index of [
'idx_catalog_name',
'idx_catalog_prefab',
'idx_catalog_tag',
'idx_catalog_id',
]) {
expect(migrations, index).toContain(index)
expect(CATALOG_SCHEMA_DDL.join('\n'), index).toContain(index)
}
// STRUCTURE ONLY. The catalog's contents change as the game's item list does, which is not
// a schema change — rows here would mean a migration and a deploy per refresh. If this
// fails, someone put data back into a migration instead of reloading it.
expect(migrations).not.toContain('INSERT INTO catalog')
expect(migrations).not.toContain('DELETE FROM catalog')
})
test('the loader maps both captures onto the columns it declares', async () => {
const { rows, collisions } = buildCatalogLoad(avatarItemsJson, skinsJson)
// Every row carries exactly one value per declared column, in that order — the loader
// renders them positionally, so a column added to one side and not the other is a silent
// mis-load rather than an error.
expect(rows.every((r) => r.values.length === CATALOG_INSERT_COLUMNS.length)).toBe(true)
const keyAt = CATALOG_INSERT_COLUMNS.indexOf('item_key')
const kindAt = CATALOG_INSERT_COLUMNS.indexOf('kind')
expect(rows.every((r) => r.values[keyAt] === r.key)).toBe(true)
// One key space, both kinds, no collisions between them.
expect(new Set(rows.map((r) => r.key)).size).toBe(rows.length)
const kinds = rows.map((r) => r.values[kindAt])
expect(kinds.filter((k) => k === 'avatar_item')).toHaveLength(avatarItemsJson.length)
// Skins are the one place the counts may legitimately differ: the capture holds five guids
// twice. A repeat is a defect rather than something the table models, so the loader keeps
// the first and RETURNS the rest for the caller to report — dropping them silently is the
// exact failure the single key exists to prevent.
const distinctSkinKeys = new Set(skinsJson.map((s) => s.ModificationGuid)).size
expect(kinds.filter((k) => k === 'skin')).toHaveLength(distinctSkinKeys)
expect(collisions).toHaveLength(skinsJson.length - distinctSkinKeys)
expect(collisions.every((c) => c.kept !== c.dropped)).toBe(true)
// And the rows really do go in: the same table these tests built accepts a sample of the
// real load unchanged, so a capture that would be rejected in production fails here. Rows
// this file already seeded are skipped — they are real capture rows too, and re-inserting
// one would trip the key constraint on the seed rather than on anything under test.
const sample: CatalogLoadRow[] = []
for (const row of [rows[0], rows[1], rows[rows.length - 2], rows[rows.length - 1]]) {
if (row && (await getCatalogItem(env.DB, row.key)) === null) sample.push(row)
}
expect(sample.length).toBeGreaterThan(0)
for (const row of sample) {
await env.DB.prepare(
`INSERT INTO catalog (${CATALOG_INSERT_COLUMNS.join(', ')})
VALUES (${CATALOG_INSERT_COLUMNS.map((_, i) => `?${i + 1}`).join(', ')})`
)
.bind(...row.values.map((v) => v ?? null))
.run()
expect((await getCatalogItem(env.DB, row.key))?.friendly_name).toBe(
row.values[CATALOG_INSERT_COLUMNS.indexOf('friendly_name')]
)
await env.DB.prepare('DELETE FROM catalog WHERE item_key = ?1').bind(row.key).run()
}
// The row the capture had a skin pasted over. It is an avatar item, and the skin that
// overwrote its name lives in skins.json where it belongs.
expect(avatarItemsJson.filter((i) => i.FriendlyName === 'Disc (Coop)')).toEqual([])
expect(skinsJson.filter((s) => s.FriendlyName === 'Disc (Coop)')).toHaveLength(1)
})
test('catalog_id is a contiguous, unique, load-order handle from 10000', async () => {
const { rows } = buildCatalogLoad(avatarItemsJson, skinsJson)
// BASE..BASE+N-1 with no gaps, in capture order — avatar items first, then skins. Numbered
// AFTER de-duplication, so a dropped duplicate must not burn a number and leave a hole.
//
// From 10000 rather than 1 because a generated storefront lists a row under this very
// number as its `PurchasableItemId`, and every captured storefront's ids are 2764 or below
// — numbering from 1 would have made one id mean two different items.
expect(rows.map((r) => r.id)).toEqual(rows.map((_, i) => CATALOG_ID_BASE + i))
expect(Math.min(...rows.map((r) => r.id))).toBe(CATALOG_ID_BASE)
expect(new Set(rows.map((r) => r.id)).size).toBe(rows.length)
// The id in the row object and the id in the values it renders are the same number — the
// loader binds `values` positionally, so a mismatch would write one and report the other.
const idAt = CATALOG_INSERT_COLUMNS.indexOf('catalog_id')
expect(rows.every((r) => r.values[idAt] === r.id)).toBe(true)
// It reads back by number, and the number is NOT the item's identity: `item_key` is. A
// caller that stored an id across a load would resolve to a different item or to nothing,
// which is why nothing may persist it.
const seeded = await getCatalogItemById(env.DB, 900_001)
expect(seeded?.item_key).toBe('_OWVy3z6iU-M3-zbQgSLig,,,')
expect(await getCatalogItemById(env.DB, 12_345_678)).toBeNull()
// Unique where set. Two rows may not share a handle — a number that names two items is
// useless as a handle.
await expect(
env.DB.prepare(
`INSERT INTO catalog (item_key, catalog_id, kind, friendly_name, rarity, platform_mask)
VALUES ('id-collision-probe', 900001, 'skin', 'Impostor', 0, -1)`
).run()
).rejects.toThrow()
// But NULL is allowed any number of times: the index is partial, because a row is
// un-numbered in the window between existing and a load numbering it, and the loader
// clears every id before handing out new ones so a merge cannot collide with stale ones.
for (const key of ['unnumbered-a', 'unnumbered-b']) {
await env.DB.prepare(
`INSERT INTO catalog (item_key, kind, friendly_name, rarity, platform_mask)
VALUES (?1, 'skin', 'Not Yet Numbered', 0, -1)`
)
.bind(key)
.run()
}
expect((await getCatalogItem(env.DB, 'unnumbered-a'))?.catalog_id).toBeNull()
expect((await getCatalogItem(env.DB, 'unnumbered-b'))?.catalog_id).toBeNull()
await env.DB.prepare("DELETE FROM catalog WHERE item_key LIKE 'unnumbered-%'").run()
})
// `runx catalog load` MERGES by default so a partial capture can add a few items without
// wiping the rest, and REPLACES only when told to. Both halves of that live in the CLI's SQL,
// so this exercises the upsert itself — the CLI's own statement, built from the same column
// list, against the same schema.
//
// MUST STAY LAST in this block: the replace half empties the table, including the rows the
// other catalog tests are seeded with.
test('a merge inserts, refreshes and preserves; a replace removes', async () => {
const columns = CATALOG_INSERT_COLUMNS.join(', ')
const binds = CATALOG_INSERT_COLUMNS.map((_, i) => `?${i + 1}`).join(', ')
// Every column but the conflict target, derived from the column list exactly as the CLI
// derives it.
const conflictUpdate = CATALOG_INSERT_COLUMNS.filter((c) => c !== 'item_key')
.map((c) => `${c} = excluded.${c}`)
.join(', ')
/** The CLI's statement: a full-width insert that upserts on the key. */
const upsert = (values: CatalogValue[]) =>
env.DB.prepare(
`INSERT INTO catalog (${columns}) VALUES (${binds})
ON CONFLICT(item_key) DO UPDATE SET ${conflictUpdate}`
)
.bind(...values.map((v) => v ?? null))
.run()
/** A skin row in column order, so a column added to the table lands here too. */
const skinValues = (key: string, name: string, rarity: number): CatalogValue[] =>
CATALOG_INSERT_COLUMNS.map((c) =>
c === 'item_key'
? key
: c === 'kind'
? CatalogKind.Skin
: c === 'friendly_name'
? name
: c === 'rarity'
? rarity
: c === 'platform_mask'
? -1
: c === 'prefab_name'
? '[MakerPen]'
: null
)
// A row nothing in a later load will mention — the one that proves a merge is not a wipe.
await upsert(skinValues('untouched-by-any-load', 'Hand-Added Sentinel', 0))
// Insert: a key the table has never seen.
await upsert(skinValues('merge-test-new', 'Freshly Datamined', 7))
expect((await getCatalogItem(env.DB, 'merge-test-new'))?.friendly_name).toBe(
'Freshly Datamined'
)
// Refresh: the SAME key again with different values updates in place rather than either
// erroring on the key or piling up a second row.
const before = await countCatalog(env.DB)
await upsert(skinValues('merge-test-new', 'Renamed By Refresh', 42))
const refreshed = await getCatalogItem(env.DB, 'merge-test-new')
expect(refreshed?.friendly_name).toBe('Renamed By Refresh')
expect(refreshed?.rarity).toBe(42)
expect(await countCatalog(env.DB)).toEqual(before)
// Preserve: neither of those touched the sentinel. This is the whole point of the default
// — a capture holding two items must not delete the other three thousand.
expect((await getCatalogItem(env.DB, 'untouched-by-any-load'))?.friendly_name).toBe(
'Hand-Added Sentinel'
)
// Every column but the key is carried by the refresh. Derived rather than written out, so
// a column added to the table and forgotten would silently stop being merged.
for (const column of CATALOG_INSERT_COLUMNS) {
expect(conflictUpdate.includes(`${column} = excluded.${column}`), column).toBe(
column !== 'item_key'
)
}
// Replace: `DELETE FROM catalog` first, and the sentinel goes with everything else. That is
// why it is opt-in — pointed at a partial capture it removes whatever the file omits.
const { rows } = buildCatalogLoad(avatarItemsJson, skinsJson)
await env.DB.prepare('DELETE FROM catalog').run()
await upsert((rows[0] as CatalogLoadRow).values)
expect(await getCatalogItem(env.DB, 'untouched-by-any-load')).toBeNull()
expect(await getCatalogItem(env.DB, 'merge-test-new')).toBeNull()
expect(await countCatalog(env.DB)).toEqual({ avatar_item: 1 })
})
})

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