mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
Compare commits
116 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7add875223 | |||
| 548089a3ec | |||
| f681af34fa | |||
| cdb17f5284 | |||
| db01d2bedd | |||
| 629a702403 | |||
| 261ff21e0d | |||
| 438475e326 | |||
| 87a1cd6b55 | |||
| 9a8fc1cab5 | |||
| 222547ee13 | |||
| 9696c56317 | |||
| a2946fb9f4 | |||
| acf70a083d | |||
| e0f802cee5 | |||
| 3d7ea3cf27 | |||
| 4bc5f51b9c | |||
| fef6754aad | |||
| 30b441aa65 | |||
| d441181b03 | |||
| 20196361c8 | |||
| bc12bb07d4 | |||
| 010c83d3ff | |||
| 36f8b01f83 | |||
| 6adb3ab741 | |||
| 9c63f077b8 | |||
| 2af5d5089d | |||
| cae0ebd4ae | |||
| ab623c376b | |||
| 1e26082f2e | |||
| 7d20f96414 | |||
| 99417e052e | |||
| 8260c5abcd | |||
| 740e9efa09 | |||
| 5928e07509 | |||
| 45a07c6ba5 | |||
| 3be2066526 | |||
| 8e452f23eb | |||
| 02c965c2ee | |||
| e07c52ced7 | |||
| 5aea5a4cf9 | |||
| a620cadde0 | |||
| 750413f240 | |||
| 77acb008b1 | |||
| 1c586e9b20 | |||
| 351fc594ac | |||
| c13f62bb91 | |||
| f8ca01dd4d | |||
| a169cf3e6e | |||
| 5e138a0646 | |||
| 2d2e2f44f3 | |||
| dcedf11c3d | |||
| 299f369925 | |||
| 0f36db2a7f | |||
| 062ed452a5 | |||
| e2ced3e797 | |||
| 35350ffa19 | |||
| 19e1d48807 | |||
| d31e05d41a | |||
| dbfc116808 | |||
| b848e34b71 | |||
| 4ca0e42cef | |||
| a12ff24068 | |||
| 7ef949bfcf | |||
| 199c34d1cb | |||
| 4ff9609a8f | |||
| 4bacdb0312 | |||
| da62d7138d | |||
| 751e1f28c2 | |||
| c5421539c3 | |||
| 7275176734 | |||
| 7c3f2a36cd | |||
| f6d92ec1e1 | |||
| 5cb2cbf967 | |||
| 599730379c | |||
| 1d8399f64a | |||
| 6a9a1a95b5 | |||
| 3b5e47c39d | |||
| f9ffbcd374 | |||
| bddd59ec1a | |||
| 39ac005adc | |||
| db9f2d292b | |||
| 5382bf96e3 | |||
| b8d5103d57 | |||
| 55ac92670f | |||
| b355b6cea9 | |||
| 0f281db32e | |||
| f1e960f950 | |||
| 6e8b91d503 | |||
| be24e62f1f | |||
| 1a2643240b | |||
| 8c5de75eed | |||
| 6fd8d6074c | |||
| dcaff859d0 | |||
| 0699e4409e | |||
| 655c7ee4d6 | |||
| 062c07cc33 | |||
| 3e59ebeaa8 | |||
| 879d8e95ab | |||
| e47ce58db3 | |||
| 038e3b3c50 | |||
| 663fdaf857 | |||
| cf1f09a22e | |||
| b60e422580 | |||
| 44be2e071d | |||
| 5db02db172 | |||
| f678f87d00 | |||
| e7eba28023 | |||
| c1850e9fbc | |||
| f2f3c7badc | |||
| 6add3437cf | |||
| 6916ed2557 | |||
| ec808d3cd8 | |||
| bf4389f480 | |||
| 244b3bca70 | |||
| 178d3b5b0e |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<img width="1063" height="409" alt="image" src="https://github.com/user-attachments/assets/521d5b11-fb93-4900-9158-71d51d2343ae" />
|
||||
|
||||

|
||||

|
||||
|
||||
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
@@ -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 room’s scene — read & create (D1) |
|
||||
| RoomieIntegrations | `roomieintegrations` | — | Not yet implemented |
|
||||
| Rooms | `rooms` | `rooms` | Room storage & queries; seeds the Dorm & Orientation rooms (D1) |
|
||||
| Storage | `storage` | — | Room uploader |
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)'),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 room’s 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 caller’s 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 .NET’s `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 .NET’s `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 caller’s 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
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 cheer’s ' +
|
||||
'effect comes from the caller’s 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 client’s 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('3–24 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 client’s 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 event’s tag names'),
|
||||
Tags: z
|
||||
.union([z.array(z.string()), z.array(z.object({ Tag: z.string(), Type: z.int() }))])
|
||||
.describe(
|
||||
'The event’s 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 event’s 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 0–4'
|
||||
),
|
||||
})
|
||||
|
||||
/** `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 event’s 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 item’s 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 ban’s 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 ban’s 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 client’s default block-screen text stands'),
|
||||
BottomMessageOverride: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('Always null — the client’s 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 caller’s 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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 store’s 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 item’s 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 server’s 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 item’s 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 Coach’s 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 client’s ' +
|
||||
'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 creator’s 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` worker’s `customAvatarItems/v1/owned`.',
|
||||
'The items an account has authored, newest first, in the same page shape as the ' +
|
||||
'`econ` worker’s `customAvatarItems/v1/owned`. Published items only — unless the ' +
|
||||
'bearer token is the creator’s, 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 creator’s 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 caller’s 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 payload’s heavy fields are the ' +
|
||||
'client’s 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 caller’s 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 caller’s 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 caller’s 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 caller’s 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 caller’s 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 screen’s 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 screen’s initial ' +
|
||||
'request. Tags are NOT searched: a `#tag` term from the browse screen’s filter ' +
|
||||
'chips is treated as text and matches nothing.',
|
||||
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 row’s `reported_player_id` is the ' +
|
||||
'item’s CREATOR, read from the item. The body’s `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 isn’t 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 item’s 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 caller’s cheer state and resyncs the invention’s `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 row’s ' +
|
||||
'`reported_player_id` is the invention’s CREATOR — who a moderator would act ' +
|
||||
'against — read from the invention rather than sent by the client. Nothing fills ' +
|
||||
'`room_id`: an invention isn’t 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 v6’s ' +
|
||||
'`{ 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 isn’t 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 creator’s 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 invention’s 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 isn’t a thing the client can draw: it is held to the same 3–24 ' +
|
||||
'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 else’s 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 doesn’t give it ' +
|
||||
'away; every field but `InventionId` is nullable and an omitted one keeps what ' +
|
||||
'the invention has.\n\n' +
|
||||
'Publishing is not undone here, and re-publishing doesn’t re-date the first ' +
|
||||
'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 else’s 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 else’s 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()
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
@@ -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 room’s page — soonest first. A ' +
|
||||
'bare array of the client’s 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 room’s 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 client’s 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 event’s 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 doesn’t ' +
|
||||
'transfer and RSVPs aren’t 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 event’s 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 event’s 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 event’s 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 event’s accessibility',
|
||||
description:
|
||||
'Sets an event’s visibility. The client sends the `RoomAccessibility` NAME here ' +
|
||||
'(`accessibility=Unlisted`), the way it does on the subroom route in `rooms` — not ' +
|
||||
'the ordinal the event’s 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 event’s 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 event’s tags',
|
||||
description:
|
||||
'Replaces an event’s 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 event’s 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 event’s description',
|
||||
description:
|
||||
'Rewrites an event’s 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 can’t be a no-op.\n\n' +
|
||||
'Capped at 512 characters, the stored length, and refused rather than truncated: ' +
|
||||
'silently cutting a player’s 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 event’s 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 event’s 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',
|
||||
|
||||
@@ -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 Pen’s 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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 server’s 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 block’s span as the pair the client reads ' +
|
||||
'them as: `TimeoutStartedAt` is the report’s `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 caller’s own account is consulted, ' +
|
||||
'not the ban-evasion arms.\n\n' +
|
||||
'Everyone else gets the reference server’s 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 stub’s nine that the 2025 client’s 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 caller’s 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 Message’s `Data` is a string on the wire, and an object there fails the ' +
|
||||
"client’s 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 instance’s 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 instance’s 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(
|
||||
|
||||
@@ -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 player’s reputation',
|
||||
description:
|
||||
'The cheer counters shown on a player’s 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 player’s 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 player’s 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 caller’s 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 caller’s `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',
|
||||
|
||||
@@ -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 caller’s 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` isn’t — ' +
|
||||
'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.
|
||||
|
||||
@@ -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 = '*'
|
||||
|
||||
|
||||
@@ -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 giver’s 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 can’t 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 account’s 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 aren’t 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 creator’s 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 creator’s 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 creator’s 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 buyer’s 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 caller’s 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 item’s 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 creator’s 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 creator’s 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 room’s 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 room’s 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 report’s 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 caller’s 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 room’s 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',
|
||||
])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 Fool’s"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 operator’s `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 operator’s `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 one’s 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(
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 account’s 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 account’s 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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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 client’s 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 envelope’s',
|
||||
'`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 sender’s 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 caller’s party thread (stub)',
|
||||
summary: 'The caller’s 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 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 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 else’s.',
|
||||
'',
|
||||
'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 else’s',
|
||||
'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 caller’s 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 client’s 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 caller’s `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>()
|
||||
'you’re part of. Answers a bare ChatResult rather than an HTTP status, as the reference',
|
||||
'does: 3 when the caller isn’t a member (which doubles as "no such thread", keeping a',
|
||||
'thread’s 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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
@@ -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(' ')
|
||||
),
|
||||
|
||||
@@ -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 caller’s 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 party’s 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 isn’t 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 doesn’t 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 won’t 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 isn’t 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 doesn’t 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 client’s 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 envelope’s 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 aren’t 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
@@ -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. */
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
])
|
||||
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"compatibility_date": "2026-06-16",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"upload_source_maps": true,
|
||||
"cache": {
|
||||
"enabled": true
|
||||
},
|
||||
"observability": {
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
'catalogue’s order rather than the query’s, 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',
|
||||
'doesn’t 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,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,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
|
||||
}
|
||||
|
||||
@@ -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,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\"]}"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -23,4 +23,4 @@
|
||||
"sourceMetadata": "Discovery.PageSource.PlayLibrary",
|
||||
"displayMetadata": "{\"DisplayTitle\":\"Library\", \"unsupportedPlatforms\":[]}"
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -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 +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}"
|
||||
}
|
||||
]
|
||||
[]
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
]
|
||||
@@ -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\"}"
|
||||
}
|
||||
]
|
||||
@@ -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\"}"
|
||||
}
|
||||
]
|
||||
@@ -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
@@ -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`)
|
||||
|
||||
Vendored
+8
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]))
|
||||
}
|
||||
@@ -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
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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 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.
|
||||
*/
|
||||
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 reference’s 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 caller’s owned equipment, matched by',
|
||||
'`ModificationGuid`. Everything else in each entry is ignored, and a guid the caller',
|
||||
'doesn’t own is silently skipped. Empty body on success.',
|
||||
'doesn’t 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 storefront’s 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 caller’s 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 client’s `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',
|
||||
'buyer’s 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 bag’s 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 that’s 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 client’s',
|
||||
'`RequestedPrice` still matches the invention’s 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 save’s',
|
||||
'`{ Value, Success, Error, error_id }` (its `InventionVersion` and `TagsResponse` null,',
|
||||
'since a buy mints neither) and the balance half names its bucket `Platform`, not v2’s',
|
||||
'`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 v2’s 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 challenge’s `Complete` stamped from the',
|
||||
'caller’s progress rows. Auth is optional — unauthenticated callers get the static',
|
||||
'catalog with every `Complete` false.',
|
||||
'This week’s rotation — generated from the calendar week — with each challenge’s',
|
||||
'`Complete` and `Config` stamped from the caller’s progress rows, the stored `Config`',
|
||||
'carrying the client’s 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 player’s 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 activity’s 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 caller’s Rec Room Plus subscription. Nothing sells subscriptions here, so the',
|
||||
'operator-granted `developer` role stands in for one: a developer’s 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 caller’s 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 token’s `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 caller’s influencer partner program status',
|
||||
summary: 'An account’s 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 account’s 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 caller’s influencer partner program tier',
|
||||
description: [
|
||||
'The caller’s own partner tier — the `my` form of the route above, taking the account',
|
||||
'from the token rather than a query parameter. A BARE NUMBER, always `0`: this server',
|
||||
'runs no partner program. Auth-gated; a missing or invalid token is a 401.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(InfluencerTierResponse, 'The caller’s 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)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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 account’s 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 client’s `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 player’s 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 activity’s table'
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 storefront’s 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 client’s 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 gift’s 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 client’s 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 v2’s', 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 rotation’s 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 week’s 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 activity’s 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
Reference in New Issue
Block a user