mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
Compare commits
95 Commits
0.0.4
...
mono-updates
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e0f090d18 | |||
| 66c09806f9 | |||
| d12806625d | |||
| d129900762 | |||
| 3aad586153 | |||
| 385e10bd55 | |||
| 8e0e92b449 | |||
| 8364a0b5f6 | |||
| d3838fb590 | |||
| 36c30a396c | |||
| da27b7e797 | |||
| 42e1fb4ab7 | |||
| ca8d40c4ec | |||
| 1afa9b7ac3 | |||
| c5ea04b39d | |||
| c2adc1ffbb | |||
| 4fb1c901b4 | |||
| efbd7936db | |||
| 7df783302b | |||
| 21024c7852 | |||
| a012b5165a | |||
| 3e8f0b3e56 | |||
| 6622302a56 | |||
| 566b212675 | |||
| 4b17b007e9 | |||
| 9164df98a9 | |||
| 7c43f2a1f3 | |||
| af64327fea | |||
| 4109317f0e | |||
| 208c1fe772 | |||
| 0b33e0b46f | |||
| 927c6757bb | |||
| aa6fdaf4b2 | |||
| 3022b3b566 | |||
| e564d3c839 | |||
| 30cf83a47d | |||
| 793b2ad37a | |||
| 6c7a634cb6 | |||
| 5c5988c730 | |||
| 6bbdf989b9 | |||
| 7fbaad1fd8 | |||
| d461961e54 | |||
| c6ec993e2d | |||
| 3d846ef2ad | |||
| 7e62f2b53c | |||
| a30d70076e | |||
| 37489d05dc | |||
| 60505a2519 | |||
| a3d9fdb8bf | |||
| df2d2af75b | |||
| c2d36009a3 | |||
| 4e578f7771 | |||
| 7bbbac6dc9 | |||
| 1e45afbcee | |||
| 9bb43f7b9c | |||
| 8bd76a4bae | |||
| 3dd6d6420b | |||
| 368ca252c1 | |||
| 51364e482c | |||
| 565d9b1aea | |||
| aeff7d50cd | |||
| 880c6ab2dc | |||
| f185ef97df | |||
| cc43d57172 | |||
| 8b804eaa33 | |||
| 6b7acc9435 | |||
| 93a46871de | |||
| 4111bc49aa | |||
| f6561f1ec9 | |||
| bc96a6245b | |||
| a73dec7c13 | |||
| ae3bef4cc4 | |||
| 1f615bab4f | |||
| a986d012f5 | |||
| b82a5e1dc0 | |||
| 6bfd4d9e50 | |||
| 079c889ccb | |||
| 9f4ce07aca | |||
| dfb1e9ab21 | |||
| 1d08ed8296 | |||
| f3e2ab422c | |||
| aa304dbede | |||
| 10eb89ac12 | |||
| 65611c15d8 | |||
| d6a0e3e6a6 | |||
| 7d300fa836 | |||
| 73bb7c4609 | |||
| dbc6d15ef5 | |||
| db003d54ef | |||
| 05b56e698e | |||
| dee7497fe5 | |||
| 12f6d7ab61 | |||
| 8d1539de03 | |||
| 7e0c26a100 | |||
| f94877347c |
@@ -0,0 +1,249 @@
|
||||
---
|
||||
name: daily-objectives
|
||||
description: Guide for parsing/writing daily objectives in /api/config/v2
|
||||
---
|
||||
|
||||
# Daily objectives — config shape and the full type enum
|
||||
|
||||
Reference for authoring `dailyObjectives` in `GET /api/config/v2`. Extracted from the 20230414
|
||||
client (`GameAssembly.dll` mtime 2026-07-23). See `SHAPES.md` for the method used.
|
||||
|
||||
> **This table survives game upgrades.** Enum *member names and values* are not obfuscated — only
|
||||
> type and method names re-roll per build. So the ids below stay valid across client versions unless
|
||||
> Rec Room adds or removes members. The obfuscated names in this file (`PNLFAAAPEID`,
|
||||
> `LCPOOJEAMJA`, …) are the only part that will go stale.
|
||||
|
||||
## Where it lives
|
||||
|
||||
`GET api/config/v2` (service `API`) → `JAGPNOHGHBG.DownloadConfigSettings`, deserialized as a bare
|
||||
`LCPOOJEAMJA` via `SendWithRequiredResponseAsync` — response required, no envelope.
|
||||
|
||||
Top-level keys, in declaration order (all accept three casings):
|
||||
|
||||
| Wire name | Type |
|
||||
| --- | --- |
|
||||
| `levelProgressionMaps` | array of objects |
|
||||
| **`dailyObjectives`** | **jagged array** — `FCAOHDFPEAP[][]` |
|
||||
| `serverMaintenance` | object |
|
||||
| `autoMicMutingConfig` | object |econ
|
||||
| `storefrontConfig` | object |
|
||||
| `roomKeyConfig` | object |
|
||||
| `roomCurrencyConfig` | object |
|
||||
| `shareBaseUrl` | string |
|
||||
|
||||
A `Dictionary<int,int>` declared first on the type carries `[IgnoreDataMember]` — client-only, never
|
||||
on the wire.
|
||||
|
||||
## `dailyObjectives` shape
|
||||
|
||||
Array of arrays. Each leaf element (`FCAOHDFPEAP`, formatter `BMBOPLBKALL`) has exactly two members:
|
||||
|
||||
| Wire name | Type |
|
||||
| --- | --- |
|
||||
| `type` | int — a value from the table below |
|
||||
| `score` | int — the target / threshold |
|
||||
|
||||
```json
|
||||
{
|
||||
"dailyObjectives": [
|
||||
[ { "type": 1, "score": 1 },
|
||||
{ "type": 6, "score": 5 },
|
||||
{ "type": 31, "score": 3 } ],
|
||||
[ { "type": 2, "score": 1 },
|
||||
{ "type": 65, "score": 2 },
|
||||
{ "type": 300, "score": 1 } ]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Unverified:** what the outer dimension indexes. The `updateobjective` DTO carries both `index` and
|
||||
`group`, which lines up with `dailyObjectives[group][index]`, and `DailyObjective1/2/3` existing as
|
||||
distinct types suggests three slots per set — but neither is confirmed against the consumer. Serve a
|
||||
distinctive jagged array and watch the `group`/`index` pairs your endpoint receives.
|
||||
|
||||
## Numbering scheme
|
||||
|
||||
The ids are blocked, which tells you where new entries belong:
|
||||
|
||||
| Range | Meaning | Count |
|
||||
| --- | --- | --- |
|
||||
| `-1` – `15` | meta / rollup / social | 16 |
|
||||
| `20` – `26` | onboarding (OOBE, NUX) | 5 |
|
||||
| `30` – `75` | general engagement | 45 |
|
||||
| `100`+ | per-activity, one block each | 67 |
|
||||
|
||||
Activity blocks follow a `Games` / `Wins` / `<activity-specific>` pattern. Quest (`1000`) additionally
|
||||
sub-blocks by scenario in steps of 10.
|
||||
|
||||
**Careful with `10`–`15`.** `DailyObjective1/2/3`, `AllDailyObjectives`, `CompleteAnyDaily` and
|
||||
`CompleteAnyWeekly` read as *rollup* types the reward system uses to track "you finished daily #1",
|
||||
not as objective definitions themselves. Using them as leaf `type` values in `dailyObjectives` is
|
||||
probably not what you want. (Inference from naming — not traced.)
|
||||
|
||||
## Full enum — `PNLFAAAPEID`, 133 values
|
||||
|
||||
| id | name |
|
||||
| --- | --- |
|
||||
| -1 | Default |
|
||||
| 1 | FirstSessionOfDay |
|
||||
| 2 | AddAFriend |
|
||||
| 3 | PartyUp |
|
||||
| 4 | AllOtherChallenges |
|
||||
| 5 | LevelUp |
|
||||
| 6 | CheerAPlayer |
|
||||
| 7 | PointedAtPlayer |
|
||||
| 8 | CheerARoom |
|
||||
| 9 | SubscribeToPlayer |
|
||||
| 10 | DailyObjective1 |
|
||||
| 11 | DailyObjective2 |
|
||||
| 12 | DailyObjective3 |
|
||||
| 13 | AllDailyObjectives |
|
||||
| 14 | CompleteAnyDaily |
|
||||
| 15 | CompleteAnyWeekly |
|
||||
| 20 | OOBE_GoToLockerRoom |
|
||||
| 21 | OOBE_GoToActivity |
|
||||
| 22 | OOBE_FinishActivity |
|
||||
| 25 | NUX_PunchcardObjective |
|
||||
| 26 | NUX_AllPunchcardObjectives |
|
||||
| 30 | GoToRecCenter |
|
||||
| 31 | FinishActivity |
|
||||
| 32 | VisitACustomRoom |
|
||||
| 33 | CreateACustomRoom |
|
||||
| 35 | ScoreBasketInRecCenter |
|
||||
| 36 | UploadPhotoToRecNet |
|
||||
| 37 | UpdatePlayerBio |
|
||||
| 38 | SaveOutfitSlot |
|
||||
| 39 | PurchaseClothingItem |
|
||||
| 40 | PurchaseNonClothingItem |
|
||||
| 41 | DrinkWater |
|
||||
| 42 | ColorOnWhiteboard |
|
||||
| 43 | SetBasketballSkin |
|
||||
| 44 | ThrowBasketball |
|
||||
| 45 | PlaceInventionInDorm |
|
||||
| 46 | ChangeDormRoomSkin |
|
||||
| 47 | ToggleOwnedClothes |
|
||||
| 48 | EquipHat |
|
||||
| 49 | LoadOutfit |
|
||||
| 50 | SaveNewOutfitSlot |
|
||||
| 51 | SpawnCamera |
|
||||
| 52 | TakeSelfie |
|
||||
| 53 | PrintSelfie |
|
||||
| 54 | TakePictureOfPlayer |
|
||||
| 55 | PrintPictureOfPlayer |
|
||||
| 56 | PublishSelfieWithPlayer |
|
||||
| 57 | SpawnFoodWithOtherPlayers |
|
||||
| 58 | EmoteInRecCenter |
|
||||
| 59 | SendRoomChatInRecCenter |
|
||||
| 60 | UseFrendotron |
|
||||
| 61 | GoToDormRoom |
|
||||
| 62 | VisitSpecificRoom |
|
||||
| 63 | VisitPublicRRO |
|
||||
| 64 | VisitPublicRoomBySource |
|
||||
| 65 | FavoriteARoom |
|
||||
| 66 | TakePhotoWithFilter |
|
||||
| 67 | OpenYourPlayerProfile |
|
||||
| 68 | OpenOnlineStatusModal |
|
||||
| 69 | ChangeProfilePicture |
|
||||
| 70 | ChangePlayerDisplayName |
|
||||
| 71 | ChangePlayerDescriptionText |
|
||||
| 72 | OpenPlayerPronounsModal |
|
||||
| 73 | OpenOtherPlayersProfile |
|
||||
| 74 | VisitPlayersPortfolio |
|
||||
| 75 | FavoriteAFriend |
|
||||
| 100 | CharadesGames |
|
||||
| 101 | CharadesWinsPerformer |
|
||||
| 102 | CharadesWinsGuesser |
|
||||
| 200 | DiscGolfWins |
|
||||
| 201 | DiscGolfGames |
|
||||
| 202 | DiscGolfHolesUnderPar |
|
||||
| 300 | DodgeballWins |
|
||||
| 301 | DodgeballGames |
|
||||
| 302 | DodgeballHits |
|
||||
| 400 | PaddleballGames |
|
||||
| 401 | PaddleballWins |
|
||||
| 402 | PaddleballScores |
|
||||
| 500 | PaintballAnyModeGames |
|
||||
| 501 | PaintballAnyModeWins |
|
||||
| 502 | PaintballAnyModeHits |
|
||||
| 600 | PaintballCTFWins |
|
||||
| 601 | PaintballCTFGames |
|
||||
| 602 | PaintballCTFHits |
|
||||
| 603 | PaintballFlagCaptures |
|
||||
| 700 | PaintballTeamBattleWins |
|
||||
| 701 | PaintballTeamBattleGames |
|
||||
| 702 | PaintballTeamBattleHits |
|
||||
| 710 | PaintballFreeForAllWins |
|
||||
| 711 | PaintballFreeForAllGames |
|
||||
| 712 | PaintballFreeForAllHits |
|
||||
| 800 | SoccerWins |
|
||||
| 801 | SoccerGames |
|
||||
| 802 | SoccerGoals |
|
||||
| 900 | BowlingGames |
|
||||
| 901 | BowlingWins |
|
||||
| 902 | BowlingStrike |
|
||||
| 1000 | QuestGames |
|
||||
| 1001 | QuestWins |
|
||||
| 1002 | QuestPlayerRevives |
|
||||
| 1003 | QuestEnemyKills |
|
||||
| 1010 | QuestGames_Goblin1 |
|
||||
| 1011 | QuestWins_Goblin1 |
|
||||
| 1012 | QuestPlayerRevives_Goblin1 |
|
||||
| 1013 | QuestEnemyKills_Goblin1 |
|
||||
| 1020 | QuestGames_Goblin2 |
|
||||
| 1021 | QuestWins_Goblin2 |
|
||||
| 1022 | QuestPlayerRevives_Goblin2 |
|
||||
| 1023 | QuestEnemyKills_Goblin2 |
|
||||
| 1030 | QuestGames_Scifi1 |
|
||||
| 1031 | QuestWins_Scifi1 |
|
||||
| 1032 | QuestPlayerRevives_Scifi1 |
|
||||
| 1033 | QuestEnemyKills_Scifi1 |
|
||||
| 1040 | QuestGames_Pirate1 |
|
||||
| 1041 | QuestWins_Pirate1 |
|
||||
| 1042 | QuestPlayerRevives_Pirate1 |
|
||||
| 1043 | QuestEnemyKills_Pirate1 |
|
||||
| 1050 | QuestGames_Dracula1 |
|
||||
| 1051 | QuestWins_Dracula1 |
|
||||
| 1052 | QuestPlayerRevives_Dracula1 |
|
||||
| 1053 | QuestEnemyKills_Dracula1 |
|
||||
| 2000 | ArenaGames |
|
||||
| 2001 | ArenaWins |
|
||||
| 2002 | ArenaPlayerRevives |
|
||||
| 2003 | ArenaHeroTags |
|
||||
| 2004 | ArenaBotTags |
|
||||
| 3000 | RecRoyaleGames |
|
||||
| 3001 | RecRoyaleWins |
|
||||
| 3002 | RecRoyaleTags |
|
||||
| 4000 | StuntRunnerGames |
|
||||
| 4001 | StuntRunnerWins |
|
||||
| 5000 | RecRallyGames |
|
||||
| 5001 | RecRallyWins |
|
||||
|
||||
## Machine-readable
|
||||
|
||||
```json
|
||||
{"Default":-1,"FirstSessionOfDay":1,"AddAFriend":2,"PartyUp":3,"AllOtherChallenges":4,"LevelUp":5,"CheerAPlayer":6,"PointedAtPlayer":7,"CheerARoom":8,"SubscribeToPlayer":9,"DailyObjective1":10,"DailyObjective2":11,"DailyObjective3":12,"AllDailyObjectives":13,"CompleteAnyDaily":14,"CompleteAnyWeekly":15,"OOBE_GoToLockerRoom":20,"OOBE_GoToActivity":21,"OOBE_FinishActivity":22,"NUX_PunchcardObjective":25,"NUX_AllPunchcardObjectives":26,"GoToRecCenter":30,"FinishActivity":31,"VisitACustomRoom":32,"CreateACustomRoom":33,"ScoreBasketInRecCenter":35,"UploadPhotoToRecNet":36,"UpdatePlayerBio":37,"SaveOutfitSlot":38,"PurchaseClothingItem":39,"PurchaseNonClothingItem":40,"DrinkWater":41,"ColorOnWhiteboard":42,"SetBasketballSkin":43,"ThrowBasketball":44,"PlaceInventionInDorm":45,"ChangeDormRoomSkin":46,"ToggleOwnedClothes":47,"EquipHat":48,"LoadOutfit":49,"SaveNewOutfitSlot":50,"SpawnCamera":51,"TakeSelfie":52,"PrintSelfie":53,"TakePictureOfPlayer":54,"PrintPictureOfPlayer":55,"PublishSelfieWithPlayer":56,"SpawnFoodWithOtherPlayers":57,"EmoteInRecCenter":58,"SendRoomChatInRecCenter":59,"UseFrendotron":60,"GoToDormRoom":61,"VisitSpecificRoom":62,"VisitPublicRRO":63,"VisitPublicRoomBySource":64,"FavoriteARoom":65,"TakePhotoWithFilter":66,"OpenYourPlayerProfile":67,"OpenOnlineStatusModal":68,"ChangeProfilePicture":69,"ChangePlayerDisplayName":70,"ChangePlayerDescriptionText":71,"OpenPlayerPronounsModal":72,"OpenOtherPlayersProfile":73,"VisitPlayersPortfolio":74,"FavoriteAFriend":75,"CharadesGames":100,"CharadesWinsPerformer":101,"CharadesWinsGuesser":102,"DiscGolfWins":200,"DiscGolfGames":201,"DiscGolfHolesUnderPar":202,"DodgeballWins":300,"DodgeballGames":301,"DodgeballHits":302,"PaddleballGames":400,"PaddleballWins":401,"PaddleballScores":402,"PaintballAnyModeGames":500,"PaintballAnyModeWins":501,"PaintballAnyModeHits":502,"PaintballCTFWins":600,"PaintballCTFGames":601,"PaintballCTFHits":602,"PaintballFlagCaptures":603,"PaintballTeamBattleWins":700,"PaintballTeamBattleGames":701,"PaintballTeamBattleHits":702,"PaintballFreeForAllWins":710,"PaintballFreeForAllGames":711,"PaintballFreeForAllHits":712,"SoccerWins":800,"SoccerGames":801,"SoccerGoals":802,"BowlingGames":900,"BowlingWins":901,"BowlingStrike":902,"QuestGames":1000,"QuestWins":1001,"QuestPlayerRevives":1002,"QuestEnemyKills":1003,"QuestGames_Goblin1":1010,"QuestWins_Goblin1":1011,"QuestPlayerRevives_Goblin1":1012,"QuestEnemyKills_Goblin1":1013,"QuestGames_Goblin2":1020,"QuestWins_Goblin2":1021,"QuestPlayerRevives_Goblin2":1022,"QuestEnemyKills_Goblin2":1023,"QuestGames_Scifi1":1030,"QuestWins_Scifi1":1031,"QuestPlayerRevives_Scifi1":1032,"QuestEnemyKills_Scifi1":1033,"QuestGames_Pirate1":1040,"QuestWins_Pirate1":1041,"QuestPlayerRevives_Pirate1":1042,"QuestEnemyKills_Pirate1":1043,"QuestGames_Dracula1":1050,"QuestWins_Dracula1":1051,"QuestPlayerRevives_Dracula1":1052,"QuestEnemyKills_Dracula1":1053,"ArenaGames":2000,"ArenaWins":2001,"ArenaPlayerRevives":2002,"ArenaHeroTags":2003,"ArenaBotTags":2004,"RecRoyaleGames":3000,"RecRoyaleWins":3001,"RecRoyaleTags":3002,"StuntRunnerGames":4000,"StuntRunnerWins":4001,"RecRallyGames":5000,"RecRallyWins":5001}
|
||||
```
|
||||
|
||||
## Related endpoints
|
||||
|
||||
| Endpoint | Purpose |
|
||||
| --- | --- |
|
||||
| `GET api/config/v2` | serves `dailyObjectives` (this file) |
|
||||
| `GET api/objectives/v1/myprogress` | player's current progress |
|
||||
| `POST api/objectives/v1/updateobjective` | one objective update — `{index, group, progress, visualProgress, isCompleted, hasClaimedReward}` → `{group, isCompleted, clearedAt}` |
|
||||
| `POST api/objectives/v1/completegroup` | group completion |
|
||||
| `POST api/objectives/v1/cleargroup` | group reset |
|
||||
|
||||
The objectives endpoints are on the **Econ** service (`econ.*`); config is on **API** (`api.*`).
|
||||
|
||||
## How this was extracted
|
||||
|
||||
```sh
|
||||
# in the il2cpp scratchpad, with Il2CppDumper output in ./out/
|
||||
grep -n "enum PNLFAAAPEID" out/dump.cs # find the block
|
||||
# then parse `public const PNLFAAAPEID <name> = <value>;` lines until the closing brace
|
||||
```
|
||||
|
||||
The `dailyObjectives` wire name came from the Utf8Json formatter, not the property name — see
|
||||
`SHAPES.md` §1. Formatter `.ctor` RVAs for this build: `LCPOOJEAMJA` → `0x3512E10`,
|
||||
`FCAOHDFPEAP` → `0x34EE5F0`.
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
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
|
||||
---
|
||||
|
||||
# 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`).
|
||||
|
||||
**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.
|
||||
|
||||
## `Config` is an escaped JSON string
|
||||
|
||||
Not a nested object. In the file it looks like:
|
||||
|
||||
```json
|
||||
"Config": "{\"ct\":0,\"ipc\":false,\"wc\":[...]}"
|
||||
```
|
||||
|
||||
Author the tree as an object and stringify it into the field — don't hand-escape:
|
||||
|
||||
```sh
|
||||
bun -e 'const t={ct:0,ipc:false,wc:[{ct:6,vs:[2]}]}; console.log(JSON.stringify(JSON.stringify(t)))'
|
||||
```
|
||||
|
||||
To read one back:
|
||||
|
||||
```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))'
|
||||
```
|
||||
|
||||
## Node types
|
||||
|
||||
Each node carries a numeric type in `ct`. Two composite kinds appear:
|
||||
|
||||
- **Match** (`ct: 0`) — `wc` is a list of predicates that must _all_ hold for one game
|
||||
result (AND).
|
||||
- **Counter** (`ct: 1`) — `ctc` holds the child node to count, `t` is the target count.
|
||||
|
||||
Which slot a node uses (`wc` vs `ctc`) tells you what its children are; a node never has
|
||||
both. `ipc` is `false` on every composite node in the reference data — purpose unknown, but
|
||||
the client echoes it back, so keep emitting it.
|
||||
|
||||
## Predicate leaves
|
||||
|
||||
Leaves carry `vs`, a list of accepted values matched as OR.
|
||||
|
||||
| `ct` | Shape | Meaning |
|
||||
| ---- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `6` | `{"ct":6,"vs":[2]}` | _(inferred)_ The kind of event being matched — a finished game/session. Present in **every** leaf group and always `[2]`; nothing observed varying it, so treat it as required boilerplate. |
|
||||
| `7` | `{"ct":7,"vs":[{"l":"<guid>"}]}` | Scene allow-list: each `l` is a subroom's `UnitySceneId` (see `apps/rooms`). Matches if the game happened in any of them. |
|
||||
| `9` | `{"ct":9,"vs":[true],"v":"won"}` | A named session variable (`v`) equals one of `vs` — here, the player won. |
|
||||
|
||||
## The two idioms
|
||||
|
||||
Every challenge in the captured rotation is one of these.
|
||||
|
||||
```jsonc
|
||||
// "Complete ^TheRiseOfJumbotron quest" — one winning session in one scene
|
||||
{ "ct": 0, "ipc": false, "wc": [
|
||||
{ "ct": 6, "vs": [2] },
|
||||
{ "ct": 9, "vs": [true], "v": "won" },
|
||||
{ "ct": 7, "vs": [{ "l": "acc06e66-…" }] } // TheRiseofJumbotron / Home
|
||||
]}
|
||||
|
||||
// "Complete 5 Charades games" — count matching sessions to a target
|
||||
{ "ct": 1, "ipc": false, "t": 5, "ctc": [
|
||||
{ "ct": 0, "ipc": false, "wc": [
|
||||
{ "ct": 6, "vs": [2] },
|
||||
{ "ct": 7, "vs": [{ "l": "a673712c-…" }, { "l": "4078dfed-…" }] } // 3DCharades + Legacy3DCharades
|
||||
]}
|
||||
]}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
## Scene ids, not room ids
|
||||
|
||||
`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.
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
cat > /tmp/scene.ts <<'EOF'
|
||||
// path is resolved against the cwd, so run this from the repo root
|
||||
const rooms = await Bun.file('apps/rooms/static/ImportRooms.json').json()
|
||||
const want = new Set(process.argv.slice(2))
|
||||
const byScene = new Map<string, string[]>()
|
||||
for (const r of rooms as any[])
|
||||
for (const s of r.SubRooms ?? [])
|
||||
byScene.set(s.UnitySceneId, [...(byScene.get(s.UnitySceneId) ?? []), `${r.Name}/${s.Name}`])
|
||||
for (const [id, names] of byScene) if (!want.size || want.has(id)) console.log(id, names.join(', '))
|
||||
EOF
|
||||
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`.
|
||||
|
||||
### Shared scenes to watch for
|
||||
|
||||
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`.
|
||||
|
||||
| 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 |
|
||||
|
||||
Regenerate this list with the script above and no arguments.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
## Authoring a new challenge
|
||||
|
||||
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
|
||||
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),
|
||||
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.
|
||||
|
||||
Sanity check the file parses and every tree parses:
|
||||
|
||||
```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)'
|
||||
```
|
||||
|
||||
Then `bun turbo -F econ test` — `src/test/integration/api.test.ts` imports the file and
|
||||
asserts `getCurrent` against 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`
|
||||
+37
-2
@@ -1,9 +1,19 @@
|
||||
# Base domain all service hosts are derived from, e.g. accounts.<domain>.
|
||||
# Base domain all service hosts are derived from, e.g. accounts.<domain>. Used by
|
||||
# `just dev` too, so a locally-run worker hands out the same addresses it would deployed.
|
||||
RECFLARE_DOMAIN=rec.example.com
|
||||
|
||||
# Optional per-app subdomain overrides, as a compact JSON object keyed by the
|
||||
# worker's directory name. Defaults to the directory name when unset.
|
||||
# worker's directory name. Defaults to the directory name when unset. Use "@" to
|
||||
# put a worker on the APEX of the domain rather than a subdomain.
|
||||
# RECFLARE_SUBDOMAINS='{"playersettings":"settings"}'
|
||||
#
|
||||
# The combined `mono` worker (an alternative to deploying the services separately:
|
||||
# it mounts most of them in one deployable and routes on the first path segment, so
|
||||
# every address is https://<domain>/rooms, https://<domain>/auth, …) belongs on the
|
||||
# apex, and won't hand out the right addresses anywhere else. It ships only when you
|
||||
# ask for it — `just deploy-mono`, never `just deploy` — since it's an alternative to
|
||||
# the split set, not part of it:
|
||||
# RECFLARE_SUBDOMAINS='{"mono":"@"}'
|
||||
|
||||
# Id of the shared `recflare` D1 database (create it manually with
|
||||
# `wrangler d1 create recflare`). All D1-backed workers bind this one database.
|
||||
@@ -52,6 +62,20 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID=3
|
||||
# RECFLARE_MAX_ACCOUNTS_PER_IP=3
|
||||
|
||||
# How far a ban reaches beyond the account it was handed to (`match` and `auth`), as a
|
||||
# comma-separated list out of `ip` and `platform` — or `off` for neither. Unset means
|
||||
# BOTH, so a ban also blocks accounts sharing a proven platform identity or an IP with a
|
||||
# banned one, and refuses a signup from either. Without that, an evader is back in the
|
||||
# game with a new account in under a minute.
|
||||
# ...`platform` matches a Steam/Meta identity the player PROVED — sharp, no false
|
||||
# positives worth the name.
|
||||
# ...`ip` matches the signup/last-login address — coarse. A household, dorm, campus or
|
||||
# mobile carrier shares one address, so this arm bans the banned player's housemates
|
||||
# along with them, and locks them out of signing up at all. Set BAN_EVASION_MATCH=platform
|
||||
# to keep the sharp arm only, or off to make a ban apply to just the banned account.
|
||||
# A ban ALWAYS applies to the account it was handed to, whatever this is set to.
|
||||
# RECFLARE_BAN_EVASION_MATCH=ip,platform
|
||||
|
||||
# How many rooms one account may create (`rooms`) and how many clubs (`clubs`).
|
||||
# Enforced on creation only — lowering either never touches what players already have,
|
||||
# it just stops new ones. Set either to 0 to turn that cap off.
|
||||
@@ -60,6 +84,17 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# RECFLARE_MAX_ROOMS_PER_ACCOUNT=10
|
||||
# RECFLARE_MAX_CLUBS_PER_ACCOUNT=10
|
||||
|
||||
# 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
|
||||
# room named MyHub instead, whether the client asked for it by id or by name, and whether
|
||||
# it came through the room list, a club's clubhouse, or a party. Substitution is a single
|
||||
# hop (2=3,3=2 swaps the two rooms), a requested subroom is dropped in favour of the
|
||||
# substitute's default one, and a target that doesn't exist leaves the original room in
|
||||
# place. Following a friend or joining a specific instance is unaffected — those join a
|
||||
# live instance, which is already in whichever room it was created in.
|
||||
# RECFLARE_ROOM_REDIRECTS=2=MyHub
|
||||
|
||||
# RecCenterTokens a new player is granted, the first time their balance is read (`econ`).
|
||||
# 0 means players start broke. Applies only to players who haven't been granted yet —
|
||||
# raising it later does NOT top up existing players.
|
||||
|
||||
@@ -70,6 +70,11 @@ inconsistency here without checking the client first.
|
||||
- A club's `AdditionalImages` (`clubs`) is an array of whole `SavedImage` records, not
|
||||
image names — a bare string array fails the client's parser ("expected '{'"). The list
|
||||
is packed: removing an image shifts the rest up, never leaving a blank slot.
|
||||
- A room's `LoadScreens` (`rooms`: `PUT /rooms/:id/loadscreen`) is an array — the
|
||||
client's parser wants one — but the client renders only the FIRST entry and only ever
|
||||
posts one. So the endpoint REPLACES the list rather than appending: an appended screen
|
||||
sits unreachable behind the old one and setting a load screen looks like it did
|
||||
nothing. Keep the array shape for eventual multi-screen support.
|
||||
- Endpoints the client re-renders from must return the updated entity, not
|
||||
`{ error, success, value: null }` — e.g. `clubs` `PUT /club/:id/clubhouse` left the old
|
||||
clubhouse on screen until it answered the full details envelope.
|
||||
@@ -94,14 +99,50 @@ inconsistency here without checking the client first.
|
||||
publish: no publish step exists in the client for them. Saves live in the
|
||||
`subroom_save` table with globally-unique ids (a bare id has to resolve —
|
||||
`StagedSubRoomDataSaveId` carries no subroom context), and nothing is overwritten, so
|
||||
`…/saves` is real history and `publish_save` doubles as restore-a-save. `…/saves` is
|
||||
auth-gated and CREATOR-only (not co-owners) — it lists unpublished staged saves. There
|
||||
`…/saves` is real history and `publish_save` doubles as restore-a-save. There
|
||||
is no `GET …/subrooms/:sid/data`; only the POST (the room save) exists on that path.
|
||||
`GET …/saves/:saveId` is the detail behind a list row, under the same gate, but in the
|
||||
CAMELCASE projection the room save's response uses — not the PascalCase rows the list
|
||||
serves. Three shapes of one save; keep them straight.
|
||||
- Both save reads (`rooms`: `…/saves` and `…/saves/:saveId`) are auth-gated and readable by
|
||||
the room's CREATOR or by anyone whose live `presence` row puts them in that room — not by
|
||||
co-owners as such (a co-owner passes only by standing there). They list unpublished
|
||||
staged saves, so they aren't public; but a visitor resolves which version an instance is
|
||||
running from this list, so creator-only locks them out of loading the room. The grant
|
||||
expires with the presence row.
|
||||
- A room save writes ONLY to the subroom and its save row — never to the room. Everything
|
||||
the body carries describes that one revision: `Description` is the save comment shown in
|
||||
`…/saves`, and `PersistenceVersion`/`InventionUsage` describe the scene just saved (the
|
||||
latter lives on the SUBROOM). The room's public description is `PUT /rooms/:id/description`'s
|
||||
alone; copying the save comment onto `room.Description` (as this once did) silently
|
||||
replaces the room's description every time someone saves.
|
||||
- Matchmaking (`match`: `/matchmake/room/:roomId/:subRoomId`) always serves the PUBLISHED
|
||||
`CurrentSave` blob, creator included. Joining a private instance, the client itself asks
|
||||
the owner whether to load the latest or the published version and resolves it from the
|
||||
`/subrooms/:sid/saves` list — the matchmake call is identical either way. Don't make
|
||||
this server-side: it would put two people in one instance on different versions.
|
||||
- A balance lives in a `(CurrencyType, Platform)` BUCKET and the client shows the SUM of the
|
||||
buckets, so `Platform` is a balance's identity, not a label. This server uses exactly one
|
||||
bucket per currency — `ALL_PLATFORMS`, -2 `NonPurchasedNotUsableInP2P` — and every surface
|
||||
must name it: the balance DTO (`econ`: `GET /api/storefronts/v4/balance/:type`), the
|
||||
`BalanceType` the storefront bodies echo, and the `Platform` on every `StorefrontBalance*`
|
||||
socket frame. Two traps, which produced two "balance doubling" bugs that both looked like
|
||||
the frames being additive when they are not:
|
||||
- Each frame SETS the bucket it names to an absolute value — `Balance` is the RESULTING
|
||||
TOTAL, never the change (`StorefrontBalancePurchase`'s `Delta`/`BalanceAddType` are
|
||||
display-only; the client logs them and stores `Balance` outright). Send a change and the
|
||||
balance becomes that change. Being absolute, a frame is idempotent: re-sending one, or
|
||||
racing a `GET /balance`, cannot drift the total, so the player reading the HTTP response
|
||||
for the same change gets a frame too.
|
||||
- The bucket key on the wire is `Platform`. The client's property is named `BalanceType`
|
||||
but carries a `[DataMember]` rename, and its decoder drops unknown members silently, so
|
||||
a frame saying `BalanceType` lands in `Platform` 0 (`SteamPurchased`) and adds a phantom
|
||||
balance to the real one — 10,000 tokens + a 250 reward read 20,250. Sending a real-but-
|
||||
different platform does the same: `Platform: RecNet` on a buy showed 34,100 to a player
|
||||
who spent 900 of 17,500, then 33,200 once the body's -900 reached the true bucket.
|
||||
The payload shapes are recovered from the client's own decoder in
|
||||
`apps/notify/src/notification-payloads.ts` — build frames against those interfaces (econ
|
||||
does) so a renamed key fails the build instead of silently vanishing on the wire.
|
||||
- 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
|
||||
|
||||
@@ -191,6 +191,7 @@ edit the value, then re-deploy the worker that reads them.
|
||||
| `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`. |
|
||||
|
||||
Then deploy just the worker that reads it:
|
||||
|
||||
@@ -267,6 +268,15 @@ printf '1x0000000000000000000000000000000AA' |
|
||||
Both `auth` account caps above still apply on top of the bot check, and the per-IP one is
|
||||
the only cap that can see a web signup.
|
||||
|
||||
`www` reaches `auth` through a **service binding**, not over `auth.<DOMAIN>`, so that the
|
||||
player's real IP survives the hop: a Worker subrequest to the public hostname re-enters
|
||||
the Cloudflare edge, which rewrites `CF-Connecting-IP` to Cloudflare's own address, and
|
||||
`auth` would then record one shared `signupIp` for every web account and cap the whole
|
||||
internet at three. Two consequences: **deploy `auth` before `www`** on a fresh account
|
||||
(the binding refuses to resolve otherwise), and web accounts created before this change
|
||||
carry that shared address as their permanent `signupIp` — harmless, but they are not
|
||||
counted against any real network.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
- `apps/` - The service workers, one deployable Worker per subdirectory. Each has
|
||||
|
||||
@@ -71,6 +71,17 @@ preview:
|
||||
deploy *args:
|
||||
bun turbo deploy "$@"
|
||||
|
||||
# Deploy the combined `mono` worker: every service in ONE Worker, routed on the first
|
||||
# path segment (https://<domain>/rooms). It's an alternative to the split deployment
|
||||
# above — for debugging, or for running the whole server as a single service — so it has
|
||||
# its own command and `just deploy` leaves it alone. Put it on the apex of your domain
|
||||
# with RECFLARE_SUBDOMAINS='{"mono":"@"}'; see .env.example.
|
||||
[group('2. local dev')]
|
||||
[positional-arguments]
|
||||
[no-cd]
|
||||
deploy-mono *args:
|
||||
bun turbo -F mono deploy:mono "$@"
|
||||
|
||||
# Apply D1 migrations (rooms + auth own them). Defaults to --remote; pass `-- --local`
|
||||
# for the dev db. Scope with -F, e.g. `just migrate -F rooms`.
|
||||
[group('2. local dev')]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { describeRoute, openAPIRouteHandler, validator } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
@@ -11,9 +11,18 @@ import {
|
||||
searchAccounts,
|
||||
updateAccount,
|
||||
} from '@repo/domain'
|
||||
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import {
|
||||
logger,
|
||||
withCleanSpec,
|
||||
withDefaultCors,
|
||||
withNotFound,
|
||||
withOnError,
|
||||
} from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } 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.
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
import {
|
||||
AccountDto,
|
||||
BioRequest,
|
||||
@@ -72,6 +81,11 @@ const DEFAULT_USERNAME_CHANGES = 1
|
||||
* Username-change result envelope: `{ success, error, value }`, always HTTP 200.
|
||||
* On success `value` is the updated account; on error `error` carries the message
|
||||
* and `value` is an empty string.
|
||||
*
|
||||
* The envelope-at-200 is the reference's (`RecNet`) convention — a refusal is a
|
||||
* successful call that answers "no", and the player-facing sentence rides in `error`.
|
||||
* `POST /account/create` does the same. This was briefly a 400 so a caller could branch
|
||||
* on the status; it isn't, because that's not what the real service does.
|
||||
*/
|
||||
function usernameResult(c: Context<App>, error = '', value: unknown = '') {
|
||||
return c.json({ success: error === '', error, value })
|
||||
@@ -105,13 +119,17 @@ function toAccountDto(account: Account) {
|
||||
/**
|
||||
* Project a stored account into the private self DTO (the /account/me shape) —
|
||||
* the public DTO plus owner-only fields. `juniorState`/`parentAccountId` are
|
||||
* OMITTED when null (emitting `null` makes the client's enum parser throw);
|
||||
* `email`/`birthday` are kept as null (not enums, so null is fine).
|
||||
* OMITTED when null (emitting `null` makes the client's enum parser throw).
|
||||
*
|
||||
* An unset `email` is `""`, never null — same as `bio`. Two reasons: the client reads
|
||||
* it as a string, and this DTO also rides the `SelfAccountUpdate` hub frame, where the
|
||||
* hub DROPS null values from `Msg` — so a null email doesn't arrive as null, it
|
||||
* vanishes from the frame entirely.
|
||||
*/
|
||||
function toSelfAccountDto(account: Account) {
|
||||
return {
|
||||
...toAccountDto(account),
|
||||
email: account.email ?? null,
|
||||
email: account.email ?? '',
|
||||
// @todo he game client needs this to be set. I forget how birthdays were set, so for now
|
||||
// everyone can be old.
|
||||
birthday: '1904-01-01T00:00:00.000Z',
|
||||
@@ -133,9 +151,13 @@ async function pushAccountUpdate(c: Context<App>, account: Account): Promise<voi
|
||||
try {
|
||||
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
|
||||
const publicDto = toAccountDto(account)
|
||||
await hub.notifyPlayer(account.accountId, 'SelfAccountUpdate', toSelfAccountDto(account))
|
||||
await hub.notifyPlayer(account.accountId, 'AccountUpdate', publicDto)
|
||||
await hub.broadcast('AccountUpdate', publicDto)
|
||||
await hub.notifyPlayer(
|
||||
account.accountId,
|
||||
NotificationType.SubscriptionUpdateSelfProfile,
|
||||
toSelfAccountDto(account)
|
||||
)
|
||||
await hub.notifyPlayer(account.accountId, NotificationType.SubscriptionUpdateProfile, publicDto)
|
||||
await hub.broadcast(NotificationType.SubscriptionUpdateProfile, publicDto)
|
||||
} catch (err) {
|
||||
logger.error('failed to push account update notifications', {
|
||||
accountId: account.accountId,
|
||||
@@ -161,6 +183,14 @@ const app = new Hono<App>()
|
||||
})(c, next)
|
||||
)
|
||||
|
||||
// The website (`www`) is a browser origin calling these endpoints directly, the way
|
||||
// rec.net's own site called the game's API — so the responses need CORS headers or
|
||||
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
|
||||
// endpoints authenticate with a bearer token in the `Authorization` header, never a
|
||||
// cookie: a hostile page can't read another origin's stored token, so there is no
|
||||
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
|
||||
.use('*', withDefaultCors())
|
||||
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
@@ -411,18 +441,20 @@ const app = new Hono<App>()
|
||||
summary: 'Set display name',
|
||||
description: 'Persisted and broadcast via an AccountUpdate notification.',
|
||||
security: AUTHED,
|
||||
requestBody: form(DisplayNameRequest, 'The new display name'),
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: { description: 'Empty display name (empty body)' },
|
||||
400: { description: 'Empty, over 15 characters, or non-alphanumeric (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
// An EMPTY 400, which is what this route already answered for an empty name: it
|
||||
// acks with a bare SuccessResponse and has never sent the client a body on
|
||||
// failure, so enforcing the schema doesn't change what a refusal looks like.
|
||||
validator('form', DisplayNameRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const displayName = (await formField(c, 'displayName')).trim()
|
||||
if (displayName === '') return c.body(null, 400)
|
||||
const { displayName } = c.req.valid('form')
|
||||
const account = await updateAccount(c.env.DB, id, { displayName })
|
||||
await pushAccountUpdate(c, account)
|
||||
return c.json({ success: true })
|
||||
@@ -438,23 +470,33 @@ const app = new Hono<App>()
|
||||
tags: ['Profile'],
|
||||
summary: 'Change username',
|
||||
description: [
|
||||
'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).',
|
||||
'Letters and digits only, at most 50 characters. 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).',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(UsernameRequest, 'The desired username'),
|
||||
responses: {
|
||||
200: json(UsernameResult, 'Result envelope (success or a validation error)'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
// Shape is checked before the handler runs, so a rejected name costs no D1 read and
|
||||
// — the part that matters — can never spend one of the account's rationed changes.
|
||||
// The message is relayed rather than zod's issue array: `nameRejection` writes the
|
||||
// sentence the player reads, and nothing can render an array of issues.
|
||||
// `c` is annotated so the hook's context matches this app's bindings, and `error` is
|
||||
// Standard Schema's flat issue list rather than a zod error object.
|
||||
validator('form', UsernameRequest, (r, c: Context<App>) =>
|
||||
r.success
|
||||
? undefined
|
||||
: usernameResult(c, r.error[0]?.message ?? 'That username cannot be used.')
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const username = (await formField(c, 'username')).trim()
|
||||
if (username === '') return usernameResult(c, 'You must enter a username.')
|
||||
const { username } = c.req.valid('form')
|
||||
|
||||
// Duplicate check first (case-insensitive); keeping your own name is allowed.
|
||||
const existing = await getAccountByUsername(c.env.DB, username)
|
||||
@@ -486,18 +528,17 @@ const app = new Hono<App>()
|
||||
summary: 'Set email',
|
||||
description: 'Persisted; surfaced only by `/account/me`. Not broadcast.',
|
||||
security: AUTHED,
|
||||
requestBody: form(EmailRequest, 'The new email'),
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: { description: 'Email without an “@” (empty body)' },
|
||||
400: { description: 'Not a syntactically valid address (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
validator('form', EmailRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const email = (await formField(c, 'email')).trim()
|
||||
if (!email.includes('@')) return c.body(null, 400)
|
||||
const { email } = c.req.valid('form')
|
||||
await updateAccount(c.env.DB, id, { email })
|
||||
return c.json({ success: true })
|
||||
}
|
||||
@@ -511,18 +552,17 @@ const app = new Hono<App>()
|
||||
summary: 'Set phone number',
|
||||
description: 'Persisted on the account row. Not broadcast.',
|
||||
security: AUTHED,
|
||||
requestBody: form(PhoneRequest, 'The new phone number'),
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: { description: 'Empty phone (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
validator('form', PhoneRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const phone = (await formField(c, 'phone')).trim()
|
||||
if (phone === '') return c.body(null, 400)
|
||||
const { phone } = c.req.valid('form')
|
||||
await updateAccount(c.env.DB, id, { phone })
|
||||
return c.json({ success: true })
|
||||
}
|
||||
@@ -597,18 +637,20 @@ const app = new Hono<App>()
|
||||
describeRoute({
|
||||
tags: ['Profile'],
|
||||
summary: 'Set bio',
|
||||
description: 'Free text; empty is allowed. Persisted and broadcast.',
|
||||
description: 'Free text up to 255 characters; empty is allowed. Persisted and broadcast.',
|
||||
security: AUTHED,
|
||||
requestBody: form(BioRequest, 'The new bio'),
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: { description: 'Bio over 255 characters (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
// Refused rather than truncated: silently storing half a sentence reads as data loss.
|
||||
validator('form', BioRequest, (r, c) => (r.success ? undefined : c.body(null, 400))),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const bio = await formField(c, 'bio')
|
||||
const { bio } = c.req.valid('form')
|
||||
const account = await updateAccount(c.env.DB, id, { bio })
|
||||
await pushAccountUpdate(c, account)
|
||||
return c.json({ success: true })
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
import { resolver } from 'hono-openapi'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
isValidBio,
|
||||
isValidEmail,
|
||||
MAX_DISPLAY_NAME_LENGTH,
|
||||
MAX_USERNAME_LENGTH,
|
||||
nameRejection,
|
||||
} from '@repo/domain'
|
||||
|
||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||
|
||||
/**
|
||||
* OpenAPI schemas for the accounts worker.
|
||||
*
|
||||
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
|
||||
* generate the spec and are never wired into `hono-openapi`'s `validator()`.
|
||||
* Most of these are DESCRIPTIVE ONLY: they are passed to `describeRoute` to generate the
|
||||
* spec, and the handler stays lenient. That is deliberate — the Rec Room client is the
|
||||
* real consumer, form fields are read as `typeof value === 'string' ? value : ''`, and
|
||||
* missing or malformed input falls through to a graceful path (or a synthesized default
|
||||
* account) rather than a hard error. A schema that rejected what the client actually
|
||||
* sends would break the game, not protect it.
|
||||
*
|
||||
* As with the auth worker, this is deliberate. The Rec Room client is the only real
|
||||
* consumer and the handlers are intentionally lenient — form fields are read as
|
||||
* `typeof value === 'string' ? value : ''` and missing/malformed input falls through
|
||||
* to a graceful path (or a synthesized default account) rather than a hard error.
|
||||
* These schemas record what the client is observed to send and what we send back; to
|
||||
* enforce one, do it per-route and land a test with it.
|
||||
* The EXCEPTION is the profile mutations a player types into a box — displayName,
|
||||
* username, email, phone, bio. Those carry real rules (see `@repo/domain`), and each is
|
||||
* wired into `hono-openapi`'s `validator()` per route, with tests, exactly as the older
|
||||
* version of this note prescribed. Wiring one up means the schema both validates the
|
||||
* request and generates the spec, so a limit can't be changed in one and not the other —
|
||||
* which is precisely how the documented email limit came to disagree with the real one.
|
||||
*
|
||||
* A validated route drops `requestBody: form(...)` from its `describeRoute`: the
|
||||
* validator registers the body itself, and declaring it twice would emit it twice.
|
||||
*/
|
||||
|
||||
/** Emit a zod schema as an `application/json` response body. */
|
||||
@@ -62,12 +77,16 @@ export const AccountDto = z.object({
|
||||
/**
|
||||
* The private self DTO (`toSelfAccountDto`, the `/account/me` shape) — the public DTO
|
||||
* plus owner-only fields. `juniorState`/`parentAccountId` are omitted entirely when
|
||||
* unset (emitting `null` makes the client's enum parser throw); `email`/`birthday` are
|
||||
* kept as nullable since they aren't enums.
|
||||
* unset (emitting `null` makes the client's enum parser throw).
|
||||
*/
|
||||
export const SelfAccountDto = AccountDto.extend({
|
||||
email: z.string().nullable(),
|
||||
birthday: z.null().describe('Always null — birthday is not stored'),
|
||||
email: z
|
||||
.string()
|
||||
.describe(
|
||||
'"" when unset — never null: the client reads it as a string, and the hub frame this ' +
|
||||
'DTO also rides drops null values outright'
|
||||
),
|
||||
birthday: z.iso.datetime().describe('A fixed placeholder — birthdays are not stored'),
|
||||
availableUsernameChanges: z.int().describe('Remaining username changes'),
|
||||
})
|
||||
|
||||
@@ -122,21 +141,54 @@ export const CreateAccountRequest = z.object({
|
||||
platformId: z.string().optional().describe('Parsed for fidelity; currently unused'),
|
||||
})
|
||||
|
||||
/** Single-string form bodies, one per profile mutation. */
|
||||
/**
|
||||
* Single-string form bodies, one per profile mutation.
|
||||
*
|
||||
* These are ENFORCED, not just described: each is handed to hono-openapi's `validator`,
|
||||
* so the same schema both validates the request and generates the spec. Before this they
|
||||
* were documentation only, and the real rule lived in the handler — which meant every
|
||||
* limit had to be edited in two places and nothing caught them disagreeing.
|
||||
*
|
||||
* The rules themselves come from `@repo/domain` so `rooms` and `clubs` can't drift from
|
||||
* `accounts`; `superRefine` is used where the message matters, because `nameRejection`
|
||||
* 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. */
|
||||
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 })
|
||||
})
|
||||
|
||||
export const DisplayNameRequest = z.object({
|
||||
displayName: z.string().describe('Trimmed; empty is rejected (400)'),
|
||||
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
|
||||
.min(1)
|
||||
.describe('Trimmed; letters and digits only, max 15. Empty or invalid is rejected (400)'),
|
||||
})
|
||||
|
||||
export const UsernameRequest = z.object({
|
||||
username: z.string().describe('Trimmed; must be unique and changes must remain'),
|
||||
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'
|
||||
),
|
||||
})
|
||||
|
||||
export const EmailRequest = z.object({
|
||||
email: z.string().describe('Must contain "@"; otherwise 400'),
|
||||
email: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(isValidEmail, 'That email address looks wrong.')
|
||||
.describe('A syntactically valid address (RFC 5321/5322, so at most 254); otherwise 400'),
|
||||
})
|
||||
|
||||
export const PhoneRequest = z.object({
|
||||
phone: z.string().describe('Trimmed; empty is rejected (400)'),
|
||||
// No shape rule on purpose: the client sends E.164 (`+15552223333`), which the name
|
||||
// rule above would reject outright by eating the leading `+`.
|
||||
phone: z.string().trim().min(1).describe('Trimmed; empty is rejected (400)'),
|
||||
})
|
||||
|
||||
export const IdentityFlagsRequest = z.object({
|
||||
@@ -147,7 +199,10 @@ export const PronounsRequest = z.object({
|
||||
pronounFlags: z.string().describe('Integer string bitmask; non-numeric is 400'),
|
||||
})
|
||||
|
||||
export const BioRequest = z.object({ bio: z.string().describe('Free text; empty is allowed') })
|
||||
export const BioRequest = z.object({
|
||||
// Not trimmed — a bio is free text, and leading whitespace is the player's business.
|
||||
bio: z.string().refine(isValidBio).describe('Free text, max 255; empty is allowed'),
|
||||
})
|
||||
|
||||
export const ProfileImageRequest = z.object({
|
||||
imageName: z.string().describe('Avatar object key; empty is rejected (400)'),
|
||||
|
||||
@@ -161,6 +161,9 @@ describe('auth-gated endpoints', () => {
|
||||
personalPronouns: 0,
|
||||
identityFlags: 0,
|
||||
availableUsernameChanges: 1,
|
||||
// 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: '',
|
||||
})
|
||||
// juniorState + parentAccountId must be omitted when null, not emitted as
|
||||
// null, or the client's enum parser throws on `juniorState`. `phone` isn't
|
||||
@@ -459,4 +462,179 @@ describe('auth-gated endpoints', () => {
|
||||
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
|
||||
}
|
||||
})
|
||||
|
||||
// hono-openapi registers a validated form body under `multipart/form-data` only, and
|
||||
// its `media` option can't say otherwise (a precedence bug — see `withCleanSpec`). The
|
||||
// real callers post `application/x-www-form-urlencoded`, so a spec that named only
|
||||
// multipart would tell an integrator to send the one thing nothing here sends.
|
||||
test('GET /openapi.json documents both form content types on validated routes', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
const spec = (await res.json()) as {
|
||||
paths: Record<string, Record<string, { requestBody?: { content: Record<string, unknown> } }>>
|
||||
}
|
||||
|
||||
for (const [path, method] of [
|
||||
['/account/me/email', 'post'],
|
||||
['/account/me/username', 'put'],
|
||||
['/account/me/displayname', 'put'],
|
||||
['/account/me/bio', 'put'],
|
||||
['/account/me/phone', 'post'],
|
||||
] as const) {
|
||||
const content = spec.paths[path]?.[method]?.requestBody?.content ?? {}
|
||||
expect(Object.keys(content).sort(), path).toEqual([
|
||||
'application/x-www-form-urlencoded',
|
||||
'multipart/form-data',
|
||||
])
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// The names a player chooses are alphanumeric and length-capped, by the same rule the
|
||||
// `rooms` worker applies (see `nameRejection` in @repo/domain). The three limits come
|
||||
// from the client's own input boxes rather than a round number, so anything stored is
|
||||
// something the game can render and re-edit.
|
||||
//
|
||||
// Server-generated names go around this deliberately — the seeded "Rec Room" account
|
||||
// above has a space in its display name, and dorms are called `@<username>'s Dorm`. The
|
||||
// check belongs at the request handler, not in the db helpers.
|
||||
describe('name, email and bio validation', () => {
|
||||
const authed = async (sub: string) => ({
|
||||
...(await bearer(sub)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
})
|
||||
|
||||
test('PUT /account/me/username refuses anything but letters and digits, max 50', async () => {
|
||||
const headers = await authed('8801')
|
||||
for (const username of ['has space', 'under_score', 'punct!', 'café', 'a'.repeat(51)]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/username`, {
|
||||
...form({ username }),
|
||||
headers,
|
||||
})
|
||||
// Refused by the SCHEMA (see openapi.ts `UsernameRequest`) before the handler
|
||||
// runs — but still the envelope at HTTP 200, like every other refusal here,
|
||||
// because the hook puts it there.
|
||||
expect(res.status, username).toBe(200)
|
||||
const body = (await res.json()) as { success: boolean; error: string; value: string }
|
||||
expect(body.success, username).toBe(false)
|
||||
expect(body.error).toMatch(/letters and numbers|at most 50 characters/)
|
||||
expect(body.value).toBe('')
|
||||
}
|
||||
|
||||
// The rationed change must NOT be spent by a refusal: an account starts with one,
|
||||
// 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)
|
||||
|
||||
// 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`, {
|
||||
...form({ username: 'a'.repeat(50) }),
|
||||
headers,
|
||||
})
|
||||
expect(((await ok.json()) as { success: boolean }).success).toBe(true)
|
||||
})
|
||||
|
||||
test('PUT /account/me/displayname refuses anything but letters and digits, max 15', async () => {
|
||||
const headers = await authed('8802')
|
||||
for (const displayName of ['has space', 'punct!', 'a'.repeat(16)]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
||||
...form({ displayName }),
|
||||
headers,
|
||||
})
|
||||
// An empty 400, matching what this route already answers for an empty name —
|
||||
// it acks with a bare `{ success: true }` and has never sent the client a body
|
||||
// on failure.
|
||||
expect(res.status, displayName).toBe(400)
|
||||
}
|
||||
|
||||
// 15 is the client's box, so it must fit.
|
||||
const ok = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
||||
...form({ displayName: 'a'.repeat(15) }),
|
||||
headers,
|
||||
})
|
||||
expect(ok.status).toBe(200)
|
||||
})
|
||||
|
||||
// Syntax comes from the `isemail` package rather than a pattern written here — this is
|
||||
// a contact address nothing is ever sent to in order to prove it, so a hand-rolled
|
||||
// regex only buys more edge cases to get wrong. It enforces the RFC's own
|
||||
// 254-character maximum, which is why there's no separate length check.
|
||||
test('POST /account/me/email requires a syntactically valid address', async () => {
|
||||
const headers = await authed('8803')
|
||||
const bad = [
|
||||
'nope', // no @ at all — what this route used to be the only check for
|
||||
'@example.com', // nothing to deliver to
|
||||
'someone@', // no domain
|
||||
'someone@example.', // empty last label
|
||||
'two words@example.com', // whitespace
|
||||
`${'a'.repeat(250)}@example.com`, // past the RFC's 254
|
||||
]
|
||||
for (const email of bad) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
|
||||
...form({ email }),
|
||||
method: 'POST',
|
||||
headers,
|
||||
})
|
||||
expect(res.status, email).toBe(400)
|
||||
}
|
||||
|
||||
// `someone@localhost` is in the ACCEPTED list on purpose: it's valid per the RFC,
|
||||
// and an undeliverable address costs nothing here.
|
||||
for (const email of [
|
||||
'someone@example.com',
|
||||
'first.last+tag@mail.example.co.uk',
|
||||
'someone@localhost',
|
||||
]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/email`, {
|
||||
...form({ email }),
|
||||
method: 'POST',
|
||||
headers,
|
||||
})
|
||||
expect(res.status, email).toBe(200)
|
||||
}
|
||||
})
|
||||
|
||||
test('PUT /account/me/bio caps the stored text at 255 characters', async () => {
|
||||
const headers = await authed('8804')
|
||||
|
||||
const ok = await exports.default.fetch(`${ORIGIN}/account/me/bio`, {
|
||||
...form({ bio: 'b'.repeat(255) }),
|
||||
headers,
|
||||
})
|
||||
expect(ok.status).toBe(200)
|
||||
|
||||
// Refused rather than truncated — storing half a sentence reads as data loss.
|
||||
const tooLong = await exports.default.fetch(`${ORIGIN}/account/me/bio`, {
|
||||
...form({ bio: 'b'.repeat(256) }),
|
||||
headers,
|
||||
})
|
||||
expect(tooLong.status).toBe(400)
|
||||
|
||||
// The refusal changed nothing: the 255-character bio is still what's stored.
|
||||
const me = await exports.default.fetch(`${ORIGIN}/account/8804/bio`)
|
||||
expect(((await me.json()) as { bio: string }).bio).toBe('b'.repeat(255))
|
||||
})
|
||||
})
|
||||
|
||||
// Phone is deliberately NOT held to the name rule above: the client sends E.164
|
||||
// (`+15552223333`), so a letters-and-digits check would reject every real number by
|
||||
// eating the leading `+`. Pinned here because this route sits between two that DID just
|
||||
// get stricter, and the obvious next "cleanup" is to make it match them.
|
||||
test('POST /account/me/phone stores an E.164 number exactly as the client sends it', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/phone`, {
|
||||
...form({ phone: '+15552223333' }),
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('8805')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
|
||||
// Read from the row: phone is stored but not surfaced by any DTO, so there's no
|
||||
// endpoint to check it through.
|
||||
const row = await env.DB.prepare(
|
||||
"SELECT json_extract(data, '$.phone') AS phone FROM account WHERE json_extract(data, '$.accountId') = 8805"
|
||||
).first<{ phone: string }>()
|
||||
// Verbatim — no normalising, no stripping of the +.
|
||||
expect(row?.phone).toBe('+15552223333')
|
||||
})
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Player-report storage. Like the relationship table (and unlike the JSON-blob
|
||||
-- tables in this shared database), a report is genuinely columnar, so it gets a
|
||||
-- normal relational table. Owned by the `api` worker; generated from
|
||||
-- src/reports-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- One row per submitted report; nothing updates or dedupes them, so the table is
|
||||
-- an append-only log of what players sent. `reporter_player_id` comes from the
|
||||
-- caller's bearer token, everything else from the form body.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS report (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
reporter_player_id INTEGER NOT NULL,
|
||||
reported_player_id INTEGER NOT NULL,
|
||||
report_category INTEGER NOT NULL DEFAULT 0,
|
||||
details TEXT,
|
||||
height_reporter REAL,
|
||||
height_reported REAL,
|
||||
room_id INTEGER,
|
||||
room_instance_type TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
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);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Moderator-issued player warnings. The counterpart to the `report` table (0004):
|
||||
-- reports are what players submit, warnings are what a moderator hands down. Also
|
||||
-- columnar rather than a JSON blob, and likewise append-only. Owned by the `api`
|
||||
-- worker; generated from src/warnings-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `moderator_player_id` is the acting moderator, taken from the caller's bearer
|
||||
-- token (the endpoint is gated on the `moderator` role); everything else comes
|
||||
-- from the form body. `display_reason` is what the warned player is shown,
|
||||
-- `moderator_note` is internal.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS warning (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
moderator_player_id INTEGER NOT NULL,
|
||||
warned_player_id INTEGER NOT NULL,
|
||||
report_category INTEGER NOT NULL DEFAULT 0,
|
||||
display_reason TEXT,
|
||||
moderator_note TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_warning_warned ON warning (warned_player_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_warning_moderator ON warning (moderator_player_id);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Player-event storage (scheduled events: a room, a window of time, and the
|
||||
-- settings the event runs under). Like the image/invention/rooms/accounts tables
|
||||
-- in this shared database, an event is a single JSON blob in the `data` column,
|
||||
-- with queryable fields exposed as SQLite generated (virtual) columns extracted
|
||||
-- from that JSON. Owned by the `api` worker; generated from src/events-db.ts
|
||||
-- (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- The stored blob IS the DTO: every read endpoint serves it verbatim, so the
|
||||
-- PascalCase field set matches Rec Room's `PlayerEvent` exactly. `start_time` /
|
||||
-- `end_time` extract ISO-8601 UTC strings, which compare lexicographically — the
|
||||
-- browse query filters finished events in SQL on that.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event (
|
||||
data TEXT NOT NULL,
|
||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerEventId')) VIRTUAL,
|
||||
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
|
||||
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
|
||||
start_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.StartTime')) VIRTUAL,
|
||||
end_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.EndTime')) VIRTUAL
|
||||
);
|
||||
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_start ON event (start_time);
|
||||
@@ -0,0 +1,23 @@
|
||||
-- Player-event RSVPs: one row per player per event, recording how they answered
|
||||
-- (`POST /api/playerevents/v1/respond`). Unlike the `event` table next to it, this
|
||||
-- one is genuinely columnar — like the relationship/report tables — so it's a
|
||||
-- normal relational table rather than a JSON blob. Owned by the `api` worker;
|
||||
-- generated from src/events-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `status` is the response type: 0 Going, 1 Interested, 2 Can't go. Only Going
|
||||
-- counts toward the event's `AttendeeCount`, which is recomputed from this table on
|
||||
-- every response. A decline is recorded rather than deleted, so the client can show
|
||||
-- a player their own answer and changing your mind is an UPDATE (the composite
|
||||
-- primary key is what makes the upsert a replace).
|
||||
--
|
||||
-- An event's creator gets a Going row at create time — that's why a fresh event's
|
||||
-- AttendeeCount is 1.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_attendee (
|
||||
event_id INTEGER NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
responded_at TEXT NOT NULL,
|
||||
PRIMARY KEY (event_id, player_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_attendee_player ON event_attendee (player_id);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Break the two visibility flags out of the invention JSON blob into queryable
|
||||
-- generated columns, the same way 0003 did for `IsFeatured`. `IsPublished` and
|
||||
-- `HideFromPlayer` are always tested together — every feed, the search/browse list and
|
||||
-- the per-room list ask for "published and not hidden" — so they move together.
|
||||
-- Generated from src/inventions-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- SQLite allows ALTER TABLE ADD COLUMN only for VIRTUAL generated columns (a STORED one
|
||||
-- would need rewriting existing rows), which is what we want anyway: the value stays
|
||||
-- derived from `data`, so nothing can drift out of sync with it. json_extract of a JSON
|
||||
-- `true` is 1, so both columns read 1/0 — and NULL for a blob missing the key, which is
|
||||
-- neither 1 nor 0 and so fails both filters exactly as the json_extract predicates it
|
||||
-- replaces did. This is a rename, not a behaviour change.
|
||||
--
|
||||
-- No index: both columns are booleans that are overwhelmingly one value (nearly every
|
||||
-- invention is published and not hidden), so an index on them would be read past rather
|
||||
-- than used. The selective one is idx_invention_featured, added in 0003, which stays.
|
||||
|
||||
ALTER TABLE invention
|
||||
ADD COLUMN is_published INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsPublished')) VIRTUAL;
|
||||
ALTER TABLE invention
|
||||
ADD COLUMN hide_from_player INTEGER GENERATED ALWAYS AS (json_extract(data, '$.HideFromPlayer')) VIRTUAL;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Turn a report into a ban. A report row already names the player it is against
|
||||
-- (`reported_player_id`), so a moderator acting on one flips `banned` on that same row
|
||||
-- rather than duplicating it into a second table — the ban then carries the report that
|
||||
-- justified it (category, details, room, who filed it) with no join.
|
||||
-- Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `ban_expires` is an ISO-8601 UTC timestamp like `created_at`, and NULL means the ban
|
||||
-- never expires. Kept as its own column rather than "banned until" alone so a lifted ban
|
||||
-- (banned = 0) is distinguishable from an expired one, and so the row remains a report
|
||||
-- once the ban is over. Rows stay append-only in every other respect.
|
||||
--
|
||||
-- Partial index: bans are rare next to reports, so indexing only the banned rows keeps
|
||||
-- the lookup (done on every matchmake and every token grant) reading a handful of pages
|
||||
-- instead of every report ever filed against that player. idx_report_reported stays —
|
||||
-- it serves the "all reports against this player" moderation read, which is unfiltered.
|
||||
|
||||
ALTER TABLE report ADD COLUMN banned INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE report ADD COLUMN ban_expires TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_report_banned ON report (reported_player_id) WHERE banned = 1;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Player-event tags: the categories an event is filed under (`workshops`, `meetup`, …),
|
||||
-- one row per tag per event. Owned by the `api` worker; generated from src/events-db.ts
|
||||
-- (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- A separate table rather than a field on the event blob, for a reason that isn't
|
||||
-- storage taste: the stored blob IS the event DTO every read serves verbatim, and the
|
||||
-- event reads do NOT carry tags — they surface only behind
|
||||
-- `GET /api/playerevents/v1/{id}?includeDetails=True`. Putting them in the blob would
|
||||
-- leak a `Tags` key into every other read.
|
||||
--
|
||||
-- `tag` is stored lowercased and is the search key: `?query=%23workshops` (a `#`-prefixed
|
||||
-- term) filters on this table, while a bare term still matches the name/description.
|
||||
-- `type` is the client's tag-category int, echoed back as sent — its enum isn't reversed
|
||||
-- yet, and nothing here interprets it.
|
||||
--
|
||||
-- The primary key is (event_id, tag): an event can't carry the same tag twice, and a tag
|
||||
-- edit REPLACES the event's set rather than accumulating.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_tag (
|
||||
event_id INTEGER NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
type INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (event_id, tag)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_tag_tag ON event_tag (tag);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Reporting a player EVENT (`POST /api/playerevents/v1/report`) reuses the report
|
||||
-- table rather than getting one of its own: it is the same submission with the same
|
||||
-- fields (category, free-text details, the reporter from the token) and the same
|
||||
-- moderation life — a moderator acting on it sets `banned` on the row exactly as they
|
||||
-- would for a player report. Generated from src/reports-db.ts (SCHEMA_DDL) — keep in
|
||||
-- sync.
|
||||
--
|
||||
-- `event_id` names the reported event; NULL on every ordinary player report, which is
|
||||
-- what tells the two kinds apart. The row's other columns are still filled in from the
|
||||
-- event: `reported_player_id` is its CREATOR (the person a moderator would act
|
||||
-- against — the column is NOT NULL, and "who is answerable for this event" is the only
|
||||
-- honest answer), and `room_id` the room it runs in, read from the event table so the
|
||||
-- client doesn't have to send either.
|
||||
--
|
||||
-- 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.
|
||||
|
||||
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;
|
||||
+14
-4
@@ -2,10 +2,11 @@ import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
|
||||
import { avatarRoutes } from './routes/avatar'
|
||||
import { configRoutes } from './routes/config'
|
||||
import { eventRoutes } from './routes/events'
|
||||
import { gameplayRoutes } from './routes/gameplay'
|
||||
import { imageRoutes } from './routes/images'
|
||||
import { inventoryRoutes } from './routes/inventory'
|
||||
@@ -39,6 +40,14 @@ const app = new Hono<App>({ strict: false })
|
||||
})(c, next)
|
||||
)
|
||||
|
||||
// The website (`www`) is a browser origin calling these endpoints directly, the way
|
||||
// rec.net's own site called the game's API — so the responses need CORS headers or
|
||||
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
|
||||
// endpoints authenticate with a bearer token in the `Authorization` header, never a
|
||||
// cookie: a hostile page can't read another origin's stored token, so there is no
|
||||
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
|
||||
.use('*', withDefaultCors())
|
||||
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
@@ -48,6 +57,7 @@ const app = new Hono<App>({ strict: false })
|
||||
.route('/', progressionRoutes)
|
||||
.route('/', avatarRoutes)
|
||||
.route('/', gameplayRoutes)
|
||||
.route('/', eventRoutes)
|
||||
.route('/', moderationRoutes)
|
||||
.route('/', inventoryRoutes)
|
||||
.route('/', roomRoutes)
|
||||
@@ -68,9 +78,9 @@ app.get(
|
||||
'The catch-all Game API for recflare, a private-server reimplementation of the Rec',
|
||||
'Room backend: everything the client calls that has not been split out into its own',
|
||||
'worker yet. Today that is config, the friend graph, inventions, saved photos,',
|
||||
'reputation and the assorted sinks the client hits while loading. Relationships,',
|
||||
'inventions and images are D1-backed; several endpoints are still stubs, noted per',
|
||||
'route.',
|
||||
'player events, reputation and the assorted sinks the client hits while loading.',
|
||||
'Relationships, inventions, images and player events are D1-backed; several',
|
||||
'endpoints are still stubs, noted per route.',
|
||||
'',
|
||||
'Expect this surface to shrink. Paths that also exist on a dedicated worker (avatar,',
|
||||
'equipment, consumables and objectives on `econ`) are already served there — the',
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Who a ban reaches — the ban itself, plus the accounts that share an identity with a
|
||||
* banned one. This is the ban-EVASION half of moderation: a ban lives on a `report` row
|
||||
* (see reports-db) and applies to one account, but a player whose account is banned can
|
||||
* make another in seconds, so the block has to follow the things that are harder to
|
||||
* change than an account: the platform identity they log in with, and the network they
|
||||
* play from.
|
||||
*
|
||||
* Three arms, in descending order of how much they prove:
|
||||
* - ACCOUNT — the caller's own account is banned. Certain.
|
||||
* - PLATFORM — the caller shares a `platform_account` link (a Steam or Meta identity
|
||||
* they PROVED to us; see the auth worker's platform-db) with a banned account. Sharp:
|
||||
* linking only ever happens off a verified proof, so this really is the same person,
|
||||
* modulo somebody handing over their Steam account.
|
||||
* - IP — the caller shares a `signupIp`/`lastLoginIp` with a banned account. COARSE,
|
||||
* and the one that will produce false positives: households, NAT, campus and mobile
|
||||
* carrier networks put many unrelated players behind one address, so this arm bans a
|
||||
* banned player's whole household along with them. It is the operator's call whether
|
||||
* that trade is worth it — hence `BAN_EVASION_MATCH` (see `banEvasionMatch`), which
|
||||
* narrows or disables the linked arms without touching the direct one.
|
||||
*
|
||||
* The direct arm can never be turned off. That is the point of the split: an operator
|
||||
* dialling back evasion matching still enforces every ban they handed down.
|
||||
*
|
||||
* Reads three tables owned by three workers — `report` (api), `account` (auth, via the
|
||||
* blob) and `platform_account` (auth) — which is why this is its own module rather than
|
||||
* part of reports-db: it is the POLICY over those tables, not any one table's storage.
|
||||
* It only ever reads them.
|
||||
*
|
||||
* The whole resolution is ONE statement. The alternative — fetch my ips, fetch my links,
|
||||
* then query bans — is three round trips on a path that runs on every matchmake and every
|
||||
* token grant. Driving from the (few) banned reports and looking each one's account up by
|
||||
* its indexed id keeps the work proportional to the number of BANS, not to the number of
|
||||
* accounts.
|
||||
*/
|
||||
|
||||
import type { ReportRow } from './reports-db'
|
||||
|
||||
/** Which arm matched — what the block is actually resting on. */
|
||||
export type BanVia = 'account' | 'platform' | 'ip'
|
||||
|
||||
/** A ban that reaches the caller, and how it reached them. */
|
||||
export interface BanMatch {
|
||||
/** The report row carrying the ban (its `reported_player_id` is who was banned). */
|
||||
ban: ReportRow
|
||||
via: BanVia
|
||||
/**
|
||||
* The banned account. Equal to the caller on a direct ban; on a linked arm it's the
|
||||
* OTHER account they were matched to — the one worth naming in the operator's log.
|
||||
*/
|
||||
bannedAccountId: number
|
||||
}
|
||||
|
||||
/** Which linked arms are enabled. The direct (account) arm is not optional. */
|
||||
export interface BanMatchArms {
|
||||
ip: boolean
|
||||
platform: boolean
|
||||
}
|
||||
|
||||
/** Both linked arms on — what an operator who sets nothing gets. */
|
||||
export const DEFAULT_BAN_MATCH_ARMS: BanMatchArms = { ip: true, platform: true }
|
||||
|
||||
/**
|
||||
* Read the `BAN_EVASION_MATCH` operator knob: a comma-separated list of the linked arms
|
||||
* to enforce, out of `ip` and `platform`. Unset (the default) means BOTH — a ban follows
|
||||
* the player. `off` (or `none`, or an empty list) leaves only the direct arm, so a ban
|
||||
* applies to exactly the account it was handed to.
|
||||
*
|
||||
* Set it to `platform` on a server whose players share networks — student halls, one
|
||||
* household, a country behind CGNAT — where the IP arm would lock out bystanders. The
|
||||
* platform arm has no such failure mode: it matches a proven identity.
|
||||
*
|
||||
* Unrecognised names are ignored rather than fatal: this is read on a request path, and a
|
||||
* typo must not take matchmaking or login down with it. `off` wins over anything else in
|
||||
* the list, so `off,ip` is off.
|
||||
*/
|
||||
export function banEvasionMatch(value: string | undefined): BanMatchArms {
|
||||
if (value === undefined) return DEFAULT_BAN_MATCH_ARMS
|
||||
const names = value
|
||||
.split(',')
|
||||
.map((n) => n.trim().toLowerCase())
|
||||
.filter((n) => n !== '')
|
||||
if (names.length === 0 || names.includes('off') || names.includes('none')) {
|
||||
return { ip: false, platform: false }
|
||||
}
|
||||
return { ip: names.includes('ip'), platform: names.includes('platform') }
|
||||
}
|
||||
|
||||
/**
|
||||
* The identity a request carries, for a caller who has no account yet — a `create_account`
|
||||
* grant, which must be refused BEFORE it mints anything, or a banned player's next account
|
||||
* exists (and has burned a signup) before the ban catches up with it.
|
||||
*/
|
||||
export interface BanIdentity {
|
||||
/** The client IP the request came from, if the edge reported one. */
|
||||
ip?: string | null
|
||||
/** A VERIFIED platform identity. An unproven one must never be passed here. */
|
||||
platform?: number | null
|
||||
platformId?: string | null
|
||||
}
|
||||
|
||||
/** Row shape of the resolution query — a report plus which arm matched it. */
|
||||
type BanMatchRow = ReportRow & {
|
||||
via_account: number
|
||||
via_ip: number
|
||||
via_platform: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Every ban in force, tested against the caller's account and against the identity they
|
||||
* present. `ips` and `ids` gather what the caller is known by: the account's stored IPs
|
||||
* and platform links (when there is an account) plus the IP/identity this request itself
|
||||
* carries (when there isn't one yet, or when it differs from what's stored).
|
||||
*
|
||||
* A NULL `?1` means "no account yet" — the `me` CTE is then empty and the account arm
|
||||
* cannot match, leaving the two linked arms to answer for a signup.
|
||||
*/
|
||||
const RESOLVE_BAN_SQL = `
|
||||
WITH me AS (
|
||||
SELECT
|
||||
NULLIF(json_extract(data, '$.signupIp'), '') AS signup_ip,
|
||||
NULLIF(json_extract(data, '$.lastLoginIp'), '') AS last_login_ip
|
||||
FROM account WHERE account_id = ?1
|
||||
),
|
||||
ips AS (
|
||||
SELECT signup_ip AS ip FROM me WHERE signup_ip IS NOT NULL
|
||||
UNION SELECT last_login_ip FROM me WHERE last_login_ip IS NOT NULL
|
||||
UNION SELECT ?3 WHERE ?3 IS NOT NULL
|
||||
),
|
||||
ids AS (
|
||||
SELECT platform, platform_id FROM platform_account WHERE account_id = ?1
|
||||
UNION SELECT ?4, ?5 WHERE ?5 IS NOT NULL
|
||||
)
|
||||
SELECT * FROM (
|
||||
SELECT r.*,
|
||||
(r.reported_player_id = ?1) AS via_account,
|
||||
(?6 = 1 AND EXISTS (
|
||||
SELECT 1 FROM account a, ips
|
||||
WHERE a.account_id = r.reported_player_id
|
||||
AND a.account_id <> COALESCE(?1, -1)
|
||||
AND ips.ip IN (
|
||||
json_extract(a.data, '$.signupIp'),
|
||||
json_extract(a.data, '$.lastLoginIp')
|
||||
)
|
||||
)) AS via_ip,
|
||||
(?7 = 1 AND EXISTS (
|
||||
SELECT 1 FROM platform_account p, ids
|
||||
WHERE p.account_id = r.reported_player_id
|
||||
AND p.account_id <> COALESCE(?1, -1)
|
||||
AND p.platform = ids.platform
|
||||
AND p.platform_id = ids.platform_id
|
||||
)) AS via_platform
|
||||
FROM report r
|
||||
WHERE r.banned = 1 AND (r.ban_expires IS NULL OR r.ban_expires > ?2)
|
||||
)
|
||||
WHERE via_account = 1 OR via_ip = 1 OR via_platform = 1
|
||||
ORDER BY via_account DESC, via_platform DESC, ban_expires IS NOT NULL, ban_expires DESC
|
||||
LIMIT 1`
|
||||
|
||||
/**
|
||||
* The ban blocking this caller, or null when nothing does.
|
||||
*
|
||||
* Pass the `accountId` when there is one (every login after the first, and every
|
||||
* matchmake) and the request's own `identity` when it adds something the account doesn't
|
||||
* already carry — on a `create_account` grant there is no account at all, and that is
|
||||
* exactly the request a ban evader makes.
|
||||
*
|
||||
* The strongest match is the one returned: a direct ban ahead of a platform match ahead
|
||||
* of an IP one, then the longest-lasting ban of those. So the log line names the evidence
|
||||
* an operator would want to see first, and a player whose own account is banned is never
|
||||
* told it was their network.
|
||||
*/
|
||||
export async function resolveBan(
|
||||
db: D1Database,
|
||||
accountId: number | null,
|
||||
options: { identity?: BanIdentity; arms?: BanMatchArms; now?: Date } = {}
|
||||
): Promise<BanMatch | null> {
|
||||
const arms = options.arms ?? DEFAULT_BAN_MATCH_ARMS
|
||||
const identity = options.identity ?? {}
|
||||
const row = await db
|
||||
.prepare(RESOLVE_BAN_SQL)
|
||||
.bind(
|
||||
accountId,
|
||||
(options.now ?? new Date()).toISOString(),
|
||||
identity.ip || null,
|
||||
identity.platform ?? 0,
|
||||
identity.platformId || null,
|
||||
arms.ip ? 1 : 0,
|
||||
arms.platform ? 1 : 0
|
||||
)
|
||||
.first<BanMatchRow>()
|
||||
if (!row) return null
|
||||
|
||||
// `via_ip` is only stripped off the row here — it's the arm left when neither of the
|
||||
// other two matched, so nothing reads it.
|
||||
const { via_account, via_ip: _via_ip, via_platform, ...ban } = row
|
||||
const via: BanVia = via_account === 1 ? 'account' : via_platform === 1 ? 'platform' : 'ip'
|
||||
return { ban: ban as ReportRow, via, bannedAccountId: ban.reported_player_id }
|
||||
}
|
||||
|
||||
/** Whether anything blocks this caller — the boolean form of `resolveBan`. */
|
||||
export async function isPlayerBlocked(
|
||||
db: D1Database,
|
||||
accountId: number | null,
|
||||
options: { identity?: BanIdentity; arms?: BanMatchArms; now?: Date } = {}
|
||||
): Promise<boolean> {
|
||||
return (await resolveBan(db, accountId, options)) !== null
|
||||
}
|
||||
@@ -0,0 +1,844 @@
|
||||
/**
|
||||
* Player-event storage on the shared `recflare` D1 database. Each event is a single
|
||||
* JSON blob in the `data` column; queryable fields (id, creator, club, start time)
|
||||
* are SQLite generated (virtual) columns extracted from that JSON — the same
|
||||
* JSON-blob pattern the image/invention/rooms/accounts tables use.
|
||||
*
|
||||
* The `api` worker owns this schema/migration (migrations/0006_event.sql and
|
||||
* 0007_event_attendee.sql, applied under its own `migrations_table` so they don't
|
||||
* clash with the other workers' migrations on the shared database).
|
||||
*
|
||||
* The stored record IS the DTO: every read endpoint serves the blob verbatim, so the
|
||||
* field set and casing here are exactly what the client parses. Timestamps are
|
||||
* normalized to `2020-11-29T22:00:00Z` (no fractional seconds) to match.
|
||||
*
|
||||
* RSVPs live alongside in `event_attendee`, one row per player per event. That one is
|
||||
* genuinely columnar (like the relationship/report tables), so it's a normal
|
||||
* relational table rather than a JSON blob.
|
||||
*/
|
||||
|
||||
import {
|
||||
glyphLength,
|
||||
MAX_EVENT_DESCRIPTION_LENGTH,
|
||||
MAX_EVENT_NAME_LENGTH,
|
||||
} from '@repo/domain'
|
||||
|
||||
/**
|
||||
* Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql, sans any
|
||||
* seed rows).
|
||||
*/
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS event (
|
||||
data TEXT NOT NULL,
|
||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerEventId')) VIRTUAL,
|
||||
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
|
||||
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
|
||||
start_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.StartTime')) VIRTUAL,
|
||||
end_time TEXT GENERATED ALWAYS AS (json_extract(data, '$.EndTime')) VIRTUAL
|
||||
)`,
|
||||
`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_start ON event (start_time)`,
|
||||
`CREATE TABLE IF NOT EXISTS event_attendee (
|
||||
event_id INTEGER NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
responded_at TEXT NOT NULL,
|
||||
PRIMARY KEY (event_id, player_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_event_attendee_player ON event_attendee (player_id)`,
|
||||
`CREATE TABLE IF NOT EXISTS event_tag (
|
||||
event_id INTEGER NOT NULL,
|
||||
tag TEXT NOT NULL,
|
||||
type INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (event_id, tag)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_event_tag_tag ON event_tag (tag)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* How a player answered an event invitation — the `Type` on
|
||||
* `POST /api/playerevents/v1/respond`, stored as `event_attendee.status`.
|
||||
*
|
||||
* Only `going` counts toward an event's `AttendeeCount`: interested is a maybe, and
|
||||
* declining is recorded rather than deleted so the client can show the player their own
|
||||
* answer (and so changing your mind is an update, not an insert).
|
||||
*/
|
||||
export const EVENT_RESPONSE = {
|
||||
going: 0,
|
||||
interested: 1,
|
||||
cantGo: 2,
|
||||
} as const
|
||||
|
||||
/** The response types, for validating an incoming `Type`. */
|
||||
const EVENT_RESPONSE_VALUES: number[] = Object.values(EVENT_RESPONSE)
|
||||
|
||||
/** Whether a number is one of the three response types. */
|
||||
export function isEventResponseType(value: number): boolean {
|
||||
return EVENT_RESPONSE_VALUES.includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* One player's answer to one event.
|
||||
*
|
||||
* `id` is the row's SQLite `rowid` — the table has a composite primary key, so it's a
|
||||
* rowid table and the implicit id is free. It's what the RSVP list serves as
|
||||
* `PlayerEventResponseId`, and it's stable: a changed answer is an UPDATE through the
|
||||
* composite key (same rowid), and nothing ever deletes an RSVP row.
|
||||
*/
|
||||
export interface EventAttendeeRow {
|
||||
id: number
|
||||
event_id: number
|
||||
player_id: number
|
||||
status: number
|
||||
responded_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* One RSVP as `GET /api/playerevents/v1/:eventId/responses` serves it — the PascalCase
|
||||
* projection of an `event_attendee` row.
|
||||
*
|
||||
* `CreatedAt` is the stored `responded_at`, so it's the time of the answer CURRENTLY
|
||||
* recorded, not of the player's first one: changing your mind updates the row in place
|
||||
* (one row per player per event), and the client shows the answer that stands.
|
||||
*/
|
||||
export interface PlayerEventResponse {
|
||||
PlayerEventResponseId: number
|
||||
PlayerEventId: number
|
||||
PlayerId: number
|
||||
CreatedAt: string
|
||||
Type: number
|
||||
}
|
||||
|
||||
/** Project an RSVP row into the response the RSVP list serves. */
|
||||
export function toEventResponse(row: EventAttendeeRow): PlayerEventResponse {
|
||||
return {
|
||||
PlayerEventResponseId: row.id,
|
||||
PlayerEventId: row.event_id,
|
||||
PlayerId: row.player_id,
|
||||
CreatedAt: row.responded_at,
|
||||
Type: row.status,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One tag on an event — the categories the browse screen's filter chips name
|
||||
* (`workshops`, `meetup`, …). `tag` is stored and matched lowercased; `type` is the
|
||||
* client's tag-category int, echoed back as sent (its enum isn't reversed yet).
|
||||
*
|
||||
* Tags live in their own table, NOT on the event blob: the blob is the DTO every read
|
||||
* serves verbatim, and tags surface only behind `includeDetails=True`.
|
||||
*/
|
||||
export interface EventTag {
|
||||
tag: string
|
||||
type: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A scheduled player event (Rec Room's `PlayerEvent`) — a room, a window of time and
|
||||
* the settings the event runs under. Served verbatim by every read endpoint.
|
||||
*
|
||||
* `SubRoomId`/`ClubId`/`ImageName` are genuinely nullable: an event can name the room
|
||||
* without pinning a subroom, needn't belong to a club, and has no banner until one is
|
||||
* uploaded. The three `*Permissions`/`State`/`Accessibility` ints are stored as the
|
||||
* client sends them — their enums aren't reversed yet, so nothing here interprets
|
||||
* them beyond the defaults below.
|
||||
*/
|
||||
export interface PlayerEvent {
|
||||
PlayerEventId: number
|
||||
CreatorPlayerId: number
|
||||
ImageName: string | null
|
||||
RoomId: number
|
||||
SubRoomId: number | null
|
||||
ClubId: number | null
|
||||
Name: string
|
||||
Description: string
|
||||
/** ISO 8601 UTC, seconds precision (`2020-11-29T22:00:00Z`). */
|
||||
StartTime: string
|
||||
EndTime: string
|
||||
AttendeeCount: number
|
||||
State: number
|
||||
Accessibility: number
|
||||
IsMultiInstance: boolean
|
||||
SupportMultiInstanceRoomChat: boolean
|
||||
DefaultBroadcastPermissions: number
|
||||
CanRequestBroadcastPermissions: number
|
||||
}
|
||||
|
||||
interface EventRow {
|
||||
data: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The envelope the create/update writes answer with — the event nested under a status,
|
||||
* rather than the bare record the read endpoints serve. `Result` is 0 on success.
|
||||
*
|
||||
* `TagModifyResult` is always null: the real API reports the outcome of the tag edit
|
||||
* that rides along with the write, and we store no event tags (see the tag-filter
|
||||
* chips, which are static). The field stays present because the client's parser
|
||||
* expects it.
|
||||
*/
|
||||
export interface PlayerEventResult {
|
||||
Result: number
|
||||
TagModifyResult: null
|
||||
PlayerEvent: PlayerEvent
|
||||
}
|
||||
|
||||
/** Wrap a stored event in the write envelope. */
|
||||
export function toEventResult(event: PlayerEvent): PlayerEventResult {
|
||||
return { Result: 0, TagModifyResult: null, PlayerEvent: event }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* them:
|
||||
*
|
||||
* - it is camelCase, where the record and every read endpoint are PascalCase;
|
||||
* - it carries `tags` and `broadcastingRoomInstanceId`, which the record has no fields
|
||||
* for (no event tags are stored, and nothing broadcasts an event yet, so both are
|
||||
* empty/null), and drops `State`;
|
||||
* - its timestamps are padded to .NET tick precision (`…T19:00:00.0000000Z`) while the
|
||||
* record stores them bare. That asymmetry is the reference server's: its notification
|
||||
* frames carry the padded form and its event reads don't.
|
||||
*/
|
||||
export interface PlayerEventNotification {
|
||||
tags: Array<{ tag: string; type: number }>
|
||||
playerEventId: number
|
||||
creatorPlayerId: number
|
||||
roomId: number
|
||||
subRoomId: number | null
|
||||
clubId: number | null
|
||||
name: string
|
||||
description: string
|
||||
imageName: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
attendeeCount: number
|
||||
accessibility: number
|
||||
isMultiInstance: boolean
|
||||
supportMultiInstanceRoomChat: boolean
|
||||
defaultBroadcastPermissions: number
|
||||
canRequestBroadcastPermissions: number
|
||||
broadcastingRoomInstanceId: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* The projection the browse feed (`GET /api/playerevents/v1`) serves. PascalCase like
|
||||
* the stored record, but not identical to it — don't unify them:
|
||||
*
|
||||
* - it drops `State`, which the feed does not carry;
|
||||
* - it carries `BroadcastingRoomInstanceId`, which the record has no field for (nothing
|
||||
* broadcasts an event yet, so it is always null).
|
||||
*
|
||||
* That's the shape observed on this endpoint; the by-id / bulk / search reads serve the
|
||||
* stored record verbatim and keep `State`.
|
||||
*/
|
||||
export interface PlayerEventListing extends Omit<PlayerEvent, 'State'> {
|
||||
BroadcastingRoomInstanceId: number | null
|
||||
}
|
||||
|
||||
/** Project a stored event into the browse feed's listing. */
|
||||
export function toEventListing(event: PlayerEvent): PlayerEventListing {
|
||||
const { State: _State, ...rest } = event
|
||||
return { ...rest, BroadcastingRoomInstanceId: null }
|
||||
}
|
||||
|
||||
/** Pad a stored timestamp out to .NET tick precision (seven fractional digits). */
|
||||
function toTickPrecision(iso: string): string {
|
||||
const match = /^(.*?)(?:\.(\d+))?Z$/.exec(iso)
|
||||
if (match === null) return iso
|
||||
return `${match[1]}.${(match[2] ?? '').padEnd(7, '0').slice(0, 7)}Z`
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a stored event into its notification frame. `imageName` becomes an empty
|
||||
* string rather than null when the event has no banner: the frame carries `""`, and a
|
||||
* null wouldn't survive the trip anyway — the hub drops null values from `Msg`.
|
||||
*
|
||||
* `tags` are passed in rather than read from the event: they live in their own table,
|
||||
* and the callers that have them already looked them up.
|
||||
*/
|
||||
export function toEventNotification(
|
||||
event: PlayerEvent,
|
||||
tags: EventTag[] = []
|
||||
): PlayerEventNotification {
|
||||
return {
|
||||
tags,
|
||||
playerEventId: event.PlayerEventId,
|
||||
creatorPlayerId: event.CreatorPlayerId,
|
||||
roomId: event.RoomId,
|
||||
subRoomId: event.SubRoomId,
|
||||
clubId: event.ClubId,
|
||||
name: event.Name,
|
||||
description: event.Description,
|
||||
imageName: event.ImageName ?? '',
|
||||
startTime: toTickPrecision(event.StartTime),
|
||||
endTime: toTickPrecision(event.EndTime),
|
||||
attendeeCount: event.AttendeeCount,
|
||||
accessibility: event.Accessibility,
|
||||
isMultiInstance: event.IsMultiInstance,
|
||||
supportMultiInstanceRoomChat: event.SupportMultiInstanceRoomChat,
|
||||
defaultBroadcastPermissions: event.DefaultBroadcastPermissions,
|
||||
canRequestBroadcastPermissions: event.CanRequestBroadcastPermissions,
|
||||
broadcastingRoomInstanceId: null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a timestamp to the form the client sends and reads back —
|
||||
* `2020-11-29T22:00:00Z`, with no fractional seconds. `toISOString()` always emits
|
||||
* milliseconds, which the samples never carry, so they're trimmed.
|
||||
*/
|
||||
function eventTime(ms: number): string {
|
||||
return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z')
|
||||
}
|
||||
|
||||
/** 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
|
||||
.prepare('SELECT tag, type FROM event_tag WHERE event_id = ?1 ORDER BY tag')
|
||||
.bind(eventId)
|
||||
.all<EventTag>()
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace an event's tags with the given set — the tag edit that rides along with a
|
||||
* create or update. A replace, not a merge: the client posts the whole set it wants,
|
||||
* so an untagging is a post with the tag left out.
|
||||
*/
|
||||
export async function setEventTags(
|
||||
db: D1Database,
|
||||
eventId: number,
|
||||
tags: EventTag[]
|
||||
): Promise<void> {
|
||||
const statements = [db.prepare('DELETE FROM event_tag WHERE event_id = ?1').bind(eventId)]
|
||||
for (const { tag, type } of tags) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO event_tag (event_id, tag, type) VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT (event_id, tag) DO UPDATE SET type = ?3`
|
||||
)
|
||||
.bind(eventId, tag, type)
|
||||
)
|
||||
}
|
||||
await db.batch(statements)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields a create or update supplies, camelCased. Every one is optional: create
|
||||
* defaults what's missing, and update leaves anything absent at its stored value —
|
||||
* which is why the nullable ids are `number | null` rather than merely absent, so a
|
||||
* posted `"ClubId": null` can genuinely clear a club.
|
||||
*/
|
||||
export interface EventInput {
|
||||
/** The whole tag set to store; absent leaves the event's tags alone. */
|
||||
tags?: EventTag[]
|
||||
imageName?: string | null
|
||||
roomId?: number
|
||||
subRoomId?: number | null
|
||||
clubId?: number | null
|
||||
name?: string
|
||||
description?: string
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
state?: number
|
||||
accessibility?: number
|
||||
isMultiInstance?: boolean
|
||||
supportMultiInstanceRoomChat?: boolean
|
||||
defaultBroadcastPermissions?: number
|
||||
canRequestBroadcastPermissions?: number
|
||||
}
|
||||
|
||||
/** Read a value as an integer, or undefined when absent / not a number. */
|
||||
function asInt(value: unknown): number | undefined {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return Math.trunc(value)
|
||||
if (typeof value === 'string') {
|
||||
const n = Number.parseInt(value, 10)
|
||||
if (!Number.isNaN(n)) return n
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* The name is measured AFTER trimming, matching what create/update actually store.
|
||||
*/
|
||||
export function eventInputRejection(input: EventInput): 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.`
|
||||
}
|
||||
if (
|
||||
input.description !== undefined &&
|
||||
glyphLength(input.description) > MAX_EVENT_DESCRIPTION_LENGTH
|
||||
) {
|
||||
return `Event descriptions can be at most ${MAX_EVENT_DESCRIPTION_LENGTH} characters.`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the `Tags` a create/update body carries, or undefined when it carries none (an
|
||||
* update that says nothing about tags leaves them alone; `[]` genuinely clears them).
|
||||
*
|
||||
* Both forms in circulation are accepted — a bare string (`"workshops"`) and the
|
||||
* `{ tag, type }` object the notification frame carries — since the browse chips are
|
||||
* plain names while the client's own event model pairs each with a category int. Tags
|
||||
* 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 {
|
||||
if (!Array.isArray(raw)) return undefined
|
||||
const byTag = new Map<string, EventTag>()
|
||||
for (const entry of raw) {
|
||||
const source = (typeof entry === 'object' && entry !== null ? entry : {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
const name = typeof entry === 'string' ? entry : (source.tag ?? source.Tag)
|
||||
if (typeof name !== 'string') continue
|
||||
const tag = name.trim().replace(/^#/, '').toLowerCase()
|
||||
if (tag === '') continue
|
||||
byTag.set(tag, { tag, type: asInt(source.type ?? source.Type) ?? 0 })
|
||||
}
|
||||
return [...byTag.values()]
|
||||
}
|
||||
|
||||
export function parseEventBody(body: unknown): EventInput {
|
||||
const outer = (typeof body === 'object' && body !== null ? body : {}) as Record<string, unknown>
|
||||
const nested = outer.PlayerEvent
|
||||
const obj = (typeof nested === 'object' && nested !== null ? nested : outer) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
|
||||
const has = (key: string): boolean => Object.hasOwn(obj, key)
|
||||
// A nullable id: absent leaves it alone, an explicit null clears it.
|
||||
const nullableInt = (key: string): number | null | undefined => {
|
||||
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 bool = (key: string): boolean | undefined => {
|
||||
const raw = obj[key]
|
||||
if (typeof raw === 'boolean') return raw
|
||||
if (raw === 'true') return true
|
||||
if (raw === 'false') return false
|
||||
return undefined
|
||||
}
|
||||
// The banner name: same absent/null distinction as the nullable ids.
|
||||
const nullableString = (key: string): string | null | undefined => {
|
||||
if (!has(key)) return undefined
|
||||
if (obj[key] === null) return null
|
||||
return typeof obj[key] === 'string' ? (obj[key] as string) : undefined
|
||||
}
|
||||
|
||||
return {
|
||||
tags: parseEventTags(obj.Tags ?? obj.tags),
|
||||
imageName: nullableString('ImageName'),
|
||||
roomId: asInt(obj.RoomId),
|
||||
subRoomId: nullableInt('SubRoomId'),
|
||||
clubId: nullableInt('ClubId'),
|
||||
name: typeof obj.Name === 'string' ? obj.Name : undefined,
|
||||
description: typeof obj.Description === 'string' ? obj.Description : undefined,
|
||||
startTime: time('StartTime'),
|
||||
endTime: time('EndTime'),
|
||||
state: asInt(obj.State),
|
||||
accessibility: asInt(obj.Accessibility),
|
||||
isMultiInstance: bool('IsMultiInstance'),
|
||||
supportMultiInstanceRoomChat: bool('SupportMultiInstanceRoomChat'),
|
||||
defaultBroadcastPermissions: asInt(obj.DefaultBroadcastPermissions),
|
||||
canRequestBroadcastPermissions: asInt(obj.CanRequestBroadcastPermissions),
|
||||
}
|
||||
}
|
||||
|
||||
/** How long an event runs when the body names a start but no end. */
|
||||
const DEFAULT_DURATION_MS = 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Insert a new event, returning the stored record.
|
||||
*
|
||||
* Lenient about what the body carries, like the other writes here: an event with no
|
||||
* name or no time window is defaulted rather than rejected, because a rejection the
|
||||
* client can't render is worse than a placeholder the creator can edit. `State` starts
|
||||
* at 0 (scheduled). The creator comes from the bearer token, never the body.
|
||||
*
|
||||
* The creator is recorded as Going in `event_attendee`, which is what makes
|
||||
* `AttendeeCount` start at 1: the count is derived from that table, so the creator
|
||||
* needs a row there for the number to stay right once other players respond.
|
||||
*/
|
||||
export async function createEvent(
|
||||
db: D1Database,
|
||||
creatorPlayerId: number,
|
||||
input: EventInput
|
||||
): Promise<PlayerEvent> {
|
||||
// Sequential id: one past the current max (the table starts empty).
|
||||
const row = await db
|
||||
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM event')
|
||||
.first<{ next: number }>()
|
||||
const now = Date.now()
|
||||
const startTime = input.startTime ?? eventTime(now)
|
||||
const event: PlayerEvent = {
|
||||
PlayerEventId: row?.next ?? 1,
|
||||
CreatorPlayerId: creatorPlayerId,
|
||||
ImageName: input.imageName ?? null,
|
||||
RoomId: input.roomId ?? 0,
|
||||
SubRoomId: input.subRoomId ?? null,
|
||||
ClubId: input.clubId ?? null,
|
||||
Name: input.name?.trim() || 'Untitled Event',
|
||||
Description: input.description ?? '',
|
||||
StartTime: startTime,
|
||||
EndTime: input.endTime ?? eventTime(Date.parse(startTime) + DEFAULT_DURATION_MS),
|
||||
AttendeeCount: 1,
|
||||
State: input.state ?? 0,
|
||||
Accessibility: input.accessibility ?? 1,
|
||||
IsMultiInstance: input.isMultiInstance ?? false,
|
||||
SupportMultiInstanceRoomChat: input.supportMultiInstanceRoomChat ?? false,
|
||||
DefaultBroadcastPermissions: input.defaultBroadcastPermissions ?? 0,
|
||||
CanRequestBroadcastPermissions: input.canRequestBroadcastPermissions ?? 0,
|
||||
}
|
||||
await db.batch([
|
||||
db.prepare('INSERT INTO event (data) VALUES (?1)').bind(JSON.stringify(event)),
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
|
||||
VALUES (?1, ?2, ?3, ?4)`
|
||||
)
|
||||
.bind(event.PlayerEventId, creatorPlayerId, EVENT_RESPONSE.going, eventTime(now)),
|
||||
])
|
||||
// Tags ride along with the write but live in their own table — they are not part of
|
||||
// the stored blob, since that blob is the DTO every read serves verbatim.
|
||||
if (input.tags !== undefined) await setEventTags(db, event.PlayerEventId, input.tags)
|
||||
return event
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a player's answer to an event, replacing whatever they said before — one row
|
||||
* per player per event, so changing your mind is an update rather than a second RSVP.
|
||||
* The event's `AttendeeCount` is recomputed from the table afterwards.
|
||||
*
|
||||
* Returns the updated event, or null when there's no such event. Anyone who can see an
|
||||
* event may respond to it, the creator included (they're already Going from create, and
|
||||
* nothing stops them declining their own event).
|
||||
*/
|
||||
export async function setEventResponse(
|
||||
db: D1Database,
|
||||
eventId: number,
|
||||
playerId: number,
|
||||
status: number
|
||||
): Promise<PlayerEvent | null> {
|
||||
const event = await getEventById(db, eventId)
|
||||
if (event === null) return null
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT (event_id, player_id) DO UPDATE SET status = ?3, responded_at = ?4`
|
||||
)
|
||||
.bind(eventId, playerId, status, eventTime(Date.now()))
|
||||
.run()
|
||||
|
||||
const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) }
|
||||
await writeEvent(db, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Add invited players to an event as Going — the bulk invite. Returns the updated
|
||||
* event (with its recounted `AttendeeCount`) and the rows actually created, or null
|
||||
* when there's no such event.
|
||||
*
|
||||
* An invite only ever INSERTS: a player who already has a row keeps the answer they
|
||||
* gave, so being invited can't flip a decline back to Going, and re-inviting the same
|
||||
* player is a no-op rather than a reset. Since the rows land as Going, the invited
|
||||
* count toward `AttendeeCount` from the moment they're invited — see the route.
|
||||
*
|
||||
* `added` is what `RETURNING` gave back, so it holds exactly the new rows: a conflict
|
||||
* inserts nothing and returns nothing. That's what the route notifies on — a player
|
||||
* whose existing answer was left alone gets no frame, because nothing changed for them.
|
||||
*
|
||||
* Ids are deduplicated by the composite primary key; an empty list is a no-op that
|
||||
* still returns the event.
|
||||
*/
|
||||
export async function inviteToEvent(
|
||||
db: D1Database,
|
||||
eventId: number,
|
||||
playerIds: number[]
|
||||
): Promise<{ event: PlayerEvent; added: EventAttendeeRow[] } | null> {
|
||||
const event = await getEventById(db, eventId)
|
||||
if (event === null) return null
|
||||
if (playerIds.length === 0) return { event, added: [] }
|
||||
|
||||
const at = eventTime(Date.now())
|
||||
const inserts = await db.batch<EventAttendeeRow>(
|
||||
playerIds.map((playerId) =>
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT (event_id, player_id) DO NOTHING
|
||||
RETURNING rowid AS id, *`
|
||||
)
|
||||
.bind(eventId, playerId, EVENT_RESPONSE.going, at)
|
||||
)
|
||||
)
|
||||
const added = inserts.flatMap((r) => r.results)
|
||||
|
||||
const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) }
|
||||
await writeEvent(db, updated)
|
||||
return { event: updated, added }
|
||||
}
|
||||
|
||||
/** How many players said they're Going — an event's `AttendeeCount`. */
|
||||
export async function countGoing(db: D1Database, eventId: number): Promise<number> {
|
||||
const row = await db
|
||||
.prepare('SELECT COUNT(*) AS going FROM event_attendee WHERE event_id = ?1 AND status = ?2')
|
||||
.bind(eventId, EVENT_RESPONSE.going)
|
||||
.first<{ going: number }>()
|
||||
return row?.going ?? 0
|
||||
}
|
||||
|
||||
/** One player's answer to one event, or null when they haven't responded. */
|
||||
export async function getEventResponse(
|
||||
db: D1Database,
|
||||
eventId: number,
|
||||
playerId: number
|
||||
): Promise<EventAttendeeRow | null> {
|
||||
return db
|
||||
.prepare('SELECT rowid AS id, * FROM event_attendee WHERE event_id = ?1 AND player_id = ?2')
|
||||
.bind(eventId, playerId)
|
||||
.first<EventAttendeeRow>()
|
||||
}
|
||||
|
||||
/**
|
||||
* Everyone who answered an event, in the order they responded — the guest list behind
|
||||
* `GET /api/playerevents/v1/:eventId/responses`. Ties on the timestamp (the creator's
|
||||
* own Going row shares its second with a fast first RSVP) break on the player id, so
|
||||
* the order is stable.
|
||||
*/
|
||||
export async function getEventAttendees(
|
||||
db: D1Database,
|
||||
eventId: number
|
||||
): Promise<EventAttendeeRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT rowid AS id, * FROM event_attendee
|
||||
WHERE event_id = ?1 ORDER BY responded_at, player_id`
|
||||
)
|
||||
.bind(eventId)
|
||||
.all<EventAttendeeRow>()
|
||||
return results
|
||||
}
|
||||
|
||||
/** Overwrite an event's stored blob in place. */
|
||||
async function writeEvent(db: D1Database, event: PlayerEvent): Promise<void> {
|
||||
await db
|
||||
.prepare('UPDATE event SET data = ?1 WHERE id = ?2')
|
||||
.bind(JSON.stringify(event), event.PlayerEventId)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an edit to an event. Only the fields the body carried change; everything else
|
||||
* keeps its stored value, so a partial post can't blank out the rest of the event.
|
||||
* The id, the creator and the attendee count are not editable — ownership doesn't
|
||||
* transfer and RSVPs aren't set by hand. Returns the updated event, or null when
|
||||
* there's no such row.
|
||||
*/
|
||||
export async function updateEvent(
|
||||
db: D1Database,
|
||||
eventId: number,
|
||||
input: EventInput
|
||||
): Promise<PlayerEvent | null> {
|
||||
const event = await getEventById(db, eventId)
|
||||
if (event === null) return null
|
||||
|
||||
const updated: PlayerEvent = {
|
||||
...event,
|
||||
ImageName: input.imageName === undefined ? event.ImageName : input.imageName,
|
||||
RoomId: input.roomId ?? event.RoomId,
|
||||
SubRoomId: input.subRoomId === undefined ? event.SubRoomId : input.subRoomId,
|
||||
ClubId: input.clubId === undefined ? event.ClubId : input.clubId,
|
||||
Name: input.name?.trim() || event.Name,
|
||||
Description: input.description ?? event.Description,
|
||||
StartTime: input.startTime ?? event.StartTime,
|
||||
EndTime: input.endTime ?? event.EndTime,
|
||||
State: input.state ?? event.State,
|
||||
Accessibility: input.accessibility ?? event.Accessibility,
|
||||
IsMultiInstance: input.isMultiInstance ?? event.IsMultiInstance,
|
||||
SupportMultiInstanceRoomChat:
|
||||
input.supportMultiInstanceRoomChat ?? event.SupportMultiInstanceRoomChat,
|
||||
DefaultBroadcastPermissions:
|
||||
input.defaultBroadcastPermissions ?? event.DefaultBroadcastPermissions,
|
||||
CanRequestBroadcastPermissions:
|
||||
input.canRequestBroadcastPermissions ?? event.CanRequestBroadcastPermissions,
|
||||
}
|
||||
await writeEvent(db, updated)
|
||||
// A body that says nothing about tags leaves them alone, like every other field
|
||||
// here; an explicit `[]` clears them.
|
||||
if (input.tags !== undefined) await setEventTags(db, eventId, input.tags)
|
||||
return updated
|
||||
}
|
||||
|
||||
/** One event by id, or null when there's no such row. */
|
||||
export async function getEventById(db: D1Database, eventId: number): Promise<PlayerEvent | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT data FROM event WHERE id = ?1')
|
||||
.bind(eventId)
|
||||
.first<EventRow>()
|
||||
return row ? (JSON.parse(row.data) as PlayerEvent) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function getEventsByIds(db: D1Database, ids: number[]): Promise<PlayerEvent[]> {
|
||||
if (ids.length === 0) return []
|
||||
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
|
||||
const { results } = await db
|
||||
.prepare(`SELECT data FROM event WHERE id IN (${placeholders})`)
|
||||
.bind(...ids)
|
||||
.all<EventRow>()
|
||||
const byId = new Map<number, PlayerEvent>()
|
||||
for (const r of results) {
|
||||
const event = JSON.parse(r.data) as PlayerEvent
|
||||
byId.set(event.PlayerEventId, event)
|
||||
}
|
||||
return ids.map((id) => byId.get(id)).filter((e): e is PlayerEvent => e !== undefined)
|
||||
}
|
||||
|
||||
/**
|
||||
* The events a player created — their "my events" list, soonest first. Uses the
|
||||
* creator_player_id index; the per-player set is small, so ordering is done in memory.
|
||||
*/
|
||||
export async function getEventsByCreator(
|
||||
db: D1Database,
|
||||
creatorPlayerId: number
|
||||
): Promise<PlayerEvent[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT data FROM event WHERE creator_player_id = ?1')
|
||||
.bind(creatorPlayerId)
|
||||
.all<EventRow>()
|
||||
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
|
||||
}
|
||||
|
||||
/**
|
||||
* The events belonging to a set of clubs — the events shelf on a club's page, soonest
|
||||
* first. Selected on the indexed club_id column. An empty id list is an empty shelf
|
||||
* rather than every event.
|
||||
*/
|
||||
export async function getEventsByClubs(db: D1Database, clubIds: number[]): Promise<PlayerEvent[]> {
|
||||
if (clubIds.length === 0) return []
|
||||
const placeholders = clubIds.map((_, i) => `?${i + 1}`).join(', ')
|
||||
const { results } = await db
|
||||
.prepare(`SELECT data FROM event WHERE club_id IN (${placeholders})`)
|
||||
.bind(...clubIds)
|
||||
.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
|
||||
* columns, so the whole filter stays in SQL.
|
||||
*/
|
||||
export async function getLiveEvents(db: D1Database, now = Date.now()): Promise<PlayerEvent[]> {
|
||||
const at = eventTime(now)
|
||||
const { results } = await db
|
||||
.prepare('SELECT data FROM event WHERE start_time <= ?1 AND end_time >= ?1')
|
||||
.bind(at)
|
||||
.all<EventRow>()
|
||||
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
|
||||
}
|
||||
|
||||
/** Soonest start first; ties broken by id so paging is stable. */
|
||||
function bySoonest(a: PlayerEvent, b: PlayerEvent): number {
|
||||
return a.StartTime.localeCompare(b.StartTime) || a.PlayerEventId - b.PlayerEventId
|
||||
}
|
||||
|
||||
/**
|
||||
* Event search — the browse query on the player-events screen. Term by term, an empty
|
||||
* query browsing everything upcoming; paginated via skip/take, soonest first.
|
||||
*
|
||||
* A term is matched one of two ways, and the `#` decides which:
|
||||
*
|
||||
* - `#workshops` is a TAG term — it matches only an event tagged `workshops`, and never
|
||||
* the word appearing in a name or description. That's what the browse screen's filter
|
||||
* chips send.
|
||||
* - `workshops` is a TEXT term, matched case-insensitively against the name and the
|
||||
* description, as before.
|
||||
*
|
||||
* Every term has to match, and the two kinds combine: `#workshops trigonometry` is the
|
||||
* workshops-tagged events whose text also mentions trigonometry.
|
||||
*
|
||||
* Events that have already finished are excluded: this backs a browse screen, where a
|
||||
* name match on something that ended last month is noise. The per-event history a
|
||||
* creator wants comes from `getEventsByCreator`, which keeps them.
|
||||
*/
|
||||
export async function searchEvents(
|
||||
db: D1Database,
|
||||
query: string,
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<PlayerEvent[]> {
|
||||
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||
// A `#` prefix makes a term a tag; the rest are matched against the text. A bare `#`
|
||||
// is dropped rather than treated as a tag nothing can carry.
|
||||
const tags = terms.filter((t) => t.startsWith('#')).map((t) => t.slice(1))
|
||||
const textTerms = terms.filter((t) => !t.startsWith('#'))
|
||||
|
||||
// end_time is a generated column of an ISO-8601 UTC string, so it compares
|
||||
// lexicographically — that filter stays in SQL, and so does the tag one: an event
|
||||
// has to carry EVERY tag asked for, which is the count of matching tag rows.
|
||||
const wanted = tags.filter(Boolean)
|
||||
const sql =
|
||||
wanted.length === 0
|
||||
? 'SELECT data FROM event WHERE end_time >= ?1'
|
||||
: `SELECT data FROM event WHERE end_time >= ?1 AND (
|
||||
SELECT COUNT(DISTINCT tag) FROM event_tag
|
||||
WHERE event_tag.event_id = event.id
|
||||
AND tag IN (${wanted.map((_, i) => `?${i + 2}`).join(', ')})
|
||||
) = ${wanted.length}`
|
||||
const { results } = await db
|
||||
.prepare(sql)
|
||||
.bind(eventTime(Date.now()), ...wanted)
|
||||
.all<EventRow>()
|
||||
let events = results.map((r) => JSON.parse(r.data) as PlayerEvent)
|
||||
|
||||
for (const term of textTerms) {
|
||||
events = events.filter(
|
||||
(e) => e.Name.toLowerCase().includes(term) || e.Description.toLowerCase().includes(term)
|
||||
)
|
||||
}
|
||||
|
||||
return events.sort(bySoonest).slice(skip, skip + take)
|
||||
}
|
||||
+11
-1
@@ -1,4 +1,4 @@
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from './context'
|
||||
@@ -12,6 +12,16 @@ export async function authedId(c: Context<App>): Promise<number | null> {
|
||||
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
}
|
||||
|
||||
/**
|
||||
* The `role` claim from a Bearer token — the operator-granted roles the auth worker
|
||||
* stamps from the account's flags (a plain player's token is just `['gameClient']`).
|
||||
* `null` when the request carries no valid token, which callers treat as a 401; an
|
||||
* empty array means a valid token with no roles. Shaped to mirror {@link authedId}.
|
||||
*/
|
||||
export async function authedRoles(c: Context<App>): Promise<string[] | null> {
|
||||
return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
}
|
||||
|
||||
/** Results.Unauthorized() equivalent — 401 with empty body. */
|
||||
export function unauthorized(c: Context<App>) {
|
||||
return c.body(null, 401)
|
||||
|
||||
@@ -1,414 +0,0 @@
|
||||
/**
|
||||
* Image-metadata storage on the shared `recflare` D1 database. Each image is a
|
||||
* single JSON blob in the `data` column; queryable fields (Id, ImageName,
|
||||
* PlayerId, RoomId) are SQLite generated (virtual) columns extracted from that
|
||||
* JSON — the same JSON-blob pattern the rooms/accounts tables use.
|
||||
*
|
||||
* Mirror of `apps/img/src/images-db.ts` — the `img` worker owns the schema and
|
||||
* migration; this worker (which handles uploads + reads) keeps a copy in sync.
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0001_image.sql, sans any seed rows). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS image (
|
||||
data TEXT NOT NULL,
|
||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Id')) VIRTUAL,
|
||||
image_name TEXT GENERATED ALWAYS AS (json_extract(data, '$.ImageName')) VIRTUAL,
|
||||
player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerId')) VIRTUAL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_image_id ON image (id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_image_name ON image (image_name)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_player_id ON image (player_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_room_id ON image (room_id)`,
|
||||
// A player's interaction with a saved image — one row per (player, image). Only
|
||||
// `cheered` for now; named generically so other per-user interactions (e.g.
|
||||
// favorited) can be added as columns. This worker writes it (cheer endpoints) and
|
||||
// keeps the image's denormalized `CheerCount` in sync from it. Schema owned by the
|
||||
// `img` worker (migrations/0002_image_interaction.sql) — keep in sync.
|
||||
`CREATE TABLE IF NOT EXISTS image_interaction (
|
||||
player_id INTEGER NOT NULL,
|
||||
saved_image_id INTEGER NOT NULL,
|
||||
cheered INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT,
|
||||
PRIMARY KEY (player_id, saved_image_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_interaction_image ON image_interaction (saved_image_id)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* Saved-image categories from the reference's `SavedImageType` enum — the value of a
|
||||
* stored image's `Type` (and the client's `imgMeta.savedImageType` on upload). Lives
|
||||
* here in the image data layer so both the upload route and the slideshow query share
|
||||
* one definition.
|
||||
*/
|
||||
export const SavedImageType = {
|
||||
None: 0,
|
||||
ShareCamera: 1,
|
||||
OutfitThumbnail: 2,
|
||||
RoomThumbnail: 3,
|
||||
ProfileThumbnail: 4,
|
||||
InventionThumbnail: 5,
|
||||
} as const
|
||||
|
||||
/** A stored image record (the client-facing SavedImage shape). */
|
||||
export interface SavedImage {
|
||||
Id: number
|
||||
/** A {@link SavedImageType} value. */
|
||||
Type: number
|
||||
Accessibility: number
|
||||
AccessibilityLocked: boolean
|
||||
ImageName: string
|
||||
Description: string | null
|
||||
PlayerId: number
|
||||
TaggedPlayerIds: number[]
|
||||
RoomId: number | null
|
||||
PlayerEventId: number | null
|
||||
CreatedAt: string
|
||||
CheerCount: number
|
||||
CommentCount: number
|
||||
}
|
||||
|
||||
interface ImageRow {
|
||||
data: string
|
||||
}
|
||||
|
||||
/** Fields supplied at upload time (from `imgMeta`); everything else defaults. */
|
||||
export interface NewImage {
|
||||
imageName: string
|
||||
playerId: number
|
||||
type?: number
|
||||
accessibility?: number
|
||||
roomId?: number | null
|
||||
description?: string | null
|
||||
taggedPlayerIds?: number[]
|
||||
playerEventId?: number | null
|
||||
}
|
||||
|
||||
/** Insert a new image record for an upload, returning the stored row. */
|
||||
export async function createImage(db: D1Database, input: NewImage): Promise<SavedImage> {
|
||||
// Sequential id: one past the current max (the table starts empty).
|
||||
const row = await db
|
||||
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM image')
|
||||
.first<{ next: number }>()
|
||||
const image: SavedImage = {
|
||||
Id: row?.next ?? 1,
|
||||
Type: input.type ?? 1,
|
||||
Accessibility: input.accessibility ?? 1,
|
||||
AccessibilityLocked: false,
|
||||
ImageName: input.imageName,
|
||||
Description: input.description ?? null,
|
||||
PlayerId: input.playerId,
|
||||
TaggedPlayerIds: input.taggedPlayerIds ?? [],
|
||||
RoomId: input.roomId ?? null,
|
||||
PlayerEventId: input.playerEventId ?? null,
|
||||
CreatedAt: new Date().toISOString(),
|
||||
CheerCount: 0,
|
||||
CommentCount: 0,
|
||||
}
|
||||
await db.prepare('INSERT INTO image (data) VALUES (?1)').bind(JSON.stringify(image)).run()
|
||||
return image
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute an image's `CheerCount` from the `image_interaction` rows and write it
|
||||
* back into the blob (nothing reads a generated column for it, but the client-facing
|
||||
* blob must stay accurate). CAST to INTEGER: D1 binds a JS number as a SQLite REAL,
|
||||
* which json_set would otherwise store as `"CheerCount":3.0`. Returns the fresh count.
|
||||
*/
|
||||
async function syncImageCheerCount(db: D1Database, savedImageId: number): Promise<number> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
'SELECT COUNT(*) AS n FROM image_interaction WHERE saved_image_id = ?1 AND cheered = 1'
|
||||
)
|
||||
.bind(savedImageId)
|
||||
.first<{ n: number }>()
|
||||
const count = row?.n ?? 0
|
||||
await db
|
||||
.prepare(
|
||||
"UPDATE image SET data = json_set(data, '$.CheerCount', CAST(?2 AS INTEGER)) WHERE id = ?1"
|
||||
)
|
||||
.bind(savedImageId, count)
|
||||
.run()
|
||||
return count
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or clear) a player's cheer on a saved image — upserts the one row per
|
||||
* (player, image) — then resyncs the image's `CheerCount`. Idempotent: re-cheering
|
||||
* an already-cheered image is a no-op on the count.
|
||||
*/
|
||||
export async function setImageCheer(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
savedImageId: number,
|
||||
cheer: boolean
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO image_interaction (player_id, saved_image_id, cheered, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(player_id, saved_image_id) DO UPDATE SET cheered = ?3`
|
||||
)
|
||||
.bind(playerId, savedImageId, cheer ? 1 : 0, new Date().toISOString())
|
||||
.run()
|
||||
await syncImageCheerCount(db, savedImageId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of the given saved-image ids the player has cheered — the set of cheered
|
||||
* ids (a subset of `ids`). Backs the bulk `cheered` lookup. Empty input → empty set.
|
||||
*/
|
||||
export async function getCheeredImageIds(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
ids: number[]
|
||||
): Promise<Set<number>> {
|
||||
if (ids.length === 0) return new Set()
|
||||
const inList = ids.map((_, i) => `?${i + 2}`).join(',')
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT saved_image_id AS id FROM image_interaction
|
||||
WHERE player_id = ?1 AND cheered = 1 AND saved_image_id IN (${inList})`
|
||||
)
|
||||
.bind(playerId, ...ids)
|
||||
.all<{ id: number }>()
|
||||
return new Set(results.map((r) => r.id))
|
||||
}
|
||||
|
||||
/** Look up an image record by its ImageName (the R2 key / filename), or null. */
|
||||
export async function getImageByName(db: D1Database, name: string): Promise<SavedImage | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT data FROM image WHERE image_name = ?1')
|
||||
.bind(name)
|
||||
.first<ImageRow>()
|
||||
return row ? (JSON.parse(row.data) as SavedImage) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an image's metadata row plus any per-player interactions (cheers) recorded
|
||||
* against it, in one batch — the row keyed by ImageName (the R2 key), its interactions
|
||||
* by the image's `Id`. Authorization and removing the object from R2 are the caller's
|
||||
* responsibility (see the deletesaved route).
|
||||
*/
|
||||
export async function deleteImage(db: D1Database, image: SavedImage): Promise<void> {
|
||||
await db.batch([
|
||||
db.prepare('DELETE FROM image WHERE image_name = ?1').bind(image.ImageName),
|
||||
db.prepare('DELETE FROM image_interaction WHERE saved_image_id = ?1').bind(image.Id),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* The public images taken in a room, for the room's photo feed. Only publicly
|
||||
* accessible images (Accessibility === 1) are returned. `filter` narrows by
|
||||
* `SavedImageType` (0 = all types); `sort` orders the feed — `1` puts the most
|
||||
* cheered first (ties broken by newest), anything else is newest-first. Paginated
|
||||
* via skip/take; returns a bare array of SavedImage. The per-room set is small, so
|
||||
* the room_id index does the lookup and filtering/sorting happens in memory.
|
||||
*
|
||||
* NOTE: the exact `sort`/`filter` enum values are best guesses — the client sends
|
||||
* `sort=1&filter=1`, and this treats them as most-cheered / ShareCamera.
|
||||
*/
|
||||
export async function getImagesByRoom(
|
||||
db: D1Database,
|
||||
roomId: number,
|
||||
sort: number,
|
||||
filter: number,
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<SavedImage[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT data FROM image WHERE room_id = ?1')
|
||||
.bind(roomId)
|
||||
.all<ImageRow>()
|
||||
let images = results
|
||||
.map((r) => JSON.parse(r.data) as SavedImage)
|
||||
.filter((img) => img.Accessibility === 1)
|
||||
|
||||
if (filter > 0) images = images.filter((img) => img.Type === filter)
|
||||
|
||||
images.sort(sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst)
|
||||
|
||||
return images.slice(skip, skip + take)
|
||||
}
|
||||
|
||||
/** Newest-first order: most recent CreatedAt, ties broken by higher Id. */
|
||||
const newestFirst = (a: SavedImage, b: SavedImage) =>
|
||||
b.CreatedAt.localeCompare(a.CreatedAt) || b.Id - a.Id
|
||||
|
||||
/**
|
||||
* The public images a player has taken — their photo list, newest first.
|
||||
* Paginated via skip/take; returns a bare array of SavedImage. Uses the
|
||||
* player_id index; the per-player set is small, so filtering/sorting is in memory.
|
||||
*/
|
||||
export async function getImagesByPlayer(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
sort: number,
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<SavedImage[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT data FROM image WHERE player_id = ?1')
|
||||
.bind(playerId)
|
||||
.all<ImageRow>()
|
||||
return results
|
||||
.map((r) => JSON.parse(r.data) as SavedImage)
|
||||
.filter((img) => img.Accessibility === 1)
|
||||
.sort(sort === 1 ? (a, b) => b.CheerCount - a.CheerCount || newestFirst(a, b) : newestFirst)
|
||||
.slice(skip, skip + take)
|
||||
}
|
||||
|
||||
/**
|
||||
* The client-facing projection of a saved image for the player photo lists (the
|
||||
* reference's `ImagesPlayer`). Same data as the stored record, but the id and type
|
||||
* are renamed — `Id` → `SavedImageId`, `Type` → `SavedImageType` — and the tagged
|
||||
* player ids aren't part of it. The client deserializes into this shape, so a raw
|
||||
* SavedImage leaves it without an image id and its thumbnails come up blank.
|
||||
*/
|
||||
export interface ImagesPlayer {
|
||||
Accessibility: number
|
||||
AccessibilityLocked: boolean
|
||||
CheerCount: number
|
||||
CommentCount: number
|
||||
CreatedAt: string
|
||||
Description: string | null
|
||||
ImageName: string
|
||||
PlayerEventId: number | null
|
||||
PlayerId: number
|
||||
RoomId: number | null
|
||||
SavedImageId: number
|
||||
SavedImageType: number
|
||||
}
|
||||
|
||||
/** Project a stored image to the client's ImagesPlayer shape. */
|
||||
export function toImagesPlayer(img: SavedImage): ImagesPlayer {
|
||||
return {
|
||||
Accessibility: img.Accessibility,
|
||||
AccessibilityLocked: img.AccessibilityLocked,
|
||||
CheerCount: img.CheerCount,
|
||||
CommentCount: img.CommentCount,
|
||||
CreatedAt: img.CreatedAt,
|
||||
Description: img.Description,
|
||||
ImageName: img.ImageName,
|
||||
PlayerEventId: img.PlayerEventId,
|
||||
PlayerId: img.PlayerId,
|
||||
RoomId: img.RoomId,
|
||||
SavedImageId: img.Id,
|
||||
SavedImageType: img.Type,
|
||||
}
|
||||
}
|
||||
|
||||
/** Default number of recent images the slideshow feed returns. */
|
||||
export const SLIDESHOW_LIMIT = 130
|
||||
|
||||
/** The slideshow projection of an image — creator username + room name joined in. */
|
||||
export interface SlideshowImage {
|
||||
SavedImageId: number
|
||||
ImageName: string
|
||||
Username: string
|
||||
RoomName: string | null
|
||||
RoomId: number | null
|
||||
SavedImageType: number
|
||||
PlayerEventId: number | null
|
||||
Accessibility: number
|
||||
PlayerIds: number[]
|
||||
}
|
||||
|
||||
/** Build the `?1,?2,…` placeholder list for an `IN (…)` clause. */
|
||||
const placeholders = (n: number): string =>
|
||||
Array.from({ length: n }, (_, i) => `?${i + 1}`).join(',')
|
||||
|
||||
/** Map account ids → username, resolved from the shared accounts table. */
|
||||
async function getUsernames(db: D1Database, ids: number[]): Promise<Map<number, string>> {
|
||||
if (ids.length === 0) return new Map()
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT account_id AS id, json_extract(data, '$.username') AS username
|
||||
FROM account WHERE account_id IN (${placeholders(ids.length)})`
|
||||
)
|
||||
.bind(...ids)
|
||||
.all<{ id: number; username: string }>()
|
||||
return new Map(results.map((r) => [r.id, r.username]))
|
||||
}
|
||||
|
||||
/** Map room ids → room name, resolved from the shared rooms table. */
|
||||
async function getRoomNames(db: D1Database, ids: number[]): Promise<Map<number, string>> {
|
||||
if (ids.length === 0) return new Map()
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT room_id AS id, json_extract(data, '$.Name') AS name
|
||||
FROM room WHERE room_id IN (${placeholders(ids.length)})`
|
||||
)
|
||||
.bind(...ids)
|
||||
.all<{ id: number; name: string }>()
|
||||
return new Map(results.map((r) => [r.id, r.name]))
|
||||
}
|
||||
|
||||
/**
|
||||
* The global slideshow feed — the most recent publicly-listable ShareCamera photos
|
||||
* across all rooms (Accessibility 0 or 1, Type 1), newest first, capped at `limit`.
|
||||
* Only ShareCamera images are surfaced (not room/profile/invention thumbnails). Each
|
||||
* row is joined to its creator's username and (if any) its room's name. Returns the
|
||||
* projected SlideshowImage shape. Usernames/room names are resolved in two batched
|
||||
* lookups to avoid an N+1 across the (at most `limit`) images.
|
||||
*/
|
||||
export async function getSlideshowImages(
|
||||
db: D1Database,
|
||||
limit = SLIDESHOW_LIMIT
|
||||
): Promise<SlideshowImage[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM image
|
||||
WHERE json_extract(data, '$.Accessibility') IN (0, 1)
|
||||
AND json_extract(data, '$.Type') = ?1
|
||||
ORDER BY id DESC LIMIT ?2`
|
||||
)
|
||||
.bind(SavedImageType.ShareCamera, limit)
|
||||
.all<ImageRow>()
|
||||
const images = results.map((r) => JSON.parse(r.data) as SavedImage)
|
||||
|
||||
const roomIds = [...new Set(images.map((i) => i.RoomId).filter((v): v is number => v != null))]
|
||||
const usernames = await getUsernames(db, [...new Set(images.map((i) => i.PlayerId))])
|
||||
const roomNames = await getRoomNames(db, roomIds)
|
||||
|
||||
return images.map((img) => ({
|
||||
SavedImageId: img.Id,
|
||||
ImageName: img.ImageName,
|
||||
// Fall back to the synthesized "Player<id>" name for accounts not in the table.
|
||||
Username: usernames.get(img.PlayerId) ?? `Player${img.PlayerId}`,
|
||||
RoomName: img.RoomId != null ? (roomNames.get(img.RoomId) ?? null) : null,
|
||||
RoomId: img.RoomId,
|
||||
SavedImageType: img.Type,
|
||||
PlayerEventId: img.PlayerEventId,
|
||||
Accessibility: img.Accessibility,
|
||||
PlayerIds: img.TaggedPlayerIds,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* A player's photo feed — the public images they took plus the ones they're
|
||||
* tagged in (TaggedPlayerIds). Newest first, paginated via skip/take; returns a
|
||||
* bare array of SavedImage. The tagged-in match uses json_each over the stored
|
||||
* TaggedPlayerIds array (there's no index for it).
|
||||
*/
|
||||
export async function getPlayerFeed(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<SavedImage[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM image
|
||||
WHERE player_id = ?1
|
||||
OR EXISTS (SELECT 1 FROM json_each(image.data, '$.TaggedPlayerIds') WHERE value = ?1)`
|
||||
)
|
||||
.bind(playerId)
|
||||
.all<ImageRow>()
|
||||
return results
|
||||
.map((r) => JSON.parse(r.data) as SavedImage)
|
||||
.filter((img) => img.Accessibility === 1)
|
||||
.sort(newestFirst)
|
||||
.slice(skip, skip + take)
|
||||
}
|
||||
+131
-31
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Saved-invention storage on the shared `recflare` D1 database. Each invention is
|
||||
* a single JSON blob in the `data` column; queryable fields (Id, CreatorPlayerId)
|
||||
* are SQLite generated (virtual) columns extracted from that JSON — the same
|
||||
* JSON-blob pattern the image/rooms/accounts tables use.
|
||||
* a single JSON blob in the `data` column; queryable fields (Id, CreatorPlayerId, the
|
||||
* visibility flags) are SQLite generated (virtual) columns extracted from that JSON —
|
||||
* the same JSON-blob pattern the image/rooms/accounts tables use.
|
||||
*
|
||||
* The `api` worker owns this schema/migration (migrations/0002_invention.sql,
|
||||
* applied under its own `migrations_table`). The invention's data file itself is
|
||||
@@ -12,19 +12,29 @@
|
||||
* The stored/returned DTO mirrors Rec Room's `RRInvention` (PascalCase), including
|
||||
* the nested `CurrentVersion` that carries the blob name and per-version costs —
|
||||
* shaped after a real `GET /api/inventions/v1?inventionId=…` response.
|
||||
*
|
||||
* Who OWNS an invention is a separate table (`inventory_invention`, written by the
|
||||
* `econ` worker at purchase time); this module only reads it — to fold bought inventions
|
||||
* into the caller's own list, and to rank the "top today" feed by what players actually
|
||||
* picked up today. See @repo/domain's inventory-invention-db.ts.
|
||||
*/
|
||||
import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domain'
|
||||
|
||||
/**
|
||||
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql,
|
||||
* sans any seed rows). `is_featured` backs the featured feed's query; json_extract
|
||||
* of a JSON `true` is 1, so the column is 1/0.
|
||||
* 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
|
||||
* NULL when the key is missing, which fails a `= 1` or `= 0` test either way.
|
||||
*/
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS invention (
|
||||
data TEXT NOT NULL,
|
||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.InventionId')) VIRTUAL,
|
||||
creator_player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorPlayerId')) VIRTUAL,
|
||||
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL
|
||||
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL,
|
||||
is_published INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsPublished')) VIRTUAL,
|
||||
hide_from_player INTEGER GENERATED ALWAYS AS (json_extract(data, '$.HideFromPlayer')) VIRTUAL
|
||||
)`,
|
||||
`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)`,
|
||||
@@ -265,6 +275,74 @@ export async function getInventionsByCreator(
|
||||
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* The player's "my inventions" shelf (`v2/mine`): everything they created, plus
|
||||
* everything they BOUGHT. Ownership of a bought invention lives in the
|
||||
* `inventory_invention` table the `econ` worker writes at purchase time — a creator is
|
||||
* never listed there (they own theirs through `CreatorPlayerId`), so the two sets are
|
||||
* disjoint in practice and merged by id anyway.
|
||||
*
|
||||
* Bought inventions are returned whatever their state: unpublished or hidden since the
|
||||
* purchase, they are still on the shelf of the player who paid for them. An owned id
|
||||
* with no invention row left (deleted) simply drops out. Newest first, like the other
|
||||
* invention lists; not paginated.
|
||||
*/
|
||||
export async function getMyInventions(db: D1Database, playerId: number): Promise<SavedInvention[]> {
|
||||
const [created, ownedIds] = await Promise.all([
|
||||
getInventionsByCreator(db, playerId),
|
||||
getOwnedInventionIds(db, playerId),
|
||||
])
|
||||
const bought = await getInventionsByIds(db, ownedIds)
|
||||
|
||||
const byId = new Map<number, SavedInvention>()
|
||||
for (const invention of [...created, ...bought]) byId.set(invention.InventionId, invention)
|
||||
return [...byId.values()].sort(
|
||||
(a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a player owns EVERY invention in a list — the `v1/fulllineageowner` check,
|
||||
* which the client runs when saving an invention BUILT OUT OF other inventions: it is
|
||||
* asking whether this player may use each piece. An invention is the player's if they
|
||||
* created it (`CreatorPlayerId`) or acquired it (a row in `inventory_invention`); an id
|
||||
* with no invention row is not owned, so a deleted or made-up id makes the whole answer
|
||||
* false.
|
||||
*
|
||||
* Ownership is the whole test — price and `GeneralPermission` deliberately don't enter
|
||||
* into it. A free invention still has to be picked up before it can be used, and econ's
|
||||
* buyInvention writes the same inventory row for a 0-token acquisition as for a paid
|
||||
* one, so "acquired" already covers "free". Reading permission here as a second way to
|
||||
* qualify would let a player build on an invention they never took.
|
||||
*
|
||||
* The lineage is whatever the CLIENT asks about: it sends the invention plus every
|
||||
* invention nested inside it as repeated `id`s, so this checks exactly the ids given
|
||||
* and does not walk `ReferencedInventions` itself. Walking it here would answer a
|
||||
* different question than the one asked — the client knows which pieces the thing it
|
||||
* is holding is actually made of, and stale references on an old record don't.
|
||||
*
|
||||
* An empty list is owned: no invention in it is unowned. The client never asks that,
|
||||
* but false would read as "you don't own something" with nothing to name.
|
||||
*/
|
||||
export async function ownsAllInventions(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
inventionIds: number[]
|
||||
): Promise<boolean> {
|
||||
if (inventionIds.length === 0) return true
|
||||
|
||||
// The client repeats an id when the same invention is nested more than once.
|
||||
const unique = [...new Set(inventionIds)]
|
||||
const [inventions, ownedIds] = await Promise.all([
|
||||
getInventionsByIds(db, unique),
|
||||
getOwnedInventionIds(db, playerId),
|
||||
])
|
||||
|
||||
const bought = new Set(ownedIds)
|
||||
const creators = new Map(inventions.map((i) => [i.InventionId, i.CreatorPlayerId]))
|
||||
return unique.every((id) => creators.get(id) === playerId || (creators.has(id) && bought.has(id)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Invention search — the browse/search list the client shows when picking an
|
||||
* invention to spawn. Only published, non-hidden inventions are visible here (a
|
||||
@@ -304,50 +382,73 @@ export async function searchInventions(
|
||||
* ones via the indexed `is_featured` column.
|
||||
*/
|
||||
async function publicInventions(db: D1Database, featuredOnly = false): Promise<SavedInvention[]> {
|
||||
// json_extract of a JSON `true` is 1, so these filters stay in SQL.
|
||||
// All three are generated columns off the JSON blob, so the filter stays in SQL.
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM invention
|
||||
WHERE json_extract(data, '$.IsPublished') = 1
|
||||
AND json_extract(data, '$.HideFromPlayer') = 0
|
||||
WHERE is_published = 1
|
||||
AND hide_from_player = 0
|
||||
${featuredOnly ? 'AND is_featured = 1' : ''}`
|
||||
)
|
||||
.all<InventionRow>()
|
||||
return results.map((r) => JSON.parse(r.data) as SavedInvention)
|
||||
}
|
||||
|
||||
/** Engagement score used to rank the top feed (downloads weigh most, then cheers). */
|
||||
function topScore(invention: SavedInvention): number {
|
||||
const n = (v: unknown): number => (typeof v === 'number' ? v : 0)
|
||||
return (
|
||||
n(invention.NumDownloads) * 3 +
|
||||
n(invention.CheerCount) * 2 +
|
||||
n(invention.NumPlayersHaveUsedInRoom)
|
||||
)
|
||||
/** Length of the "today" window — a trailing day, not the calendar one. */
|
||||
const TOP_TODAY_WINDOW_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/** 24 hours ago, as the ISO timestamp `acquired_at` is compared against. */
|
||||
function startOfWindow(): string {
|
||||
return new Date(Date.now() - TOP_TODAY_WINDOW_MS).toISOString()
|
||||
}
|
||||
|
||||
/**
|
||||
* The "top today" feed — published inventions ranked by engagement. The real feed
|
||||
* ranks by *today's* activity; we don't track per-day counters, so this ranks by
|
||||
* lifetime engagement instead. Ties fall back to invention id so paging is stable.
|
||||
* Paginated via skip/take; returns a bare array, like the other invention feeds.
|
||||
* The "top today" feed — the inventions other players picked up in the last 24 hours,
|
||||
* most first.
|
||||
*
|
||||
* Ranked from the acquisitions the `econ` worker records in `inventory_invention` at
|
||||
* purchase time, grouped by invention, rather than from the lifetime counters on the
|
||||
* invention itself: those never reset, so "top today" used to mean "top ever" and the
|
||||
* shelf only changed when something overtook a total built up over months.
|
||||
*
|
||||
* "Today" is a TRAILING 24 hours, not the calendar UTC day, so the feed doesn't empty
|
||||
* itself at midnight UTC and slowly refill through the small hours — it always covers a
|
||||
* full day's worth of activity. It is still genuinely a window: an invention nobody has
|
||||
* picked up since yesterday falls off, and the feed IS EMPTY when nothing at all was
|
||||
* acquired in a day. Nothing stands in for it, the same way the featured feed serves
|
||||
* nothing while nothing is curated.
|
||||
*
|
||||
* An acquired invention that has since been unpublished or hidden drops out: this is a
|
||||
* public feed, so it is filtered like every other one. Paginated via skip/take AFTER
|
||||
* that filtering, so a hidden invention doesn't leave a hole in a page.
|
||||
*/
|
||||
export async function getTopInventions(
|
||||
db: D1Database,
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<SavedInvention[]> {
|
||||
const inventions = await publicInventions(db)
|
||||
return inventions
|
||||
.sort((a, b) => topScore(b) - topScore(a) || b.InventionId - a.InventionId)
|
||||
.slice(skip, skip + take)
|
||||
const counts = await getInventionAcquisitionCounts(db, startOfWindow())
|
||||
if (counts.length === 0) return []
|
||||
|
||||
// getInventionsByIds answers in the order it is asked, so the ranking survives the
|
||||
// load; ids with no invention row left (deleted) simply drop out.
|
||||
const ranked = await getInventionsByIds(
|
||||
db,
|
||||
counts.map((c) => c.inventionId)
|
||||
)
|
||||
return ranked.filter((i) => i.IsPublished && !i.HideFromPlayer).slice(skip, skip + take)
|
||||
}
|
||||
|
||||
/**
|
||||
* The featured feed — published inventions flagged `IsFeatured`, newest first.
|
||||
* Selected on the indexed `is_featured` column rather than by parsing every public
|
||||
* invention. Nothing sets that flag yet, so this falls back to the top feed rather
|
||||
* than handing the client an empty shelf; once inventions are curated it serves them.
|
||||
* invention.
|
||||
*
|
||||
* Curated means curated: when nothing is flagged this serves an EMPTY list rather than
|
||||
* standing in the top feed. It used to fall back, from when no invention could be
|
||||
* featured at all, but a fallback makes the shelf lie — the client labels these as
|
||||
* hand-picked, and a feed that silently becomes "top today" hides the fact that nobody
|
||||
* has picked anything.
|
||||
*/
|
||||
export async function getFeaturedInventions(
|
||||
db: D1Database,
|
||||
@@ -355,7 +456,6 @@ export async function getFeaturedInventions(
|
||||
take: number
|
||||
): Promise<SavedInvention[]> {
|
||||
const featured = await publicInventions(db, true)
|
||||
if (featured.length === 0) return getTopInventions(db, skip, take)
|
||||
return featured
|
||||
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
|
||||
.slice(skip, skip + take)
|
||||
@@ -579,8 +679,8 @@ export async function getInventionsByRoom(
|
||||
.prepare(
|
||||
`SELECT data FROM invention
|
||||
WHERE json_extract(data, '$.CreationRoomId') = ?1
|
||||
AND json_extract(data, '$.IsPublished') = 1
|
||||
AND json_extract(data, '$.HideFromPlayer') = 0`
|
||||
AND is_published = 1
|
||||
AND hide_from_player = 0`
|
||||
)
|
||||
.bind(roomId)
|
||||
.all<InventionRow>()
|
||||
|
||||
+242
-12
@@ -97,6 +97,16 @@ export const BareString = z.string()
|
||||
/** The `{ error }` body the 400 / 403 branches return. */
|
||||
export const ErrorResponse = z.object({ error: z.string() })
|
||||
|
||||
/**
|
||||
* The `{ success, error }` envelope the report / warning writes and the message send
|
||||
* answer with — `error` is an empty string on success, never null, and the rejected
|
||||
* branches use the same shape so there is only one thing to parse.
|
||||
*/
|
||||
export const SuccessErrorEnvelope = z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().describe('Empty string when the call succeeded'),
|
||||
})
|
||||
|
||||
// ---- Config ----------------------------------------------------------------
|
||||
|
||||
/** `GET /api/config/v1/amplitude` — analytics keys (all disabled on this server). */
|
||||
@@ -160,9 +170,49 @@ export const RelationshipDto = z.object({
|
||||
Muted: z.int().describe('0/1 — the caller‘s own flag'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/messages/v2/send` form body — a message sent to another player. Everything
|
||||
* is a string on the wire (it's form-encoded). The sender is NOT in the body — it's
|
||||
* taken from the bearer token.
|
||||
*/
|
||||
export const SendMessageRequest = z.object({
|
||||
ToPlayerId: z.string().describe('Account id of the recipient'),
|
||||
Type: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The Message-model type, e.g. `10`. Passed through unmapped; defaults to 0'),
|
||||
Data: z.string().optional().describe('The message payload; often empty'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/messages/v1/sendMultiple` JSON body — the same message fanned out to
|
||||
* several recipients. Unlike the form-encoded single send, this one is real JSON, so
|
||||
* `Type` arrives as a number and `ToPlayerIds` as an array of numbers. The sender is
|
||||
* still taken from the bearer token, not the body.
|
||||
*/
|
||||
export const SendMultipleMessagesRequest = z.object({
|
||||
ToPlayerIds: z.array(z.int()).describe('Account ids of the recipients'),
|
||||
Type: z
|
||||
.int()
|
||||
.optional()
|
||||
.describe('The Message-model type, e.g. `20`. Passed through unmapped; defaults to 0'),
|
||||
Data: z.string().optional().describe('The message payload; often empty'),
|
||||
})
|
||||
|
||||
/** The `{ Success, Message }` ack the flag toggles answer with. */
|
||||
export const AckResponse = z.object({ Success: z.boolean(), Message: z.string() })
|
||||
|
||||
/**
|
||||
* One entry of `GET /api/relationships/mutualfriends` — a friend both players share.
|
||||
* A trimmed account card, not a relationship: no relationship type or flags.
|
||||
*/
|
||||
export const MutualFriendDto = z.object({
|
||||
AccountId: z.int(),
|
||||
Username: z.string(),
|
||||
DisplayName: z.string(),
|
||||
ProfileImage: z.string().describe('The image name; an empty string when the account has none'),
|
||||
})
|
||||
|
||||
// ---- Progression -----------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -285,8 +335,14 @@ export const InventionPersonalDetails = z.object({
|
||||
/** `POST /api/inventions/v1/settags` JSON body — both lists are replaced wholesale. */
|
||||
export const SetTagsRequest = z.object({
|
||||
InventionId: z.int(),
|
||||
AutoTags: z.array(z.string()).optional().describe('Client-derived tags (Type 2)'),
|
||||
CustomTags: z.array(z.string()).optional().describe('Creator-submitted tags (Type 0)'),
|
||||
AutoTags: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Client-derived tags (Type 2); each at most 15 letters once lowercased'),
|
||||
CustomTags: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Creator-submitted tags (Type 0); each at most 15 letters once lowercased'),
|
||||
})
|
||||
|
||||
/** `POST /api/inventions/v1/settags` response — `Tags` is the flat list of tag NAMES. */
|
||||
@@ -306,8 +362,14 @@ export const SaveInventionRequest = z.object({
|
||||
inventionDataFilename: z
|
||||
.string()
|
||||
.describe('The blob uploaded through the storage worker; the one required field'),
|
||||
name: z.string().optional().describe('Defaults to “Untitled”'),
|
||||
description: z.string().optional(),
|
||||
name: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('3–24 chars: letters, digits, spaces, dashes, colons. Omitted/blank ⇒ “Untitled”'),
|
||||
description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('At most 512 chars. Omitted/blank ⇒ “No description yet”'),
|
||||
imageName: z.string().optional(),
|
||||
instantiationCost: z.int().optional(),
|
||||
lightsCost: z.int().optional(),
|
||||
@@ -373,10 +435,142 @@ export const KeepsakeConfig = z.object({
|
||||
SocialXpBoostEnabled: z.boolean(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /api/keepsakes/categories` — the keepsake catalog, as a counted result set
|
||||
* rather than the bare list the stubs around it serve. Empty until a catalog exists.
|
||||
*/
|
||||
export const KeepsakeCategories = z.object({
|
||||
Results: JsonArray.describe('The categories — empty, as no keepsake catalog is stored'),
|
||||
TotalResults: z.int().describe('How many results `Results` carries'),
|
||||
})
|
||||
|
||||
/**
|
||||
* A scheduled player event (Rec Room's `PlayerEvent`) — the record every read endpoint
|
||||
* serves verbatim. The `State` / `Accessibility` / `*Permissions` ints are stored and
|
||||
* echoed as the client sends them; their enums aren't reversed yet.
|
||||
*/
|
||||
export const PlayerEventDto = z.object({
|
||||
PlayerEventId: z.int(),
|
||||
CreatorPlayerId: z.int(),
|
||||
ImageName: z.string().nullable().describe('Banner image; null until one is uploaded'),
|
||||
RoomId: z.int(),
|
||||
SubRoomId: z.int().nullable().describe('Null when the event doesn’t pin a subroom'),
|
||||
ClubId: z.int().nullable().describe('Null when the event isn’t a club’s'),
|
||||
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'),
|
||||
AttendeeCount: z.int().describe('Starts at 1 — the creator attends their own event'),
|
||||
State: z.int().describe('0 = scheduled'),
|
||||
Accessibility: z.int(),
|
||||
IsMultiInstance: z.boolean(),
|
||||
SupportMultiInstanceRoomChat: z.boolean(),
|
||||
DefaultBroadcastPermissions: z.int(),
|
||||
CanRequestBroadcastPermissions: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /api/playerevents/v1/:eventId?includeDetails=True` — the record plus the one
|
||||
* field the flag adds: the LOWERCASE `tags`, in an otherwise PascalCase record. Always
|
||||
* empty, since no event tags are stored; the key is absent altogether when the flag
|
||||
* isn't passed. The entry shape is the one the notification projection declares.
|
||||
*/
|
||||
export const PlayerEventDetailsDto = PlayerEventDto.extend({
|
||||
tags: z
|
||||
.array(z.object({ tag: z.string(), type: z.int() }))
|
||||
.optional()
|
||||
.describe('Present only with `includeDetails=True`, and always empty'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /api/playerevents/v1` — the browse feed's listing. The same record minus
|
||||
* `State`, plus a `BroadcastingRoomInstanceId` (always null — nothing broadcasts an
|
||||
* event yet). That's the shape observed on this endpoint; the other reads serve the
|
||||
* stored record verbatim, so don't unify the two.
|
||||
*/
|
||||
export const PlayerEventListingDto = PlayerEventDto.omit({ State: true }).extend({
|
||||
BroadcastingRoomInstanceId: z
|
||||
.int()
|
||||
.nullable()
|
||||
.describe('Always null — no event broadcasts to a room instance yet'),
|
||||
})
|
||||
|
||||
/** The `{ Result, TagModifyResult, PlayerEvent }` envelope the event writes answer with. */
|
||||
export const PlayerEventResultDto = z.object({
|
||||
Result: z.int().describe('0 = success'),
|
||||
TagModifyResult: z
|
||||
.null()
|
||||
.describe('Always null — the write carries no tag edit, as no event tags are stored'),
|
||||
PlayerEvent: PlayerEventDto,
|
||||
})
|
||||
|
||||
/**
|
||||
* 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
|
||||
* posted at the top level or nested under `PlayerEvent` — the client posts back the
|
||||
* same envelope it read — and both forms are accepted. `PlayerEventId`,
|
||||
* `CreatorPlayerId` and `AttendeeCount` are ignored if present: the id is assigned
|
||||
* here, the creator comes from the bearer token, and RSVPs aren't set by hand.
|
||||
*/
|
||||
export const PlayerEventRequest = PlayerEventDto.partial().extend({
|
||||
PlayerEvent: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('The event’s fields, if nested rather than posted at the top level'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /api/playerevents/v1/:eventId/responses` — one player's RSVP to one event, as
|
||||
* the guest list serves it.
|
||||
*/
|
||||
export const PlayerEventResponseDto = z.object({
|
||||
PlayerEventResponseId: z.int().describe('Stable id of the RSVP row'),
|
||||
PlayerEventId: z.int(),
|
||||
PlayerId: z.int(),
|
||||
CreatedAt: z
|
||||
.string()
|
||||
.describe(
|
||||
'When the answer that stands was given — a changed answer updates the row, so this ' +
|
||||
'moves with it rather than recording the player’s first response'
|
||||
),
|
||||
Type: z.int().describe('0 Going, 1 Interested, 2 Can’t go'),
|
||||
})
|
||||
|
||||
/** `POST /api/playerevents/v1/respond` JSON body — how the caller is answering. */
|
||||
export const PlayerEventRespondRequest = z.object({
|
||||
PlayerEventId: z.int(),
|
||||
Type: z.int().describe('0 Going, 1 Interested, 2 Can’t go'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/playerevents/v1/report` JSON body — a report against an event. JSON, note,
|
||||
* where the player report next to it is form-encoded. The reporter is NOT in the body:
|
||||
* it's the bearer token's player.
|
||||
*/
|
||||
export const PlayerEventReportRequest = z.object({
|
||||
PlayerEventId: z.int().describe('The event being reported'),
|
||||
ReportCategory: z
|
||||
.int()
|
||||
.optional()
|
||||
.describe('The reason picked in the report UI, e.g. `101`. 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(),
|
||||
InvitedPlayerIds: z
|
||||
.array(z.int())
|
||||
.describe('Ids to invite; duplicates and the caller are ignored'),
|
||||
})
|
||||
|
||||
/** `GET /api/playerevents/v1/all` — the caller's created events and RSVPs. */
|
||||
export const PlayerEventsAll = z.object({
|
||||
Created: JsonArray,
|
||||
Responses: JsonArray,
|
||||
Created: z.array(PlayerEventDto).describe('Events the caller created, soonest first'),
|
||||
Responses: JsonArray.describe(
|
||||
'Events the caller RSVP’d to — always empty; RSVPs are stored, but this field’s ' +
|
||||
'entry shape has not been observed yet'
|
||||
),
|
||||
})
|
||||
|
||||
/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
|
||||
@@ -385,12 +579,6 @@ export const PlayerEventsPage = z.object({
|
||||
Events: JsonArray,
|
||||
})
|
||||
|
||||
/** `POST /api/CampusCard/v1/UpdateAndGetSubscription` — both null (no subs yet). */
|
||||
export const SubscriptionResponse = z.object({
|
||||
subscription: z.null(),
|
||||
platformAccountSubscribedPlayerId: z.null(),
|
||||
})
|
||||
|
||||
// ---- Moderation ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -411,6 +599,48 @@ export const ModerationBlockDetails = z.object({
|
||||
TimeoutStartedAt: z.string().nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/PlayerReporting/v3/create` form body — a player report. Everything is a
|
||||
* string on the wire (it's form-encoded); only `PlayerIdReported` is required. The
|
||||
* reporter is NOT in the body — it's taken from the bearer token.
|
||||
*/
|
||||
export const CreateReportRequest = z.object({
|
||||
PlayerIdReported: z.string().describe('Account id of the player being reported'),
|
||||
ReportCategory: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The reason picked in the report UI, e.g. `100`. Stored verbatim; unmapped'),
|
||||
Details: z.string().optional().describe('The free-text description the reporter typed'),
|
||||
HeightReporter: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Reporter’s player height in metres at report time, e.g. `1.64`'),
|
||||
HeightReported: z.string().optional().describe('Reported player’s height in metres'),
|
||||
RoomId: z.string().optional().describe('Room the report was raised in, if any'),
|
||||
RoomInstanceType: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Instance type name, e.g. `Public`. Stored verbatim'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/playerwarnings` form body — a warning a moderator hands down. Everything
|
||||
* is a string on the wire (it's form-encoded); only `WarnedPlayerId` is required. The
|
||||
* moderator is NOT in the body — it's taken from the bearer token.
|
||||
*/
|
||||
export const CreateWarningRequest = z.object({
|
||||
WarnedPlayerId: z.string().describe('Account id of the player being warned'),
|
||||
ReportCategory: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The reason category, e.g. `101`. Stored verbatim; unmapped'),
|
||||
DisplayReason: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('What the warned player is shown, e.g. `Sexual gestures`'),
|
||||
ModeratorNote: z.string().optional().describe('Internal note; never shown to the player'),
|
||||
})
|
||||
|
||||
/** `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'),
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
/**
|
||||
* Friendship / relationship storage on the shared `recflare` D1 database.
|
||||
*
|
||||
* Unlike the JSON-blob tables in this database (rooms/accounts/image), a
|
||||
* relationship is genuinely columnar, so it gets a normal relational table
|
||||
* (mirroring the Go/GORM `Relationship` model). Exactly ONE row exists per
|
||||
* unordered pair of players: the player who initiated is the `requester`, the
|
||||
* other is the `target`. `relationship_type` is stored from the requester's
|
||||
* point of view; when we project the row for the *target* we flip
|
||||
* Sent↔Received (Friend/None are symmetric).
|
||||
*
|
||||
* The `api` worker owns this schema/migration (migrations/0001_relationship.sql,
|
||||
* applied under its own `migrations_table` so it doesn't clash with the other
|
||||
* workers' migrations that share the database).
|
||||
*/
|
||||
|
||||
/** Relationship state from the perspective of the player asking (mirrors the reference). */
|
||||
export enum RelationshipType {
|
||||
None = 0,
|
||||
FriendRequestSent = 1,
|
||||
FriendRequestReceived = 2,
|
||||
Friend = 3,
|
||||
}
|
||||
|
||||
/** Schema DDL (mirror of migrations/0001_relationship.sql, sans seed rows). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS relationship (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
requester_id INTEGER NOT NULL,
|
||||
target_id INTEGER NOT NULL,
|
||||
relationship_type INTEGER NOT NULL DEFAULT 0,
|
||||
requester_favorited INTEGER NOT NULL DEFAULT 0,
|
||||
requester_ignored INTEGER NOT NULL DEFAULT 0,
|
||||
requester_muted INTEGER NOT NULL DEFAULT 0,
|
||||
target_favorited INTEGER NOT NULL DEFAULT 0,
|
||||
target_ignored INTEGER NOT NULL DEFAULT 0,
|
||||
target_muted INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_relationship ON relationship (requester_id, target_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_relationship_target ON relationship (target_id)`,
|
||||
]
|
||||
|
||||
/** A stored relationship row (snake_case columns, one row per player pair). */
|
||||
interface RelationshipRow {
|
||||
requester_id: number
|
||||
target_id: number
|
||||
relationship_type: number
|
||||
requester_favorited: number
|
||||
requester_ignored: number
|
||||
requester_muted: number
|
||||
target_favorited: number
|
||||
target_ignored: number
|
||||
target_muted: number
|
||||
}
|
||||
|
||||
/** The per-player relationship projection returned to the client (RelationshipResponse). */
|
||||
export interface RelationshipResponse {
|
||||
Favorited: number
|
||||
Ignored: number
|
||||
Muted: number
|
||||
PlayerID: number
|
||||
RelationshipType: RelationshipType
|
||||
}
|
||||
|
||||
/**
|
||||
* The result of a friend-graph mutation. These changes are visible to BOTH players, and
|
||||
* each sees a different projection of the same row (the target of a request sees
|
||||
* `FriendRequestReceived` where the sender sees `Sent`), so callers get both — `self` for
|
||||
* the HTTP response and the acting player's notification, `other` for the target's.
|
||||
*
|
||||
* `changed` is false when the mutation was a no-op: re-sending a request that's already
|
||||
* outstanding, befriending someone you're already friends with, accepting something that
|
||||
* isn't pending. Nothing was written, so no RelationshipChanged notification should go out
|
||||
* (the reference server is likewise silent on its no-change branch).
|
||||
*/
|
||||
export interface RelationshipChange {
|
||||
self: RelationshipResponse
|
||||
other: RelationshipResponse
|
||||
changed: boolean
|
||||
}
|
||||
|
||||
/** The projection reported for a pair with no stored relationship. */
|
||||
function noneResponse(otherId: number): RelationshipResponse {
|
||||
return {
|
||||
PlayerID: otherId,
|
||||
RelationshipType: RelationshipType.None,
|
||||
Favorited: 0,
|
||||
Ignored: 0,
|
||||
Muted: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Flip a pending request to the other side's point of view; Friend/None are symmetric. */
|
||||
function flipType(type: number): RelationshipType {
|
||||
if (type === RelationshipType.FriendRequestSent) return RelationshipType.FriendRequestReceived
|
||||
if (type === RelationshipType.FriendRequestReceived) return RelationshipType.FriendRequestSent
|
||||
return type as RelationshipType
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a stored row into the RelationshipResponse for `playerId` (who must be
|
||||
* one of the pair). `PlayerID` is the *other* player; the type and the
|
||||
* favorited/ignored/muted flags are taken from `playerId`'s side of the row.
|
||||
*/
|
||||
function toResponse(row: RelationshipRow, playerId: number): RelationshipResponse {
|
||||
const isRequester = row.requester_id === playerId
|
||||
return {
|
||||
PlayerID: isRequester ? row.target_id : row.requester_id,
|
||||
RelationshipType: isRequester ? (row.relationship_type as RelationshipType) : flipType(row.relationship_type),
|
||||
Favorited: isRequester ? row.requester_favorited : row.target_favorited,
|
||||
Ignored: isRequester ? row.requester_ignored : row.target_ignored,
|
||||
Muted: isRequester ? row.requester_muted : row.target_muted,
|
||||
}
|
||||
}
|
||||
|
||||
/** Project a written row for both players in the pair. */
|
||||
function toChange(
|
||||
row: RelationshipRow,
|
||||
playerId: number,
|
||||
otherId: number,
|
||||
changed: boolean
|
||||
): RelationshipChange {
|
||||
return { self: toResponse(row, playerId), other: toResponse(row, otherId), changed }
|
||||
}
|
||||
|
||||
/** Find the single row for an unordered pair (either direction), or null. */
|
||||
async function findPair(db: D1Database, a: number, b: number): Promise<RelationshipRow | null> {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT * FROM relationship
|
||||
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||
)
|
||||
.bind(a, b)
|
||||
.first<RelationshipRow>()
|
||||
}
|
||||
|
||||
/**
|
||||
* All of a player's relationships, projected from that player's point of view.
|
||||
*
|
||||
* `None` rows are included: they are how an unfriending, or an ignore/mute of someone you
|
||||
* were never friends with, is recorded, and they still carry that player's
|
||||
* favorited/ignored/muted flags. Dropping them would lose the flags on the client.
|
||||
*/
|
||||
export async function getRelationshipsForPlayer(
|
||||
db: D1Database,
|
||||
playerId: number
|
||||
): Promise<RelationshipResponse[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT * FROM relationship
|
||||
WHERE requester_id = ?1 OR target_id = ?1`
|
||||
)
|
||||
.bind(playerId)
|
||||
.all<RelationshipRow>()
|
||||
return results.map((row) => toResponse(row, playerId))
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist `type` for the pair, with `requesterId` recorded as the row's
|
||||
* requester. Inserts a new row or, if one already exists for the pair (either
|
||||
* direction), rewrites it so the requester is normalized to `requesterId` and
|
||||
* the flags are preserved for whichever side each player is on. Returns the
|
||||
* row as written, for the caller to project onto whichever side it needs.
|
||||
*/
|
||||
async function upsertPair(
|
||||
db: D1Database,
|
||||
requesterId: number,
|
||||
targetId: number,
|
||||
type: RelationshipType
|
||||
): Promise<RelationshipRow> {
|
||||
const existing = await findPair(db, requesterId, targetId)
|
||||
if (!existing) {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO relationship (requester_id, target_id, relationship_type)
|
||||
VALUES (?1, ?2, ?3)`
|
||||
)
|
||||
.bind(requesterId, targetId, type)
|
||||
.run()
|
||||
return {
|
||||
requester_id: requesterId,
|
||||
target_id: targetId,
|
||||
relationship_type: type,
|
||||
requester_favorited: 0,
|
||||
requester_ignored: 0,
|
||||
requester_muted: 0,
|
||||
target_favorited: 0,
|
||||
target_ignored: 0,
|
||||
target_muted: 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Keep each player's flags with that player as the row is normalized to
|
||||
// requester = requesterId.
|
||||
const reqIsRequester = existing.requester_id === requesterId
|
||||
const reqFlags = {
|
||||
favorited: reqIsRequester ? existing.requester_favorited : existing.target_favorited,
|
||||
ignored: reqIsRequester ? existing.requester_ignored : existing.target_ignored,
|
||||
muted: reqIsRequester ? existing.requester_muted : existing.target_muted,
|
||||
}
|
||||
const tgtFlags = {
|
||||
favorited: reqIsRequester ? existing.target_favorited : existing.requester_favorited,
|
||||
ignored: reqIsRequester ? existing.target_ignored : existing.requester_ignored,
|
||||
muted: reqIsRequester ? existing.target_muted : existing.requester_muted,
|
||||
}
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE relationship
|
||||
SET requester_id = ?1, target_id = ?2, relationship_type = ?3,
|
||||
requester_favorited = ?4, requester_ignored = ?5, requester_muted = ?6,
|
||||
target_favorited = ?7, target_ignored = ?8, target_muted = ?9
|
||||
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||
)
|
||||
.bind(
|
||||
requesterId,
|
||||
targetId,
|
||||
type,
|
||||
reqFlags.favorited,
|
||||
reqFlags.ignored,
|
||||
reqFlags.muted,
|
||||
tgtFlags.favorited,
|
||||
tgtFlags.ignored,
|
||||
tgtFlags.muted
|
||||
)
|
||||
.run()
|
||||
return {
|
||||
requester_id: requesterId,
|
||||
target_id: targetId,
|
||||
relationship_type: type,
|
||||
requester_favorited: reqFlags.favorited,
|
||||
requester_ignored: reqFlags.ignored,
|
||||
requester_muted: reqFlags.muted,
|
||||
target_favorited: tgtFlags.favorited,
|
||||
target_ignored: tgtFlags.ignored,
|
||||
target_muted: tgtFlags.muted,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a friend request from `requesterId` to `targetId`. If the target already
|
||||
* has a pending request out to the requester, the two become friends instead
|
||||
* (the request crosses an existing one). Already-friends, and re-sending a request
|
||||
* that's already outstanding, are no-ops.
|
||||
*/
|
||||
export async function sendFriendRequest(
|
||||
db: D1Database,
|
||||
requesterId: number,
|
||||
targetId: number
|
||||
): Promise<RelationshipChange> {
|
||||
const existing = await findPair(db, requesterId, targetId)
|
||||
if (existing) {
|
||||
// Already friends, or we already have a request out to them — nothing to write.
|
||||
if (
|
||||
existing.relationship_type === RelationshipType.Friend ||
|
||||
(existing.requester_id === requesterId &&
|
||||
existing.relationship_type === RelationshipType.FriendRequestSent)
|
||||
) {
|
||||
return toChange(existing, requesterId, targetId, false)
|
||||
}
|
||||
// The target already requested us → crossing requests become a friendship.
|
||||
if (
|
||||
existing.requester_id === targetId &&
|
||||
existing.relationship_type === RelationshipType.FriendRequestSent
|
||||
) {
|
||||
const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
||||
return toChange(row, requesterId, targetId, true)
|
||||
}
|
||||
}
|
||||
const row = await upsertPair(db, requesterId, targetId, RelationshipType.FriendRequestSent)
|
||||
return toChange(row, requesterId, targetId, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* `accepterId` accepts a pending friend request from `otherId`. Only upgrades to
|
||||
* Friend when a request from `otherId` is actually pending; otherwise the current
|
||||
* state is returned as a no-op. (The reference server answers 403 there instead;
|
||||
* we stay lenient, but either way nothing changed.)
|
||||
*/
|
||||
export async function acceptFriendRequest(
|
||||
db: D1Database,
|
||||
accepterId: number,
|
||||
otherId: number
|
||||
): Promise<RelationshipChange> {
|
||||
const existing = await findPair(db, accepterId, otherId)
|
||||
if (
|
||||
existing &&
|
||||
existing.requester_id === otherId &&
|
||||
existing.relationship_type === RelationshipType.FriendRequestSent
|
||||
) {
|
||||
const row = await upsertPair(db, otherId, accepterId, RelationshipType.Friend)
|
||||
return toChange(row, accepterId, otherId, true)
|
||||
}
|
||||
return existing
|
||||
? toChange(existing, accepterId, otherId, false)
|
||||
: { self: noneResponse(otherId), other: noneResponse(accepterId), changed: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Directly make `requesterId` and `targetId` friends (no pending request step).
|
||||
*/
|
||||
export async function addFriend(
|
||||
db: D1Database,
|
||||
requesterId: number,
|
||||
targetId: number
|
||||
): Promise<RelationshipChange> {
|
||||
const existing = await findPair(db, requesterId, targetId)
|
||||
if (existing && existing.relationship_type === RelationshipType.Friend) {
|
||||
return toChange(existing, requesterId, targetId, false)
|
||||
}
|
||||
const row = await upsertPair(db, requesterId, targetId, RelationshipType.Friend)
|
||||
return toChange(row, requesterId, targetId, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any relationship between the two players (unfriend / cancel request /
|
||||
* decline).
|
||||
*
|
||||
* The row is set to `None` rather than deleted, matching the reference server: the
|
||||
* per-player favorited/ignored/muted flags live on that row and must survive an
|
||||
* unfriending (someone you ignored stays ignored after you drop them as a friend).
|
||||
*/
|
||||
export async function removeFriend(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
otherId: number
|
||||
): Promise<RelationshipChange> {
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE relationship SET relationship_type = ?3
|
||||
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||
)
|
||||
.bind(playerId, otherId, RelationshipType.None)
|
||||
.run()
|
||||
const updated = await findPair(db, playerId, otherId)
|
||||
return updated
|
||||
? toChange(updated, playerId, otherId, true)
|
||||
: { self: noneResponse(otherId), other: noneResponse(playerId), changed: true }
|
||||
}
|
||||
|
||||
/** A per-player relationship flag — each is stored on the player's own side of the row. */
|
||||
export type RelationshipFlag = 'favorited' | 'ignored' | 'muted'
|
||||
|
||||
/**
|
||||
* Set one of `playerId`'s per-side flags (favorited/ignored/muted) on their
|
||||
* relationship with `otherId`. These flags are stored per player, so the write
|
||||
* targets the caller's OWN side of the row — `requester_*` when the caller
|
||||
* initiated the pair, `target_*` otherwise. When the pair has no relationship yet
|
||||
* (you can ignore/mute someone you aren't friends with) a fresh `None` row is
|
||||
* created with the caller as requester. Returns the relationship from `playerId`'s
|
||||
* point of view. The `flag`/side names are a fixed union, so interpolating them
|
||||
* into the SQL is safe (same pattern as the room interaction toggles).
|
||||
*/
|
||||
export async function setRelationshipFlag(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
otherId: number,
|
||||
flag: RelationshipFlag,
|
||||
value: boolean
|
||||
): Promise<RelationshipResponse> {
|
||||
const existing = await findPair(db, playerId, otherId)
|
||||
const v = value ? 1 : 0
|
||||
if (!existing) {
|
||||
// New row: the caller is the requester, so the flag lives on the requester side.
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO relationship (requester_id, target_id, relationship_type, requester_${flag})
|
||||
VALUES (?1, ?2, ?3, ?4)`
|
||||
)
|
||||
.bind(playerId, otherId, RelationshipType.None, v)
|
||||
.run()
|
||||
} else {
|
||||
// Update whichever side the caller is on, leaving the other player's flag alone.
|
||||
const side = existing.requester_id === playerId ? 'requester' : 'target'
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE relationship SET ${side}_${flag} = ?3
|
||||
WHERE (requester_id = ?1 AND target_id = ?2) OR (requester_id = ?2 AND target_id = ?1)`
|
||||
)
|
||||
.bind(playerId, otherId, v)
|
||||
.run()
|
||||
}
|
||||
|
||||
const updated = await findPair(db, playerId, otherId)
|
||||
return updated ? toResponse(updated, playerId) : noneResponse(otherId)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Player-report storage on the shared `recflare` D1 database.
|
||||
*
|
||||
* Like the relationship table (and unlike the JSON-blob tables here — rooms /
|
||||
* accounts / image / invention), a report is genuinely columnar, so it gets a
|
||||
* normal relational table. Rows are append-only in the sense that nothing rewrites
|
||||
* 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).
|
||||
*
|
||||
* 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 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Schema DDL (mirror of migrations/0004_report.sql + 0009_report_ban.sql +
|
||||
* 0011_report_event.sql).
|
||||
*/
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS report (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
reporter_player_id INTEGER NOT NULL,
|
||||
reported_player_id INTEGER NOT NULL,
|
||||
report_category INTEGER NOT NULL DEFAULT 0,
|
||||
details TEXT,
|
||||
height_reporter REAL,
|
||||
height_reported REAL,
|
||||
room_id INTEGER,
|
||||
room_instance_type TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
banned INTEGER NOT NULL DEFAULT 0,
|
||||
ban_expires TEXT,
|
||||
event_id INTEGER
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id)`,
|
||||
`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). */
|
||||
export interface ReportRow {
|
||||
id: number
|
||||
reporter_player_id: number
|
||||
reported_player_id: number
|
||||
report_category: number
|
||||
details: string | null
|
||||
/** Player height in metres, as the client measured it at report time. */
|
||||
height_reporter: number | null
|
||||
height_reported: number | null
|
||||
room_id: number | null
|
||||
/** The instance's `RoomInstanceType` name, e.g. `Public`. Stored verbatim. */
|
||||
room_instance_type: string | null
|
||||
created_at: string
|
||||
/** 1 when a moderator turned this report into a ban of `reported_player_id`. */
|
||||
banned: number
|
||||
/** ISO-8601 UTC instant the ban lifts; NULL means it never does. */
|
||||
ban_expires: string | null
|
||||
/**
|
||||
* The player event this report is against, or NULL for an ordinary player report —
|
||||
* which is what tells the two kinds apart. See `POST /api/playerevents/v1/report`:
|
||||
* `reported_player_id` and `room_id` are filled in from the event itself.
|
||||
*/
|
||||
event_id: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A report as submitted — everything but the reporter (which comes from the bearer
|
||||
* token) and the timestamp. Only the reported player is required; the client omits
|
||||
* fields it has no value for (a report raised outside a room carries no `RoomId`),
|
||||
* so the rest are optional and stored as NULL when absent.
|
||||
*/
|
||||
export interface NewReport {
|
||||
reporterPlayerId: number
|
||||
reportedPlayerId: number
|
||||
reportCategory?: number
|
||||
details?: string | null
|
||||
heightReporter?: number | null
|
||||
heightReported?: number | null
|
||||
roomId?: number | null
|
||||
roomInstanceType?: string | null
|
||||
/** Set only when reporting a player EVENT; absent on an ordinary player report. */
|
||||
eventId?: number | null
|
||||
}
|
||||
|
||||
/** Record a submitted report, returning the stored row (with its assigned id). */
|
||||
export async function createReport(db: D1Database, input: NewReport): Promise<ReportRow> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`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)
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(
|
||||
input.reporterPlayerId,
|
||||
input.reportedPlayerId,
|
||||
input.reportCategory ?? 0,
|
||||
input.details ?? null,
|
||||
input.heightReporter ?? null,
|
||||
input.heightReported ?? null,
|
||||
input.roomId ?? null,
|
||||
input.roomInstanceType ?? null,
|
||||
new Date().toISOString(),
|
||||
input.eventId ?? null
|
||||
)
|
||||
.first<ReportRow>()
|
||||
// RETURNING always yields the inserted row; the non-null assert keeps the caller
|
||||
// from having to handle an impossible null.
|
||||
return row!
|
||||
}
|
||||
|
||||
/** Every report filed against a player, newest first. Backs a future moderation view. */
|
||||
export async function getReportsAgainst(db: D1Database, playerId: number): Promise<ReportRow[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT * FROM report WHERE reported_player_id = ?1 ORDER BY id DESC')
|
||||
.bind(playerId)
|
||||
.all<ReportRow>()
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* The ban currently in force against a player, or null when they aren't banned.
|
||||
*
|
||||
* "In force" is narrower than `banned = 1`: a row whose `ban_expires` has passed is a
|
||||
* ban that has SERVED ITS TIME, and the player is let back in without anyone having to
|
||||
* go and clear the flag — the row stays as the record that it happened. A permanent ban
|
||||
* carries no expiry at all (NULL), which is why that arm is checked separately rather
|
||||
* than by comparing against some far-future date.
|
||||
*
|
||||
* When several bans are in force, the longest-lasting one wins: permanent first (NULL
|
||||
* sorts ahead because `ban_expires IS NOT NULL` is 0 for it), then the latest expiry. So
|
||||
* a fresh short ban can never shorten a standing one.
|
||||
*/
|
||||
export async function getActiveBan(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
now: Date = new Date()
|
||||
): Promise<ReportRow | null> {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT * FROM report
|
||||
WHERE reported_player_id = ?1 AND banned = 1
|
||||
AND (ban_expires IS NULL OR ban_expires > ?2)
|
||||
ORDER BY ban_expires IS NOT NULL, ban_expires DESC
|
||||
LIMIT 1`
|
||||
)
|
||||
.bind(playerId, now.toISOString())
|
||||
.first<ReportRow>()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export async function isPlayerBanned(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
now: Date = new Date()
|
||||
): Promise<boolean> {
|
||||
return (await getActiveBan(db, playerId, now)) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a report into a ban of the player it was filed against — the moderator action the
|
||||
* `banned` column exists for. `banExpires` is an ISO-8601 UTC instant, or null for a
|
||||
* permanent ban. Passing `banned: false` lifts the ban and clears the expiry, leaving the
|
||||
* report itself intact.
|
||||
*
|
||||
* Returns the updated row, or null when there is no report with that id — so the caller
|
||||
* can tell "banned" from "banned nobody" (wrangler's `d1 execute --json` reports no
|
||||
* changes count, hence RETURNING).
|
||||
*/
|
||||
export async function banFromReport(
|
||||
db: D1Database,
|
||||
reportId: number,
|
||||
options: { banned?: boolean; banExpires?: string | null } = {}
|
||||
): Promise<ReportRow | null> {
|
||||
const banned = options.banned ?? true
|
||||
return db
|
||||
.prepare('UPDATE report SET banned = ?2, ban_expires = ?3 WHERE id = ?1 RETURNING *')
|
||||
.bind(reportId, banned ? 1 : 0, banned ? (options.banExpires ?? null) : null)
|
||||
.first<ReportRow>()
|
||||
}
|
||||
+165
-45
@@ -1,18 +1,25 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import {
|
||||
inventionDescriptionRejection,
|
||||
inventionNameRejection,
|
||||
inventionTagRejection,
|
||||
} from '@repo/domain'
|
||||
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
createInvention,
|
||||
getFeaturedInventions,
|
||||
getInventionById,
|
||||
getInventionsByCreator,
|
||||
getInventionsByIds,
|
||||
getInventionsByRoom,
|
||||
getInventionTagFilters,
|
||||
getInventionTags,
|
||||
getInventionVersion,
|
||||
getMyInventions,
|
||||
getTopInventions,
|
||||
ownsAllInventions,
|
||||
parsePermissionLevel,
|
||||
publishInvention,
|
||||
searchInventions,
|
||||
@@ -76,6 +83,20 @@ async function creatorsInvention(
|
||||
return { invention }
|
||||
}
|
||||
|
||||
/**
|
||||
* The `?id=1&id=2` list the invention batch endpoints take. `id` repeats, and each
|
||||
* value may itself be a comma-separated list; anything non-numeric is dropped.
|
||||
*/
|
||||
function inventionIdQuery(c: Context<App>): number[] {
|
||||
return (
|
||||
c.req
|
||||
.queries('id')
|
||||
?.flatMap((raw) => raw.split(','))
|
||||
.map((raw) => Number.parseInt(raw.trim(), 10))
|
||||
.filter((id) => !Number.isNaN(id)) ?? []
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Avatar gifts ----------------------------------------------------------
|
||||
// The avatar read endpoints (`v4/items`, `v2`, `v2/set`, `v3/saved`, `v2/gifts`) and
|
||||
// gift-box consume live in the `econ` worker, which the client calls on the econ host
|
||||
@@ -270,12 +291,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
responses: { 200: json(InventionDto.array(), 'The inventions the caller may see') },
|
||||
}),
|
||||
async (c) => {
|
||||
const ids = c.req
|
||||
.queries('id')
|
||||
?.flatMap((raw) => raw.split(','))
|
||||
.map((raw) => Number.parseInt(raw.trim(), 10))
|
||||
.filter((id) => !Number.isNaN(id))
|
||||
if (ids === undefined || ids.length === 0) return c.json([])
|
||||
const ids = inventionIdQuery(c)
|
||||
if (ids.length === 0) return c.json([])
|
||||
|
||||
const playerId = await authedId(c)
|
||||
const inventions = await getInventionsByIds(c.env.DB, ids)
|
||||
@@ -287,6 +304,40 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Whether the caller owns every invention in a lineage (`?id=101&id=102&id=103`) —
|
||||
// the invention plus everything nested inside it, as the client enumerates it. One
|
||||
// bare `true`/`false` for the whole set, not a verdict per id. Auth-gated: the
|
||||
// question is about the caller.
|
||||
.get(
|
||||
'/api/inventions/v1/fulllineageowner',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Does the caller own this whole lineage?',
|
||||
description:
|
||||
'Asked when saving an invention built out of other inventions: may this player use ' +
|
||||
'every piece? The client sends the whole lineage as repeated `id`s, and this ' +
|
||||
'answers a single bare `true`/`false` for the set — false as soon as one is not the ' +
|
||||
'caller’s. An invention is theirs if they created it or acquired it; an id with no ' +
|
||||
'invention behind it is not owned. Price and permission don’t enter into it — a ' +
|
||||
'free invention still has to be picked up, and that writes the same inventory row ' +
|
||||
'a paid one does.\n\n' +
|
||||
'Only the ids asked about are checked — this does not walk `ReferencedInventions` ' +
|
||||
'to widen the lineage, since the client knows what the thing it is holding is ' +
|
||||
'actually made of. No ids at all is `true`: nothing in an empty lineage is unowned.',
|
||||
security: AUTHED,
|
||||
parameters: [intQuery('id', 'Repeatable; each value may be a comma-separated list of ids')],
|
||||
responses: {
|
||||
200: json(BareBoolean, 'Whether the caller owns every invention asked about'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const playerId = await authedId(c)
|
||||
if (playerId === null) return unauthorized(c)
|
||||
return c.json(await ownsAllInventions(c.env.DB, playerId, inventionIdQuery(c)))
|
||||
}
|
||||
)
|
||||
|
||||
// A room's inventions (`?id=76`) — published inventions created in that room,
|
||||
// newest first. Paginated via skip/take (take defaults to 100). Bare array.
|
||||
.get(
|
||||
@@ -371,34 +422,43 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Edit an invention's metadata. A GET that writes — that's what the client sends
|
||||
// (`?inventionId=1&description=my+description`), with the fields to change as
|
||||
// query params. Absent params keep their stored value; `permission` sets what
|
||||
// other players may do with it (a name like `useonly` or the raw number). An
|
||||
// empty `description` clears it, but an empty `name`/`imageName` is ignored
|
||||
// rather than blanking the invention. Publishing and pricing are separate
|
||||
// endpoints. Auth-gated, creator only; answers the save envelope.
|
||||
.get(
|
||||
// Edit an invention's metadata. The fields to change ride as QUERY PARAMS on both
|
||||
// verbs (`?inventionId=1&description=my+description`) — the client sends this as a
|
||||
// GET that writes in some places and as a bodyless POST in others (the permission
|
||||
// picker posts `?inventionId=84&permission=Publish`), so both are registered and
|
||||
// neither reads a body. Absent params keep their stored value; `permission` sets
|
||||
// what other players may do with it. An empty `description` clears it, but an empty
|
||||
// `name`/`imageName` is ignored rather than blanking the invention. Publishing and
|
||||
// pricing are separate endpoints. Auth-gated, creator only; answers the save envelope.
|
||||
.on(
|
||||
['GET', 'POST'],
|
||||
'/api/inventions/v1/update',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Edit an invention’s metadata',
|
||||
description:
|
||||
'A GET that writes — that is what the client sends, with the fields to change as ' +
|
||||
'query params. Absent params keep their stored value. An empty `description` ' +
|
||||
'clears it, but an empty `name`/`imageName` is ignored rather than blanking the ' +
|
||||
'invention. Publishing and pricing are separate endpoints.',
|
||||
'GET or POST — the client sends both, and the fields to change ride as query ' +
|
||||
'params either way; no body is read. Absent params keep their stored value. An ' +
|
||||
'empty `description` clears it, but an empty `name`/`imageName` is ignored rather ' +
|
||||
'than blanking the invention. A supplied name/description must satisfy the same ' +
|
||||
'rules `v6/save` enforces. Publishing and pricing are separate endpoints.',
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
intQuery('inventionId', 'Invention id; required'),
|
||||
stringQuery('name', 'New name; empty is ignored'),
|
||||
stringQuery('description', 'New description; present-but-empty clears it'),
|
||||
stringQuery('name', '3–24 chars, letters/digits/spaces/dashes/colons; empty is ignored'),
|
||||
stringQuery('description', 'Max 512 chars; present-but-empty clears it'),
|
||||
stringQuery('imageName', 'New thumbnail; empty is ignored'),
|
||||
stringQuery('allowTrial', '`true`/`1` to allow trials'),
|
||||
stringQuery('permission', 'A name like `useonly`, or the raw permission number'),
|
||||
stringQuery(
|
||||
'permission',
|
||||
'What other players get (`GeneralPermission`). The picker sends `UseOnly`, ' +
|
||||
'`EditAndSave` or `Publish`; any ladder name (case- and underscore-insensitive) ' +
|
||||
'or the raw number is accepted'
|
||||
),
|
||||
],
|
||||
responses: {
|
||||
200: json(InventionSaveResult, 'The updated invention, in the save envelope'),
|
||||
400: json(ErrorResponse, 'A supplied name or description breaks its rule'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||||
404: { description: 'No such invention' },
|
||||
@@ -416,10 +476,23 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
const allowTrial = c.req.query('allowTrial')
|
||||
const permission = c.req.query('permission')
|
||||
|
||||
// Only a name that's actually being changed is checked — an absent or empty one
|
||||
// keeps the stored name, which was already validated when it was set.
|
||||
const name = nonEmpty('name')
|
||||
const nameRejection = name === undefined ? null : inventionNameRejection(name)
|
||||
if (nameRejection !== null) return c.json({ error: nameRejection }, 400)
|
||||
|
||||
// The description is checked on presence, not emptiness: empty is how a creator
|
||||
// clears it, and the length rule accepts that.
|
||||
const description = c.req.query('description')
|
||||
const descriptionRejection =
|
||||
description === undefined ? null : inventionDescriptionRejection(description)
|
||||
if (descriptionRejection !== null) return c.json({ error: descriptionRejection }, 400)
|
||||
|
||||
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
|
||||
name: nonEmpty('name'),
|
||||
name,
|
||||
// Present-but-empty clears the description, so this checks presence.
|
||||
description: c.req.query('description'),
|
||||
description,
|
||||
imageName: nonEmpty('imageName'),
|
||||
allowTrial:
|
||||
allowTrial === undefined
|
||||
@@ -523,13 +596,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
'`CustomTags` are the creator’s own (Type 0), `AutoTags` the ones the client ' +
|
||||
'derives from the invention (Type 2); both lists are replaced wholesale. Creator ' +
|
||||
'only.\n\n' +
|
||||
'Every tag in either list must be at most 15 letters (a–z once lowercased); one ' +
|
||||
'that isn’t fails the whole call, so no tag is ever silently dropped.\n\n' +
|
||||
'Note the asymmetry: this answers the flat list of tag *names* (auto first, then ' +
|
||||
'custom), while `v1/details` serves the typed `{ Tag, Type }` objects.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(SetTagsRequest, 'The replacement tag lists'),
|
||||
responses: {
|
||||
200: json(SetTagsResponse, 'The resulting tag names'),
|
||||
400: json(ErrorResponse, 'Unparseable body'),
|
||||
400: json(ErrorResponse, 'Unparseable body, or a tag that breaks the rule'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||||
404: { description: 'No such invention' },
|
||||
@@ -546,11 +621,29 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
const strings = (v: unknown): string[] =>
|
||||
Array.isArray(v) ? v.filter((t): t is string => typeof t === 'string') : []
|
||||
|
||||
const autoTags = strings(body.AutoTags)
|
||||
const customTags = strings(body.CustomTags)
|
||||
|
||||
// Both lists are held to the tag rule, and one bad tag fails the whole call rather
|
||||
// than being dropped — a silently missing tag looks to the creator like a tag that
|
||||
// saved. Checked against the normalized form `setInventionTags` will store, so the
|
||||
// rejection quotes the tag as it would have been stored, not as it was typed.
|
||||
// Blanks are skipped, not rejected: the store already drops them, and the client
|
||||
// pads its list with empties.
|
||||
for (const raw of [...autoTags, ...customTags]) {
|
||||
const tag = raw.trim().toLowerCase()
|
||||
if (tag === '') continue
|
||||
const rejection = inventionTagRejection(tag)
|
||||
if (rejection !== null) {
|
||||
return c.json({ error: `${rejection} (“${tag}”)` }, 400)
|
||||
}
|
||||
}
|
||||
|
||||
const tags = await setInventionTags(
|
||||
c.env.DB,
|
||||
gate.invention.InventionId,
|
||||
strings(body.AutoTags),
|
||||
strings(body.CustomTags)
|
||||
autoTags,
|
||||
customTags
|
||||
)
|
||||
return c.json({ Result: 0, Tags: (tags ?? []).map((t) => t.Tag) })
|
||||
}
|
||||
@@ -581,17 +674,21 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// The "top today" invention feed — published inventions ranked by engagement
|
||||
// (lifetime, not per-day: we keep no daily counters). Paginated via skip/take
|
||||
// (take defaults to 50, as the client asks for). Bare array.
|
||||
// The "top today" invention feed — the inventions most acquired in the last 24 hours,
|
||||
// counted from the purchase rows the `econ` worker writes. A real day window, so an
|
||||
// empty list is a quiet day rather than a bug. Paginated via skip/take (take defaults
|
||||
// to 50, as the client asks for). Bare array.
|
||||
.get(
|
||||
'/api/inventions/v1/toptoday',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'The “top today” feed',
|
||||
description:
|
||||
'Published inventions ranked by engagement — lifetime, not per-day: we keep no ' +
|
||||
'daily counters, so “today” is a label, not a window.',
|
||||
'Published inventions ranked by how many players acquired them in the last 24 ' +
|
||||
'hours, counted from the purchase records — free grants included, one per ' +
|
||||
'player per invention. Genuinely a window: an invention nobody has picked up ' +
|
||||
'since yesterday falls off, and a day with no acquisitions at all serves an ' +
|
||||
'empty list. It trails the clock rather than resetting at midnight.',
|
||||
parameters: pageParams(50),
|
||||
responses: { 200: json(InventionDto.array(), 'The top inventions') },
|
||||
}),
|
||||
@@ -602,16 +699,17 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// The featured invention feed — curated (`IsFeatured`) inventions, falling back
|
||||
// to the top feed while nothing is curated. Bare array, like toptoday.
|
||||
// The featured invention feed — the curated (`IsFeatured`) inventions and nothing
|
||||
// else, newest first. Empty until someone flags one. Bare array, like toptoday.
|
||||
.get(
|
||||
'/api/inventions/v1/featured',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'The featured feed',
|
||||
description:
|
||||
'Curated (`IsFeatured`) inventions, falling back to the top feed while nothing is ' +
|
||||
'curated — so this is never empty just because no one has picked favourites.',
|
||||
'Curated (`IsFeatured`) inventions, newest first — published and non-hidden only. ' +
|
||||
'Serves an empty list while nothing is flagged rather than standing in the top ' +
|
||||
'feed: the client presents these as hand-picked, so a fallback would be a lie.',
|
||||
parameters: pageParams(50),
|
||||
responses: { 200: json(InventionDto.array(), 'The featured inventions') },
|
||||
}),
|
||||
@@ -648,16 +746,20 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// The signed-in player's saved inventions ("my inventions"), newest first.
|
||||
// Auth-gated; returns a bare array (empty when the player has saved none).
|
||||
// The signed-in player's invention shelf ("my inventions"), newest first — the ones
|
||||
// they created AND the ones they bought (`inventory_invention`, written by the `econ`
|
||||
// worker's buyInvention). A bought invention stays on the shelf whatever happens to it
|
||||
// afterwards: unpublished or hidden since, the buyer paid for it.
|
||||
// Auth-gated; returns a bare array (empty when the player has neither).
|
||||
.get(
|
||||
'/api/inventions/v2/mine',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'The caller’s own inventions',
|
||||
description:
|
||||
'“My inventions”, newest first — including unpublished ones, which nobody else can ' +
|
||||
'see. Not paginated.',
|
||||
'“My inventions”, newest first — the ones the caller created plus the ones they ' +
|
||||
'bought. Includes unpublished ones, which nobody else can see, and keeps a bought ' +
|
||||
'invention listed even if it has since been unpublished or hidden. Not paginated.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(InventionDto.array(), 'The caller’s inventions'),
|
||||
@@ -667,7 +769,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(await getInventionsByCreator(c.env.DB, id))
|
||||
return c.json(await getMyInventions(c.env.DB, id))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -686,14 +788,19 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
'Records 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 omitted ' +
|
||||
'name/description is defaulted rather than rejected.\n\n' +
|
||||
'name/description is defaulted rather than rejected; a supplied one must be 3–24 ' +
|
||||
'characters of letters, digits, spaces, dashes and colons (name) or at most 512 ' +
|
||||
'characters (description).\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(SaveInventionRequest, 'The invention metadata (camelCase)'),
|
||||
responses: {
|
||||
200: json(InventionSaveResult, 'The stored invention, carrying its assigned id'),
|
||||
400: json(ErrorResponse, 'Unparseable body, or no inventionDataFilename'),
|
||||
400: json(
|
||||
ErrorResponse,
|
||||
'Unparseable body, no inventionDataFilename, or an invalid name/description'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
@@ -712,11 +819,24 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
return c.json({ error: 'inventionDataFilename is required' }, 400)
|
||||
}
|
||||
|
||||
// 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,
|
||||
name: str(body.name),
|
||||
description: str(body.description),
|
||||
name,
|
||||
description,
|
||||
imageName: str(body.imageName),
|
||||
instantiationCost: num(body.instantiationCost),
|
||||
lightsCost: num(body.lightsCost),
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
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 {
|
||||
createEvent,
|
||||
eventInputRejection,
|
||||
getEventAttendees,
|
||||
getEventById,
|
||||
getEventResponse,
|
||||
getEventsByClubs,
|
||||
getEventsByCreator,
|
||||
getEventsByIds,
|
||||
getEventTags,
|
||||
getLiveEvents,
|
||||
inviteToEvent,
|
||||
isEventResponseType,
|
||||
parseEventBody,
|
||||
searchEvents,
|
||||
setEventResponse,
|
||||
toEventListing,
|
||||
toEventNotification,
|
||||
toEventResponse,
|
||||
toEventResult,
|
||||
updateEvent,
|
||||
} from '../events-db'
|
||||
import { authedId, queryIds, unauthorized } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
idParam,
|
||||
intQuery,
|
||||
json,
|
||||
jsonBody,
|
||||
pageParams,
|
||||
PlayerEventBulkInviteRequest,
|
||||
PlayerEventDetailsDto,
|
||||
PlayerEventDto,
|
||||
PlayerEventListingDto,
|
||||
PlayerEventReportRequest,
|
||||
PlayerEventRequest,
|
||||
PlayerEventRespondRequest,
|
||||
PlayerEventResponseDto,
|
||||
PlayerEventResultDto,
|
||||
PlayerEventsAll,
|
||||
PlayerEventsPage,
|
||||
stringQuery,
|
||||
SuccessErrorEnvelope,
|
||||
TagFilters,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from '../openapi'
|
||||
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'
|
||||
|
||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||
const HUB_INSTANCE = 'global'
|
||||
|
||||
/**
|
||||
* Push a `PlayerEventCreated` notification for a freshly scheduled event to its
|
||||
* creator — what makes the event appear on their own screen without a refetch.
|
||||
*
|
||||
* Hub failures are logged and swallowed: the event is already stored, so a hub hiccup
|
||||
* must not fail the create. Note the frame carries the camelCase
|
||||
* {@link toEventNotification} projection, not the PascalCase record the response does.
|
||||
*/
|
||||
async function notifyEventCreated(
|
||||
c: Context<App>,
|
||||
event: PlayerEvent,
|
||||
tags: EventTag[]
|
||||
): Promise<void> {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
event.CreatorPlayerId,
|
||||
NotificationType.PlayerEventCreated,
|
||||
{ ...toEventNotification(event, tags) }
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push PlayerEventCreated notification', {
|
||||
playerEventId: event.PlayerEventId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a `PlayerEventResponseChanged` (83) to each player a bulk invite just added —
|
||||
* what puts the event on their screen without a refetch, since an invite writes their
|
||||
* response row for them.
|
||||
*
|
||||
* Only the players who actually gained a row are notified: an invite that hit an
|
||||
* existing answer changed nothing, so there is nothing to tell them about.
|
||||
*
|
||||
* The frame carries BOTH nested objects the client's decoder expects. That is not
|
||||
* optional — several of its handlers dereference one level down with no null guard, so
|
||||
* omitting one surfaces as a NullReferenceException in the client rather than a missing
|
||||
* field (see notification-payloads.ts). The event goes in the same camelCase
|
||||
* {@link toEventNotification} projection the `PlayerEventCreated` frame uses, and the
|
||||
* response in the PascalCase {@link toEventResponse} one the RSVP list serves; the
|
||||
* decoder accepts either casing, so the two need not agree.
|
||||
*
|
||||
* Hub failures are logged and swallowed, and one player's failure doesn't stop the
|
||||
* rest: the invites are already stored by the time this runs.
|
||||
*/
|
||||
async function notifyInvited(
|
||||
c: Context<App>,
|
||||
event: PlayerEvent,
|
||||
added: EventAttendeeRow[]
|
||||
): Promise<void> {
|
||||
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
|
||||
const PlayerEvent = { ...toEventNotification(event) }
|
||||
for (const row of added) {
|
||||
const payload = {
|
||||
PlayerEvent,
|
||||
PlayerEventResponse: { ...toEventResponse(row) },
|
||||
} satisfies PlayerEventResponsePayload
|
||||
try {
|
||||
await hub.notifyPlayer(row.player_id, NotificationType.PlayerEventResponseChanged, payload)
|
||||
} catch (err) {
|
||||
logger.error('failed to push PlayerEventResponseChanged notification', {
|
||||
playerEventId: event.PlayerEventId,
|
||||
playerId: row.player_id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Player events — scheduled events players and clubs host in a room.
|
||||
*
|
||||
* D1-backed (the `event` table, owned by this worker; see events-db.ts). The stored
|
||||
* blob IS the DTO, so every read here serves it verbatim; only the create/update
|
||||
* writes wrap it, in the `{ Result, TagModifyResult, PlayerEvent }` envelope.
|
||||
*
|
||||
* Watch the response shapes: the two club feeds deliberately differ (bare array for
|
||||
* the multi-club form, paged envelope for the single-club one) and the client chokes
|
||||
* if they're unified.
|
||||
*/
|
||||
export const eventRoutes = new Hono<App>({ strict: false })
|
||||
// The player-events browse feed — everything upcoming or running, soonest first. Same
|
||||
// query `/search` runs with no text, but its own projection: this feed drops `State`
|
||||
// and carries a `BroadcastingRoomInstanceId`, so it goes through `toEventListing`.
|
||||
.get(
|
||||
'/api/playerevents/v1',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'The player-events browse feed',
|
||||
description:
|
||||
'The default feed on the player-events screen: every event that has not finished ' +
|
||||
'yet — upcoming and running — soonest first, paginated via skip/take. A bare ' +
|
||||
'array.\n\n' +
|
||||
'Each entry is the browse LISTING, not the stored record the by-id, bulk and ' +
|
||||
'search reads serve: it drops `State` and carries ' +
|
||||
'`BroadcastingRoomInstanceId` (always null — nothing broadcasts an event yet). ' +
|
||||
'That is the shape observed on this endpoint; keep the two projections apart.',
|
||||
parameters: pageParams(50),
|
||||
responses: { 200: json(PlayerEventListingDto.array(), 'The events that have not ended') },
|
||||
}),
|
||||
async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '', 10) || 50
|
||||
const events = await searchEvents(c.env.DB, '', skip, take)
|
||||
return c.json(events.map(toEventListing))
|
||||
}
|
||||
)
|
||||
|
||||
.get(
|
||||
'/api/playerevents/v1/all',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'The caller’s player events',
|
||||
description:
|
||||
'Events the player created and events they have RSVP’d to. `Created` is served ' +
|
||||
'from the event table, soonest first.\n\n' +
|
||||
'`Responses` is still always empty. RSVPs ARE stored now (see ' +
|
||||
'`/api/playerevents/v1/respond` and the `event_attendee` table) — what isn’t known ' +
|
||||
'is the shape this field wants: whether an entry is a bare event like `Created`, ' +
|
||||
'or the event plus the answer, which is the useful thing to render. Serving the ' +
|
||||
'wrong one renders nothing rather than erroring, so it stays empty until a real ' +
|
||||
'response is observed.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(PlayerEventsAll, 'The caller’s created events, and an empty RSVP list'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({ Created: await getEventsByCreator(c.env.DB, id), Responses: [] })
|
||||
}
|
||||
)
|
||||
|
||||
// The tag filter chips on the player-events browse screen. Static: these are the
|
||||
// categories the client offers when creating an event, so the list doesn't depend on
|
||||
// what's stored. `TrendingFilters` is null even in the reference — it needs
|
||||
// recent-activity data we don't keep, and the client renders no trending row for null.
|
||||
.get(
|
||||
'/api/playerevents/v1/tagfilters',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Player-event filter chips',
|
||||
description:
|
||||
'The filter chips on the player-events browse screen — the event categories the ' +
|
||||
'client offers. Static: the same set regardless of what is stored. ' +
|
||||
'`TrendingFilters` is null even in the reference (it needs recent-activity data), ' +
|
||||
'and the client renders no trending row for null.',
|
||||
security: AUTHED,
|
||||
responses: { 200: json(TagFilters, 'The filter chips'), 401: UNAUTHORIZED_RESPONSE },
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({
|
||||
PinnedFilters: [
|
||||
'workshops',
|
||||
'celebration',
|
||||
'game',
|
||||
'meetup',
|
||||
'performance',
|
||||
'coop',
|
||||
'grandopening',
|
||||
'class',
|
||||
'competition',
|
||||
],
|
||||
PopularFilters: [
|
||||
'workshops',
|
||||
'celebration',
|
||||
'class',
|
||||
'coop',
|
||||
'competition',
|
||||
'game',
|
||||
'grandopening',
|
||||
'meetup',
|
||||
'performance',
|
||||
],
|
||||
TrendingFilters: null,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Player events for a set of clubs (`?id=1&id=2`) — the events shelf on a club's
|
||||
// page. A bare array: the client deserializes this one as a list, and chokes on the
|
||||
// `{ ContinuationToken, Events }` envelope the single-club form uses.
|
||||
.get(
|
||||
'/api/playerevents/v1/clubs',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Player events across several clubs',
|
||||
description:
|
||||
'The events shelf for a set of clubs (`?id=1&id=2`), soonest first. This form ' +
|
||||
'returns a BARE ARRAY — the client deserializes it as a list and chokes on the ' +
|
||||
'paged envelope the single-club form below uses. Do not unify the two. No ids ' +
|
||||
'means an empty shelf, not every event.',
|
||||
parameters: [intQuery('id', 'Repeatable club id')],
|
||||
responses: { 200: json(PlayerEventDto.array(), 'The clubs’ events') },
|
||||
}),
|
||||
async (c) => c.json(await getEventsByClubs(c.env.DB, queryIds(c)))
|
||||
)
|
||||
|
||||
// The same feed for a single club (`/club/1`) — the form the reference serves,
|
||||
// which *does* wrap the events with a paging cursor (empty = no next page).
|
||||
.get(
|
||||
'/api/playerevents/v1/club/:clubId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Player events for one club',
|
||||
description:
|
||||
'The same feed for a single club — and this form DOES wrap the events with a ' +
|
||||
'paging cursor, matching the reference. The cursor is always empty: a club’s event ' +
|
||||
'list is small enough to serve in one page.',
|
||||
parameters: [idParam('clubId', 'Club id')],
|
||||
responses: { 200: json(PlayerEventsPage, 'The club’s events, in a single page') },
|
||||
}),
|
||||
async (c) => {
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const events = await getEventsByClubs(c.env.DB, [clubId])
|
||||
return c.json({ ContinuationToken: '', Events: events })
|
||||
}
|
||||
)
|
||||
|
||||
// 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(
|
||||
'/api/playerevents/v1/searchlive',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Live player events',
|
||||
description:
|
||||
'The "happening now" row on the player-events browse screen: events that have ' +
|
||||
'started and not yet ended, soonest first. A bare array.',
|
||||
responses: { 200: json(PlayerEventDto.array(), 'The events running right now') },
|
||||
}),
|
||||
async (c) => c.json(await getLiveEvents(c.env.DB))
|
||||
)
|
||||
|
||||
// Event search — the browse query. Text is matched term by term against name and
|
||||
// description; finished events are left out (this backs a browse screen).
|
||||
.get(
|
||||
'/api/playerevents/v1/search',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Search player events',
|
||||
description:
|
||||
'The browse query on the player-events screen, term by term; an empty query ' +
|
||||
'browses everything upcoming. A `#` decides how a term is matched: `#workshops` is ' +
|
||||
'a TAG term, matching only events tagged `workshops` and never the word in a name ' +
|
||||
'or description, which is what the filter chips send; a bare `workshops` is TEXT, ' +
|
||||
'matched case-insensitively against the name and description. Every term must ' +
|
||||
'match and the two kinds combine, so `#workshops trigonometry` is the ' +
|
||||
'workshops-tagged events whose text also mentions trigonometry.\n\n' +
|
||||
'Events that have already finished are left out — a name match on something that ' +
|
||||
'ended last month is noise on a browse screen. Soonest first, paginated via ' +
|
||||
'skip/take. A bare array.',
|
||||
parameters: [
|
||||
stringQuery('query', 'Search terms; `#tag` matches a tag, anything else the text'),
|
||||
stringQuery(
|
||||
'sort',
|
||||
'Accepted and echoed by the client as `StartTime`, which is the only order ' +
|
||||
'served (soonest first); any other value sorts the same way'
|
||||
),
|
||||
...pageParams(50),
|
||||
],
|
||||
responses: { 200: json(PlayerEventDto.array(), 'The matching events') },
|
||||
}),
|
||||
async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '', 10) || 50
|
||||
return c.json(await searchEvents(c.env.DB, c.req.query('query') ?? '', skip, take))
|
||||
}
|
||||
)
|
||||
|
||||
// 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(
|
||||
'/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') },
|
||||
}),
|
||||
async (c) => c.json(await getEventsByIds(c.env.DB, queryIds(c)))
|
||||
)
|
||||
|
||||
// RSVP. One row per player per event, so responding again replaces the previous
|
||||
// answer rather than stacking up. Note this is the v1 path while create/update are
|
||||
// v2 — that's how the client calls them.
|
||||
.post(
|
||||
'/api/playerevents/v1/respond',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Answer a player event',
|
||||
description:
|
||||
'Records how the caller is answering an event — `Type` is 0 Going, 1 Interested, ' +
|
||||
'2 Can’t go. Responding again replaces the previous answer; there is one row per ' +
|
||||
'player per event, and a decline is recorded rather than deleted so the client can ' +
|
||||
'show a player what they said.\n\n' +
|
||||
'Only Going counts toward the event’s `AttendeeCount`, which is recomputed from ' +
|
||||
'the RSVP table on every response. Anyone may respond, the creator included — ' +
|
||||
'they are already Going from create, and nothing stops them declining their own ' +
|
||||
'event. Answers the same `{ Result, TagModifyResult, PlayerEvent }` envelope the ' +
|
||||
'v2 writes do, carrying the event with its updated count, so the client can ' +
|
||||
're-render from the response.\n\n' +
|
||||
'A body with no usable `PlayerEventId`, or a `Type` outside 0–2, is a 400; an ' +
|
||||
'unknown event is a 404.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(PlayerEventRespondRequest, 'The event and the answer'),
|
||||
responses: {
|
||||
200: json(PlayerEventResultDto, 'The event, with its updated attendee count'),
|
||||
400: { description: 'Missing `PlayerEventId` or an unknown `Type` (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: { description: 'No such event (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req
|
||||
.json<{ PlayerEventId?: unknown; Type?: unknown }>()
|
||||
.catch(() => ({}) as { PlayerEventId?: unknown; Type?: unknown })
|
||||
const eventId = Number(body.PlayerEventId)
|
||||
const type = Number(body.Type)
|
||||
// Both are rejected rather than defaulted: an unrecognized answer stored as
|
||||
// Going would silently inflate the count.
|
||||
if (!Number.isInteger(eventId) || !isEventResponseType(type)) return c.body(null, 400)
|
||||
|
||||
const updated = await setEventResponse(c.env.DB, eventId, id, type)
|
||||
return updated === null ? c.body(null, 404) : c.json(toEventResult(updated))
|
||||
}
|
||||
)
|
||||
|
||||
// Report an event. Stored in the `report` table the player reports use — same fields,
|
||||
// same moderation life — with `event_id` set. See migrations/0011_report_event.sql.
|
||||
.post(
|
||||
'/api/playerevents/v1/report',
|
||||
describeRoute({
|
||||
tags: ['Events', 'Moderation'],
|
||||
summary: 'Report a player event',
|
||||
description:
|
||||
'Files a report against an event. Stored as a row in the same `report` table a ' +
|
||||
'player report goes to (`POST /api/PlayerReporting/v3/create`) — it is the same ' +
|
||||
'submission with the same moderation life, and a moderator converts either into a ' +
|
||||
'ban the same way. What marks it as an event report is `event_id`; the row’s ' +
|
||||
'`reported_player_id` is the event’s CREATOR (who a moderator would act against) ' +
|
||||
'and its `room_id` the room the event runs in, both read from the event rather ' +
|
||||
'than sent by the client.\n\n' +
|
||||
'The reporter is the caller (from the bearer token), never a body field. Note this ' +
|
||||
'body is JSON, where the player report’s is form-encoded. `ReportCategory` is ' +
|
||||
'stored verbatim — the enum is not mapped here. Nothing dedupes the rows: ' +
|
||||
'reporting the same event twice files two reports.\n\n' +
|
||||
'Answers the same `{ success, error }` envelope as the player 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(PlayerEventReportRequest, 'The report'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'No usable `PlayerEventId` in the body'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: json(SuccessErrorEnvelope, 'No such event'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const reporterId = await authedId(c)
|
||||
if (reporterId === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req
|
||||
.json<{ PlayerEventId?: unknown; ReportCategory?: unknown; Details?: unknown }>()
|
||||
.catch(() => ({}) as Record<string, unknown>)
|
||||
const eventId = Number(body.PlayerEventId)
|
||||
if (!Number.isInteger(eventId)) {
|
||||
return c.json({ success: false, error: 'PlayerEventId is required' }, 400)
|
||||
}
|
||||
|
||||
// The event supplies the two columns the client doesn't send. An unknown event is
|
||||
// refused rather than filed against nobody: the row's reported player has to be
|
||||
// someone, and a report naming an event that never existed isn't actionable.
|
||||
const event = await getEventById(c.env.DB, eventId)
|
||||
if (event === null) return c.json({ success: false, error: 'No such event' }, 404)
|
||||
|
||||
const category = Number(body.ReportCategory)
|
||||
await createReport(c.env.DB, {
|
||||
reporterPlayerId: reporterId,
|
||||
reportedPlayerId: event.CreatorPlayerId,
|
||||
reportCategory: Number.isInteger(category) ? category : 0,
|
||||
details: typeof body.Details === 'string' ? body.Details : null,
|
||||
roomId: event.RoomId > 0 ? event.RoomId : null,
|
||||
eventId,
|
||||
})
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// Bulk invite — the "invite friends" button on an event. Adds the invited players to
|
||||
// the same `event_attendee` table an RSVP writes to, as Going.
|
||||
.post(
|
||||
'/api/playerevents/v1/bulkInvite',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Invite players to an event',
|
||||
description:
|
||||
'Adds the invited players to the event as Going — the same `event_attendee` rows ' +
|
||||
'an RSVP writes, so an invited player shows up in `…/responses` and counts toward ' +
|
||||
'`AttendeeCount` immediately, without having answered.\n\n' +
|
||||
'An invite never overwrites an answer: a player who already responded keeps what ' +
|
||||
'they said, so inviting someone who declined does not flip them back to Going, and ' +
|
||||
're-inviting is a no-op. The caller is skipped (they are already on the list), as ' +
|
||||
'are duplicate ids.\n\n' +
|
||||
'The caller must be on the event themselves — its creator, or a player with a ' +
|
||||
'response row of any kind. Anyone else gets 403: an invite adds attendees, so it ' +
|
||||
'is not something a passer-by can do. Answers the same ' +
|
||||
'`{ Result, TagModifyResult, PlayerEvent }` envelope the other event writes do, ' +
|
||||
'carrying the updated attendee count.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(PlayerEventBulkInviteRequest, 'The event and who to invite'),
|
||||
responses: {
|
||||
200: json(PlayerEventResultDto, 'The event, with its updated attendee count'),
|
||||
400: { description: 'Missing `PlayerEventId` or `InvitedPlayerIds` (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: { description: 'The caller is not on the event (empty body)' },
|
||||
404: { description: 'No such event (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req
|
||||
.json<{ PlayerEventId?: unknown; InvitedPlayerIds?: unknown }>()
|
||||
.catch(() => ({}) as { PlayerEventId?: unknown; InvitedPlayerIds?: unknown })
|
||||
const eventId = Number(body.PlayerEventId)
|
||||
if (!Number.isInteger(eventId) || !Array.isArray(body.InvitedPlayerIds)) {
|
||||
return c.body(null, 400)
|
||||
}
|
||||
|
||||
const event = await getEventById(c.env.DB, eventId)
|
||||
if (event === null) return c.body(null, 404)
|
||||
// On the event themselves, one way or the other. The creator has a Going row from
|
||||
// create, so the response lookup would usually cover them — but it's checked
|
||||
// explicitly so a creator who deleted their own answer can still invite.
|
||||
if (
|
||||
event.CreatorPlayerId !== id &&
|
||||
(await getEventResponse(c.env.DB, eventId, id)) === null
|
||||
) {
|
||||
return c.body(null, 403)
|
||||
}
|
||||
|
||||
// Unusable entries are dropped rather than failing the invite: a client sending one
|
||||
// bad id shouldn't lose the other nine invites.
|
||||
const invited = [
|
||||
...new Set(
|
||||
body.InvitedPlayerIds.map((v) => Number(v)).filter((v) => Number.isInteger(v) && v !== id)
|
||||
),
|
||||
]
|
||||
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))
|
||||
}
|
||||
)
|
||||
|
||||
// Create. The creator comes from the bearer token, never the body — posting someone
|
||||
// else's `CreatorPlayerId` doesn't make it theirs.
|
||||
.post(
|
||||
'/api/playerevents/v2',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Create a player event',
|
||||
description:
|
||||
'Schedules a new event. The creator is taken from the bearer token, never the ' +
|
||||
'body; the id is assigned here. Lenient about the rest, like the other writes ' +
|
||||
'here — a missing name becomes “Untitled Event” and a missing time window becomes ' +
|
||||
'an hour from now, rather than an error the client can’t render.\n\n' +
|
||||
'`State` starts at 0, and the creator is recorded as Going in the RSVP table — ' +
|
||||
'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' +
|
||||
'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.',
|
||||
security: AUTHED,
|
||||
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)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const body = await c.req.json<unknown>().catch(() => ({}))
|
||||
const input = parseEventBody(body)
|
||||
// The one thing this route isn't lenient about. Everything else here defaults a
|
||||
// missing or unusable field, but a name or description past the stored length
|
||||
// can't be defaulted into something sensible — and truncating a player's event
|
||||
// description silently is worse than refusing it.
|
||||
if (eventInputRejection(input) !== null) return c.body(null, 400)
|
||||
const event = await createEvent(c.env.DB, id, input)
|
||||
await notifyEventCreated(c, event, input.tags ?? [])
|
||||
return c.json(toEventResult(event))
|
||||
}
|
||||
)
|
||||
|
||||
// Update. Creator-only, and a partial body only changes what it carries.
|
||||
.post(
|
||||
'/api/playerevents/v2/:eventId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'Update a player event',
|
||||
description:
|
||||
'Edits an event the caller created. Only the fields the body carries change; ' +
|
||||
'everything else keeps its stored value, so a partial post can’t blank out the ' +
|
||||
'rest of the event. A posted `null` on `ImageName` / `SubRoomId` / `ClubId` does ' +
|
||||
'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.',
|
||||
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)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: { description: 'Not the event’s creator (empty body)' },
|
||||
404: { description: 'No such event (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
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 body = await c.req.json<unknown>().catch(() => ({}))
|
||||
const input = parseEventBody(body)
|
||||
if (eventInputRejection(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(toEventResult(updated!))
|
||||
}
|
||||
)
|
||||
|
||||
// An event's guest list — every RSVP row, whatever the answer.
|
||||
.get(
|
||||
'/api/playerevents/v1/:eventId{[0-9]+}/responses',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'An event’s RSVPs',
|
||||
description:
|
||||
'Every answer given to an event, in the order they were given — declines and ' +
|
||||
'maybes included, not just the Going rows `AttendeeCount` counts. One entry per ' +
|
||||
'player: a player who changed their mind has one row carrying the answer that ' +
|
||||
'stands, and `CreatedAt` moves with it.\n\n' +
|
||||
'A bare array, and an unknown event is an empty one rather than a 404 — like the ' +
|
||||
'other list reads here. An event always has at least its creator’s Going row.',
|
||||
parameters: [idParam('eventId', 'Event id')],
|
||||
responses: { 200: json(PlayerEventResponseDto.array(), 'The event’s RSVPs') },
|
||||
}),
|
||||
async (c) => {
|
||||
const eventId = Number.parseInt(c.req.param('eventId'), 10)
|
||||
const attendees = await getEventAttendees(c.env.DB, eventId)
|
||||
return c.json(attendees.map(toEventResponse))
|
||||
}
|
||||
)
|
||||
|
||||
// A single event. Registered last so the literal `/bulk` and `/search` paths above
|
||||
// are matched first; the `[0-9]+` constraint keeps them apart regardless.
|
||||
.get(
|
||||
'/api/playerevents/v1/:eventId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'One player event',
|
||||
description:
|
||||
'A single event by id, served as the bare record — no envelope, unlike the ' +
|
||||
'create/update writes. 404 when there is no such event.\n\n' +
|
||||
'`includeDetails=True` adds exactly one field, the lowercase `tags` — that is the ' +
|
||||
'whole of what the flag does. It is always an empty array here: no event tags are ' +
|
||||
'stored (see the tag-filter chips, which are static, and `TagModifyResult`, which ' +
|
||||
'is always null). Without the flag the key is ABSENT rather than empty, since a ' +
|
||||
'caller that didn’t ask for details shouldn’t be told the event has no tags.',
|
||||
parameters: [
|
||||
idParam('eventId', 'Event id'),
|
||||
stringQuery('includeDetails', 'Pass `True` to add the `tags` array'),
|
||||
],
|
||||
responses: {
|
||||
200: json(PlayerEventDetailsDto, 'The event, with `tags` when details were asked for'),
|
||||
404: { description: 'No such event (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
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)
|
||||
// The client sends `True`; accepted case-insensitively, and `1` alongside it.
|
||||
const details = /^(true|1)$/i.test(c.req.query('includeDetails') ?? '')
|
||||
if (!details) return c.json(event)
|
||||
return c.json({ ...event, tags: await getEventTags(c.env.DB, eventId) })
|
||||
}
|
||||
)
|
||||
+18
-104
@@ -6,25 +6,21 @@ import communityBoard from '../../static/community-board.json'
|
||||
import {
|
||||
BareString,
|
||||
idParam,
|
||||
intQuery,
|
||||
IsPureResponse,
|
||||
json,
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
JsonObject,
|
||||
KeepsakeCategories,
|
||||
KeepsakeConfig,
|
||||
PlayerEventsAll,
|
||||
PlayerEventsPage,
|
||||
SanitizeRequest,
|
||||
stringParam,
|
||||
SubscriptionResponse,
|
||||
TagFilters,
|
||||
} from '../openapi'
|
||||
|
||||
import type { App } from '../context'
|
||||
|
||||
// Text sanitization, keepsakes, objectives/events/rewards, and the misc
|
||||
// analytics/subscription sinks the client hits during load.
|
||||
// Text sanitization, keepsakes, objectives/events/rewards, and the misc analytics
|
||||
// sinks the client hits during load.
|
||||
export const gameplayRoutes = new Hono<App>({ strict: false })
|
||||
// Text sanitization (display names, room names, chat). `v1` echoes the input
|
||||
// value back; `isPure` reports the text is clean.
|
||||
@@ -102,15 +98,25 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
|
||||
}),
|
||||
(c) => c.body(null, 204)
|
||||
)
|
||||
// A counted result set, NOT the bare list the stubs around it serve: the client parses
|
||||
// this one as an object and an array fails it outright — "expected:'{', actual:'[', at
|
||||
// offset:0", logged as "Failed to get keepsake categories" — which takes the keepsake
|
||||
// load down with it. `TotalResults` is the length of `Results`, not a total behind a
|
||||
// page; the reference returns `results.Length`.
|
||||
.get(
|
||||
'/api/keepsakes/categories',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Keepsake categories',
|
||||
description: 'No keepsake catalog yet, so this is an empty list.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
description:
|
||||
'No keepsake catalog yet, so the result set is empty — but it IS a result set ' +
|
||||
'(`{ Results, TotalResults }`), not the empty list the stubs around it serve. ' +
|
||||
"The client parses this one as an object and fails on an array (\"expected '{', " +
|
||||
"actual '['\"), taking the keepsake load down with it. `TotalResults` counts " +
|
||||
'`Results` itself — there is no paging here.',
|
||||
responses: { 200: json(KeepsakeCategories, 'An empty result set') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
(c) => c.json({ Results: [], TotalResults: 0 })
|
||||
)
|
||||
|
||||
// ---- Objectives / events / rewards ---------------------------------------
|
||||
@@ -128,86 +134,8 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
|
||||
}),
|
||||
(c) => c.json(communityBoard)
|
||||
)
|
||||
.get(
|
||||
'/api/playerevents/v1/all',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'The caller’s player events',
|
||||
description:
|
||||
'Events the player created and events they have RSVP’d to. No player-event ' +
|
||||
'storage yet, so both lists are empty.',
|
||||
responses: { 200: json(PlayerEventsAll, 'Two empty lists') },
|
||||
}),
|
||||
(c) => c.json({ Created: [], Responses: [] })
|
||||
)
|
||||
|
||||
// The tag filter chips on the player-events browse screen. Derived from the tags in
|
||||
// use across events — we store no events, so there are no chips to offer.
|
||||
// `TrendingFilters` is null even in the reference (it needs recent-activity data).
|
||||
.get(
|
||||
'/api/playerevents/v1/tagfilters',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Player-event filter chips',
|
||||
description:
|
||||
'The filter chips on the player-events browse screen, derived from the tags in use ' +
|
||||
'across events. We store no events, so there are no chips to offer. ' +
|
||||
'`TrendingFilters` is null even in the reference — it needs recent-activity data.',
|
||||
responses: { 200: json(TagFilters, 'Empty chip lists') },
|
||||
}),
|
||||
(c) => c.json({ PinnedFilters: [], PopularFilters: [], TrendingFilters: null })
|
||||
)
|
||||
|
||||
// Player events for a set of clubs (`?id=1&id=2`) — the events shelf on a club's
|
||||
// page. A bare array: the client deserializes this one as a list, and chokes on the
|
||||
// `{ ContinuationToken, Events }` envelope the single-club form uses. No
|
||||
// player-event storage yet, so the feed is empty.
|
||||
.get(
|
||||
'/api/playerevents/v1/clubs',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Player events across several clubs',
|
||||
description:
|
||||
'The events shelf for a set of clubs (`?id=1&id=2`). This form returns a BARE ' +
|
||||
'ARRAY — the client deserializes it as a list and chokes on the paged envelope the ' +
|
||||
'single-club form below uses. Do not unify the two. No player-event storage yet, ' +
|
||||
'so the feed is empty.',
|
||||
parameters: [intQuery('id', 'Repeatable club id')],
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// The same feed for a single club (`/club/1`) — the form the reference serves,
|
||||
// which *does* wrap the events with a paging cursor (empty = no next page).
|
||||
.get(
|
||||
'/api/playerevents/v1/club/:clubId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Player events for one club',
|
||||
description:
|
||||
'The same feed for a single club — and this form DOES wrap the events with a ' +
|
||||
'paging cursor, matching the reference. An empty `ContinuationToken` means no next ' +
|
||||
'page.',
|
||||
parameters: [idParam('clubId', 'Club id')],
|
||||
responses: { 200: json(PlayerEventsPage, 'An empty page') },
|
||||
}),
|
||||
(c) => c.json({ ContinuationToken: '', Events: [] })
|
||||
)
|
||||
// Live player-event search (the "happening now" browse query). No player-event
|
||||
// storage yet, so there's nothing live to return — a bare empty array.
|
||||
.get(
|
||||
'/api/playerevents/v1/searchlive',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Search live player events',
|
||||
description:
|
||||
'The "happening now" search on the player-events browse screen. No player-event ' +
|
||||
'storage yet, so there are no live events — returns an empty list.',
|
||||
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(
|
||||
'/api/announcement/v1/get',
|
||||
describeRoute({
|
||||
@@ -232,17 +160,3 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
|
||||
}),
|
||||
(c) => c.body(null, 200)
|
||||
)
|
||||
|
||||
// ---- Subscription ---------------------------------------------------------
|
||||
.post(
|
||||
'/api/CampusCard/v1/UpdateAndGetSubscription',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'The caller’s subscription',
|
||||
description:
|
||||
'Rec Room Plus subscription state. There are no subscriptions on this server, so ' +
|
||||
'both fields are null. Also served by the `econ` worker on its own host.',
|
||||
responses: { 200: json(SubscriptionResponse, 'No subscription') },
|
||||
}),
|
||||
(c) => c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
createImage,
|
||||
deleteImage,
|
||||
@@ -13,8 +12,12 @@ import {
|
||||
getSlideshowImages,
|
||||
SavedImageType,
|
||||
setImageCheer,
|
||||
SLIDESHOW_LIMIT,
|
||||
SLIDESHOW_MAX_LIMIT,
|
||||
toImagesPlayer,
|
||||
} from '../images-db'
|
||||
} from '@repo/domain'
|
||||
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
CheeredEntry,
|
||||
@@ -312,6 +315,10 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
// creator's username and room name. Public (no auth): it only surfaces already-public
|
||||
// images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`,
|
||||
// where ValidTill is a short (2-minute) cache hint the client refreshes against.
|
||||
// Serves 10 by default and never more than SLIDESHOW_MAX_LIMIT (100): it's public and
|
||||
// unauthenticated, so an unclamped `take` would let anyone ask for the whole image
|
||||
// table — and the callers that rotate one photo at a time (the website's hero) don't
|
||||
// want a long feed anyway.
|
||||
.get(
|
||||
'/api/images/v1/slideshow',
|
||||
describeRoute({
|
||||
@@ -323,10 +330,21 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
'Deliberately public — it surfaces only already-public images and backs the ' +
|
||||
'anonymous homepage slideshow. `ValidTill` is a short (2-minute) cache hint the ' +
|
||||
'client refreshes against.',
|
||||
parameters: [
|
||||
intQuery(
|
||||
'take',
|
||||
`How many photos to return (default ${SLIDESHOW_LIMIT}, capped at ${SLIDESHOW_MAX_LIMIT})`
|
||||
),
|
||||
],
|
||||
responses: { 200: json(SlideshowResponse, 'The feed plus its cache hint') },
|
||||
}),
|
||||
async (c) => {
|
||||
const Images = await getSlideshowImages(c.env.DB)
|
||||
// Junk, zero and negative takes fall back to the default rather than 400ing or
|
||||
// serving an empty stage — the caller is a homepage, and no photos reads as the
|
||||
// server being down.
|
||||
const asked = Number.parseInt(c.req.query('take') ?? '', 10)
|
||||
const take = asked > 0 ? Math.min(asked, SLIDESHOW_MAX_LIMIT) : SLIDESHOW_LIMIT
|
||||
const Images = await getSlideshowImages(c.env.DB, take)
|
||||
const ValidTill = new Date(Date.now() + 2 * 60 * 1000).toISOString()
|
||||
return c.json({ Images, ValidTill })
|
||||
}
|
||||
|
||||
@@ -1,31 +1,80 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { authedId, authedRoles, unauthorized } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
BareBoolean,
|
||||
CreateReportRequest,
|
||||
CreateWarningRequest,
|
||||
DeviceIdRequest,
|
||||
form,
|
||||
json,
|
||||
JsonArray,
|
||||
ModerationBlockDetails,
|
||||
SuccessErrorEnvelope,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from '../openapi'
|
||||
import { createReport } from '../reports-db'
|
||||
import { createWarning } from '../warnings-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from '../context'
|
||||
|
||||
/**
|
||||
* Roles allowed to hand down a warning — the operator-granted elevated roles the auth
|
||||
* worker stamps from an account's isModerator/isDeveloper flags (see the admin CLI's
|
||||
* `grant-moderator` / `grant-developer`). Same set the `notify` / `www` workers gate
|
||||
* their admin surfaces on: a warning is a moderation action, but staff hold both.
|
||||
*/
|
||||
const MODERATOR_ROLES = new Set(['moderator', 'developer'])
|
||||
|
||||
/**
|
||||
* Read one field of a submitted form. The client posts these form-encoded, but the
|
||||
* same names also arrive as a query string on some builds, so both are accepted.
|
||||
*/
|
||||
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 field as an integer, or null when absent / not a number. */
|
||||
const asInt = (v: string | undefined): number | null => {
|
||||
if (v === undefined) return null
|
||||
const n = Number.parseInt(v, 10)
|
||||
return Number.isNaN(n) ? null : n
|
||||
}
|
||||
|
||||
/** Parse a field as a float (the reported heights), or null when absent / not a number. */
|
||||
const asFloat = (v: string | undefined): number | null => {
|
||||
if (v === undefined) return null
|
||||
const n = Number.parseFloat(v)
|
||||
return Number.isNaN(n) ? null : n
|
||||
}
|
||||
|
||||
// ---- Player reporting ------------------------------------------------------
|
||||
export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
// Whether the caller is currently blocked (banned / timed out / host-kicked). No
|
||||
// ban storage yet, so this is always the "not blocked" answer. `ReportCategory` is
|
||||
// -1 (no category) rather than 0, which is a real category; `Message` is null, not
|
||||
// an empty string — the client distinguishes "no message" from a blank one.
|
||||
// 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.
|
||||
// `ReportCategory` is -1 (no category) rather than 0, which is a real category;
|
||||
// `Message` is null, not an empty string — the client distinguishes "no message"
|
||||
// from a blank one.
|
||||
.get(
|
||||
'/api/PlayerReporting/v1/moderationBlockDetails',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Whether the caller is blocked',
|
||||
description:
|
||||
'Ban / timeout / host-kick state for the caller. There is no ban storage yet, so ' +
|
||||
'this is always the “not blocked” answer. Two details matter to the client: ' +
|
||||
'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. Two details matter ' +
|
||||
'to the client: ' +
|
||||
'`ReportCategory` is -1 (no category) rather than 0, which is a real category, and ' +
|
||||
'`Message` is null rather than an empty string — the client distinguishes “no ' +
|
||||
'message” from a blank one.',
|
||||
@@ -69,6 +118,120 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
(c) => c.json(false)
|
||||
)
|
||||
|
||||
// The report the client actually submits. Auth-gated: the reporter is taken from
|
||||
// the bearer token rather than the body, so a report can't be filed as someone else.
|
||||
.post(
|
||||
'/api/PlayerReporting/v3/create',
|
||||
describeRoute({
|
||||
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 ' +
|
||||
'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' +
|
||||
'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 ' +
|
||||
'NULL. `ReportCategory` and `RoomInstanceType` are stored verbatim — neither ' +
|
||||
'enum is mapped here. A `RoomId` of 0 or below means “no room”.\n\n' +
|
||||
'Answers the real service’s `{ success, error }` envelope, where `error` is an ' +
|
||||
'empty string rather than null. The rejected branch uses the same envelope so ' +
|
||||
'the client only ever parses one shape.',
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateReportRequest, 'The report'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'No `PlayerIdReported` in the request'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const reporterId = await authedId(c)
|
||||
if (reporterId === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const reportedPlayerId = asInt(formField(body, c, 'PlayerIdReported'))
|
||||
if (reportedPlayerId === null) {
|
||||
return c.json({ success: false, error: 'PlayerIdReported is required' }, 400)
|
||||
}
|
||||
|
||||
// 0 / -1 are the client's "no room" values — store null rather than a bogus id.
|
||||
const roomId = asInt(formField(body, c, 'RoomId'))
|
||||
|
||||
await createReport(c.env.DB, {
|
||||
reporterPlayerId: reporterId,
|
||||
reportedPlayerId,
|
||||
reportCategory: asInt(formField(body, c, 'ReportCategory')) ?? 0,
|
||||
details: formField(body, c, 'Details') ?? null,
|
||||
heightReporter: asFloat(formField(body, c, 'HeightReporter')),
|
||||
heightReported: asFloat(formField(body, c, 'HeightReported')),
|
||||
roomId: roomId !== null && roomId > 0 ? roomId : null,
|
||||
roomInstanceType: formField(body, c, 'RoomInstanceType') ?? null,
|
||||
})
|
||||
|
||||
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(
|
||||
'/api/playerwarnings',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Issue a player warning',
|
||||
description:
|
||||
'Records a moderator-issued warning in the `warning` table — an append-only log ' +
|
||||
'like `report`; nothing dispatches the warning to the player or acts on the rows ' +
|
||||
'yet.\n\n' +
|
||||
'**Staff only.** The token must carry the `moderator` or `developer` role (granted ' +
|
||||
'per account by the operator, see the admin CLI’s `grant-moderator` / ' +
|
||||
'`grant-developer`); a valid token with neither gets a 403. The acting moderator ' +
|
||||
'is the caller, NOT a body field.\n\n' +
|
||||
'Only `WarnedPlayerId` is required; the rest are stored as NULL when absent. ' +
|
||||
'`ReportCategory` is stored verbatim — the enum is not mapped here. ' +
|
||||
'`DisplayReason` is what the warned player would be shown; `ModeratorNote` is ' +
|
||||
'internal and never surfaced to them.\n\n' +
|
||||
'Answers the same `{ success, error }` envelope as the report write, with `error` ' +
|
||||
'an empty string rather than null — including on the rejected branches, so there ' +
|
||||
'is only one shape to parse.',
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateWarningRequest, 'The warning'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'No `WarnedPlayerId` in the request'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(SuccessErrorEnvelope, 'A valid token with neither staff role'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const moderatorId = await authedId(c)
|
||||
if (moderatorId === null) return unauthorized(c)
|
||||
|
||||
const roles = await authedRoles(c)
|
||||
if (!roles?.some((role) => MODERATOR_ROLES.has(role))) {
|
||||
return c.json({ success: false, error: 'Forbidden' }, 403)
|
||||
}
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const warnedPlayerId = asInt(formField(body, c, 'WarnedPlayerId'))
|
||||
if (warnedPlayerId === null) {
|
||||
return c.json({ success: false, error: 'WarnedPlayerId is required' }, 400)
|
||||
}
|
||||
|
||||
await createWarning(c.env.DB, {
|
||||
moderatorPlayerId: moderatorId,
|
||||
warnedPlayerId,
|
||||
reportCategory: asInt(formField(body, c, 'ReportCategory')) ?? 0,
|
||||
displayReason: formField(body, c, 'DisplayReason') ?? null,
|
||||
moderatorNote: formField(body, c, 'ModeratorNote') ?? null,
|
||||
})
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// The client reporting its device id (form-encoded `oldDeviceId`, `newDeviceId`,
|
||||
// `platform`), rotating from the id it thinks we hold to the current one. Carries no
|
||||
// bearer token and fires before account creation, so there is no caller to attribute
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { getProgression, getProgressions } 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 {
|
||||
BulkIdsRequest,
|
||||
@@ -13,8 +19,35 @@ import {
|
||||
ReputationDto,
|
||||
} from '../openapi'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { Progression } from '@repo/domain'
|
||||
import type { App } from '../context'
|
||||
|
||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||
const HUB_INSTANCE = 'global'
|
||||
|
||||
/**
|
||||
* Push the caller's own progression back at them over the socket, mirroring the reference's
|
||||
* `HubSendProgressionUpdate` on this same read. Pushing from a GET looks odd, but it is how
|
||||
* a client that just connected gets its level bar right: the frame is what the client acts
|
||||
* on, the response body is only what it asked for. Best-effort — a hub failure leaves the
|
||||
* body correct.
|
||||
*/
|
||||
async function pushProgression(c: Context<App>, progression: Progression): Promise<void> {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
progression.PlayerId,
|
||||
NotificationType.PlayerProgressionLevelUpdate,
|
||||
{ PlayerId: progression.PlayerId, Level: progression.Level, XP: progression.XP }
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push PlayerProgressionLevelUpdate notification', {
|
||||
accountId: progression.PlayerId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -69,13 +102,20 @@ export const progressionRoutes = new Hono<App>({ strict: false })
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'A player’s level and XP',
|
||||
description: 'Nothing awards XP yet, so everyone is level 1 with 0 XP.',
|
||||
description:
|
||||
'The level and XP banked in `progression` (game rewards pay into it from the `econ` ' +
|
||||
'worker); `XP` is the progress into the current level, not a lifetime total. A ' +
|
||||
'player who has earned none has no row and reads back as level 1 with 0 XP. Also ' +
|
||||
'pushes the same values as a `PlayerProgressionLevelUpdate` frame, as the reference ' +
|
||||
'does — that is what moves the client’s bar.',
|
||||
parameters: [idParam('id', 'Account id')],
|
||||
responses: { 200: json(ProgressionDto, 'The player’s progression') },
|
||||
}),
|
||||
(c) => {
|
||||
async (c) => {
|
||||
const id = Number.parseInt(c.req.param('id'), 10)
|
||||
return c.json({ PlayerId: id, Level: 1, XP: 0 })
|
||||
const progression = await getProgression(c.env.DB, id)
|
||||
await pushProgression(c, progression)
|
||||
return c.json(progression)
|
||||
}
|
||||
)
|
||||
.post(
|
||||
@@ -160,12 +200,13 @@ export const progressionRoutes = new Hono<App>({ strict: false })
|
||||
tags: ['Progression'],
|
||||
summary: 'Progressions in bulk (GET form)',
|
||||
description:
|
||||
'What the 2023 client sends. Unlike the POST forms this one does answer — a ' +
|
||||
'default level-1 progression per requested id, in request order.',
|
||||
'What the 2023 client sends. Unlike the POST forms this one does answer — one ' +
|
||||
'progression per requested id, in request order, defaulting to level 1 / 0 XP for ' +
|
||||
'ids that have earned nothing.',
|
||||
parameters: BULK_ID_QUERY,
|
||||
responses: { 200: json(ProgressionDto.array(), 'One progression per requested id') },
|
||||
}),
|
||||
(c) => c.json(queryIds(c).map((id) => ({ PlayerId: id, Level: 1, XP: 0 })))
|
||||
async (c) => c.json(await getProgressions(c.env.DB, queryIds(c)))
|
||||
)
|
||||
.post(
|
||||
'/api/v1/progression/bulk',
|
||||
|
||||
+240
-13
@@ -1,41 +1,83 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import {
|
||||
acceptFriendRequest,
|
||||
addFriend,
|
||||
getAccountsByIds,
|
||||
getMutualFriendIds,
|
||||
getRelationshipsForPlayer,
|
||||
MUTUAL_FRIENDS_LIMIT,
|
||||
removeFriend,
|
||||
sendFriendRequest,
|
||||
setRelationshipFlag,
|
||||
} 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 { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
AckResponse,
|
||||
AUTHED,
|
||||
ErrorResponse,
|
||||
form,
|
||||
intQuery,
|
||||
json,
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
MutualFriendDto,
|
||||
RelationshipDto,
|
||||
SendMessageRequest,
|
||||
SendMultipleMessagesRequest,
|
||||
SuccessErrorEnvelope,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from '../openapi'
|
||||
import {
|
||||
acceptFriendRequest,
|
||||
addFriend,
|
||||
getRelationshipsForPlayer,
|
||||
removeFriend,
|
||||
sendFriendRequest,
|
||||
setRelationshipFlag,
|
||||
} from '../relationships-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { App } from '../context'
|
||||
import type {
|
||||
RelationshipChange,
|
||||
RelationshipFlag,
|
||||
RelationshipResponse,
|
||||
} from '../relationships-db'
|
||||
} from '@repo/domain'
|
||||
import type { App } from '../context'
|
||||
|
||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||
const HUB_INSTANCE = 'global'
|
||||
|
||||
/** NotificationType.RelationshipChanged (see apps/notify/src/notification-types.ts). */
|
||||
const RELATIONSHIP_CHANGED = 1
|
||||
/**
|
||||
* The Message a `MessageReceived` frame carries. A type alias rather than an interface:
|
||||
* `notifyPlayer` takes an index-signature record, which only aliases satisfy implicitly.
|
||||
*/
|
||||
type Message = {
|
||||
FromPlayerId: number
|
||||
ToPlayerId: number
|
||||
Type: number
|
||||
Data: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Push one `MessageReceived` frame, resolving false when the hub could not be reached.
|
||||
* Unlike the relationship pushes, a failure here is NOT swallowed by the caller: there
|
||||
* is no message store behind this, so the notification is the whole delivery.
|
||||
*/
|
||||
async function pushMessage(c: Context<App>, message: Message): Promise<boolean> {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
message.ToPlayerId,
|
||||
NotificationType.MessageReceived,
|
||||
message
|
||||
)
|
||||
return true
|
||||
} catch (err) {
|
||||
logger.error('failed to push MessageReceived notification', {
|
||||
toPlayerId: message.ToPlayerId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a `RelationshipChanged` notification carrying `rel` to one player. Hub failures are
|
||||
@@ -50,7 +92,7 @@ async function notifyRelationship(
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
playerId,
|
||||
RELATIONSHIP_CHANGED,
|
||||
NotificationType.RelationshipChanged,
|
||||
{ ...rel }
|
||||
)
|
||||
} catch (err) {
|
||||
@@ -199,6 +241,191 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// The friends the caller and another player have in common. Unlike the other
|
||||
// relationship routes this answers account cards, not relationships — it's what the
|
||||
// client shows on someone else's profile.
|
||||
.get(
|
||||
'/api/relationships/mutualfriends',
|
||||
describeRoute({
|
||||
tags: ['Social'],
|
||||
summary: 'Friends in common with another player',
|
||||
description:
|
||||
'The accounts the caller and `id` are both friends with — a bare array, ascending ' +
|
||||
`by account id and capped at ${MUTUAL_FRIENDS_LIMIT}. Only real friendships count; ` +
|
||||
'pending requests on either side are ignored.\n\n' +
|
||||
'Answers an empty array rather than an error for the degenerate cases: no target ' +
|
||||
'id, an id of 0 or below, or the caller asking for mutuals with themselves. ' +
|
||||
'Mutual ids with no account row are dropped, so the list can be shorter than the ' +
|
||||
'intersection.\n\n' +
|
||||
'Each entry is a trimmed account card. `ProfileImage` is an empty string, never ' +
|
||||
'null, when the account has no image.',
|
||||
security: AUTHED,
|
||||
parameters: [intQuery('id', 'The other player')],
|
||||
responses: {
|
||||
200: json(MutualFriendDto.array(), 'The shared friends; empty when there are none'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const raw = c.req.query('id')
|
||||
const otherId = raw === undefined ? Number.NaN : Number.parseInt(raw, 10)
|
||||
// Nothing to intersect: no/garbage id, a non-positive one, or the caller
|
||||
// themselves. An empty list, not an error — this feeds a profile panel.
|
||||
if (Number.isNaN(otherId) || otherId <= 0 || otherId === id) return c.json([])
|
||||
|
||||
const mutualIds = await getMutualFriendIds(c.env.DB, id, otherId)
|
||||
const accounts = await getAccountsByIds(c.env.DB, mutualIds)
|
||||
return c.json(
|
||||
accounts
|
||||
.map((a) => ({
|
||||
AccountId: a.accountId,
|
||||
Username: a.username,
|
||||
DisplayName: a.displayName,
|
||||
ProfileImage: a.profileImage ?? '',
|
||||
}))
|
||||
// getAccountsByIds doesn't promise an order; keep the ascending one.
|
||||
.sort((a, b) => a.AccountId - b.AccountId)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// A message from one player to another — the "invite me!" style prompts the client
|
||||
// sends. Nothing is stored: the message IS the notification, pushed to the
|
||||
// recipient's hub connection (and queued by the hub if they're offline).
|
||||
.post(
|
||||
'/api/messages/v2/send',
|
||||
describeRoute({
|
||||
tags: ['Social'],
|
||||
summary: 'Send a message to another player',
|
||||
description:
|
||||
'Pushes a `MessageReceived` notification to `ToPlayerId` carrying the message — ' +
|
||||
'the same frame the Coach broadcast sends (see the `notify` worker’s ' +
|
||||
'`coachMessageAll`), except `FromPlayerId` is the caller rather than the Coach ' +
|
||||
'account and it goes to one player. The hub queues it when the recipient is ' +
|
||||
'offline, so it arrives on their next connect.\n\n' +
|
||||
'Nothing is persisted here — there is no message store, the notification is the ' +
|
||||
'whole delivery. The sender is the caller (from the bearer token), NOT a body ' +
|
||||
'field. `Type` is a Message-model type (a different enum from `NotificationType`) ' +
|
||||
'passed through unmapped, defaulting to 0; `Data` is the payload and is commonly ' +
|
||||
'empty.\n\n' +
|
||||
'Answers the same `{ success, error }` envelope as the report / warning writes, ' +
|
||||
'`error` an empty string on success. A hub failure is reported honestly as a 500 ' +
|
||||
'with `success: false` — with no store behind it, a swallowed error would be a ' +
|
||||
'silently dropped message.',
|
||||
security: AUTHED,
|
||||
requestBody: form(SendMessageRequest, 'The message'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'No `ToPlayerId` in the request'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const fromPlayerId = await authedId(c)
|
||||
if (fromPlayerId === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||||
const toPlayerId = Number.parseInt(str(body.ToPlayerId) ?? '', 10)
|
||||
if (Number.isNaN(toPlayerId)) {
|
||||
return c.json({ success: false, error: 'ToPlayerId is required' }, 400)
|
||||
}
|
||||
|
||||
// The Message the notification carries. Mirrors the coach message's shape with
|
||||
// a real sender and recipient; `Data` stays a string, empty included (the hub
|
||||
// drops only null/undefined from the frame).
|
||||
const delivered = await pushMessage(c, {
|
||||
FromPlayerId: fromPlayerId,
|
||||
ToPlayerId: toPlayerId,
|
||||
Type: Number.parseInt(str(body.Type) ?? '', 10) || 0,
|
||||
Data: str(body.Data) ?? '',
|
||||
})
|
||||
if (!delivered) {
|
||||
return c.json({ success: false, error: 'Failed to deliver message' }, 500)
|
||||
}
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// The bulk form of the send above: one message, several recipients. Posted as JSON
|
||||
// (`{"ToPlayerIds":[205],"Type":20,"Data":""}`), not the form encoding the single
|
||||
// send uses, so `Type` arrives as a number here.
|
||||
.post(
|
||||
'/api/messages/v1/sendMultiple',
|
||||
describeRoute({
|
||||
tags: ['Social'],
|
||||
summary: 'Send one message to several players',
|
||||
description:
|
||||
'The bulk form of `POST /api/messages/v2/send`: pushes the same ' +
|
||||
'`MessageReceived` frame to every id in `ToPlayerIds`, each addressed to its own ' +
|
||||
'recipient (`ToPlayerId` differs per frame — the payload is not shared). Same ' +
|
||||
'sender rule: the caller’s bearer token, never a body field. Same non-store: the ' +
|
||||
'notification is the whole delivery, queued by the hub for whoever is offline.\n\n' +
|
||||
'The body is JSON rather than the single send’s form encoding, so `Type` is a ' +
|
||||
'number (still an unmapped Message-model type, defaulting to 0) and `Data` a ' +
|
||||
'string, commonly empty. Repeated ids are delivered once.\n\n' +
|
||||
'Answers the same `{ success, error }` envelope. Delivery is attempted for every ' +
|
||||
'recipient even after one fails, but a hub failure for ANY of them is reported ' +
|
||||
'honestly as a 500 — the envelope has no room to say which, and with no store ' +
|
||||
'behind it a swallowed error would be a silently dropped message.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(SendMultipleMessagesRequest, 'The message and its recipients'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'No usable id in `ToPlayerIds`'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const fromPlayerId = await authedId(c)
|
||||
if (fromPlayerId === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.json<Record<string, unknown>>().catch(() => ({}))) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
// Ids may arrive as numbers or as numeric strings; drop anything that isn't an
|
||||
// id and de-duplicate, so a repeated id doesn't deliver the message twice.
|
||||
const toPlayerIds = [
|
||||
...new Set(
|
||||
(Array.isArray(body.ToPlayerIds) ? body.ToPlayerIds : [])
|
||||
.map((v) => (typeof v === 'number' ? v : Number.parseInt(String(v), 10)))
|
||||
.filter((n) => Number.isInteger(n) && n > 0)
|
||||
),
|
||||
]
|
||||
if (toPlayerIds.length === 0) {
|
||||
return c.json({ success: false, error: 'ToPlayerIds is required' }, 400)
|
||||
}
|
||||
|
||||
const type = typeof body.Type === 'number' ? body.Type : Number(body.Type) || 0
|
||||
const data = typeof body.Data === 'string' ? body.Data : ''
|
||||
|
||||
// Every recipient is attempted even if an earlier one fails — the reachable
|
||||
// players get their message either way.
|
||||
const results = await Promise.all(
|
||||
toPlayerIds.map((toPlayerId) =>
|
||||
pushMessage(c, {
|
||||
FromPlayerId: fromPlayerId,
|
||||
ToPlayerId: toPlayerId,
|
||||
Type: type,
|
||||
Data: data,
|
||||
})
|
||||
)
|
||||
)
|
||||
if (results.includes(false)) {
|
||||
return c.json({ success: false, error: 'Failed to deliver message' }, 500)
|
||||
}
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// Send a friend request to another player (the target arrives as `?id=`). The
|
||||
// client calls this as a GET; the mutations accept GET or POST (the Go handlers
|
||||
// matched any method). Auth-gated. Returns the resulting relationship from the
|
||||
|
||||
@@ -3,7 +3,19 @@ import { exports } from 'cloudflare:workers'
|
||||
import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
addXp,
|
||||
applyLevelUps,
|
||||
createImage,
|
||||
GAME_VERSION,
|
||||
getImageByName,
|
||||
grantInvention,
|
||||
IMAGE_SCHEMA_DDL,
|
||||
INVENTORY_INVENTION_SCHEMA_DDL,
|
||||
LEVEL_REQUIRED_XP,
|
||||
LEVEL_REWARDS,
|
||||
MAX_LEVEL,
|
||||
PROGRESSION_SCHEMA_DDL,
|
||||
RELATIONSHIP_SCHEMA_DDL,
|
||||
ROOM_SCHEMA_DDL,
|
||||
seedRoomWithSubRooms,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
@@ -11,12 +23,28 @@ import {
|
||||
|
||||
import '../../api.app'
|
||||
|
||||
import { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
|
||||
import { PLATFORM_SCHEMA_DDL } from '../../../../auth/src/platform-db'
|
||||
import { banEvasionMatch, resolveBan } from '../../bans-db'
|
||||
import {
|
||||
countGoing,
|
||||
SCHEMA_DDL as EVENTS_SCHEMA_DDL,
|
||||
getEventAttendees,
|
||||
getEventResponse,
|
||||
} from '../../events-db'
|
||||
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
|
||||
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
|
||||
import {
|
||||
banFromReport,
|
||||
createReport,
|
||||
getActiveBan,
|
||||
getReportsAgainst,
|
||||
isPlayerBanned,
|
||||
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
|
||||
} from '../../reports-db'
|
||||
import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
|
||||
|
||||
import type { SavedImage } from '@repo/domain'
|
||||
import type { Env } from '../../context'
|
||||
import type { SavedImage } from '../../images-db'
|
||||
import type { PlayerEvent, PlayerEventResult } from '../../events-db'
|
||||
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
@@ -74,13 +102,29 @@ beforeAll(async () => {
|
||||
.run()
|
||||
|
||||
// Images table (owned by the img worker) — uploadsaved records a row here.
|
||||
for (const stmt of IMAGES_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of IMAGE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Relationships table (owned by the api worker) — friendship endpoints use it.
|
||||
for (const stmt of RELATIONSHIPS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of RELATIONSHIP_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Inventions table (owned by the api worker) — invention save/mine use it.
|
||||
for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Bought-invention ownership (owned by the econ worker) — `v2/mine` folds it in.
|
||||
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of PROGRESSION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Reports table (owned by the api worker) — player reports are recorded here.
|
||||
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Platform identity links (owned by the auth worker) — the sharp arm of the
|
||||
// ban-evasion resolution matches on them.
|
||||
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Warnings table (owned by the api worker) — moderator-issued warnings land here.
|
||||
for (const stmt of WARNINGS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// 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()
|
||||
})
|
||||
|
||||
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
|
||||
@@ -94,10 +138,13 @@ function b64url(input: ArrayBuffer | string): string {
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
async function bearer(sub = '42'): Promise<Record<string, string>> {
|
||||
// `roles` mints the `role` claim the auth worker stamps from an account's flags; left
|
||||
// off, the token carries none, which is what a plain player's looks like to the
|
||||
// role-gated routes.
|
||||
async function bearer(sub = '42', roles?: string[]): Promise<Record<string, string>> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
||||
JSON.stringify({ sub, exp: now + 3600 })
|
||||
JSON.stringify({ sub, exp: now + 3600, ...(roles && { role: roles }) })
|
||||
)}`
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
@@ -194,17 +241,6 @@ describe('public endpoints', () => {
|
||||
expect(reps.map((r) => r.AccountId)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/tagfilters returns empty filter chips', async () => {
|
||||
// No player-event storage → no tags in use → no chips. Trending is null.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/tagfilters`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
PinnedFilters: [],
|
||||
PopularFilters: [],
|
||||
TrendingFilters: null,
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -214,26 +250,6 @@ describe('public endpoints', () => {
|
||||
expect(words[0]).toEqual({ Id: 1, Difficulty: 0, EN_US: 'David Bowie' })
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/clubs returns an empty event list', async () => {
|
||||
// The client deserializes this as a bare array — an envelope here fails with
|
||||
// "expected:'[', actual:'{'". No player-event storage yet → empty.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/clubs?id=1&id=2`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
|
||||
// The single-club form does wrap its events with a paging cursor.
|
||||
const one = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/club/1`)
|
||||
expect(one.status).toBe(200)
|
||||
expect(await one.json()).toEqual({ ContinuationToken: '', Events: [] })
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/searchlive returns an empty list', async () => {
|
||||
// No player-event storage yet → nothing live to return.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/searchlive`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/PlayerReporting/v1/moderationBlockDetails reports "not blocked"', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/PlayerReporting/v1/moderationBlockDetails`
|
||||
@@ -296,6 +312,89 @@ describe('public endpoints', () => {
|
||||
expect(body[0]).toMatchObject({ Level: 1, XP: 0 })
|
||||
})
|
||||
|
||||
test('progression reads back the XP game rewards banked, levelled up', async () => {
|
||||
// The two workers share this table; `econ` writes it when a game reward is claimed (5 XP
|
||||
// at a time). Granted in one lump here to exercise a multi-level climb: 25 XP from level
|
||||
// 1 pays the 10 to reach 2 and the 10 to reach 3, leaving 5.
|
||||
expect(await addXp(env.DB, 4242, 25)).toEqual({
|
||||
progression: { PlayerId: 4242, Level: 3, XP: 5 },
|
||||
levelsGained: 2,
|
||||
})
|
||||
// The next 25 lands on 5: 10 to reach level 4, then 20 to reach 5, leaving nothing.
|
||||
await addXp(env.DB, 4242, 25)
|
||||
|
||||
const single = await exports.default.fetch(`${ORIGIN}/api/players/v1/progression/4242`)
|
||||
expect(await single.json()).toEqual({ PlayerId: 4242, Level: 5, XP: 0 })
|
||||
|
||||
// A player who has earned nothing has no row, and still gets a record — the bulk form
|
||||
// renders a card per id, so a missing one must not shorten the list.
|
||||
const bulk = await exports.default.fetch(
|
||||
`${ORIGIN}/api/players/v2/progression/bulk?id=4242&id=4243`
|
||||
)
|
||||
expect(await bulk.json()).toEqual([
|
||||
{ PlayerId: 4242, Level: 5, XP: 0 },
|
||||
{ PlayerId: 4243, Level: 1, XP: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
test('the level ladder the server uses is the one the client is served', async () => {
|
||||
// The client draws its bar against `LevelProgressionMaps` from this config; the server
|
||||
// levels by LEVEL_REQUIRED_XP. If they drift, the bar fills to a different mark than
|
||||
// the level-up fires at.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/config/v2`)
|
||||
expect(res.status).toBe(200)
|
||||
const config = (await res.json()) as {
|
||||
LevelProgressionMaps: Array<{ Level: number; RequiredXp: number; GiftRarity: number }>
|
||||
}
|
||||
expect(config.LevelProgressionMaps.map((m) => m.RequiredXp)).toEqual([...LEVEL_REQUIRED_XP])
|
||||
// The config's own `GiftRarity` is deliberately NOT asserted against `LEVEL_REWARDS`:
|
||||
// it is a coarse per-band tier (flat 10 to level 14, 20 to 39, 30 to 49, 50 at the cap)
|
||||
// and we grant from the published per-level table instead, which disagrees in places —
|
||||
// level 15 is 2-Star there and 20 here. Only the XP costs have to match.
|
||||
expect(config.LevelProgressionMaps.map((m) => m.GiftRarity)).toHaveLength(LEVEL_REWARDS.length)
|
||||
// Indexed by level, so entry N is what a level-N player spends to reach N+1.
|
||||
expect(config.LevelProgressionMaps.map((m) => m.Level)).toEqual(
|
||||
LEVEL_REQUIRED_XP.map((_, level) => level)
|
||||
)
|
||||
})
|
||||
|
||||
test('the level rewards match the published reward table', async () => {
|
||||
// Rec Room's published level-reward table, spot-checked at the points where it turns:
|
||||
// consumables early, then clothing at a rising star rating (2★ = 10, 3★ = 20, 4★ = 30,
|
||||
// 5★ = 50). These are the levels an off-by-one in the table would move.
|
||||
expect(LEVEL_REWARDS[0]).toBe(0) // nobody reaches level 0
|
||||
expect([1, 3, 5, 6, 7, 9].map((level) => LEVEL_REWARDS[level])).toEqual([
|
||||
-1, -1, -1, -1, -1, -1,
|
||||
])
|
||||
expect([2, 4, 8, 10, 21].map((level) => LEVEL_REWARDS[level])).toEqual([10, 10, 10, 10, 10])
|
||||
expect([22, 30].map((level) => LEVEL_REWARDS[level])).toEqual([20, 20])
|
||||
expect([31, 35, 40, 49].map((level) => LEVEL_REWARDS[level])).toEqual([30, 30, 30, 30])
|
||||
expect(LEVEL_REWARDS[50]).toBe(50) // the only 5-Star in the progression
|
||||
expect(LEVEL_REWARDS).toHaveLength(51)
|
||||
})
|
||||
|
||||
test('the ladder matches the published XP curve', async () => {
|
||||
// Rec Room's own level-curve chart, read at its gridlines: cumulative XP to finish each
|
||||
// level. The per-level costs are easy to edit one at a time and hard to eyeball as a
|
||||
// curve, so the milestones are what actually pin the shape.
|
||||
const cumulative = LEVEL_REQUIRED_XP.reduce<number[]>((totals, cost, level) => {
|
||||
totals[level] = level === 0 ? 0 : (totals[level - 1] ?? 0) + cost
|
||||
return totals
|
||||
}, [])
|
||||
expect(cumulative[10]).toBe(170)
|
||||
expect(cumulative[20]).toBe(620)
|
||||
expect(cumulative[30]).toBe(1770)
|
||||
expect(cumulative[40]).toBe(5370)
|
||||
expect(cumulative[50]).toBe(16170)
|
||||
})
|
||||
|
||||
test('levelling stops at the top of the ladder', async () => {
|
||||
// Nothing above MAX_LEVEL to buy, so a huge grant banks XP and stays put.
|
||||
expect(applyLevelUps(MAX_LEVEL, 100_000)).toEqual({ level: MAX_LEVEL, xp: 100_000 })
|
||||
// …and a grant that doesn't cover the current level's cost just accrues.
|
||||
expect(applyLevelUps(1, 9)).toEqual({ level: 1, xp: 9 })
|
||||
})
|
||||
|
||||
test('POST /api/players/v2/progression/bulk returns an array', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/players/v2/progression/bulk`, {
|
||||
method: 'POST',
|
||||
@@ -358,12 +457,14 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toMatchObject({ KeepsakeFeatureEnabled: true })
|
||||
})
|
||||
|
||||
test('GET /api/keepsakes/rooms/:id returns 204; categories returns []', async () => {
|
||||
test('GET /api/keepsakes/rooms/:id returns 204; categories returns an empty result set', async () => {
|
||||
const room = await exports.default.fetch(`${ORIGIN}/api/keepsakes/rooms/1`)
|
||||
expect(room.status).toBe(204)
|
||||
// A result set, not a list: the client parses this one as an object and an array
|
||||
// fails it outright ("expected '{', actual '['").
|
||||
const cats = await exports.default.fetch(`${ORIGIN}/api/keepsakes/categories`)
|
||||
expect(cats.status).toBe(200)
|
||||
expect(await cats.json()).toEqual([])
|
||||
expect(await cats.json()).toEqual({ Results: [], TotalResults: 0 })
|
||||
})
|
||||
|
||||
test('GET /voice/config returns an object', async () => {
|
||||
@@ -431,7 +532,7 @@ describe('public endpoints', () => {
|
||||
const withExt = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5150')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Already .inv', inventionDataFilename: '2026-07-12/x.inv' }),
|
||||
body: JSON.stringify({ name: 'Already Suffixed', inventionDataFilename: '2026-07-12/x.inv' }),
|
||||
})
|
||||
expect(((await withExt.json()) as InventionSaveResult).InventionVersion.BlobName).toBe(
|
||||
'2026-07-12/x.inv'
|
||||
@@ -463,6 +564,49 @@ describe('public endpoints', () => {
|
||||
expect((await one.json()) as SavedInvention).toMatchObject({ InventionId: saved.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.
|
||||
const save = async (sub: string, name: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, inventionDataFilename: `${name}.inv` }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
return ((await res.json()) as InventionSaveResult).Invention
|
||||
}
|
||||
const mine = async (sub: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
|
||||
headers: await bearer(sub),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
return (await res.json()) as SavedInvention[]
|
||||
}
|
||||
|
||||
const bought = await save('6100', 'bought-invention')
|
||||
const own = await save('6101', 'own-invention')
|
||||
await grantInvention(env.DB, 6101, bought.InventionId)
|
||||
|
||||
// Newest first, whichever set it came from: 6101 saved theirs after buying.
|
||||
const list = await mine('6101')
|
||||
expect(list.map((i) => i.InventionId)).toEqual([own.InventionId, bought.InventionId])
|
||||
// A bought invention is still the creator's — it is listed, not re-attributed.
|
||||
expect(list.find((i) => i.InventionId === bought.InventionId)?.CreatorPlayerId).toBe(6100)
|
||||
// It is unpublished (a fresh save is), and stays on the buyer's shelf regardless.
|
||||
expect(list.find((i) => i.InventionId === bought.InventionId)?.IsPublished).toBe(false)
|
||||
|
||||
// The seller's own list is unaffected by the sale.
|
||||
expect((await mine('6100')).map((i) => i.InventionId)).toEqual([bought.InventionId])
|
||||
|
||||
// An ownership row pointing at an invention that no longer exists just drops out.
|
||||
await grantInvention(env.DB, 6101, 999_888)
|
||||
expect((await mine('6101')).map((i) => i.InventionId)).toEqual([
|
||||
own.InventionId,
|
||||
bought.InventionId,
|
||||
])
|
||||
})
|
||||
|
||||
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',
|
||||
@@ -494,6 +638,51 @@ describe('public endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v6/save enforces the name and description rules', async () => {
|
||||
const save = async (fields: Record<string, unknown>): Promise<Response> =>
|
||||
exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('6262')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inventionDataFilename: 'a.inv', ...fields }),
|
||||
})
|
||||
|
||||
// A name is 3–24 characters of letters, digits, spaces, dashes and colons.
|
||||
expect((await save({ name: 'ab' })).status).toBe(400)
|
||||
expect((await save({ name: 'a'.repeat(25) })).status).toBe(400)
|
||||
expect((await save({ name: 'Rocket!' })).status).toBe(400)
|
||||
expect((await save({ name: 'Café Lamp' })).status).toBe(400)
|
||||
const ok = await save({ name: 'Rocket Sofa-Bed 2' })
|
||||
expect(ok.status).toBe(200)
|
||||
expect(((await ok.json()) as InventionSaveResult).Invention.Name).toBe('Rocket Sofa-Bed 2')
|
||||
|
||||
// The rejection carries the player-facing sentence, not a code.
|
||||
const short = await save({ name: 'ab' })
|
||||
expect((await short.json()) as { error: string }).toEqual({
|
||||
error: 'Invention names must be at least 3 characters.',
|
||||
})
|
||||
|
||||
// A description is prose: any characters, at most 512 of them.
|
||||
expect((await save({ name: 'Long Winded', description: 'x'.repeat(513) })).status).toBe(400)
|
||||
expect((await save({ name: 'Long Winded', description: 'x'.repeat(512) })).status).toBe(200)
|
||||
expect((await save({ name: 'Punctuated', description: 'Yes! It’s 100% good.' })).status).toBe(
|
||||
200
|
||||
)
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v6/save accepts the client’s auto-generated timestamp name', async () => {
|
||||
// The real client names an unnamed invention after the moment it was saved
|
||||
// (`071126 13:10:50`, captured from a live save), so the colon is in the allowed name
|
||||
// charset on purpose. Dropping it from the pattern would 400 every unnamed save the
|
||||
// game makes — this test is what would catch that.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('6363')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inventionDataFilename: 'a.inv', name: '071126 13:10:50' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(((await res.json()) as InventionSaveResult).Invention.Name).toBe('071126 13:10:50')
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1 404s for an unknown invention', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=999999`)
|
||||
expect(res.status).toBe(404)
|
||||
@@ -573,6 +762,39 @@ describe('public endpoints', () => {
|
||||
})
|
||||
expect(await replaced.json()).toEqual({ Result: 0, Tags: ['modern', 'bed'] })
|
||||
|
||||
// A tag is at most 15 letters once lowercased. One bad tag in either list fails the
|
||||
// whole call — nothing is dropped silently — and leaves the stored tags alone.
|
||||
const punctuated = await settags({
|
||||
InventionId: Invention.InventionId,
|
||||
CustomTags: ['racing', 'Cool Stuff!'],
|
||||
})
|
||||
expect(punctuated.status).toBe(400)
|
||||
expect((await punctuated.json()) as { error: string }).toEqual({
|
||||
error: 'Invention tags can only contain letters. (“cool stuff!”)',
|
||||
})
|
||||
expect(
|
||||
(await settags({ InventionId: Invention.InventionId, AutoTags: ['a'.repeat(16)] })).status
|
||||
).toBe(400)
|
||||
expect(
|
||||
(await settags({ InventionId: Invention.InventionId, CustomTags: ['tag2'] })).status
|
||||
).toBe(400)
|
||||
const stillThere = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/details?inventionId=${Invention.InventionId}`
|
||||
)
|
||||
expect(await stillThere.json()).toEqual({
|
||||
Tags: [
|
||||
{ Tag: 'modern', Type: 0 },
|
||||
{ Tag: 'bed', Type: 0 },
|
||||
],
|
||||
})
|
||||
|
||||
// Blank entries are skipped rather than rejected: the store already drops them.
|
||||
const padded = await settags({
|
||||
InventionId: Invention.InventionId,
|
||||
CustomTags: ['modern', '', ' '],
|
||||
})
|
||||
expect(await padded.json()).toEqual({ Result: 0, Tags: ['modern'] })
|
||||
|
||||
// Only the creator may retag; unknown inventions 404; no token → 401.
|
||||
const notMine = await settags({ InventionId: Invention.InventionId, CustomTags: ['x'] }, '9999')
|
||||
expect(notMine.status).toBe(403)
|
||||
@@ -720,6 +942,52 @@ describe('public endpoints', () => {
|
||||
expect(await batch('')).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/fulllineageowner answers for the whole set of ids', async () => {
|
||||
const save = async (sub: string, name: string): Promise<SavedInvention> => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, inventionDataFilename: 'a.inv' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
return ((await res.json()) as InventionSaveResult).Invention
|
||||
}
|
||||
const owns = async (query: string, sub: string): Promise<unknown> => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/fulllineageowner?${query}`,
|
||||
{ headers: await bearer(sub) }
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
// 7301 makes two; 7302 makes one and buys one of 7301's.
|
||||
const own = await save('7301', 'Lineage Root')
|
||||
const nested = await save('7301', 'Lineage Nested')
|
||||
const others = await save('7302', 'Someone Elses')
|
||||
await grantInvention(env.DB, 7302, nested.InventionId)
|
||||
|
||||
// The creator owns their own lineage; one invention that isn't theirs sinks it.
|
||||
expect(await owns(`id=${own.InventionId}&id=${nested.InventionId}`, '7301')).toBe(true)
|
||||
expect(
|
||||
await owns(`id=${own.InventionId}&id=${nested.InventionId}&id=${others.InventionId}`, '7301')
|
||||
).toBe(false)
|
||||
|
||||
// Bought counts as owned, and comma-separated ids parse like the batch endpoint.
|
||||
expect(await owns(`id=${nested.InventionId},${others.InventionId}`, '7302')).toBe(true)
|
||||
expect(await owns(`id=${own.InventionId}`, '7302')).toBe(false)
|
||||
|
||||
// An id with no invention behind it is not owned, whoever asks.
|
||||
expect(await owns(`id=${own.InventionId}&id=999999`, '7301')).toBe(false)
|
||||
// No ids at all: nothing in an empty lineage is unowned.
|
||||
expect(await owns('', '7301')).toBe(true)
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/fulllineageowner 401s without a bearer token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fulllineageowner?id=1`)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/room lists a room’s published inventions', async () => {
|
||||
// Two inventions created in room 76, one of them still a draft.
|
||||
const create = async (name: string, room: number): Promise<SavedInvention> => {
|
||||
@@ -901,6 +1169,16 @@ describe('public endpoints', () => {
|
||||
const cleared = (await (await update('description=&name=')).json()) as InventionSaveResult
|
||||
expect(cleared.Invention).toMatchObject({ Description: '', Name: 'Draft Lamp' })
|
||||
|
||||
// A supplied name/description is held to the same rules as the save path, and a
|
||||
// rejected edit changes nothing.
|
||||
expect((await update('name=xy')).status).toBe(400)
|
||||
expect((await update(`name=${encodeURIComponent('Lamp?')}`)).status).toBe(400)
|
||||
expect((await update(`description=${'x'.repeat(513)}`)).status).toBe(400)
|
||||
const unchanged = (await (await update('permission=20')).json()) as InventionSaveResult
|
||||
expect(unchanged.Invention).toMatchObject({ Name: 'Draft Lamp', Description: '' })
|
||||
const renamed = (await (await update('name=Draft-Lamp%20Two')).json()) as InventionSaveResult
|
||||
expect(renamed.Invention.Name).toBe('Draft-Lamp Two')
|
||||
|
||||
// allowTrial takes true/1.
|
||||
const trial = (await (await update('allowTrial=true')).json()) as InventionSaveResult
|
||||
expect(trial.Invention.AllowTrial).toBe(true)
|
||||
@@ -921,6 +1199,42 @@ describe('public endpoints', () => {
|
||||
expect(anon.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v1/update takes the permission picker’s query params', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('3232')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Posted Lamp', inventionDataFilename: 'a.inv' }),
|
||||
})
|
||||
const { Invention } = (await save.json()) as InventionSaveResult
|
||||
const post = async (query: string, sub = '3232'): Promise<Response> =>
|
||||
exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/update?inventionId=${Invention.InventionId}&${query}`,
|
||||
{ method: 'POST', headers: await bearer(sub) }
|
||||
)
|
||||
|
||||
// The picker posts the permission by CamelCase name, with no body at all.
|
||||
const permission = async (name: string): Promise<number> => {
|
||||
const res = await post(`permission=${name}`)
|
||||
expect(res.status).toBe(200)
|
||||
return ((await res.json()) as InventionSaveResult).Invention.GeneralPermission
|
||||
}
|
||||
expect(await permission('UseOnly')).toBe(20)
|
||||
expect(await permission('EditAndSave')).toBe(40)
|
||||
expect(await permission('Publish')).toBe(60)
|
||||
|
||||
// Setting the permission is not publishing — that stays v3/publish's job.
|
||||
const still = await post('permission=Publish')
|
||||
expect(((await still.json()) as InventionSaveResult).Invention.IsPublished).toBe(false)
|
||||
|
||||
// Same gate as the GET: creator only, and a token is required.
|
||||
expect((await post('permission=UseOnly', '9999')).status).toBe(403)
|
||||
const anonPost = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/update?inventionId=${Invention.InventionId}&permission=Publish`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
expect(anonPost.status).toBe(401)
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v3/publish publishes + prices; search then lists it', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
@@ -1012,12 +1326,15 @@ describe('public endpoints', () => {
|
||||
const ids = async (res: Response): Promise<number[]> =>
|
||||
((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
|
||||
|
||||
// Nothing is flagged IsFeatured yet → featured falls back to the top feed.
|
||||
const beforeTop = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
|
||||
const beforeFeatured = await ids(
|
||||
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`)
|
||||
// Both feeds start EMPTY, for different reasons: nothing is flagged IsFeatured, and
|
||||
// the only inventions acquired so far in this file are an unpublished one and an id
|
||||
// with no invention row — neither of which a public feed may show.
|
||||
expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))).toEqual(
|
||||
[]
|
||||
)
|
||||
expect(await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))).toEqual(
|
||||
[]
|
||||
)
|
||||
expect(beforeFeatured).toEqual(beforeTop)
|
||||
|
||||
const feedInvention = (
|
||||
id: number,
|
||||
@@ -1055,19 +1372,42 @@ describe('public endpoints', () => {
|
||||
.run()
|
||||
}
|
||||
|
||||
// Top: engagement-ranked, so the biggest download counts lead.
|
||||
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
|
||||
expect(top.slice(0, 3)).toEqual([202, 203, 201])
|
||||
expect(top).not.toContain(204)
|
||||
expect(top).not.toContain(205)
|
||||
// Recent acquisitions, which is what "top today" now counts: 201 picked up by three
|
||||
// players, 203 by one. 204/205 are acquired too — an unpublished and a hidden
|
||||
// invention can still be owned — and must not surface in a public feed.
|
||||
for (const accountId of [7001, 7002, 7003]) await grantInvention(env.DB, accountId, 201)
|
||||
await grantInvention(env.DB, 7001, 203)
|
||||
await grantInvention(env.DB, 7001, 204)
|
||||
await grantInvention(env.DB, 7002, 205)
|
||||
// 202 was acquired 25 hours ago, just past the trailing 24-hour window, so it is out —
|
||||
// the feed really does forget, rather than accumulating every acquisition ever.
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO inventory_invention (account_id, invention_id, acquired_at) VALUES (?1, ?2, ?3)'
|
||||
)
|
||||
.bind(7004, 202, new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString())
|
||||
.run()
|
||||
|
||||
// Featured: only the flagged, visible inventions — newest first.
|
||||
// Top: most acquisitions in the window first. Download counts no longer rank anything —
|
||||
// 202 has the biggest of them and is absent entirely.
|
||||
const top = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday`))
|
||||
expect(top).toEqual([201, 203])
|
||||
|
||||
// Featured: only the flagged, visible inventions — newest first. 201 is published but
|
||||
// unflagged, so it stays out however popular it is.
|
||||
const featured = await ids(await exports.default.fetch(`${ORIGIN}/api/inventions/v1/featured`))
|
||||
expect(featured).toEqual([203, 202])
|
||||
|
||||
// skip/take paginate the top feed.
|
||||
// skip/take paginate both feeds.
|
||||
const page = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?skip=1&take=1`)
|
||||
expect(await ids(page)).toEqual([203])
|
||||
// Pagination happens after the visibility filter, so the hidden/unpublished
|
||||
// acquisitions don't leave holes in a page.
|
||||
const firstPage = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/toptoday?take=1`)
|
||||
expect(await ids(firstPage)).toEqual([201])
|
||||
const featuredPage = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/featured?skip=1&take=1`
|
||||
)
|
||||
expect(await ids(featuredPage)).toEqual([202])
|
||||
})
|
||||
|
||||
test('POST /api/sanitize/v1 echoes the value; isPure reports true', async () => {
|
||||
@@ -1099,6 +1439,257 @@ describe('auth-gated endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('player reports', () => {
|
||||
const submit = async (fields: Record<string, string>, headers?: Record<string, string>) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/create`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
|
||||
body: new URLSearchParams(fields),
|
||||
})
|
||||
|
||||
test('POST /api/PlayerReporting/v3/create records the report', async () => {
|
||||
const res = await submit(
|
||||
{
|
||||
PlayerIdReported: '205',
|
||||
ReportCategory: '100',
|
||||
Details: 'ya know',
|
||||
HeightReporter: '1.64',
|
||||
HeightReported: '1.65',
|
||||
RoomId: '58',
|
||||
RoomInstanceType: 'Public',
|
||||
},
|
||||
await bearer()
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
// `error` is an empty string, not null — the real service's envelope.
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
const [row] = await getReportsAgainst(env.DB, 205)
|
||||
expect(row).toMatchObject({
|
||||
// The reporter is the token's subject, not a body field.
|
||||
reporter_player_id: 42,
|
||||
reported_player_id: 205,
|
||||
report_category: 100,
|
||||
details: 'ya know',
|
||||
height_reporter: 1.64,
|
||||
height_reported: 1.65,
|
||||
room_id: 58,
|
||||
room_instance_type: 'Public',
|
||||
})
|
||||
expect(row?.created_at).toBeTruthy()
|
||||
})
|
||||
|
||||
// Everything but the reported player is optional — a report raised outside a room
|
||||
// carries no RoomId, and 0 means "no room" rather than room zero.
|
||||
test('POST /api/PlayerReporting/v3/create stores absent fields as null', async () => {
|
||||
const res = await submit({ PlayerIdReported: '206', RoomId: '0' }, await bearer())
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const [row] = await getReportsAgainst(env.DB, 206)
|
||||
expect(row).toMatchObject({
|
||||
reporter_player_id: 42,
|
||||
reported_player_id: 206,
|
||||
report_category: 0,
|
||||
details: null,
|
||||
height_reporter: null,
|
||||
height_reported: null,
|
||||
room_id: null,
|
||||
room_instance_type: null,
|
||||
})
|
||||
})
|
||||
|
||||
// Append-only: a second report against the same player is a second row.
|
||||
test('POST /api/PlayerReporting/v3/create appends rather than dedupes', async () => {
|
||||
await submit({ PlayerIdReported: '207', Details: 'first' }, await bearer())
|
||||
await submit({ PlayerIdReported: '207', Details: 'second' }, await bearer())
|
||||
const rows = await getReportsAgainst(env.DB, 207)
|
||||
expect(rows).toHaveLength(2)
|
||||
// Newest first.
|
||||
expect(rows.map((r) => r.details)).toEqual(['second', 'first'])
|
||||
})
|
||||
|
||||
test('POST /api/PlayerReporting/v3/create 401s without a bearer token', async () => {
|
||||
const res = await submit({ PlayerIdReported: '205' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /api/PlayerReporting/v3/create 400s without a reported player', async () => {
|
||||
const res = await submit({ Details: 'ya know' }, await bearer())
|
||||
expect(res.status).toBe(400)
|
||||
// Same envelope as the success branch — the client parses only one shape.
|
||||
expect(await res.json()).toEqual({ success: false, error: 'PlayerIdReported is required' })
|
||||
})
|
||||
|
||||
// A report is filed unbanned; a moderator converting it into a ban is what the
|
||||
// `banned` / `ban_expires` columns are for. `match` and `auth` read exactly this.
|
||||
test('a report is filed unbanned', async () => {
|
||||
await submit({ PlayerIdReported: '210' }, await bearer())
|
||||
const [row] = await getReportsAgainst(env.DB, 210)
|
||||
expect(row).toMatchObject({ banned: 0, ban_expires: null })
|
||||
expect(await isPlayerBanned(env.DB, 210)).toBe(false)
|
||||
})
|
||||
|
||||
test('banFromReport bans the reported player, permanently by default', async () => {
|
||||
await submit({ PlayerIdReported: '211', Details: 'the evidence' }, await bearer())
|
||||
const [row] = await getReportsAgainst(env.DB, 211)
|
||||
|
||||
const banned = await banFromReport(env.DB, row!.id)
|
||||
expect(banned).toMatchObject({ banned: 1, ban_expires: null })
|
||||
// The report the ban was made from is still attached to it — the point of
|
||||
// banning on the row rather than in a table of its own.
|
||||
expect(banned?.details).toBe('the evidence')
|
||||
expect(await isPlayerBanned(env.DB, 211)).toBe(true)
|
||||
// It bans the REPORTED player, not the reporter who filed it.
|
||||
expect(await isPlayerBanned(env.DB, 42)).toBe(false)
|
||||
})
|
||||
|
||||
// A timed ban lifts itself: nothing clears the flag, the expiry just passes.
|
||||
test('a ban with a past expiry is no longer in force', async () => {
|
||||
await submit({ PlayerIdReported: '212' }, await bearer())
|
||||
const [row] = await getReportsAgainst(env.DB, 212)
|
||||
await banFromReport(env.DB, row!.id, { banExpires: '2020-01-01T00:00:00.000Z' })
|
||||
|
||||
expect(await isPlayerBanned(env.DB, 212)).toBe(false)
|
||||
// Still on the row, as the record that it happened.
|
||||
expect((await getReportsAgainst(env.DB, 212))[0]).toMatchObject({ banned: 1 })
|
||||
// And in force while it lasted.
|
||||
expect(await isPlayerBanned(env.DB, 212, new Date('2019-06-01T00:00:00.000Z'))).toBe(true)
|
||||
})
|
||||
|
||||
test('a ban with a future expiry is in force', async () => {
|
||||
await submit({ PlayerIdReported: '213' }, await bearer())
|
||||
const [row] = await getReportsAgainst(env.DB, 213)
|
||||
const expires = new Date(Date.now() + 86_400_000).toISOString()
|
||||
await banFromReport(env.DB, row!.id, { banExpires: expires })
|
||||
|
||||
expect(await isPlayerBanned(env.DB, 213)).toBe(true)
|
||||
expect((await getActiveBan(env.DB, 213))?.ban_expires).toBe(expires)
|
||||
})
|
||||
|
||||
// Two bans in force: the longest-lasting one is the one reported, so a fresh short
|
||||
// ban can't shorten a standing permanent one.
|
||||
test('getActiveBan prefers the permanent ban', async () => {
|
||||
await submit({ PlayerIdReported: '214', Details: 'timed' }, await bearer())
|
||||
await submit({ PlayerIdReported: '214', Details: 'permanent' }, await bearer())
|
||||
const rows = await getReportsAgainst(env.DB, 214)
|
||||
const timed = rows.find((r) => r.details === 'timed')!
|
||||
const permanent = rows.find((r) => r.details === 'permanent')!
|
||||
await banFromReport(env.DB, timed.id, {
|
||||
banExpires: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
})
|
||||
await banFromReport(env.DB, permanent.id)
|
||||
|
||||
expect(await getActiveBan(env.DB, 214)).toMatchObject({ details: 'permanent' })
|
||||
})
|
||||
|
||||
test('banFromReport with banned:false lifts the ban and clears the expiry', async () => {
|
||||
await submit({ PlayerIdReported: '215' }, await bearer())
|
||||
const [row] = await getReportsAgainst(env.DB, 215)
|
||||
await banFromReport(env.DB, row!.id, { banExpires: '2999-01-01T00:00:00.000Z' })
|
||||
expect(await isPlayerBanned(env.DB, 215)).toBe(true)
|
||||
|
||||
const lifted = await banFromReport(env.DB, row!.id, { banned: false })
|
||||
expect(lifted).toMatchObject({ banned: 0, ban_expires: null })
|
||||
expect(await isPlayerBanned(env.DB, 215)).toBe(false)
|
||||
})
|
||||
|
||||
// No such report — the caller can tell that from having banned nobody.
|
||||
test('banFromReport returns null for an unknown report', async () => {
|
||||
expect(await banFromReport(env.DB, 999_999)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('player warnings', () => {
|
||||
const MOD = ['gameClient', 'moderator']
|
||||
|
||||
const issue = async (fields: Record<string, string>, headers?: Record<string, string>) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/playerwarnings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
|
||||
body: new URLSearchParams(fields),
|
||||
})
|
||||
|
||||
test('POST /api/playerwarnings records the warning', async () => {
|
||||
const res = await issue(
|
||||
{
|
||||
WarnedPlayerId: '205',
|
||||
ReportCategory: '101',
|
||||
DisplayReason: 'Sexual gestures',
|
||||
ModeratorNote: 'dfg',
|
||||
},
|
||||
await bearer('42', MOD)
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
const [row] = await getWarningsAgainst(env.DB, 205)
|
||||
expect(row).toMatchObject({
|
||||
// The moderator is the token's subject, not a body field.
|
||||
moderator_player_id: 42,
|
||||
warned_player_id: 205,
|
||||
report_category: 101,
|
||||
display_reason: 'Sexual gestures',
|
||||
moderator_note: 'dfg',
|
||||
})
|
||||
expect(row?.created_at).toBeTruthy()
|
||||
})
|
||||
|
||||
test('POST /api/playerwarnings stores absent fields as null', async () => {
|
||||
const res = await issue({ WarnedPlayerId: '206' }, await bearer('42', MOD))
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const [row] = await getWarningsAgainst(env.DB, 206)
|
||||
expect(row).toMatchObject({
|
||||
warned_player_id: 206,
|
||||
report_category: 0,
|
||||
display_reason: null,
|
||||
moderator_note: null,
|
||||
})
|
||||
})
|
||||
|
||||
// Append-only, like reports: warning the same player twice is two rows.
|
||||
test('POST /api/playerwarnings appends rather than dedupes', async () => {
|
||||
await issue({ WarnedPlayerId: '207', ModeratorNote: 'first' }, await bearer('42', MOD))
|
||||
await issue({ WarnedPlayerId: '207', ModeratorNote: 'second' }, await bearer('42', MOD))
|
||||
const rows = await getWarningsAgainst(env.DB, 207)
|
||||
expect(rows).toHaveLength(2)
|
||||
// Newest first.
|
||||
expect(rows.map((r) => r.moderator_note)).toEqual(['second', 'first'])
|
||||
})
|
||||
|
||||
test('POST /api/playerwarnings 401s without a bearer token', async () => {
|
||||
const res = await issue({ WarnedPlayerId: '205' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
// A valid token is not enough — a plain player's carries neither staff role.
|
||||
// Nothing is written on the rejected branch.
|
||||
test('POST /api/playerwarnings 403s without a staff role', async () => {
|
||||
for (const roles of [undefined, ['gameClient']]) {
|
||||
const res = await issue({ WarnedPlayerId: '208' }, await bearer('42', roles))
|
||||
expect(res.status).toBe(403)
|
||||
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
|
||||
}
|
||||
expect(await getWarningsAgainst(env.DB, 208)).toHaveLength(0)
|
||||
})
|
||||
|
||||
// `developer` gets in as well as `moderator` — staff hold both.
|
||||
test('POST /api/playerwarnings accepts the developer role', async () => {
|
||||
const res = await issue(
|
||||
{ WarnedPlayerId: '209' },
|
||||
await bearer('42', ['gameClient', 'developer'])
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await getWarningsAgainst(env.DB, 209)).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('POST /api/playerwarnings 400s without a warned player', async () => {
|
||||
const res = await issue({ ModeratorNote: 'dfg' }, await bearer('42', MOD))
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({ success: false, error: 'WarnedPlayerId is required' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('rooms', () => {
|
||||
test('POST /api/rooms/v1/verifyRole checks creator + room roles', async () => {
|
||||
const verify = async (fields: Record<string, string>, sub?: string): Promise<boolean> => {
|
||||
@@ -1206,6 +1797,29 @@ describe('images', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// The feed is public and unauthenticated, so `take` is clamped rather than trusted:
|
||||
// without the cap a single anonymous request could pull the whole image table through
|
||||
// the two joins behind it.
|
||||
test('GET /api/images/v1/slideshow serves 10 by default and caps take at 100', async () => {
|
||||
// 120 public ShareCamera photos — more than both the default and the cap.
|
||||
for (let i = 0; i < 120; i++) {
|
||||
await createImage(env.DB, { imageName: `bulkslide${i}.jpg`, playerId: 42 })
|
||||
}
|
||||
const feed = async (query: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow${query}`)
|
||||
expect(res.status).toBe(200)
|
||||
return ((await res.json()) as { Images: unknown[] }).Images.length
|
||||
}
|
||||
|
||||
expect(await feed('')).toBe(10)
|
||||
expect(await feed('?take=25')).toBe(25)
|
||||
expect(await feed('?take=500')).toBe(100)
|
||||
// Junk and non-positive takes fall back rather than erroring or emptying the stage.
|
||||
expect(await feed('?take=0')).toBe(10)
|
||||
expect(await feed('?take=-5')).toBe(10)
|
||||
expect(await feed('?take=lots')).toBe(10)
|
||||
})
|
||||
|
||||
test('POST /api/images/v1/cheer persists, syncs CheerCount, and the bulk lookup reflects it', async () => {
|
||||
// Seed an image to cheer.
|
||||
// Its own player id: 700's photos are asserted on exactly in the player-list test.
|
||||
@@ -1919,6 +2533,1019 @@ describe('relationships', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('messages', () => {
|
||||
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
|
||||
type Sent = {
|
||||
playerId: number
|
||||
notificationType: number
|
||||
data: { FromPlayerId: number; ToPlayerId: number; Type: number; Data: string }
|
||||
}
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
const pushed = async (): Promise<Sent[]> =>
|
||||
(await (await hub().fetch('http://do/all')).json()) as Sent[]
|
||||
|
||||
const send = async (fields: Record<string, string>, headers?: Record<string, string>) => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
return exports.default.fetch(`${ORIGIN}/api/messages/v2/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
|
||||
body: new URLSearchParams(fields),
|
||||
})
|
||||
}
|
||||
|
||||
// NotificationType.MessageReceived — the same frame the Coach broadcast uses.
|
||||
const MESSAGE_RECEIVED = 2
|
||||
|
||||
test('POST /api/messages/v2/send pushes MessageReceived to the recipient', async () => {
|
||||
const res = await send({ ToPlayerId: '2', Type: '10', Data: '' }, await bearer('42'))
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
expect(await pushed()).toEqual([
|
||||
{
|
||||
// Delivered to the recipient, not the sender.
|
||||
playerId: 2,
|
||||
notificationType: MESSAGE_RECEIVED,
|
||||
// FromPlayerId is the token's subject, not a body field.
|
||||
data: { FromPlayerId: 42, ToPlayerId: 2, Type: 10, Data: '' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('POST /api/messages/v2/send defaults Type and Data when omitted', async () => {
|
||||
const res = await send({ ToPlayerId: '2' }, await bearer('42'))
|
||||
expect(res.status).toBe(200)
|
||||
expect((await pushed())[0]?.data).toEqual({
|
||||
FromPlayerId: 42,
|
||||
ToPlayerId: 2,
|
||||
Type: 0,
|
||||
Data: '',
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/messages/v2/send 400s without a recipient, pushing nothing', async () => {
|
||||
const res = await send({ Type: '10' }, await bearer('42'))
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({ success: false, error: 'ToPlayerId is required' })
|
||||
expect(await pushed()).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /api/messages/v2/send is auth-gated', async () => {
|
||||
const res = await send({ ToPlayerId: '2' })
|
||||
expect(res.status).toBe(401)
|
||||
expect(await pushed()).toEqual([])
|
||||
})
|
||||
|
||||
// The bulk form takes a JSON body, not the form encoding the single send uses.
|
||||
const sendMultiple = async (body: unknown, headers?: Record<string, string>) => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
return exports.default.fetch(`${ORIGIN}/api/messages/v1/sendMultiple`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
test('POST /api/messages/v1/sendMultiple pushes one frame per recipient', async () => {
|
||||
const res = await sendMultiple(
|
||||
{ ToPlayerIds: [205, 206], Type: 20, Data: 'hi' },
|
||||
await bearer('42')
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
// Each frame is addressed to its own recipient; the sender is the token's subject.
|
||||
expect(await pushed()).toEqual([
|
||||
{
|
||||
playerId: 205,
|
||||
notificationType: MESSAGE_RECEIVED,
|
||||
data: { FromPlayerId: 42, ToPlayerId: 205, Type: 20, Data: 'hi' },
|
||||
},
|
||||
{
|
||||
playerId: 206,
|
||||
notificationType: MESSAGE_RECEIVED,
|
||||
data: { FromPlayerId: 42, ToPlayerId: 206, Type: 20, Data: 'hi' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('POST /api/messages/v1/sendMultiple defaults Type and Data, and de-duplicates ids', async () => {
|
||||
const res = await sendMultiple({ ToPlayerIds: [205, 205] }, await bearer('42'))
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const sent = await pushed()
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]?.data).toEqual({ FromPlayerId: 42, ToPlayerId: 205, Type: 0, Data: '' })
|
||||
})
|
||||
|
||||
test('POST /api/messages/v1/sendMultiple 400s with no usable recipient, pushing nothing', async () => {
|
||||
for (const body of [{ Type: 20 }, { ToPlayerIds: [] }, { ToPlayerIds: ['nope', 0] }]) {
|
||||
const res = await sendMultiple(body, await bearer('42'))
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({ success: false, error: 'ToPlayerIds is required' })
|
||||
expect(await pushed()).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
test('POST /api/messages/v1/sendMultiple is auth-gated', async () => {
|
||||
const res = await sendMultiple({ ToPlayerIds: [205] })
|
||||
expect(res.status).toBe(401)
|
||||
expect(await pushed()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutual friends', () => {
|
||||
// High, distinct ids so the friendships seeded here don't collide with the
|
||||
// relationship tests above.
|
||||
const CALLER = 800
|
||||
const OTHER = 801
|
||||
|
||||
type Card = { AccountId: number; Username: string; DisplayName: string; ProfileImage: string }
|
||||
|
||||
const mutuals = async (query: string, sub = String(CALLER)): Promise<Response> =>
|
||||
exports.default.fetch(`${ORIGIN}/api/relationships/mutualfriends${query}`, {
|
||||
headers: await bearer(sub),
|
||||
})
|
||||
|
||||
beforeAll(async () => {
|
||||
const rel = (a: number, b: number, type = 3) =>
|
||||
env.DB.prepare(
|
||||
'INSERT INTO relationship (requester_id, target_id, relationship_type) VALUES (?1, ?2, ?3)'
|
||||
).bind(a, b, type)
|
||||
// 804 has no profileImage key at all — the projection must still answer a
|
||||
// string. 806 is deliberately given no account row.
|
||||
const account = (id: number, extra: Record<string, unknown>) =>
|
||||
env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)').bind(
|
||||
JSON.stringify({ accountId: id, username: `P${id}`, displayName: `Player ${id}`, ...extra })
|
||||
)
|
||||
|
||||
await env.DB.batch([
|
||||
account(CALLER, { profileImage: 'p800.jpg' }),
|
||||
account(OTHER, { profileImage: 'p801.jpg' }),
|
||||
account(802, { profileImage: 'p802.jpg' }),
|
||||
account(803, { profileImage: 'p803.jpg' }),
|
||||
account(804, {}),
|
||||
// Seeded 804-first so the ascending order of the answer is the code's doing,
|
||||
// not the insertion order's.
|
||||
rel(CALLER, 804),
|
||||
rel(802, CALLER), // friendship recorded from the other direction
|
||||
rel(CALLER, 803),
|
||||
rel(CALLER, 806),
|
||||
rel(OTHER, 804), // shared → in the answer
|
||||
rel(OTHER, 802), // shared → in the answer
|
||||
rel(803, OTHER, 1), // only a pending request → NOT a friend of OTHER
|
||||
rel(OTHER, 806), // shared, but 806 has no account row → dropped
|
||||
])
|
||||
})
|
||||
|
||||
test('GET /api/relationships/mutualfriends returns the shared friends', async () => {
|
||||
const res = await mutuals(`?id=${OTHER}`)
|
||||
expect(res.status).toBe(200)
|
||||
const cards = (await res.json()) as Card[]
|
||||
// 803 is only a pending request on OTHER's side, and 806 has no account row.
|
||||
expect(cards.map((p) => p.AccountId)).toEqual([802, 804])
|
||||
expect(cards[0]).toEqual({
|
||||
AccountId: 802,
|
||||
Username: 'P802',
|
||||
DisplayName: 'Player 802',
|
||||
ProfileImage: 'p802.jpg',
|
||||
})
|
||||
// No stored image → an empty string, never null/undefined.
|
||||
expect(cards[1]?.ProfileImage).toBe('')
|
||||
})
|
||||
|
||||
// The degenerate cases answer an empty list rather than an error — this feeds a
|
||||
// profile panel, which would otherwise have nothing to render.
|
||||
// `?id=` is the only accepted form — `?playerId=` reads as no id at all.
|
||||
test('GET /api/relationships/mutualfriends answers [] for a missing/self/bad id', async () => {
|
||||
for (const query of ['', '?id=0', '?id=-5', '?id=abc', `?id=${CALLER}`, `?playerId=${OTHER}`]) {
|
||||
const res = await mutuals(query)
|
||||
expect(res.status, query).toBe(200)
|
||||
expect(await res.json(), query).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
// Symmetric: 802 and 803 aren't friends with each other, but both are friends with
|
||||
// 800, so 800 is what they have in common.
|
||||
test('GET /api/relationships/mutualfriends works between two other players', async () => {
|
||||
const cards = (await (await mutuals('?id=803', '802')).json()) as Card[]
|
||||
expect(cards.map((p) => p.AccountId)).toEqual([CALLER])
|
||||
})
|
||||
|
||||
test('GET /api/relationships/mutualfriends answers [] with nothing in common', async () => {
|
||||
// 809 has no relationships at all.
|
||||
const cards = (await (await mutuals('?id=809', '802')).json()) as Card[]
|
||||
expect(cards).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/relationships/mutualfriends is auth-gated', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/relationships/mutualfriends?id=${OTHER}`)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('player events', () => {
|
||||
const HOUR = 60 * 60 * 1000
|
||||
/**
|
||||
* Seconds precision, no milliseconds — the form the client sends and reads back.
|
||||
*
|
||||
* Anchored to one instant fixed when this suite is defined, NOT to `Date.now()` per
|
||||
* call: the same offset is evaluated once to build a fixture and again to assert what
|
||||
* came back, and a re-read clock makes those two strings differ by a second whenever
|
||||
* the pair straddles a second boundary. Offsets are whole hours, so pinning the anchor
|
||||
* leaves the upcoming/live/finished distinction the browse queries make intact.
|
||||
*/
|
||||
const NOW = Date.now()
|
||||
const at = (offsetMs: number): string =>
|
||||
new Date(NOW + offsetMs).toISOString().replace(/\.\d{3}Z$/, 'Z')
|
||||
|
||||
const post = async (path: string, body: unknown, sub = '42'): Promise<Response> =>
|
||||
exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
const create = async (body: unknown, sub = '42'): Promise<PlayerEvent> => {
|
||||
const res = await post('/api/playerevents/v2', body, sub)
|
||||
expect(res.status).toBe(200)
|
||||
return ((await res.json()) as PlayerEventResult).PlayerEvent
|
||||
}
|
||||
|
||||
const get = async (path: string, sub?: string): Promise<Response> =>
|
||||
exports.default.fetch(`${ORIGIN}${path}`, sub ? { headers: await bearer(sub) } : undefined)
|
||||
|
||||
// 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: PlayerEvent
|
||||
let clubEvent: PlayerEvent
|
||||
let liveEvent: PlayerEvent
|
||||
let pastEvent: PlayerEvent
|
||||
|
||||
beforeAll(async () => {
|
||||
// Posted nested under `PlayerEvent` — the envelope form the client sends back.
|
||||
upcoming = await create({
|
||||
PlayerEvent: {
|
||||
ImageName: 'e63dcbffe8d14a7696bea7117dc3dd28.jpg',
|
||||
RoomId: 10916706,
|
||||
SubRoomId: 11195660,
|
||||
ClubId: null,
|
||||
Name: 'Building a Better Room Using Trigonometry',
|
||||
Description: '',
|
||||
StartTime: at(HOUR),
|
||||
EndTime: at(2 * HOUR),
|
||||
State: 0,
|
||||
Accessibility: 1,
|
||||
IsMultiInstance: false,
|
||||
SupportMultiInstanceRoomChat: true,
|
||||
DefaultBroadcastPermissions: 0,
|
||||
CanRequestBroadcastPermissions: 0,
|
||||
},
|
||||
})
|
||||
// …and this one at the top level, the other form in circulation.
|
||||
clubEvent = await create({
|
||||
RoomId: 23570830,
|
||||
ClubId: 7,
|
||||
Name: 'DUNGEONS Escape ROOM',
|
||||
Description: 'Try and escape the DUNGEONS with upto 4 players!',
|
||||
StartTime: at(3 * HOUR),
|
||||
EndTime: at(4 * HOUR),
|
||||
CanRequestBroadcastPermissions: 2147483647,
|
||||
})
|
||||
liveEvent = await create(
|
||||
{ RoomId: 3, ClubId: 7, Name: 'Live Jam', StartTime: at(-HOUR), EndTime: at(HOUR) },
|
||||
'43'
|
||||
)
|
||||
pastEvent = await create({
|
||||
RoomId: 3,
|
||||
Name: 'Trigonometry Retrospective',
|
||||
StartTime: at(-3 * HOUR),
|
||||
EndTime: at(-2 * HOUR),
|
||||
})
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/tagfilters serves the event categories, auth-gated', async () => {
|
||||
expect((await get('/api/playerevents/v1/tagfilters')).status).toBe(401)
|
||||
|
||||
const res = await get('/api/playerevents/v1/tagfilters', '42')
|
||||
expect(res.status).toBe(200)
|
||||
// Static — the categories the client offers, not derived from stored events.
|
||||
// Trending is null even in the reference: it needs recent-activity data.
|
||||
expect(await res.json()).toEqual({
|
||||
PinnedFilters: [
|
||||
'workshops',
|
||||
'celebration',
|
||||
'game',
|
||||
'meetup',
|
||||
'performance',
|
||||
'coop',
|
||||
'grandopening',
|
||||
'class',
|
||||
'competition',
|
||||
],
|
||||
PopularFilters: [
|
||||
'workshops',
|
||||
'celebration',
|
||||
'class',
|
||||
'coop',
|
||||
'competition',
|
||||
'game',
|
||||
'grandopening',
|
||||
'meetup',
|
||||
'performance',
|
||||
],
|
||||
TrendingFilters: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2 creates an event, auth-gated', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/playerevents/v2`, {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
|
||||
// The stored record carries exactly the client's field set — nothing more.
|
||||
expect(upcoming).toEqual({
|
||||
PlayerEventId: upcoming.PlayerEventId,
|
||||
CreatorPlayerId: 42,
|
||||
ImageName: 'e63dcbffe8d14a7696bea7117dc3dd28.jpg',
|
||||
RoomId: 10916706,
|
||||
SubRoomId: 11195660,
|
||||
ClubId: null,
|
||||
Name: 'Building a Better Room Using Trigonometry',
|
||||
Description: '',
|
||||
StartTime: at(HOUR),
|
||||
EndTime: at(2 * HOUR),
|
||||
AttendeeCount: 1,
|
||||
State: 0,
|
||||
Accessibility: 1,
|
||||
IsMultiInstance: false,
|
||||
SupportMultiInstanceRoomChat: true,
|
||||
DefaultBroadcastPermissions: 0,
|
||||
CanRequestBroadcastPermissions: 0,
|
||||
})
|
||||
// Timestamps come back at seconds precision, as the client sends them.
|
||||
expect(upcoming.StartTime).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/)
|
||||
})
|
||||
|
||||
// The one thing the event writes are strict about. Everything else here defaults a
|
||||
// missing or unusable field (a nameless event becomes "Untitled Event"), but a name or
|
||||
// description past the stored length can't be defaulted into anything sensible, and
|
||||
// truncating a player's description silently is worse than refusing the write.
|
||||
//
|
||||
// Deliberately length ONLY: an event name is a title, not an identifier — the fixture
|
||||
// above is called "Building a Better Room Using Trigonometry" — so the alphanumeric
|
||||
// rule that guards usernames and room names would be wrong here.
|
||||
test('POST /api/playerevents/v2 caps the name at 64 and the description at 512', async () => {
|
||||
expect((await post('/api/playerevents/v2', { Name: 'n'.repeat(65), RoomId: 3 })).status).toBe(
|
||||
400
|
||||
)
|
||||
expect((await post('/api/playerevents/v2', { Name: 'n'.repeat(64), RoomId: 3 })).status).toBe(
|
||||
200
|
||||
)
|
||||
|
||||
const withDescription = (description: string) =>
|
||||
post('/api/playerevents/v2', { Name: 'Described', RoomId: 3, Description: description })
|
||||
expect((await withDescription('d'.repeat(513))).status).toBe(400)
|
||||
expect((await withDescription('d'.repeat(512))).status).toBe(200)
|
||||
// Counted in code points, so an emoji costs one character rather than two.
|
||||
expect((await withDescription('🎉'.repeat(512))).status).toBe(200)
|
||||
|
||||
// Spaces and punctuation stay fine — this is a title, not an identifier.
|
||||
expect(
|
||||
(await post('/api/playerevents/v2', { Name: "Bob's Big Night (2)!", RoomId: 3 })).status
|
||||
).toBe(200)
|
||||
|
||||
// The update path enforces the same limits, and a refusal leaves the event alone.
|
||||
const event = await create({ Name: 'EditMe', RoomId: 3 })
|
||||
const tooLong = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
|
||||
Name: 'n'.repeat(65),
|
||||
})
|
||||
expect(tooLong.status).toBe(400)
|
||||
const after = await get(`/api/playerevents/v1/${event.PlayerEventId}`)
|
||||
expect(((await after.json()) as PlayerEvent).Name).toBe('EditMe')
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2 answers the write envelope, not the bare event', async () => {
|
||||
const res = await post('/api/playerevents/v2', { Name: 'Enveloped', RoomId: 3 })
|
||||
const body = (await res.json()) as PlayerEventResult
|
||||
expect(body.Result).toBe(0)
|
||||
// Always null: no event tags are stored, but the field has to be present.
|
||||
expect(body.TagModifyResult).toBeNull()
|
||||
expect(body.PlayerEvent.Name).toBe('Enveloped')
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2 pushes a PlayerEventCreated notification to the creator', async () => {
|
||||
// The notify DO is stubbed to record its last notifyPlayer call (see vitest.config).
|
||||
const event = await create({
|
||||
RoomId: 58,
|
||||
Name: 'Open Mic',
|
||||
Description: 'come hang',
|
||||
StartTime: at(HOUR),
|
||||
EndTime: at(3 * HOUR),
|
||||
})
|
||||
const res = await env.RECFLARE_NOTIFICATIONS_HUB.getByName('global').fetch('http://do/last')
|
||||
const last = (await res.json()) as {
|
||||
playerId: number
|
||||
notificationType: number
|
||||
data: Record<string, unknown>
|
||||
}
|
||||
expect(last.playerId).toBe(42) // the creator
|
||||
expect(last.notificationType).toBe(80) // NotificationType.PlayerEventCreated
|
||||
|
||||
// camelCase, unlike the PascalCase record the response carries; `tags` and
|
||||
// `broadcastingRoomInstanceId` don't exist on the record, and `State` is dropped.
|
||||
// The real hub strips the null values from the frame before it goes on the wire.
|
||||
expect(last.data).toEqual({
|
||||
tags: [],
|
||||
playerEventId: event.PlayerEventId,
|
||||
creatorPlayerId: 42,
|
||||
roomId: 58,
|
||||
subRoomId: null,
|
||||
clubId: null,
|
||||
name: 'Open Mic',
|
||||
description: 'come hang',
|
||||
imageName: '', // empty string, not the record's null
|
||||
startTime: `${event.StartTime.slice(0, -1)}.0000000Z`,
|
||||
endTime: `${event.EndTime.slice(0, -1)}.0000000Z`,
|
||||
attendeeCount: 1,
|
||||
accessibility: 1,
|
||||
isMultiInstance: false,
|
||||
supportMultiInstanceRoomChat: false,
|
||||
defaultBroadcastPermissions: 0,
|
||||
canRequestBroadcastPermissions: 0,
|
||||
broadcastingRoomInstanceId: null,
|
||||
})
|
||||
// Tick precision on the frame; the stored record keeps its bare form.
|
||||
expect(event.StartTime).toMatch(/:\d{2}Z$/)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2 takes the creator from the token, not the body', async () => {
|
||||
const event = await create({ Name: 'Not Yours', RoomId: 3, CreatorPlayerId: 999 })
|
||||
expect(event.CreatorPlayerId).toBe(42)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2 defaults an empty body rather than rejecting it', async () => {
|
||||
const event = await create({})
|
||||
expect(event).toMatchObject({
|
||||
Name: 'Untitled Event',
|
||||
Description: '',
|
||||
RoomId: 0,
|
||||
SubRoomId: null,
|
||||
ClubId: null,
|
||||
ImageName: null,
|
||||
AttendeeCount: 1,
|
||||
State: 0,
|
||||
Accessibility: 1,
|
||||
IsMultiInstance: false,
|
||||
SupportMultiInstanceRoomChat: false,
|
||||
DefaultBroadcastPermissions: 0,
|
||||
CanRequestBroadcastPermissions: 0,
|
||||
})
|
||||
// A start with no end runs for an hour.
|
||||
expect(Date.parse(event.EndTime) - Date.parse(event.StartTime)).toBe(HOUR)
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/:eventId serves the bare event', async () => {
|
||||
const res = await get(`/api/playerevents/v1/${upcoming.PlayerEventId}`)
|
||||
expect(res.status).toBe(200)
|
||||
// No envelope here — unlike the writes.
|
||||
expect(await res.json()).toEqual(upcoming)
|
||||
|
||||
expect((await get('/api/playerevents/v1/999999')).status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/:eventId?includeDetails=True adds only `tags`', async () => {
|
||||
const path = `/api/playerevents/v1/${upcoming.PlayerEventId}`
|
||||
// The flag's whole effect: the lowercase `tags`, empty (no event tags are stored).
|
||||
expect(await (await get(`${path}?includeDetails=True`)).json()).toEqual({
|
||||
...upcoming,
|
||||
tags: [],
|
||||
})
|
||||
// Accepted case-insensitively — the client sends `True`.
|
||||
expect(await (await get(`${path}?includeDetails=true`)).json()).toEqual({
|
||||
...upcoming,
|
||||
tags: [],
|
||||
})
|
||||
// Anything else is the bare record, with no `tags` key at all.
|
||||
expect(await (await get(`${path}?includeDetails=False`)).json()).toEqual(upcoming)
|
||||
expect(await (await get(path)).json()).toEqual(upcoming)
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/bulk answers in request order, skipping unknown ids', async () => {
|
||||
const res = await get(
|
||||
`/api/playerevents/v1/bulk?id=${clubEvent.PlayerEventId}&id=999999&id=${upcoming.PlayerEventId}`
|
||||
)
|
||||
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,
|
||||
])
|
||||
|
||||
// No ids is an empty list, not every event.
|
||||
expect(await (await get('/api/playerevents/v1/bulk')).json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/search matches name and description, skipping finished events', async () => {
|
||||
const search = async (qs: string): Promise<PlayerEvent[]> =>
|
||||
(await (await get(`/api/playerevents/v1/search${qs}`)).json()) as PlayerEvent[]
|
||||
|
||||
// Every term has to match, across name OR description.
|
||||
expect((await search('?query=dungeons+escape')).map((e) => e.PlayerEventId)).toEqual([
|
||||
clubEvent.PlayerEventId,
|
||||
])
|
||||
// …matched case-insensitively, and against the description too.
|
||||
expect((await search('?query=upto%204%20players')).map((e) => e.PlayerEventId)).toEqual([
|
||||
clubEvent.PlayerEventId,
|
||||
])
|
||||
|
||||
// `pastEvent` matches on name but has already ended, so the browse query drops it.
|
||||
const trig = await search('?query=trigonometry')
|
||||
expect(trig.map((e) => e.PlayerEventId)).toEqual([upcoming.PlayerEventId])
|
||||
expect(trig.map((e) => e.PlayerEventId)).not.toContain(pastEvent.PlayerEventId)
|
||||
|
||||
// Soonest first, and take/skip page through that order.
|
||||
const all = await search('')
|
||||
const starts = all.map((e) => e.StartTime)
|
||||
expect([...starts].sort()).toEqual(starts)
|
||||
expect(await search('?take=1')).toEqual([all[0]])
|
||||
expect(await search('?skip=1&take=1')).toEqual([all[1]])
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1 serves the browse feed as listings', async () => {
|
||||
const res = await get('/api/playerevents/v1')
|
||||
expect(res.status).toBe(200)
|
||||
const feed = (await res.json()) as Array<PlayerEvent & { BroadcastingRoomInstanceId: null }>
|
||||
|
||||
// Upcoming and live, soonest first; what has already ended is left out.
|
||||
const ids = feed.map((e) => e.PlayerEventId)
|
||||
expect(ids).toContain(upcoming.PlayerEventId)
|
||||
expect(ids).toContain(liveEvent.PlayerEventId)
|
||||
expect(ids).not.toContain(pastEvent.PlayerEventId)
|
||||
const starts = feed.map((e) => e.StartTime)
|
||||
expect([...starts].sort()).toEqual(starts)
|
||||
|
||||
// The listing projection — no `State`, and a null broadcasting instance — not the
|
||||
// stored record the by-id read serves.
|
||||
const entry = feed.find((e) => e.PlayerEventId === upcoming.PlayerEventId)!
|
||||
expect(entry).toEqual({ ...upcoming, State: undefined, BroadcastingRoomInstanceId: null })
|
||||
expect(Object.hasOwn(entry, 'State')).toBe(false)
|
||||
|
||||
// Paged like the other feeds.
|
||||
expect(await (await get('/api/playerevents/v1?take=1')).json()).toEqual([feed[0]])
|
||||
expect(await (await get('/api/playerevents/v1?skip=1&take=1')).json()).toEqual([feed[1]])
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/search matches `#tag` terms against tags, not text', async () => {
|
||||
const search = async (qs: string): Promise<PlayerEvent[]> =>
|
||||
(await (await get(`/api/playerevents/v1/search${qs}`)).json()) as PlayerEvent[]
|
||||
|
||||
// Two tagged events, one of which only MENTIONS the word in its description.
|
||||
const tagged = await create({
|
||||
RoomId: 3,
|
||||
Name: 'Sawdust Session',
|
||||
StartTime: at(HOUR),
|
||||
// Both forms in circulation: a bare name and the `{ tag, type }` pair.
|
||||
Tags: ['#Workshops', { tag: 'meetup', type: 2 }],
|
||||
})
|
||||
const textOnly = await create({
|
||||
RoomId: 3,
|
||||
Name: 'Talking About Workshops',
|
||||
Description: 'we discuss workshops, untagged',
|
||||
StartTime: at(HOUR),
|
||||
})
|
||||
|
||||
// `#workshops` is the tag alone — the untagged event that says "workshops" twice
|
||||
// doesn't match.
|
||||
const byTag = await search('?query=%23workshops&sort=StartTime')
|
||||
expect(byTag.map((e) => e.PlayerEventId)).toEqual([tagged.PlayerEventId])
|
||||
// …and the bare word is the mirror image: a text search, which finds the event that
|
||||
// says "workshops" and NOT the one merely tagged with it.
|
||||
const byText = await search('?query=workshops')
|
||||
expect(byText.map((e) => e.PlayerEventId)).toEqual([textOnly.PlayerEventId])
|
||||
|
||||
// Tag terms combine with text terms, and with each other (every one must match).
|
||||
expect((await search('?query=%23workshops+sawdust')).map((e) => e.PlayerEventId)).toEqual([
|
||||
tagged.PlayerEventId,
|
||||
])
|
||||
expect(await search('?query=%23workshops+%23meetup')).toHaveLength(1)
|
||||
expect(await search('?query=%23workshops+%23celebration')).toEqual([])
|
||||
expect(await search('?query=%23nosuchtag')).toEqual([])
|
||||
|
||||
// The tags are what `includeDetails` serves — lowercased, `#` stripped, and the
|
||||
// type kept (defaulting to 0 for the bare-string form).
|
||||
const details = (await (
|
||||
await get(`/api/playerevents/v1/${tagged.PlayerEventId}?includeDetails=True`)
|
||||
).json()) as { tags: Array<{ tag: string; type: number }> }
|
||||
expect(details.tags).toEqual([
|
||||
{ tag: 'meetup', type: 2 },
|
||||
{ tag: 'workshops', type: 0 },
|
||||
])
|
||||
// …and they are NOT on the plain record, which every other read serves verbatim.
|
||||
expect(
|
||||
await (await get(`/api/playerevents/v1/${tagged.PlayerEventId}`)).json()
|
||||
).not.toHaveProperty('tags')
|
||||
|
||||
// An update REPLACES the set; a body that says nothing about tags leaves it alone.
|
||||
await post(`/api/playerevents/v2/${tagged.PlayerEventId}`, { Tags: ['celebration'] })
|
||||
expect((await search('?query=%23celebration')).map((e) => e.PlayerEventId)).toEqual([
|
||||
tagged.PlayerEventId,
|
||||
])
|
||||
expect(await search('?query=%23workshops')).toEqual([])
|
||||
await post(`/api/playerevents/v2/${tagged.PlayerEventId}`, { Name: 'Sawdust Session II' })
|
||||
expect((await search('?query=%23celebration')).map((e) => e.PlayerEventId)).toEqual([
|
||||
tagged.PlayerEventId,
|
||||
])
|
||||
// An explicit empty list does clear them.
|
||||
await post(`/api/playerevents/v2/${tagged.PlayerEventId}`, { Tags: [] })
|
||||
expect(await search('?query=%23celebration')).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/searchlive serves what is running right now', async () => {
|
||||
const res = await get('/api/playerevents/v1/searchlive')
|
||||
expect(res.status).toBe(200)
|
||||
const ids = ((await res.json()) as PlayerEvent[]).map((e) => e.PlayerEventId)
|
||||
expect(ids).toContain(liveEvent.PlayerEventId)
|
||||
// Started in an hour / finished already — neither is live.
|
||||
expect(ids).not.toContain(upcoming.PlayerEventId)
|
||||
expect(ids).not.toContain(pastEvent.PlayerEventId)
|
||||
})
|
||||
|
||||
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.
|
||||
const many = await get('/api/playerevents/v1/clubs?id=7&id=8')
|
||||
expect(many.status).toBe(200)
|
||||
const events = (await many.json()) as PlayerEvent[]
|
||||
expect(events.map((e) => e.PlayerEventId)).toEqual([
|
||||
liveEvent.PlayerEventId, // started an hour ago — soonest first
|
||||
clubEvent.PlayerEventId,
|
||||
])
|
||||
|
||||
// The single-club form does wrap its events with a paging cursor.
|
||||
const one = await get('/api/playerevents/v1/club/7')
|
||||
expect(one.status).toBe(200)
|
||||
expect(await one.json()).toEqual({ ContinuationToken: '', Events: events })
|
||||
|
||||
// A club with no events, and the no-ids case.
|
||||
expect(await (await get('/api/playerevents/v1/club/8')).json()).toEqual({
|
||||
ContinuationToken: '',
|
||||
Events: [],
|
||||
})
|
||||
expect(await (await get('/api/playerevents/v1/clubs')).json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/all lists the caller’s own events, auth-gated', async () => {
|
||||
expect((await get('/api/playerevents/v1/all')).status).toBe(401)
|
||||
|
||||
const mine = (await (await get('/api/playerevents/v1/all', '42')).json()) as {
|
||||
Created: PlayerEvent[]
|
||||
Responses: unknown[]
|
||||
}
|
||||
const ids = mine.Created.map((e) => e.PlayerEventId)
|
||||
expect(ids).toContain(upcoming.PlayerEventId)
|
||||
// 43 created that one, not 42.
|
||||
expect(ids).not.toContain(liveEvent.PlayerEventId)
|
||||
// Finished events stay in the creator's own list — only the browse queries drop them.
|
||||
expect(ids).toContain(pastEvent.PlayerEventId)
|
||||
// Nothing records an RSVP yet.
|
||||
expect(mine.Responses).toEqual([])
|
||||
|
||||
const theirs = (await (await get('/api/playerevents/v1/all', '43')).json()) as {
|
||||
Created: PlayerEvent[]
|
||||
}
|
||||
expect(theirs.Created.map((e) => e.PlayerEventId)).toEqual([liveEvent.PlayerEventId])
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/respond records an RSVP and recounts attendees', async () => {
|
||||
const respond = async (body: unknown, sub = '42'): Promise<Response> =>
|
||||
post('/api/playerevents/v1/respond', body, sub)
|
||||
|
||||
const event = await create({ RoomId: 3, Name: 'RSVP Test', StartTime: at(HOUR) })
|
||||
const id = event.PlayerEventId
|
||||
// The creator is Going from create, which is where the initial 1 comes from.
|
||||
expect(event.AttendeeCount).toBe(1)
|
||||
expect(await countGoing(env.DB, id)).toBe(1)
|
||||
|
||||
// 43 says Going → 2 attendees, and the envelope carries the updated event.
|
||||
const res = await respond({ PlayerEventId: id, Type: 0 }, '43')
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as PlayerEventResult
|
||||
expect(body.Result).toBe(0)
|
||||
expect(body.PlayerEvent.AttendeeCount).toBe(2)
|
||||
expect(await getEventResponse(env.DB, id, 43)).toMatchObject({
|
||||
event_id: id,
|
||||
player_id: 43,
|
||||
status: 0,
|
||||
})
|
||||
|
||||
// Changing the answer REPLACES it — one row per player, not a second RSVP.
|
||||
const changed = await respond({ PlayerEventId: id, Type: 2 }, '43')
|
||||
expect(((await changed.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(1)
|
||||
expect(await getEventResponse(env.DB, id, 43)).toMatchObject({ player_id: 43, status: 2 })
|
||||
expect((await getEventAttendees(env.DB, id)).map((a) => a.player_id)).toEqual([42, 43])
|
||||
|
||||
// Interested is a maybe — recorded, but not counted.
|
||||
await respond({ PlayerEventId: id, Type: 1 }, '43')
|
||||
expect(await countGoing(env.DB, id)).toBe(1)
|
||||
|
||||
// And the count sticks on the stored event, not just the response.
|
||||
const fetched = (await (await get(`/api/playerevents/v1/${id}`)).json()) as PlayerEvent
|
||||
expect(fetched.AttendeeCount).toBe(1)
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/:eventId/responses lists every RSVP, one per player', async () => {
|
||||
const event = await create({ RoomId: 3, Name: 'Guest List', StartTime: at(HOUR) })
|
||||
const id = event.PlayerEventId
|
||||
const responses = async (): Promise<
|
||||
Array<{
|
||||
PlayerEventResponseId: number
|
||||
PlayerEventId: number
|
||||
PlayerId: number
|
||||
CreatedAt: string
|
||||
Type: number
|
||||
}>
|
||||
> => (await (await get(`/api/playerevents/v1/${id}/responses`)).json()) as never
|
||||
|
||||
// The creator's own Going row, from create.
|
||||
const initial = await responses()
|
||||
expect(initial).toEqual([
|
||||
{
|
||||
PlayerEventResponseId: expect.any(Number),
|
||||
PlayerEventId: id,
|
||||
PlayerId: 42,
|
||||
CreatedAt: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/),
|
||||
Type: 0,
|
||||
},
|
||||
])
|
||||
|
||||
// Declines and maybes are listed too — not just what AttendeeCount counts.
|
||||
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 2 }, '43')
|
||||
const withDecline = await responses()
|
||||
expect(withDecline.map((r) => [r.PlayerId, r.Type])).toEqual([
|
||||
[42, 0],
|
||||
[43, 2],
|
||||
])
|
||||
|
||||
// Changing an answer updates the row in place: same id, new Type — never a second
|
||||
// entry for the player.
|
||||
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 1 }, '43')
|
||||
const changed = await responses()
|
||||
expect(changed).toHaveLength(2)
|
||||
expect(changed[1]!.PlayerEventResponseId).toBe(withDecline[1]!.PlayerEventResponseId)
|
||||
expect(changed[1]!.Type).toBe(1)
|
||||
|
||||
// An unknown event is an empty list, not a 404 — like the other list reads.
|
||||
const unknown = await get('/api/playerevents/v1/999999/responses')
|
||||
expect(unknown.status).toBe(200)
|
||||
expect(await unknown.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/respond rejects a bad body, an unknown event and no token', async () => {
|
||||
const event = await create({ RoomId: 3, Name: 'Guarded' })
|
||||
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/respond`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ PlayerEventId: event.PlayerEventId, Type: 0 }),
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
|
||||
// An unrecognized Type is rejected rather than defaulted — stored as Going it
|
||||
// would silently inflate the count.
|
||||
expect((await post('/api/playerevents/v1/respond', { PlayerEventId: 1, Type: 7 })).status).toBe(
|
||||
400
|
||||
)
|
||||
expect((await post('/api/playerevents/v1/respond', { Type: 0 })).status).toBe(400)
|
||||
expect((await post('/api/playerevents/v1/respond', {})).status).toBe(400)
|
||||
expect(
|
||||
(await post('/api/playerevents/v1/respond', { PlayerEventId: 999999, Type: 0 })).status
|
||||
).toBe(404)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/report files a report row against the event', async () => {
|
||||
const event = await create({ RoomId: 58, Name: 'Reportable', StartTime: at(HOUR) }, '43')
|
||||
|
||||
const res = await post(
|
||||
'/api/playerevents/v1/report',
|
||||
{ ReportCategory: 101, PlayerEventId: event.PlayerEventId, Details: 'bad event' },
|
||||
'42'
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
// One row in the shared report table, marked as an event report by `event_id` —
|
||||
// with the reported player and the room filled in FROM the event, not the body.
|
||||
const row = await env.DB.prepare('SELECT * FROM report WHERE event_id = ?1')
|
||||
.bind(event.PlayerEventId)
|
||||
.first<Record<string, unknown>>()
|
||||
expect(row).toMatchObject({
|
||||
reporter_player_id: 42,
|
||||
reported_player_id: 43, // the event's creator
|
||||
report_category: 101,
|
||||
details: 'bad event',
|
||||
room_id: 58,
|
||||
event_id: event.PlayerEventId,
|
||||
banned: 0, // filed unbanned, like any report
|
||||
})
|
||||
|
||||
// A body with no usable event id, and one naming an event that doesn't exist —
|
||||
// both answer the same envelope shape as the success branch.
|
||||
expect(await (await post('/api/playerevents/v1/report', { Details: 'x' })).json()).toEqual({
|
||||
success: false,
|
||||
error: 'PlayerEventId is required',
|
||||
})
|
||||
const unknown = await post('/api/playerevents/v1/report', { PlayerEventId: 999999 })
|
||||
expect(unknown.status).toBe(404)
|
||||
expect(await unknown.json()).toEqual({ success: false, error: 'No such event' })
|
||||
|
||||
// Auth-gated: the reporter comes from the token, so there's no filing one signed out.
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/report`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ PlayerEventId: event.PlayerEventId }),
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/bulkInvite adds invitees as Going without overwriting answers', async () => {
|
||||
const event = await create({ RoomId: 3, Name: 'Invite Test', StartTime: at(HOUR) })
|
||||
const id = event.PlayerEventId
|
||||
|
||||
// 43 declines BEFORE being invited — the invite must not flip that back.
|
||||
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 2 }, '43')
|
||||
|
||||
const res = await post(
|
||||
'/api/playerevents/v1/bulkInvite',
|
||||
// 42 is the caller (already on the event) and 187 is repeated — both are skipped.
|
||||
{ PlayerEventId: id, InvitedPlayerIds: [187, 2, 187, 42, 43] },
|
||||
'42'
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as PlayerEventResult
|
||||
expect(body.Result).toBe(0)
|
||||
// The creator plus the two newly invited — 43 keeps their decline, so isn't counted.
|
||||
expect(body.PlayerEvent.AttendeeCount).toBe(3)
|
||||
|
||||
const responses = (await (await get(`/api/playerevents/v1/${id}/responses`)).json()) as Array<{
|
||||
PlayerId: number
|
||||
Type: number
|
||||
}>
|
||||
expect(
|
||||
responses.sort((a, b) => a.PlayerId - b.PlayerId).map((r) => [r.PlayerId, r.Type])
|
||||
).toEqual([
|
||||
[2, 0],
|
||||
[42, 0],
|
||||
[43, 2],
|
||||
[187, 0],
|
||||
])
|
||||
|
||||
// Re-inviting is a no-op, not a reset: 43 still declines and the count holds.
|
||||
const again = await post(
|
||||
'/api/playerevents/v1/bulkInvite',
|
||||
{ PlayerEventId: id, InvitedPlayerIds: [187, 43] },
|
||||
'42'
|
||||
)
|
||||
expect(((await again.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(3)
|
||||
|
||||
// An empty list is a no-op that still answers the event.
|
||||
const none = await post('/api/playerevents/v1/bulkInvite', {
|
||||
PlayerEventId: id,
|
||||
InvitedPlayerIds: [],
|
||||
})
|
||||
expect(((await none.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(3)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/bulkInvite notifies only the players it actually added', async () => {
|
||||
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
const event = await create({ RoomId: 3, Name: 'Invite Frames', StartTime: at(HOUR) })
|
||||
const id = event.PlayerEventId
|
||||
// 43 answers first, so the invite leaves them alone — and must not notify them.
|
||||
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 1 }, '43')
|
||||
|
||||
await hub.fetch('http://do/all', { method: 'DELETE' })
|
||||
await post('/api/playerevents/v1/bulkInvite', { PlayerEventId: id, InvitedPlayerIds: [2, 43] })
|
||||
const sent = (await (await hub.fetch('http://do/all')).json()) as Array<{
|
||||
playerId: number
|
||||
notificationType: number
|
||||
data: Record<string, Record<string, unknown>>
|
||||
}>
|
||||
|
||||
// One frame, to the one player who gained a row. 43 kept their answer, so nothing
|
||||
// changed for them and nothing is pushed.
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]!.playerId).toBe(2)
|
||||
expect(sent[0]!.notificationType).toBe(83) // PlayerEventResponseChanged
|
||||
|
||||
// BOTH nested objects are present — the client dereferences them without a null
|
||||
// guard, so a missing one is a NullReferenceException rather than a blank field.
|
||||
expect(sent[0]!.data.PlayerEvent).toMatchObject({
|
||||
playerEventId: id,
|
||||
name: 'Invite Frames',
|
||||
attendeeCount: 2,
|
||||
})
|
||||
expect(sent[0]!.data.PlayerEventResponse).toEqual({
|
||||
PlayerEventResponseId: expect.any(Number),
|
||||
PlayerEventId: id,
|
||||
PlayerId: 2,
|
||||
CreatedAt: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/),
|
||||
Type: 0,
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/bulkInvite is gated on the caller being on the event', async () => {
|
||||
const event = await create({ RoomId: 3, Name: 'Invite Gate', StartTime: at(HOUR) })
|
||||
const id = event.PlayerEventId
|
||||
const invite = async (body: unknown, sub = '42'): Promise<Response> =>
|
||||
post('/api/playerevents/v1/bulkInvite', body, sub)
|
||||
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/api/playerevents/v1/bulkInvite`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ PlayerEventId: id, InvitedPlayerIds: [2] }),
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
|
||||
// 44 has no response row on this event — not theirs to invite to.
|
||||
expect((await invite({ PlayerEventId: id, InvitedPlayerIds: [2] }, '44')).status).toBe(403)
|
||||
// …until they respond, which puts them on it.
|
||||
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 1 }, '44')
|
||||
expect((await invite({ PlayerEventId: id, InvitedPlayerIds: [2] }, '44')).status).toBe(200)
|
||||
|
||||
expect((await invite({ PlayerEventId: 999999, InvitedPlayerIds: [2] })).status).toBe(404)
|
||||
expect((await invite({ InvitedPlayerIds: [2] })).status).toBe(400)
|
||||
expect((await invite({ PlayerEventId: id })).status).toBe(400)
|
||||
expect((await invite({})).status).toBe(400)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2/:eventId edits only what the body carries, creator-only', async () => {
|
||||
const event = await create({
|
||||
RoomId: 5,
|
||||
SubRoomId: 6,
|
||||
ClubId: 9,
|
||||
Name: 'Original',
|
||||
Description: 'Original description',
|
||||
StartTime: at(5 * HOUR),
|
||||
EndTime: at(6 * HOUR),
|
||||
})
|
||||
const path = `/api/playerevents/v2/${event.PlayerEventId}`
|
||||
|
||||
expect(
|
||||
(await exports.default.fetch(`${ORIGIN}${path}`, { method: 'POST', body: '{}' })).status
|
||||
).toBe(401)
|
||||
// 43 didn't create it.
|
||||
expect((await post(path, { Name: 'Hijacked' }, '43')).status).toBe(403)
|
||||
expect((await post('/api/playerevents/v2/999999', { Name: 'Nope' })).status).toBe(404)
|
||||
|
||||
const res = await post(path, { Name: 'Renamed' })
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as PlayerEventResult
|
||||
expect(body.Result).toBe(0)
|
||||
// Only the name moved; a partial post can't blank out the rest.
|
||||
expect(body.PlayerEvent).toEqual({ ...event, Name: 'Renamed' })
|
||||
|
||||
// And it stuck.
|
||||
expect(await (await get(`/api/playerevents/v1/${event.PlayerEventId}`)).json()).toEqual(
|
||||
body.PlayerEvent
|
||||
)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2/:eventId clears a nullable id when the body sends null', async () => {
|
||||
const event = await create({ RoomId: 5, SubRoomId: 6, ClubId: 9, Name: 'Clearable' })
|
||||
const res = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
|
||||
// Nested form again, and an explicit null — absent leaves the value alone,
|
||||
// null genuinely clears it.
|
||||
PlayerEvent: { ClubId: null, ImageName: null },
|
||||
})
|
||||
const updated = ((await res.json()) as PlayerEventResult).PlayerEvent
|
||||
expect(updated.ClubId).toBeNull()
|
||||
expect(updated.ImageName).toBeNull()
|
||||
expect(updated.SubRoomId).toBe(6)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v2/:eventId cannot move ownership or the attendee count', async () => {
|
||||
const event = await create({ RoomId: 5, Name: 'Fixed' })
|
||||
const res = await post(`/api/playerevents/v2/${event.PlayerEventId}`, {
|
||||
PlayerEventId: 424242,
|
||||
CreatorPlayerId: 43,
|
||||
AttendeeCount: 500,
|
||||
})
|
||||
const updated = ((await res.json()) as PlayerEventResult).PlayerEvent
|
||||
expect(updated.PlayerEventId).toBe(event.PlayerEventId)
|
||||
expect(updated.CreatorPlayerId).toBe(42)
|
||||
expect(updated.AttendeeCount).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('openapi', () => {
|
||||
test('GET /openapi.json documents every route', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
@@ -1935,7 +3562,8 @@ describe('openapi', () => {
|
||||
// Every route the worker serves is described. This is the drift guard: adding a
|
||||
// route without a describeRoute() block fails here rather than silently shipping
|
||||
// an incomplete spec. Hono's `:param` syntax becomes OpenAPI's `{param}`; the
|
||||
// `.on(['GET','POST'], …)` relationship routes contribute both methods.
|
||||
// `.on(['GET','POST'], …)` routes (the relationship mutations, invention update)
|
||||
// contribute both methods.
|
||||
const documented = new Set(
|
||||
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||
@@ -1972,6 +3600,7 @@ describe('openapi', () => {
|
||||
'GET /api/inventions/v1',
|
||||
'GET /api/inventions/v1/details',
|
||||
'GET /api/inventions/v1/featured',
|
||||
'GET /api/inventions/v1/fulllineageowner',
|
||||
'GET /api/inventions/v1/personaldetails/{inventionId}',
|
||||
'GET /api/inventions/v1/room',
|
||||
'GET /api/inventions/v1/tagfilters',
|
||||
@@ -1989,14 +3618,20 @@ describe('openapi', () => {
|
||||
'GET /api/messages/v2/get',
|
||||
'GET /api/playerReputation/v1/{id}',
|
||||
'GET /api/playerReputation/v2/bulk',
|
||||
'GET /api/playerevents/v1',
|
||||
'GET /api/playerevents/v1/all',
|
||||
'GET /api/playerevents/v1/bulk',
|
||||
'GET /api/playerevents/v1/club/{clubId}',
|
||||
'GET /api/playerevents/v1/clubs',
|
||||
'GET /api/playerevents/v1/search',
|
||||
'GET /api/playerevents/v1/searchlive',
|
||||
'GET /api/playerevents/v1/tagfilters',
|
||||
'GET /api/playerevents/v1/{eventId}',
|
||||
'GET /api/playerevents/v1/{eventId}/responses',
|
||||
'GET /api/players/v1/progression/{id}',
|
||||
'GET /api/players/v2/progression/bulk',
|
||||
'GET /api/quickPlay/v1/getandclear',
|
||||
'GET /api/relationships/mutualfriends',
|
||||
'GET /api/relationships/v1/favorite',
|
||||
'GET /api/relationships/v1/ignore',
|
||||
'GET /api/relationships/v1/mute',
|
||||
@@ -2013,20 +3648,29 @@ describe('openapi', () => {
|
||||
'GET /api/rooms/v1/filters',
|
||||
'GET /api/versioncheck/v4',
|
||||
'GET /voice/config',
|
||||
'POST /api/CampusCard/v1/UpdateAndGetSubscription',
|
||||
'POST /api/PlayerReporting/v1/deviceId',
|
||||
'POST /api/PlayerReporting/v1/hile',
|
||||
'POST /api/PlayerReporting/v3/create',
|
||||
'POST /api/avatar/v2/gifts/generate',
|
||||
'POST /api/gamesight/event',
|
||||
'POST /api/images/v1/cheer',
|
||||
'POST /api/images/v4/uploadsaved',
|
||||
'POST /api/inventions/v1/settags',
|
||||
'POST /api/inventions/v1/update',
|
||||
'POST /api/inventions/v1/updateprice',
|
||||
'POST /api/inventions/v6/save',
|
||||
'POST /api/messages/v1/sendMultiple',
|
||||
'POST /api/messages/v2/send',
|
||||
'POST /api/playerReputation/v1/bulk',
|
||||
'POST /api/playerReputation/v2/bulk',
|
||||
'POST /api/playerevents/v1/bulkInvite',
|
||||
'POST /api/playerevents/v1/report',
|
||||
'POST /api/playerevents/v1/respond',
|
||||
'POST /api/playerevents/v2',
|
||||
'POST /api/playerevents/v2/{eventId}',
|
||||
'POST /api/players/v1/progression/bulk',
|
||||
'POST /api/players/v2/progression/bulk',
|
||||
'POST /api/playerwarnings',
|
||||
'POST /api/relationships/v1/favorite',
|
||||
'POST /api/relationships/v1/ignore',
|
||||
'POST /api/relationships/v1/mute',
|
||||
@@ -2072,3 +3716,195 @@ describe('openapi', () => {
|
||||
expect(raw.match(/"example":12345/g)?.length).toBe(integers.length)
|
||||
})
|
||||
})
|
||||
|
||||
// A ban follows the player, not just the account row it was written on: an evader makes
|
||||
// a new account in seconds, so the block also reaches accounts sharing a PROVEN platform
|
||||
// identity or an IP with a banned one. See bans-db.ts — and note the IP arm is the coarse
|
||||
// one, which is why `BAN_EVASION_MATCH` can narrow or disable both linked arms.
|
||||
describe('ban evasion', () => {
|
||||
/** Seed an account with the IPs it signed up / last logged in from. */
|
||||
const account = async (id: number, ips: { signupIp?: string; lastLoginIp?: string } = {}) => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: id, username: `Evader${id}`, ...ips }))
|
||||
.run()
|
||||
}
|
||||
|
||||
/** Link a proven platform identity to an account, as a verified login does. */
|
||||
const link = async (id: number, platform: number, platformId: string) => {
|
||||
await env.DB.prepare(
|
||||
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
|
||||
VALUES (?1, ?2, ?3, ?4)`
|
||||
)
|
||||
.bind(id, platform, platformId, new Date().toISOString())
|
||||
.run()
|
||||
}
|
||||
|
||||
/** File a report against `playerId` and convert it into a ban. */
|
||||
const ban = async (playerId: number, banExpires: string | null = null) => {
|
||||
const row = await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: playerId })
|
||||
await banFromReport(env.DB, row.id, { banExpires })
|
||||
}
|
||||
|
||||
test('a banned account is matched directly', async () => {
|
||||
await account(7001)
|
||||
await ban(7001)
|
||||
expect(await resolveBan(env.DB, 7001)).toMatchObject({ via: 'account', bannedAccountId: 7001 })
|
||||
})
|
||||
|
||||
test('an unrelated account is not matched', async () => {
|
||||
await account(7002, { signupIp: '198.51.100.9' })
|
||||
await link(7002, 0, 'steam-clean')
|
||||
expect(await resolveBan(env.DB, 7002)).toBeNull()
|
||||
})
|
||||
|
||||
test('an account sharing a signup IP with a banned account is matched', async () => {
|
||||
await account(7010, { signupIp: '203.0.113.7' })
|
||||
await ban(7010)
|
||||
await account(7011, { signupIp: '203.0.113.7' })
|
||||
|
||||
const match = await resolveBan(env.DB, 7011)
|
||||
expect(match).toMatchObject({ via: 'ip', bannedAccountId: 7010 })
|
||||
})
|
||||
|
||||
// The IPs are compared as SETS: the new account's last-login IP against the banned
|
||||
// account's signup IP counts, which is the shape evasion actually takes (sign up
|
||||
// somewhere else, come back to the same connection).
|
||||
test('a last-login IP matching a banned signup IP is matched', async () => {
|
||||
await account(7012, { signupIp: '203.0.113.20' })
|
||||
await ban(7012)
|
||||
await account(7013, { signupIp: '198.51.100.1', lastLoginIp: '203.0.113.20' })
|
||||
|
||||
expect(await resolveBan(env.DB, 7013)).toMatchObject({ via: 'ip', bannedAccountId: 7012 })
|
||||
})
|
||||
|
||||
test('an account sharing a platform identity with a banned account is matched', async () => {
|
||||
await account(7020)
|
||||
await link(7020, 0, 'steam-76561')
|
||||
await ban(7020)
|
||||
await account(7021)
|
||||
await link(7021, 0, 'steam-76561')
|
||||
|
||||
expect(await resolveBan(env.DB, 7021)).toMatchObject({ via: 'platform', bannedAccountId: 7020 })
|
||||
})
|
||||
|
||||
// The same id on a DIFFERENT platform is a different person — ids are namespaced per
|
||||
// platform, so the arm matches the pair, not the bare id.
|
||||
test('the same platform id on another platform is not matched', async () => {
|
||||
await account(7022)
|
||||
await link(7022, 0, 'id-collision')
|
||||
await ban(7022)
|
||||
await account(7023)
|
||||
await link(7023, 1, 'id-collision')
|
||||
|
||||
expect(await resolveBan(env.DB, 7023)).toBeNull()
|
||||
})
|
||||
|
||||
// Two accounts that merely both lack an IP have nothing in common — "unknown" must
|
||||
// never match "unknown", or every IP-less account would be banned by the first one.
|
||||
test('accounts with no IP at all are not matched to each other', async () => {
|
||||
await account(7030)
|
||||
await ban(7030)
|
||||
await account(7031)
|
||||
expect(await resolveBan(env.DB, 7031)).toBeNull()
|
||||
// Nor does an empty-string IP, which is what a login outside the CF edge stores.
|
||||
await account(7032, { signupIp: '', lastLoginIp: '' })
|
||||
expect(await resolveBan(env.DB, 7032)).toBeNull()
|
||||
})
|
||||
|
||||
test('an expired ban reaches nobody, linked or not', async () => {
|
||||
await account(7040, { signupIp: '203.0.113.40' })
|
||||
await link(7040, 0, 'steam-expired')
|
||||
await ban(7040, '2020-01-01T00:00:00.000Z')
|
||||
await account(7041, { signupIp: '203.0.113.40' })
|
||||
await link(7041, 0, 'steam-expired')
|
||||
|
||||
expect(await resolveBan(env.DB, 7040)).toBeNull()
|
||||
expect(await resolveBan(env.DB, 7041)).toBeNull()
|
||||
})
|
||||
|
||||
// The strongest evidence is reported: a player whose own account is banned is told
|
||||
// that, not that their network was.
|
||||
test('a direct ban outranks a linked one', async () => {
|
||||
await account(7050, { signupIp: '203.0.113.50' })
|
||||
await ban(7050)
|
||||
await account(7051, { signupIp: '203.0.113.50' })
|
||||
await ban(7051)
|
||||
|
||||
expect(await resolveBan(env.DB, 7051)).toMatchObject({ via: 'account', bannedAccountId: 7051 })
|
||||
})
|
||||
|
||||
test('a platform match outranks an IP one', async () => {
|
||||
await account(7060, { signupIp: '203.0.113.60' })
|
||||
await ban(7060)
|
||||
await account(7061)
|
||||
await link(7061, 0, 'steam-both')
|
||||
await ban(7061)
|
||||
// 7062 shares an IP with 7060 and a platform identity with 7061.
|
||||
await account(7062, { signupIp: '203.0.113.60' })
|
||||
await link(7062, 0, 'steam-both')
|
||||
|
||||
expect(await resolveBan(env.DB, 7062)).toMatchObject({ via: 'platform', bannedAccountId: 7061 })
|
||||
})
|
||||
|
||||
// A signup has no account yet — the identity the request carries is all there is to
|
||||
// go on, and refusing it there is what stops the next account being created at all.
|
||||
test('an identity with no account is matched on its IP and platform id', async () => {
|
||||
await account(7070, { signupIp: '203.0.113.70' })
|
||||
await link(7070, 0, 'steam-signup')
|
||||
await ban(7070)
|
||||
|
||||
expect(await resolveBan(env.DB, null, { identity: { ip: '203.0.113.70' } })).toMatchObject({
|
||||
via: 'ip',
|
||||
bannedAccountId: 7070,
|
||||
})
|
||||
expect(
|
||||
await resolveBan(env.DB, null, { identity: { platform: 0, platformId: 'steam-signup' } })
|
||||
).toMatchObject({ via: 'platform', bannedAccountId: 7070 })
|
||||
// An identity that matches nothing is not blocked.
|
||||
expect(
|
||||
await resolveBan(env.DB, null, {
|
||||
identity: { ip: '198.51.100.200', platform: 0, platformId: 'steam-unknown' },
|
||||
})
|
||||
).toBeNull()
|
||||
// And an identity carrying nothing at all can't be matched to anyone.
|
||||
expect(await resolveBan(env.DB, null, { identity: {} })).toBeNull()
|
||||
})
|
||||
|
||||
// The arms an operator can turn off — and the one they cannot.
|
||||
test('BAN_EVASION_MATCH arms narrow the linked matching only', async () => {
|
||||
await account(7080, { signupIp: '203.0.113.80' })
|
||||
await link(7080, 0, 'steam-arms')
|
||||
await ban(7080)
|
||||
await account(7081, { signupIp: '203.0.113.80' }) // shares the IP only
|
||||
await account(7082)
|
||||
await link(7082, 0, 'steam-arms') // shares the identity only
|
||||
|
||||
const arms = (value: string | undefined) => ({ arms: banEvasionMatch(value) })
|
||||
// Default: both arms reach.
|
||||
expect(await resolveBan(env.DB, 7081, arms(undefined))).toMatchObject({ via: 'ip' })
|
||||
expect(await resolveBan(env.DB, 7082, arms(undefined))).toMatchObject({ via: 'platform' })
|
||||
// Platform only: the household bystander is let through, the evader isn't.
|
||||
expect(await resolveBan(env.DB, 7081, arms('platform'))).toBeNull()
|
||||
expect(await resolveBan(env.DB, 7082, arms('platform'))).toMatchObject({ via: 'platform' })
|
||||
// Off: neither linked arm reaches...
|
||||
expect(await resolveBan(env.DB, 7081, arms('off'))).toBeNull()
|
||||
expect(await resolveBan(env.DB, 7082, arms('off'))).toBeNull()
|
||||
// ...but the ban itself still applies to the account it was handed to.
|
||||
expect(await resolveBan(env.DB, 7080, arms('off'))).toMatchObject({ via: 'account' })
|
||||
})
|
||||
|
||||
test('banEvasionMatch reads the knob', () => {
|
||||
expect(banEvasionMatch(undefined)).toEqual({ ip: true, platform: true })
|
||||
expect(banEvasionMatch('ip,platform')).toEqual({ ip: true, platform: true })
|
||||
expect(banEvasionMatch(' PLATFORM ')).toEqual({ ip: false, platform: true })
|
||||
expect(banEvasionMatch('ip')).toEqual({ ip: true, platform: false })
|
||||
expect(banEvasionMatch('off')).toEqual({ ip: false, platform: false })
|
||||
expect(banEvasionMatch('none')).toEqual({ ip: false, platform: false })
|
||||
expect(banEvasionMatch('')).toEqual({ ip: false, platform: false })
|
||||
// `off` wins over anything else in the list, and a typo is ignored rather than
|
||||
// fatal — this is read on the matchmake path.
|
||||
expect(banEvasionMatch('off,ip')).toEqual({ ip: false, platform: false })
|
||||
expect(banEvasionMatch('ipv6')).toEqual({ ip: false, platform: false })
|
||||
expect(banEvasionMatch('ip,typo')).toEqual({ ip: true, platform: false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Moderator-issued player warnings on the shared `recflare` D1 database.
|
||||
*
|
||||
* The counterpart to the `report` table (see reports-db.ts): a report is what a
|
||||
* player submits, a warning is what a moderator hands down. Same shape of storage —
|
||||
* columnar rather than a JSON blob, append-only, nothing dedupes or acts on the
|
||||
* rows yet.
|
||||
*
|
||||
* The `api` worker owns this schema/migration (migrations/0005_warning.sql,
|
||||
* applied under its own `migrations_table` so it doesn't clash with the other
|
||||
* workers' migrations that share the database).
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0005_warning.sql, sans seed rows). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS warning (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
moderator_player_id INTEGER NOT NULL,
|
||||
warned_player_id INTEGER NOT NULL,
|
||||
report_category INTEGER NOT NULL DEFAULT 0,
|
||||
display_reason TEXT,
|
||||
moderator_note TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_warning_warned ON warning (warned_player_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_warning_moderator ON warning (moderator_player_id)`,
|
||||
]
|
||||
|
||||
/** A stored warning row (snake_case columns, one row per warning issued). */
|
||||
export interface WarningRow {
|
||||
id: number
|
||||
/** The moderator who issued it, from their bearer token. */
|
||||
moderator_player_id: number
|
||||
warned_player_id: number
|
||||
report_category: number
|
||||
/** What the warned player is shown, e.g. `Sexual gestures`. */
|
||||
display_reason: string | null
|
||||
/** Internal note — never surfaced to the warned player. */
|
||||
moderator_note: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A warning as issued — everything but the moderator (which comes from the bearer
|
||||
* token) and the timestamp. Only the warned player is required; the rest are
|
||||
* optional and stored as NULL when absent.
|
||||
*/
|
||||
export interface NewWarning {
|
||||
moderatorPlayerId: number
|
||||
warnedPlayerId: number
|
||||
reportCategory?: number
|
||||
displayReason?: string | null
|
||||
moderatorNote?: string | null
|
||||
}
|
||||
|
||||
/** Record an issued warning, returning the stored row (with its assigned id). */
|
||||
export async function createWarning(db: D1Database, input: NewWarning): Promise<WarningRow> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO warning (
|
||||
moderator_player_id, warned_player_id, report_category,
|
||||
display_reason, moderator_note, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(
|
||||
input.moderatorPlayerId,
|
||||
input.warnedPlayerId,
|
||||
input.reportCategory ?? 0,
|
||||
input.displayReason ?? null,
|
||||
input.moderatorNote ?? null,
|
||||
new Date().toISOString()
|
||||
)
|
||||
.first<WarningRow>()
|
||||
// RETURNING always yields the inserted row; the non-null assert keeps the caller
|
||||
// from having to handle an impossible null.
|
||||
return row!
|
||||
}
|
||||
|
||||
/** Every warning issued against a player, newest first. Backs a future moderation view. */
|
||||
export async function getWarningsAgainst(db: D1Database, playerId: number): Promise<WarningRow[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT * FROM warning WHERE warned_player_id = ?1 ORDER BY id DESC')
|
||||
.bind(playerId)
|
||||
.all<WarningRow>()
|
||||
return results
|
||||
}
|
||||
@@ -33,19 +33,19 @@
|
||||
"EndTime": null,
|
||||
"Key": "AntiHile.DC",
|
||||
"StartTime": null,
|
||||
"Value": "true"
|
||||
"Value": "false"
|
||||
},
|
||||
{
|
||||
"EndTime": null,
|
||||
"Key": "AntiHile.LPD",
|
||||
"StartTime": null,
|
||||
"Value": "true"
|
||||
"Value": "false"
|
||||
},
|
||||
{
|
||||
"EndTime": null,
|
||||
"Key": "AntiHile.QD",
|
||||
"StartTime": null,
|
||||
"Value": "true"
|
||||
"Value": "false"
|
||||
},
|
||||
{
|
||||
"EndTime": null,
|
||||
@@ -117,7 +117,7 @@
|
||||
"EndTime": null,
|
||||
"Key": "Backtrace.stopTimeUTC",
|
||||
"StartTime": null,
|
||||
"Value": "9999-09-28 23:55"
|
||||
"Value": "2026-06-01 00:00"
|
||||
},
|
||||
{
|
||||
"EndTime": null,
|
||||
|
||||
+208
-11
@@ -21,14 +21,18 @@ import {
|
||||
updateAccount,
|
||||
verifyPassword,
|
||||
} from '@repo/domain'
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { intVar, logger, withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
// The account-wide ban lives on a `report` row, whose table the api worker owns; its db
|
||||
// module is plain D1 queries with no runtime deps, so it imports cleanly here.
|
||||
import { banEvasionMatch, resolveBan } from '../../api/src/bans-db'
|
||||
import { verifyMetaNonce } from './meta-nonce'
|
||||
import {
|
||||
CachedLogin,
|
||||
ChangePasswordRequest,
|
||||
ChangePasswordResponse,
|
||||
FakeCachedLogin,
|
||||
form,
|
||||
json,
|
||||
OAuthError,
|
||||
@@ -57,6 +61,47 @@ import type { PlatformLink } from './platform-db'
|
||||
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.
|
||||
*/
|
||||
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
|
||||
|
||||
/**
|
||||
* The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded
|
||||
* build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded
|
||||
* headset reports this same value. Two things follow, and both are enforced below:
|
||||
* - it is never verifiable (`verifyPlatformProof` refuses it outright), and
|
||||
* - it is therefore never LINKED to an account. A link is a password-free way in, so
|
||||
* one link on a shared id would open that account to every sideloaded build.
|
||||
* It exists only to get such a client onto the username/password login screen.
|
||||
*/
|
||||
const SIDELOAD_PLATFORM_ID = '1'
|
||||
|
||||
/**
|
||||
* The canned entry served for the one Oculus cached-login lookup below — the sideloaded
|
||||
* APK's way onto the password login screen. Not backed by a link, an account or a
|
||||
* platform proof, hence `requirePassword: true`.
|
||||
*/
|
||||
const FAKE_OCULUS_CACHED_LOGIN = {
|
||||
platform: PlatformType.Oculus,
|
||||
platformId: SIDELOAD_PLATFORM_ID,
|
||||
accountId: 1,
|
||||
lastLoginTime: '2026-07-19T17:13:29.225Z',
|
||||
requirePassword: true,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Signup caps, enforced on create_account only (never on login — an existing account
|
||||
* always stays reachable, however many accounts its owner has since accumulated).
|
||||
@@ -150,18 +195,35 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
||||
}
|
||||
|
||||
/**
|
||||
* The elevated role names for an account's token `role` claim, derived from its
|
||||
* role flags. Base roles (gameClient) are added by generateToken — these are only
|
||||
* the operator-granted extras. Order is stable so tokens are deterministic.
|
||||
* The role names beyond `gameClient` for an account's token `role` claim. Base roles
|
||||
* (gameClient) are added by generateToken. `screenshare` rides on EVERY token — the
|
||||
* client gates the screen-share feature on it and nothing grants it per-account, so it
|
||||
* is unconditional (even with no account resolved). The rest are the operator-granted
|
||||
* extras, plus `junior` off the account's own `isJunior` flag. Order is stable so
|
||||
* tokens are deterministic.
|
||||
*/
|
||||
function accountRoles(account: Pick<Account, 'isDeveloper' | 'isModerator'> | null): string[] {
|
||||
if (!account) return []
|
||||
const roles: string[] = []
|
||||
function accountRoles(
|
||||
account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior'> | null
|
||||
): string[] {
|
||||
const roles = ['screenshare']
|
||||
if (!account) return roles
|
||||
if (account.isDeveloper) roles.push('developer')
|
||||
if (account.isModerator) roles.push('moderator')
|
||||
if (account.isJunior) roles.push('junior')
|
||||
return roles
|
||||
}
|
||||
|
||||
/**
|
||||
* The account's token `rn.privilege` claim. Despite the scope-shaped name it is a CLAIM,
|
||||
* read out of the same claims dictionary as `role` — it never belongs in `scope`. The
|
||||
* client knows exactly two values, both chat restrictions, and both ride on a junior
|
||||
* account: `BanVChat` (voice) and `BanRmChat` (room chat). Empty for everyone else, which
|
||||
* drops the claim rather than sending a blank one.
|
||||
*/
|
||||
function accountPrivileges(account: Pick<Account, 'isJunior'> | null): string[] {
|
||||
return account?.isJunior ? ['BanVChat', 'BanRmChat'] : []
|
||||
}
|
||||
|
||||
/**
|
||||
* The platform an account's `platformId` belongs to. Nothing defaults the `platform`
|
||||
* field (see defaultAccount), so an account can carry a platform identity with no
|
||||
@@ -292,6 +354,20 @@ async function verifyPlatformProof(
|
||||
platformAuth: string,
|
||||
postedPlatformId: string
|
||||
): Promise<PlatformProof> {
|
||||
// A sideloaded APK reports the placeholder id (see SIDELOAD_PLATFORM_ID) because it
|
||||
// has no Meta SDK behind it. Refuse it here, before anything is asked of Meta, so no
|
||||
// caller downstream can treat it as an identity — above all `linkLoginIdentity` on the
|
||||
// password grant, which is the path such a client actually takes. Linking it would
|
||||
// hand every sideloaded headset a password-free login into that account, since they
|
||||
// all report this same id.
|
||||
//
|
||||
// Refusing costs a sideloaded player nothing: their password login still succeeds (a
|
||||
// password grant carries its own credential and only *links* on a verified proof), it
|
||||
// just never gets a cached login, so they type their password each launch. That is
|
||||
// the intended shape of the sideload flow.
|
||||
if (platform === PlatformType.Oculus && postedPlatformId === SIDELOAD_PLATFORM_ID) {
|
||||
return { status: 'rejected', reason: 'sideload placeholder platform id is never an identity' }
|
||||
}
|
||||
if (platform === PlatformType.Steam) {
|
||||
const verified = platformAuth ? await verifySteamTicket(platformAuth) : null
|
||||
if (!verified) return { status: 'rejected', reason: 'invalid or missing Steam ticket' }
|
||||
@@ -325,6 +401,14 @@ const app = new Hono<App>()
|
||||
})(c, next)
|
||||
)
|
||||
|
||||
// The website (`www`) is a browser origin calling these endpoints directly, the way
|
||||
// rec.net's own site called the game's API — so the responses need CORS headers or
|
||||
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
|
||||
// endpoints authenticate with a bearer token in the `Authorization` header, never a
|
||||
// cookie: a hostile page can't read another origin's stored token, so there is no
|
||||
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
|
||||
.use('*', withDefaultCors())
|
||||
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
@@ -361,6 +445,10 @@ const app = new Hono<App>()
|
||||
'`cached_login` grant (both read the same table). An account linked to several',
|
||||
'platforms appears in each of their pickers. An unknown id yields `[]` (not a 404)',
|
||||
'and the client falls back to a fresh login or create_account.',
|
||||
'EXCEPT the exact identity `1/1` (Oculus, id `1`), which is stubbed for SIDELOADED',
|
||||
'APKs: with no Meta SDK they have no real identity to ask about and stall on an',
|
||||
'empty picker. It consults nothing and returns one canned, non-redeemable entry',
|
||||
'with `requirePassword: true`, sending the build to username/password login.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
@@ -379,13 +467,30 @@ const app = new Hono<App>()
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(CachedLogin.array(), 'Matching accounts; `[]` if none'),
|
||||
200: json(
|
||||
CachedLogin.or(FakeCachedLogin).array(),
|
||||
'Matching accounts; `[]` if none. The canned entry for `1/1`.'
|
||||
),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const { platform, id } = c.req.param()
|
||||
logger.info('cached login lookup', { platform, id })
|
||||
const platformInt = Number.parseInt(platform, 10)
|
||||
// SIDELOADED APKs ONLY. A sideloaded build has no Meta SDK behind it, so it can't
|
||||
// produce a real Meta identity or a nonce to prove one with — it asks about the
|
||||
// placeholder identity `1/1`, and an empty picker leaves it stuck on the platform
|
||||
// login screen with nothing to do. Hand back one canned entry to push it onto the
|
||||
// username/password login instead, which is the only flow such a build can finish.
|
||||
// `requirePassword` is true for exactly that reason: there's no platform proof here,
|
||||
// and the `cached_login` grant would (correctly) refuse this entry.
|
||||
//
|
||||
// Scoped to that ONE identity rather than to all of platform 1 — store builds do
|
||||
// real Meta logins, and shadowing the whole platform would hide genuine links from
|
||||
// their pickers.
|
||||
if (platformInt === PlatformType.Oculus && id === SIDELOAD_PLATFORM_ID) {
|
||||
return c.json([FAKE_OCULUS_CACHED_LOGIN])
|
||||
}
|
||||
// Listed straight from the link table, which is also what the `cached_login`
|
||||
// grant authorizes against — so the picker can't offer an account the grant
|
||||
// then refuses.
|
||||
@@ -471,8 +576,31 @@ const app = new Hono<App>()
|
||||
'when it is unset. The first identity linked also becomes the account’s primary',
|
||||
'(what the account DTO and a refreshed token report); later ones only link.',
|
||||
'',
|
||||
'The one platform id that is never verified and never linked is `1` on platform `1`',
|
||||
'— what a SIDELOADED Oculus APK reports, having no Meta SDK to ask. Every such',
|
||||
'build reports it, so it identifies nobody. A password login that carries it still',
|
||||
'succeeds; it simply links nothing, and the player types their password each launch.',
|
||||
'',
|
||||
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
|
||||
'powers refresh on every login and every refresh grant.',
|
||||
'powers refresh on every login and every refresh grant. `junior` rides along for an',
|
||||
'account flagged `isJunior`, and `screenshare` is on every token — it is a feature',
|
||||
'gate the client reads, not a privilege anyone is granted. A junior also carries',
|
||||
'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.',
|
||||
'',
|
||||
'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.',
|
||||
].join('\n'),
|
||||
requestBody: form(
|
||||
TokenRequest,
|
||||
@@ -484,7 +612,8 @@ const app = new Hono<App>()
|
||||
OAuthError,
|
||||
[
|
||||
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
|
||||
'invalid/expired refresh token, a missing account identifier, or a signup cap reached',
|
||||
'invalid/expired refresh token, a missing account identifier, a signup cap reached,',
|
||||
'or a banned account',
|
||||
].join(' ')
|
||||
),
|
||||
500: json(
|
||||
@@ -627,6 +756,35 @@ 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
|
||||
// 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.
|
||||
//
|
||||
// Nothing here can match the account arm (there is no account yet), so this is
|
||||
// purely the linked matching, and BAN_EVASION_MATCH=off leaves signup open —
|
||||
// which is the honest default position: a server that won't accept the IP arm's
|
||||
// false positives is choosing to let evaders re-register.
|
||||
const blocked = await resolveBan(c.env.DB, null, {
|
||||
identity: {
|
||||
ip: clientIp,
|
||||
platform: verifiedPlatform,
|
||||
platformId: verifiedPlatformId,
|
||||
},
|
||||
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
|
||||
})
|
||||
if (blocked) {
|
||||
logger.info('signup refused: player banned', {
|
||||
via: blocked.via,
|
||||
bannedAccountId: blocked.bannedAccountId,
|
||||
ip: clientIp,
|
||||
platformId: verifiedPlatformId,
|
||||
})
|
||||
return c.json({ error: 'invalid_grant', error_description: BLOCKED_DESCRIPTION }, 400)
|
||||
}
|
||||
|
||||
// Signup caps. Checked before minting anything, so a rejected signup leaves no
|
||||
// account behind. Each arm is skipped when it's disabled (var <= 0) or when its
|
||||
// identity is unknown (no verified platform id / no client IP) — an unattributable
|
||||
@@ -796,6 +954,44 @@ 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.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// The request's own IP and proven identity are passed alongside the account, so a
|
||||
// ban also reaches an old, clean account logged into from the banned player's
|
||||
// device or network — the stored ips alone would only catch that on the SECOND
|
||||
// login. create_account was already refused before it minted anything (above);
|
||||
// this still runs for it, so a signup that raced one is refused too.
|
||||
const ban = await resolveBan(c.env.DB, Number(accountId), {
|
||||
identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId },
|
||||
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
|
||||
})
|
||||
if (ban) {
|
||||
logger.info('token refused: player banned', {
|
||||
accountId,
|
||||
grantType,
|
||||
via: ban.via,
|
||||
bannedAccountId: ban.bannedAccountId,
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
|
||||
// binding) would still yield a well-formed token — but one signed with an empty
|
||||
// key, which every worker validates against, so anyone could forge it. Refuse to
|
||||
@@ -825,7 +1021,8 @@ const app = new Hono<App>()
|
||||
platformId,
|
||||
platform,
|
||||
jwtSecret,
|
||||
accountRoles(roleAccount)
|
||||
accountRoles(roleAccount),
|
||||
accountPrivileges(roleAccount)
|
||||
)
|
||||
// Issue a fresh, persisted refresh token (single-use; the client redeems it via
|
||||
// grant_type=refresh_token). A refresh grant thus rotates its token.
|
||||
|
||||
@@ -26,6 +26,18 @@ export type Env = SharedHonoEnv & {
|
||||
// read them through `intVar`, never as a bare number.
|
||||
MAX_ACCOUNTS_PER_PLATFORM_ID?: string | number
|
||||
MAX_ACCOUNTS_PER_IP?: string | number
|
||||
/**
|
||||
* Which linked arms a ban is enforced through, as a comma-separated list out of `ip`
|
||||
* and `platform` — or `off` for neither. Unset means BOTH: a ban reaches the accounts
|
||||
* that share a proven platform identity or an IP with the banned one, and refuses a
|
||||
* signup from either, which is what stops an evader simply making a new account.
|
||||
*
|
||||
* The `ip` arm is coarse (households, NAT, campus and carrier networks share one
|
||||
* address), so `platform` alone is the setting for a server whose players share
|
||||
* networks. Whatever this says, a ban always applies to the account it was handed to.
|
||||
* Read through `banEvasionMatch`; the `match` worker reads the same knob.
|
||||
*/
|
||||
BAN_EVASION_MATCH?: string
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -103,6 +103,15 @@ export const CachedLogin = z.object({
|
||||
.describe('Always false — platform ownership is the credential for a cached login'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The stubbed Oculus cached login served to sideloaded APKs. Same shape as `CachedLogin`,
|
||||
* but `requirePassword` is true — with no Meta SDK there is nothing to prove platform
|
||||
* ownership with, so the client falls through to username/password.
|
||||
*/
|
||||
export const FakeCachedLogin = CachedLogin.extend({
|
||||
requirePassword: z.literal(true).describe('Always true — the entry is not platform-backed'),
|
||||
})
|
||||
|
||||
/** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */
|
||||
export const OAuthError = z.object({
|
||||
error: z.enum(['invalid_grant', 'invalid_request', 'server_error']),
|
||||
|
||||
@@ -13,7 +13,13 @@ import {
|
||||
seedRoomWithSubRooms,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
import { TOKEN_TTL_SECONDS } from '@repo/jwt'
|
||||
|
||||
import {
|
||||
banFromReport,
|
||||
createReport,
|
||||
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
|
||||
} from '../../../../api/src/reports-db'
|
||||
import {
|
||||
getLinksForAccount,
|
||||
linkPlatformIdentity,
|
||||
@@ -80,8 +86,27 @@ 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.
|
||||
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
/**
|
||||
* Ban an account the way a moderator would: file a report against it and convert that
|
||||
* report into a ban. `banExpires` null is a permanent ban.
|
||||
*/
|
||||
async function banAccount(accountId: number, banExpires: string | null = null): Promise<void> {
|
||||
const row = await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: accountId })
|
||||
await banFromReport(env.DB, row.id, { banExpires })
|
||||
}
|
||||
|
||||
/** Seed an account with LOGIN_PASSWORD set, so it can be logged into. */
|
||||
async function seedAccount(accountId: number, username: string): Promise<void> {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId, username, passwordHash: await hashPassword(LOGIN_PASSWORD) }))
|
||||
.run()
|
||||
}
|
||||
|
||||
/** Decode a JWT payload (no verification) for asserting claims. */
|
||||
function decodePayload(token: string): Record<string, unknown> {
|
||||
const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||
@@ -198,6 +223,23 @@ describe('auth worker routes', () => {
|
||||
}
|
||||
)
|
||||
|
||||
// The one stubbed identity: `1/1` consults nothing and always answers the canned
|
||||
// entry, which is how a sideloaded APK (no Meta SDK, so no real identity) gets off
|
||||
// the platform login screen and onto username/password.
|
||||
test('GET /cachedlogin/forplatformid/1/1 returns the canned Oculus entry', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/cachedlogin/forplatformid/1/1`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([
|
||||
{
|
||||
platform: 1,
|
||||
platformId: '1',
|
||||
accountId: 1,
|
||||
lastLoginTime: '2026-07-19T17:13:29.225Z',
|
||||
requirePassword: true,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
// Only Steam (0) and Meta (1) can be verified — Steam by its signed platform_auth
|
||||
// ticket, Meta by validating its nonce with Meta. Every OTHER platform is rejected
|
||||
// on the platform-authenticated grants: we won't bind or authorize an identity we
|
||||
@@ -456,7 +498,7 @@ describe('auth worker routes', () => {
|
||||
expires_in: number
|
||||
}
|
||||
expect(json.token_type).toBe('Bearer')
|
||||
expect(json.expires_in).toBe(3600)
|
||||
expect(json.expires_in).toBe(TOKEN_TTL_SECONDS)
|
||||
// header.payload.signature
|
||||
const parts = json.access_token.split('.')
|
||||
expect(parts).toHaveLength(3)
|
||||
@@ -473,9 +515,14 @@ describe('auth worker routes', () => {
|
||||
expect(payload.iss).toBe('https://auth.recflare.net')
|
||||
expect(payload.aud).toBe('https://auth.recflare.net')
|
||||
expect(payload.role).toContain('gameClient')
|
||||
// A plain account carries only the base role — no elevated roles.
|
||||
// screenshare is a feature gate, not a grant — every token carries it.
|
||||
expect(payload.role).toContain('screenshare')
|
||||
// A plain adult account carries nothing beyond those — no elevated roles.
|
||||
expect(payload.role).not.toContain('developer')
|
||||
expect(payload.role).not.toContain('moderator')
|
||||
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()
|
||||
expect(payload.scope).toContain('rn.api')
|
||||
})
|
||||
|
||||
@@ -495,6 +542,25 @@ describe('auth worker routes', () => {
|
||||
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator']))
|
||||
})
|
||||
|
||||
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(
|
||||
JSON.stringify({
|
||||
accountId: 92,
|
||||
username: 'JuniorPlayer',
|
||||
passwordHash: await hashPassword(LOGIN_PASSWORD),
|
||||
isJunior: true,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
const payload = await tokenFor(`account_id=92&password=${LOGIN_PASSWORD}`)
|
||||
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'screenshare', 'junior']))
|
||||
expect(payload.role).not.toContain('developer')
|
||||
// `rn.privilege` is a claim, not a scope — it sits beside `role`, never in `scope`.
|
||||
expect(payload['rn.privilege']).toEqual(['BanVChat', 'BanRmChat'])
|
||||
expect(payload.scope).not.toContain('rn.privilege')
|
||||
})
|
||||
|
||||
test('POST /connect/token 400s when no account_id is posted (never defaults to 1)', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, { method: 'POST' })
|
||||
expect(res.status).toBe(400)
|
||||
@@ -876,6 +942,26 @@ describe('auth worker routes', () => {
|
||||
expect(await getLinksForAccount(env.DB, 7103)).toEqual([])
|
||||
})
|
||||
|
||||
test('a sideloaded APK (platform id 1) logs in but is never linked', async () => {
|
||||
// The sideload placeholder identifies nobody — every sideloaded headset reports
|
||||
// `1`, so a link on it would be a password-free way into this account from any of
|
||||
// them. The password login still stands; Meta is never even asked, since there is
|
||||
// nothing there to validate.
|
||||
await seedPasswordAccount(7105, 'sideloader')
|
||||
const login = await metaLogin(
|
||||
`grant_type=password&username=sideloader&password=${LOGIN_PASSWORD}` +
|
||||
`&platform=1&platform_id=1` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}`,
|
||||
true // even with Meta answering yes to everything
|
||||
)
|
||||
expect(login.status).toBe(200)
|
||||
expect(login.graphCalls).toHaveLength(0)
|
||||
expect(await getLinksForAccount(env.DB, 7105)).toEqual([])
|
||||
// And so the picker never offers this account off the placeholder — only the
|
||||
// canned stub entry is there.
|
||||
expect((await cachedLogins(1, '1')).map((a) => a.accountId)).toEqual([1])
|
||||
})
|
||||
|
||||
test('linking obeys the per-identity account cap, without failing the login', async () => {
|
||||
// Otherwise the signup cap would be trivially bypassable: create accounts with a
|
||||
// password, then link the capped identity into all of them.
|
||||
@@ -1047,3 +1133,272 @@ describe('auth worker routes', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// The website is a browser origin calling these endpoints directly — the same ones the
|
||||
// game calls — instead of proxying them through `www`. That only works if the responses
|
||||
// carry CORS headers: without them the browser discards a perfectly good token response
|
||||
// and sign-in fails with nothing in any server log to explain it.
|
||||
describe('CORS', () => {
|
||||
test('answers the preflight the browser sends before a token grant', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
new Request(`${ORIGIN}/connect/token`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
origin: 'https://www.example.com',
|
||||
'access-control-request-method': 'POST',
|
||||
'access-control-request-headers': 'content-type',
|
||||
},
|
||||
}),
|
||||
env
|
||||
)
|
||||
expect(res.status).toBe(204)
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe('*')
|
||||
expect(res.headers.get('access-control-allow-headers')?.toLowerCase()).toContain('content-type')
|
||||
})
|
||||
|
||||
// The header has to be on the REAL response too, not just the preflight — and on a
|
||||
// refusal as much as a success, or a rejected sign-in reaches the page as an opaque
|
||||
// network error rather than "that password is incorrect".
|
||||
test('allows the origin on the response itself, refusals included', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
new Request(`${ORIGIN}/connect/token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
origin: 'https://www.example.com',
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({ grant_type: 'password', username: 'nobody' }).toString(),
|
||||
}),
|
||||
env
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe('*')
|
||||
})
|
||||
|
||||
// The bearer header is what the SPA authenticates with, so it must be allowed by name
|
||||
// — a preflight that omits it makes every signed-in call fail.
|
||||
test('allows the Authorization header the SPA signs its calls with', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
new Request(`${ORIGIN}/account/me/changepassword`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
origin: 'https://www.example.com',
|
||||
'access-control-request-method': 'POST',
|
||||
'access-control-request-headers': 'authorization',
|
||||
},
|
||||
}),
|
||||
env
|
||||
)
|
||||
expect(res.status).toBe(204)
|
||||
expect(res.headers.get('access-control-allow-headers')?.toLowerCase()).toContain(
|
||||
'authorization'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// 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.
|
||||
describe('banned accounts', () => {
|
||||
test('POST /connect/token refuses a password grant from 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')
|
||||
})
|
||||
|
||||
test('POST /connect/token refuses a username login from a banned account', 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')
|
||||
})
|
||||
|
||||
// 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 () => {
|
||||
await seedAccount(6103, 'BannedLater')
|
||||
const login = await postToken(`account_id=6103&password=${LOGIN_PASSWORD}`)
|
||||
expect(login.status).toBe(200)
|
||||
const refreshToken = login.json.refresh_token as string
|
||||
|
||||
await banAccount(6103)
|
||||
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')
|
||||
})
|
||||
|
||||
// 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.
|
||||
test('a wrong password on a banned account is still a credential refusal', async () => {
|
||||
await seedAccount(6104, 'BannedWrongPw')
|
||||
await banAccount(6104)
|
||||
|
||||
const res = await postToken('account_id=6104&password=not-the-password')
|
||||
expect(res.status).toBe(400)
|
||||
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 () => {
|
||||
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')
|
||||
})
|
||||
|
||||
// A report is not a ban until a moderator converts it.
|
||||
test('an unbanned report does not refuse the login', async () => {
|
||||
await seedAccount(6107, 'MerelyReported')
|
||||
await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: 6107 })
|
||||
|
||||
const res = await postToken(`account_id=6107&password=${LOGIN_PASSWORD}`)
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
// The ban is the ACCOUNT's: nothing here stops the player signing up again, which is
|
||||
// the signup caps' job, not this check's.
|
||||
test('a banned player can still create a new account', async () => {
|
||||
await seedAccount(6108, 'BannedButNew')
|
||||
await banAccount(6108)
|
||||
|
||||
const created = await postToken('grant_type=create_account&platform_id=steam-after-ban')
|
||||
expect(created.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
// 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.
|
||||
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> = {}) => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: id,
|
||||
username: name,
|
||||
passwordHash: await hashPassword(LOGIN_PASSWORD),
|
||||
...ips,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
}
|
||||
|
||||
const login = (id: number, ip?: string) =>
|
||||
postToken(`account_id=${id}&password=${LOGIN_PASSWORD}`, ip)
|
||||
|
||||
test('an account sharing a banned account’s platform identity cannot log in', async () => {
|
||||
await account(6301, 'EvaderOne')
|
||||
await linkPlatformIdentity(env.DB, 6301, 0, 'steam-tokenevader')
|
||||
await banAccount(6301)
|
||||
await account(6302, 'EvaderTwo')
|
||||
await linkPlatformIdentity(env.DB, 6302, 0, 'steam-tokenevader')
|
||||
|
||||
const res = await login(6302)
|
||||
expect(res.status).toBe(400)
|
||||
// A vaguer sentence than a direct ban: this account may belong to somebody else.
|
||||
expect(res.json.error_description).toBe('this device or network is blocked')
|
||||
})
|
||||
|
||||
test('an account sharing a banned account’s IP cannot log in', async () => {
|
||||
await account(6303, 'SameHouseBanned', { signupIp: '203.0.113.30' })
|
||||
await banAccount(6303)
|
||||
await account(6304, 'SameHouseClean', { signupIp: '203.0.113.30' })
|
||||
|
||||
const res = await login(6304)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error_description).toBe('this device or network is blocked')
|
||||
})
|
||||
|
||||
// The address the request arrives from counts, so an account that never logged in
|
||||
// from the banned network before is caught on the first attempt rather than the second.
|
||||
test('the request’s own IP is matched even when the account has none stored', async () => {
|
||||
await account(6305, 'BannedAtHome', { signupIp: '203.0.113.31' })
|
||||
await banAccount(6305)
|
||||
await account(6306, 'CleanElsewhere')
|
||||
|
||||
expect((await login(6306, '203.0.113.31')).status).toBe(400)
|
||||
// The same account from any other network signs in normally.
|
||||
expect((await login(6306, '198.51.100.31')).status).toBe(200)
|
||||
})
|
||||
|
||||
test('an unrelated account signs in normally', async () => {
|
||||
await account(6307, 'Unrelated', { signupIp: '198.51.100.7' })
|
||||
await banAccount(6307 + 1000) // a ban on somebody else entirely
|
||||
expect((await login(6307)).status).toBe(200)
|
||||
})
|
||||
|
||||
// The point of checking before minting: a refused signup must leave nothing behind,
|
||||
// or the evader keeps the account (and burns a slot off the signup caps) anyway.
|
||||
test('create_account from a banned IP is refused and creates no account', async () => {
|
||||
await account(6310, 'BannedSignupSource', { signupIp: '203.0.113.40' })
|
||||
await banAccount(6310)
|
||||
|
||||
const before = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{ n: number }>()
|
||||
const res = await postToken('grant_type=create_account', '203.0.113.40')
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error_description).toBe('this device or network is blocked')
|
||||
const after = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{ n: number }>()
|
||||
expect(after?.n).toBe(before?.n)
|
||||
})
|
||||
|
||||
test('create_account from an unrelated IP still works', async () => {
|
||||
const res = await postToken('grant_type=create_account', '198.51.100.99')
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
// 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 () => {
|
||||
const original = env.BAN_EVASION_MATCH
|
||||
await account(6320, 'KnobBanned', { signupIp: '203.0.113.50' })
|
||||
await linkPlatformIdentity(env.DB, 6320, 0, 'steam-knobevader')
|
||||
await banAccount(6320)
|
||||
await account(6321, 'KnobHousemate', { signupIp: '203.0.113.50' })
|
||||
await account(6322, 'KnobEvader')
|
||||
await linkPlatformIdentity(env.DB, 6322, 0, 'steam-knobevader')
|
||||
|
||||
try {
|
||||
env.BAN_EVASION_MATCH = 'platform'
|
||||
expect((await login(6321)).status).toBe(200)
|
||||
expect((await login(6322)).status).toBe(400)
|
||||
// And signup from that network is open again.
|
||||
expect((await postToken('grant_type=create_account', '203.0.113.50')).status).toBe(200)
|
||||
|
||||
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')
|
||||
} finally {
|
||||
env.BAN_EVASION_MATCH = original
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+73
-38
@@ -2,7 +2,13 @@ import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import {
|
||||
withCleanSpec,
|
||||
withDefaultCors,
|
||||
withNotFound,
|
||||
withOnError,
|
||||
writeContentRange,
|
||||
} from '@repo/hono-helpers'
|
||||
|
||||
import loadingScreenTipData from '../static/loading-screen-tip-data.json'
|
||||
import {
|
||||
@@ -23,38 +29,41 @@ import type { App, Env } from './context'
|
||||
* streamed out of the shared `recflare-cdn` R2 bucket, keyed by prefix.
|
||||
*/
|
||||
|
||||
/** Parse a single-range `Range: bytes=start-end` header into an R2 range. */
|
||||
function parseRange(header: string | undefined): R2Range | undefined {
|
||||
if (!header) return undefined
|
||||
const m = /^bytes=(\d*)-(\d*)$/.exec(header.trim())
|
||||
if (!m) return undefined
|
||||
const start = m[1]
|
||||
const end = m[2]
|
||||
if (start === '' && end !== '') return { suffix: Number(end) } // last N bytes
|
||||
if (start !== '') {
|
||||
return end !== ''
|
||||
? { offset: Number(start), length: Number(end) - Number(start) + 1 }
|
||||
: { offset: Number(start) }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a binary asset from the CDN R2 bucket as application/octet-stream,
|
||||
* honoring Range requests. 404s when the file is missing.
|
||||
* Supports conditional GET and byte-range requests (206) — large-file
|
||||
* downloaders fetch in ranges, and a 200 where a 206 is expected corrupts the
|
||||
* reassembled file (e.g. EAC "Signatures don't match").
|
||||
*
|
||||
* This is why `cache.enabled` is false in wrangler.jsonc: Workers Caching strips `Range`
|
||||
* before the worker is invoked and slices the 206 out of its own cache, which silently
|
||||
* degrades to a whole-object 200 whenever the response is not cacheable. The range
|
||||
* answer has to be ours to guarantee.
|
||||
*/
|
||||
async function serveAsset(c: Context<App>, key: string) {
|
||||
if (key.includes('..')) return c.body(null, 400)
|
||||
|
||||
const ifNoneMatch = c.req.header('if-none-match')?.replace(/"/g, '')
|
||||
const range = parseRange(c.req.header('range'))
|
||||
const object = await (c.env as Env).CDN_ASSETS.get(key, {
|
||||
// R2 parses the `Range` header itself when handed the request headers, so there is no
|
||||
// grammar to reimplement here. It resolves every form (`bytes=a-b`, `bytes=a-`,
|
||||
// `bytes=-n`) to a concrete offset/length, and anything it cannot parse or satisfy to
|
||||
// the whole object — see the 206 branch, which is what turns that back into a 200.
|
||||
// With no `Range` header present this is an ordinary whole-object read.
|
||||
let object
|
||||
try {
|
||||
object = await (c.env as Env).CDN_ASSETS.get(key, {
|
||||
...(ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : {}),
|
||||
...(range ? { range } : {}),
|
||||
range: c.req.raw.headers,
|
||||
})
|
||||
} catch (e) {
|
||||
// Defensive: R2 documents InvalidRange (10039) for a range it can't satisfy, which
|
||||
// is a 416 rather than the 500 the error handler would otherwise turn it into.
|
||||
// Locally it never fires — workerd resolves an unsatisfiable range to the whole
|
||||
// object instead of throwing — so this covers the service behaving as documented.
|
||||
if (e instanceof Error && e.message.includes('(10039)')) return c.body(null, 416)
|
||||
throw e
|
||||
}
|
||||
if (!object) return c.notFound()
|
||||
|
||||
const headers = new Headers()
|
||||
@@ -67,20 +76,12 @@ async function serveAsset(c: Context<App>, key: string) {
|
||||
// Precondition matched (If-None-Match) → R2 returns no body.
|
||||
if (!('body' in object)) return new Response(null, { status: 304, headers })
|
||||
|
||||
// Range honored → 206 Partial Content with Content-Range.
|
||||
if (object.range && c.req.header('range')) {
|
||||
// R2 hands back the RESOLVED range, and the object it returns carries all three
|
||||
// keys with the inapplicable ones set to undefined — so `'suffix' in r` is true
|
||||
// even for an offset/length range and cannot discriminate between the two forms.
|
||||
// (It read as a suffix range every time, making offset/length NaN and the
|
||||
// Content-Range header garbage.) Read the values, not the keys. A `bytes=-N`
|
||||
// request already comes back resolved to a concrete offset/length; the suffix
|
||||
// fallback below is only there in case that ever stops being true.
|
||||
const r = object.range as { offset?: number; length?: number; suffix?: number }
|
||||
const length = r.length ?? r.suffix ?? object.size - (r.offset ?? 0)
|
||||
const offset = r.offset ?? object.size - length
|
||||
headers.set('content-length', String(length))
|
||||
headers.set('content-range', `bytes ${offset}-${offset + length - 1}/${object.size}`)
|
||||
// A `bytes=` request is ALWAYS answered 206 with a Content-Range naming the bytes
|
||||
// actually enclosed — never a bare 200 carrying the whole object. That is the one
|
||||
// answer a chunked downloader cannot survive: it asked for a slice, so it writes
|
||||
// whatever comes back at that offset, and a whole-object body silently corrupts the
|
||||
// reassembled file (EAC "Signatures don't match"). See writeContentRange().
|
||||
if (writeContentRange(headers, c.req.raw.headers, object)) {
|
||||
return new Response(object.body, { status: 206, headers })
|
||||
}
|
||||
|
||||
@@ -98,6 +99,15 @@ const app = new Hono<App>()
|
||||
})(c, next)
|
||||
)
|
||||
|
||||
// The website lets a room's owner download their own scene blobs (see the room page
|
||||
// in `www`), which means a browser reading these bytes from another origin — without
|
||||
// these headers it can fetch them but not touch the result. `origin: '*'` gives away
|
||||
// nothing: every route here is already unauthenticated and public to anyone holding
|
||||
// the key, and nothing on this worker reads a cookie or a token, so there is no
|
||||
// ambient credential for `*` to expose. The keys are unguessable UUIDs, and that is
|
||||
// unchanged by who may read a response they already had to name exactly.
|
||||
.use('*', withDefaultCors())
|
||||
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
@@ -195,6 +205,28 @@ const app = new Hono<App>()
|
||||
(c) => serveAsset(c, `invention/${c.req.param('dataBlob')}`)
|
||||
)
|
||||
|
||||
// Generic client data by name. Anything the client uploads as FileType 2 lands
|
||||
// under `data/` (a Holotar recording is the one seen in the wild) and the client
|
||||
// fetches it back from this prefix. Date-foldered like the room and invention
|
||||
// blobs, so the rest of the path is matched as-is.
|
||||
.get(
|
||||
'/data/:id{.+}',
|
||||
describeRoute({
|
||||
tags: ['Assets'],
|
||||
summary: 'Serve a client data blob',
|
||||
description: [
|
||||
'Streams the object stored under `data/<id>` — whatever the client uploaded as',
|
||||
'`UploadFileType` 2 (see the `storage` worker), a Holotar recording being the case',
|
||||
'observed. Like room and invention blobs the name is date-foldered by the upload,',
|
||||
'e.g. `2026-02-03/<uuid>`, so it contains slashes. The worker does not interpret the',
|
||||
'bytes — the prefix exists because the client expects to read these back from `/data/`.',
|
||||
].join(' '),
|
||||
parameters: [keyParam('id', 'The blob name.', true), ...CONDITIONAL_HEADERS],
|
||||
responses: assetResponses('The data blob'),
|
||||
}),
|
||||
(c) => serveAsset(c, `data/${c.req.param('id')}`)
|
||||
)
|
||||
|
||||
// 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(
|
||||
@@ -209,10 +241,11 @@ app.get(
|
||||
description: [
|
||||
'Binary asset delivery for recflare, a private-server reimplementation of the Rec',
|
||||
'Room backend. Streams the blobs the client downloads while playing — anti-cheat',
|
||||
'signatures, saved room scenes and invention data — out of the shared `recflare-cdn`',
|
||||
'R2 bucket, plus the one bundled config file the loading screen reads.',
|
||||
'signatures, saved room scenes, invention data and generic client uploads — out of',
|
||||
'the shared `recflare-cdn` R2 bucket, plus the one bundled config file the loading',
|
||||
'screen reads.',
|
||||
'',
|
||||
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`) and served as',
|
||||
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`) and served as',
|
||||
'`application/octet-stream`; the worker never interprets what it hands back. Reads',
|
||||
'are unauthenticated — a caller needs the exact key, which only comes from an',
|
||||
'authenticated call to another worker.',
|
||||
@@ -224,7 +257,9 @@ app.get(
|
||||
'byte ranges (`Range` → 206). The ranges matter: large-file downloaders fetch in',
|
||||
'chunks, and answering 200 where a 206 is expected corrupts the reassembled file —',
|
||||
'which surfaces as an anti-cheat “Signatures don’t match” failure, not a download',
|
||||
'error.',
|
||||
'error. So a `bytes=` request is never answered with a whole-object 200: the 206',
|
||||
'always carries a `Content-Range` stating which bytes the body holds, even where',
|
||||
'that turns out to be all of them.',
|
||||
].join('\n'),
|
||||
},
|
||||
servers: [{ url: 'https://cdn.recflare.net', description: 'Production' }],
|
||||
|
||||
@@ -46,6 +46,7 @@ export function assetResponses(description: string): OpenAPIV3_1.ResponsesObject
|
||||
304: { description: '`If-None-Match` matched the stored etag (no body)' },
|
||||
400: { description: 'The key contains `..` (no body)' },
|
||||
404: { description: 'No such object in the bucket' },
|
||||
416: { description: 'The `Range` header could not be satisfied (no body)' },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +57,7 @@ export const CONDITIONAL_HEADERS: OpenAPIV3_1.ParameterObject[] = [
|
||||
in: 'header',
|
||||
required: false,
|
||||
description:
|
||||
'A single byte range (`bytes=start-end`, `bytes=start-`, `bytes=-suffix`). Honoured with a 206; a malformed or multi-range value is ignored and the whole object served.',
|
||||
'A single byte range (`bytes=start-end`, `bytes=start-`, `bytes=-suffix`), parsed by R2 itself. Any `bytes=` value is answered 206 with a `Content-Range` naming the bytes enclosed — never a bare 200 carrying the whole object, which a chunked downloader would write at the offset it asked for. A multi-range or unsatisfiable value yields the whole object, but says so in the `Content-Range`. A unit other than `bytes` is ignored (200).',
|
||||
schema: { type: 'string', example: 'bytes=0-1023' },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -73,6 +73,44 @@ describe('cdn endpoints', () => {
|
||||
expect(new Uint8Array(await suffix.arrayBuffer())).toEqual(new Uint8Array([14, 15]))
|
||||
})
|
||||
|
||||
// The corrupting answer to a byte-range request is a bare 200 carrying the whole
|
||||
// object: the downloader asked for a slice, so it writes the body at that offset and
|
||||
// the reassembled file is wrong (EAC "Signatures don't match"). R2 resolves a value
|
||||
// it cannot parse or satisfy to the WHOLE object rather than failing, so these are
|
||||
// exactly the inputs that used to fall through to a 200 — every one of them must
|
||||
// still come back 206 with a Content-Range stating what the body actually holds.
|
||||
test('GET /sigs/:sigName never answers a bytes range with a whole-object 200', async () => {
|
||||
await env.CDN_ASSETS.put('sigs/ranged3', new Uint8Array([10, 11, 12, 13, 14, 15]))
|
||||
const fetchRange = (range: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/sigs/ranged3`, { headers: { Range: range } })
|
||||
|
||||
for (const range of [
|
||||
'bytes=100-200', // wholly past the end of a 6-byte object
|
||||
'bytes=abc', // not the byte-range grammar
|
||||
'bytes=0-1,3-4', // multi-range, which R2 does not serve
|
||||
'bytes=0-5', // satisfiable, and covers everything
|
||||
]) {
|
||||
const res = await fetchRange(range)
|
||||
expect(res.status, range).toBe(206)
|
||||
expect(res.headers.get('content-range'), range).toBe('bytes 0-5/6')
|
||||
}
|
||||
|
||||
// A range that runs off the end but starts inside is a real partial read.
|
||||
const partial = await fetchRange('bytes=4-99')
|
||||
expect(partial.status).toBe(206)
|
||||
expect(partial.headers.get('content-range')).toBe('bytes 4-5/6')
|
||||
expect(new Uint8Array(await partial.arrayBuffer())).toEqual(new Uint8Array([14, 15]))
|
||||
|
||||
// A unit other than bytes must be ignored outright — RFC 9110 — not answered
|
||||
// with a byte-denominated Content-Range.
|
||||
const other = await fetchRange('items=0-1')
|
||||
expect(other.status).toBe(200)
|
||||
expect(other.headers.get('content-range')).toBeNull()
|
||||
expect(new Uint8Array(await other.arrayBuffer())).toEqual(
|
||||
new Uint8Array([10, 11, 12, 13, 14, 15])
|
||||
)
|
||||
})
|
||||
|
||||
test('GET /room/:dataBlob streams the room blob from R2', async () => {
|
||||
await env.CDN_ASSETS.put('room/94tp5zjtwz0gppp8xlv1j9l5b.room', new Uint8Array([9, 8, 7]))
|
||||
const res = await exports.default.fetch(`${ORIGIN}/room/94tp5zjtwz0gppp8xlv1j9l5b.room`)
|
||||
@@ -86,6 +124,20 @@ describe('cdn endpoints', () => {
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
// The website lets a room's owner download their own scene data (the room page in
|
||||
// `www`), which is a browser reading these bytes from another origin. Without the
|
||||
// header it can fetch them but not read the result — and the page can't tell that
|
||||
// apart from the blob being gone.
|
||||
test('answers CORS so a browser on another origin can read a blob', async () => {
|
||||
await env.CDN_ASSETS.put('room/2026-08-01/cors-check', new Uint8Array([4, 2]))
|
||||
const res = await exports.default.fetch(`${ORIGIN}/room/2026-08-01/cors-check`, {
|
||||
headers: { origin: 'https://www.example.net' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe('*')
|
||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([4, 2]))
|
||||
})
|
||||
|
||||
test('GET /invention/:dataBlob streams the invention blob from R2', async () => {
|
||||
// Date-foldered, `.inv`-suffixed — the name the storage worker generates and the
|
||||
// api worker hands back as the invention's BlobName.
|
||||
@@ -101,6 +153,21 @@ describe('cdn endpoints', () => {
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /data/:id streams the data blob from R2', async () => {
|
||||
// Date-foldered — the name the storage worker generates for a FileType 2 upload.
|
||||
const name = '2026-08-05/3b9c1f0a-5d2e-4c1b-9a77-2e6f0b4d8c31'
|
||||
await env.CDN_ASSETS.put(`data/${name}`, new Uint8Array([4, 5, 6]))
|
||||
const res = await exports.default.fetch(`${ORIGIN}/data/${name}`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toBe('application/octet-stream')
|
||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([4, 5, 6]))
|
||||
})
|
||||
|
||||
test('GET /data/:id 404s when the blob is absent', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/data/missing`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /openapi.json documents every route', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -124,6 +191,7 @@ describe('cdn endpoints', () => {
|
||||
expect([...documented].sort()).toEqual([
|
||||
'GET /',
|
||||
'GET /config/LoadingScreenTipData',
|
||||
'GET /data/{id}',
|
||||
'GET /invention/{dataBlob}',
|
||||
'GET /room/{dataBlob}',
|
||||
'GET /sigs/{sigName}',
|
||||
|
||||
+10
-1
@@ -4,8 +4,17 @@
|
||||
"main": "src/cdn.app.ts",
|
||||
"compatibility_date": "2026-06-16",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
// Workers Caching is OFF here, and must stay off: it STRIPS the `Range` header before
|
||||
// invoking the worker, asks for the whole body, and slices the 206 out of its own
|
||||
// cache. That works only while the response is actually cacheable — on any bypass
|
||||
// (see the automatic bypass rules) nothing slices, and the client that asked for a
|
||||
// byte range receives the whole object with a 200. A chunked downloader writes that
|
||||
// at the offset it asked for and the reassembled file is corrupt (EAC "Signatures
|
||||
// don't match"). With caching off the `Range` header reaches serveAsset, which
|
||||
// always answers a `bytes=` request with a 206 and a truthful Content-Range.
|
||||
// The cost is that every asset read hits R2; correctness on these blobs is worth it.
|
||||
"cache": {
|
||||
"enabled": true
|
||||
"enabled": false
|
||||
},
|
||||
// CDN binaries (signature blobs + room build data) are stored as R2 objects
|
||||
// and streamed back by key. Keys are prefixed `sigs/` and `room/`.
|
||||
|
||||
@@ -1,965 +0,0 @@
|
||||
/**
|
||||
* Club storage on the shared `recflare` D1 database. A club is a single JSON blob
|
||||
* in the `data` column (the client-facing Club DTO); queryable fields (ClubId,
|
||||
* Name, Category, Visibility, State, CreatorAccountId) are SQLite generated
|
||||
* (virtual) columns extracted from that JSON and indexed — the same JSON-blob
|
||||
* pattern the rooms/accounts tables use. Mirrors the Go/GORM `Club` model.
|
||||
*
|
||||
* Membership lives in a separate `club_member` table (one row per club/account);
|
||||
* the club's `MemberCount` is a denormalized field kept in sync from those rows.
|
||||
*
|
||||
* The `clubs` worker owns this schema/migration (migrations/0001_club.sql, applied
|
||||
* under its own `migrations_table` so it doesn't clash with the other workers'
|
||||
* migrations that share the database). `SCHEMA_DDL` mirrors that migration so tests
|
||||
* can build the tables directly.
|
||||
*/
|
||||
|
||||
import { getSavedImagesByNames, placeholderSavedImage } from '@repo/domain'
|
||||
|
||||
import type { SavedImage } from '@repo/domain'
|
||||
|
||||
/** Schema DDL (mirror of migrations/0001_club.sql, sans seed rows). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS club (
|
||||
data TEXT NOT NULL,
|
||||
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ClubId')) VIRTUAL,
|
||||
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
|
||||
category TEXT GENERATED ALWAYS AS (json_extract(data, '$.Category')) VIRTUAL,
|
||||
visibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Visibility')) VIRTUAL,
|
||||
state INTEGER GENERATED ALWAYS AS (json_extract(data, '$.State')) VIRTUAL,
|
||||
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_club_club_id ON club (club_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_club_name_lower ON club (name_lower)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_club_category ON club (category)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_club_creator ON club (creator_account_id)`,
|
||||
// Club membership — one row per (club, account); `membership_type` (see
|
||||
// ClubMembershipType) encodes bans, pending requests/invites, and roles in a
|
||||
// single field. Surrogate PK mirrors the Go model; the UNIQUE (club_id,
|
||||
// account_id) index enforces one membership per pair (and backs the upsert). The
|
||||
// club's MemberCount is kept in sync from the rows that count as real members.
|
||||
`CREATE TABLE IF NOT EXISTS club_member (
|
||||
club_member_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
club_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
membership_type INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_club_member_pair ON club_member (club_id, account_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_club_member_account ON club_member (account_id)`,
|
||||
// Club announcements — the club's noticeboard, newest first. Columns rather than a
|
||||
// JSON blob (mirroring the Go model), since nothing here is client-shaped beyond
|
||||
// the fields themselves.
|
||||
`CREATE TABLE IF NOT EXISTS club_announcement (
|
||||
announcement_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
club_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
image_name TEXT NOT NULL DEFAULT '',
|
||||
meta TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_club_announcement_club ON club_announcement (club_id)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* A player's membership state in a club (mirror of the Go `ClubMembershipType`).
|
||||
* The single field spans bans, the pending request/invite states, and the member
|
||||
* role tiers; `Member` (10) is the threshold at/above which someone is an actual
|
||||
* member (below it is pending/none/banned).
|
||||
*/
|
||||
export enum ClubMembershipType {
|
||||
Banned = -1,
|
||||
None = 0,
|
||||
PendingRequested = 1,
|
||||
PendingInvited = 2,
|
||||
PendingDenied = 3,
|
||||
Member = 10,
|
||||
Moderator = 20,
|
||||
Coowner = 30,
|
||||
Creator = 100,
|
||||
}
|
||||
|
||||
/** A club's visibility (mirror of the Go `ClubVisibility`). */
|
||||
export enum ClubVisibility {
|
||||
Private = 0,
|
||||
Public = 1,
|
||||
}
|
||||
|
||||
/** How a player may join a club (mirror of the Go `ClubJoinability`). */
|
||||
export enum ClubJoinability {
|
||||
Open = 0,
|
||||
InviteOnly = 1,
|
||||
AskToJoin = 2,
|
||||
}
|
||||
|
||||
/** Membership types at/above which a row counts as an actual member (not pending/banned). */
|
||||
const MEMBER_THRESHOLD = ClubMembershipType.Member
|
||||
|
||||
/**
|
||||
* Client-facing club shape (PascalCase, mirror of the Go `Club` JSON tags). The
|
||||
* Go model's `CreatedAt` is `json:"-"` — stored but never serialized — so it lives
|
||||
* in the blob (see StoredClub) but is dropped from this DTO.
|
||||
*/
|
||||
export interface Club {
|
||||
ClubId: number
|
||||
Name: string
|
||||
Description: string
|
||||
Category: string
|
||||
Visibility: number
|
||||
Joinability: number
|
||||
AllowJuniors: boolean
|
||||
MainImageName: string
|
||||
ClubType: number
|
||||
ClubhouseRoomId: number | null
|
||||
CreatorAccountId: number
|
||||
IsRRO: boolean
|
||||
MinLevel: number
|
||||
State: number
|
||||
MemberCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored club — the DTO plus fields the client never sees on the Club object
|
||||
* itself: `CreatedAt` (`json:"-"` in Go) and the club's custom tags, which the Go
|
||||
* server keeps in a `club_custom_tags` table but which we keep on the blob, since
|
||||
* they're only ever read and written with the club.
|
||||
*/
|
||||
interface StoredClub extends Club {
|
||||
CreatedAt: string
|
||||
CustomTags?: string[]
|
||||
/**
|
||||
* The club's gallery image names, in order (the client PUTs to
|
||||
* `/additionalimage/{index}`). Packed, never sparse: removing one shifts the rest
|
||||
* up, so the list is always the images the club actually has.
|
||||
*/
|
||||
AdditionalImages?: string[]
|
||||
}
|
||||
|
||||
/** How many gallery images a club has room for (slots 0..2). */
|
||||
export const MAX_ADDITIONAL_IMAGES = 3
|
||||
|
||||
interface ClubRow {
|
||||
data: string
|
||||
}
|
||||
|
||||
/** Project a stored club to the client DTO (drops the non-serialized CreatedAt). */
|
||||
function toDto(s: StoredClub): Club {
|
||||
return {
|
||||
ClubId: s.ClubId,
|
||||
Name: s.Name,
|
||||
Description: s.Description,
|
||||
Category: s.Category,
|
||||
Visibility: s.Visibility,
|
||||
Joinability: s.Joinability,
|
||||
AllowJuniors: s.AllowJuniors,
|
||||
MainImageName: s.MainImageName,
|
||||
ClubType: s.ClubType,
|
||||
ClubhouseRoomId: s.ClubhouseRoomId,
|
||||
CreatorAccountId: s.CreatorAccountId,
|
||||
IsRRO: s.IsRRO,
|
||||
MinLevel: s.MinLevel,
|
||||
State: s.State,
|
||||
MemberCount: s.MemberCount,
|
||||
}
|
||||
}
|
||||
|
||||
const parseOne = (row: ClubRow | null): Club | null =>
|
||||
row ? toDto(JSON.parse(row.data) as StoredClub) : null
|
||||
const parseAll = (rows: ClubRow[]): Club[] =>
|
||||
rows.map((r) => toDto(JSON.parse(r.data) as StoredClub))
|
||||
|
||||
/**
|
||||
* Recompute a club's `MemberCount` from the `club_member` rows and write it back
|
||||
* into the blob (the generated column follows). Returns the fresh count. Keeping
|
||||
* the count derived avoids drift from concurrent joins/leaves.
|
||||
*/
|
||||
async function syncMemberCount(db: D1Database, clubId: number): Promise<number> {
|
||||
const row = await db
|
||||
.prepare('SELECT COUNT(*) AS n FROM club_member WHERE club_id = ?1 AND membership_type >= ?2')
|
||||
.bind(clubId, MEMBER_THRESHOLD)
|
||||
.first<{ n: number }>()
|
||||
const count = row?.n ?? 0
|
||||
await db
|
||||
// CAST to INTEGER: D1 binds a JS number as a SQLite REAL, which json_set would write
|
||||
// into the blob as `"MemberCount":3.0` — and this blob is served to the client.
|
||||
.prepare(
|
||||
"UPDATE club SET data = json_set(data, '$.MemberCount', CAST(?2 AS INTEGER)) WHERE club_id = ?1"
|
||||
)
|
||||
.bind(clubId, count)
|
||||
.run()
|
||||
return count
|
||||
}
|
||||
|
||||
/** Read a player's membership type in a club (None when there's no row). */
|
||||
export async function getMembership(
|
||||
db: D1Database,
|
||||
clubId: number,
|
||||
accountId: number
|
||||
): Promise<ClubMembershipType> {
|
||||
const row = await db
|
||||
.prepare('SELECT membership_type AS t FROM club_member WHERE club_id = ?1 AND account_id = ?2')
|
||||
.bind(clubId, accountId)
|
||||
.first<{ t: number }>()
|
||||
return (row?.t ?? ClubMembershipType.None) as ClubMembershipType
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a player's membership type for a club (one row per pair). `created_at` is
|
||||
* stamped on first insert and preserved on later type changes.
|
||||
*/
|
||||
async function setMembership(
|
||||
db: D1Database,
|
||||
clubId: number,
|
||||
accountId: number,
|
||||
type: ClubMembershipType
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO club_member (club_id, account_id, membership_type, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(club_id, account_id) DO UPDATE SET membership_type = ?3`
|
||||
)
|
||||
.bind(clubId, accountId, type, new Date().toISOString())
|
||||
.run()
|
||||
}
|
||||
|
||||
/** Fields a caller may supply when creating a club; everything else takes the Go defaults. */
|
||||
export interface NewClub {
|
||||
name: string
|
||||
description?: string
|
||||
category?: string
|
||||
visibility?: number
|
||||
joinability?: number
|
||||
allowJuniors?: boolean
|
||||
mainImageName?: string
|
||||
clubType?: number
|
||||
clubhouseRoomId?: number | null
|
||||
isRRO?: boolean
|
||||
minLevel?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a club owned by `creatorAccountId`. The id is the next free integer (the
|
||||
* Go model uses `autoIncrement:false`, i.e. an app-assigned id). Unset fields fall
|
||||
* back to the Go model's column defaults. The creator is added as the club's first
|
||||
* member (Owner), so the returned club has MemberCount 1.
|
||||
*/
|
||||
export async function createClub(
|
||||
db: D1Database,
|
||||
creatorAccountId: number,
|
||||
input: NewClub
|
||||
): Promise<Club> {
|
||||
const idRow = await db
|
||||
.prepare('SELECT COALESCE(MAX(club_id), 0) + 1 AS next FROM club')
|
||||
.first<{ next: number }>()
|
||||
const clubId = idRow?.next ?? 1
|
||||
const now = new Date().toISOString()
|
||||
|
||||
const stored: StoredClub = {
|
||||
ClubId: clubId,
|
||||
Name: input.name,
|
||||
Description: input.description ?? '',
|
||||
Category: input.category ?? '',
|
||||
Visibility: input.visibility ?? ClubVisibility.Public,
|
||||
Joinability: input.joinability ?? ClubJoinability.Open,
|
||||
AllowJuniors: input.allowJuniors ?? true,
|
||||
MainImageName: input.mainImageName ?? 'DefaultImgPurple',
|
||||
ClubType: input.clubType ?? 0,
|
||||
ClubhouseRoomId: input.clubhouseRoomId ?? null,
|
||||
CreatorAccountId: creatorAccountId,
|
||||
IsRRO: input.isRRO ?? false,
|
||||
MinLevel: input.minLevel ?? 0,
|
||||
State: 0,
|
||||
MemberCount: 0,
|
||||
CreatedAt: now,
|
||||
}
|
||||
await db.prepare('INSERT INTO club (data) VALUES (?1)').bind(JSON.stringify(stored)).run()
|
||||
|
||||
// The creator is the club's first member, joining as its Creator.
|
||||
await setMembership(db, clubId, creatorAccountId, ClubMembershipType.Creator)
|
||||
const count = await syncMemberCount(db, clubId)
|
||||
return { ...toDto(stored), MemberCount: count }
|
||||
}
|
||||
|
||||
/**
|
||||
* What each membership tier is allowed to do in a club. These are the defaults every
|
||||
* new club gets (co-owners can do everything, moderators can approve/ban, plain
|
||||
* members can do none of it); nothing edits them yet, so they're derived per club
|
||||
* rather than stored.
|
||||
*/
|
||||
export interface ClubPermission {
|
||||
ClubId: number
|
||||
Type: number
|
||||
ApproveMember: boolean
|
||||
BanUnban: boolean
|
||||
CreateEvent: boolean
|
||||
EditDetails: boolean
|
||||
EditPermissionSettings: boolean
|
||||
PostAnnouncement: boolean
|
||||
}
|
||||
|
||||
function clubPermission(
|
||||
clubId: number,
|
||||
type: ClubMembershipType,
|
||||
granted: Partial<Omit<ClubPermission, 'ClubId' | 'Type'>> = {}
|
||||
): ClubPermission {
|
||||
return {
|
||||
ClubId: clubId,
|
||||
Type: type,
|
||||
ApproveMember: false,
|
||||
BanUnban: false,
|
||||
CreateEvent: false,
|
||||
EditDetails: false,
|
||||
EditPermissionSettings: false,
|
||||
PostAnnouncement: false,
|
||||
...granted,
|
||||
}
|
||||
}
|
||||
|
||||
/** The club-details payload the client reads from create/details. */
|
||||
export interface ClubDetails {
|
||||
/**
|
||||
* The club's gallery images as whole image records — the same `SavedImage` shape
|
||||
* every other image on the site is served as. The client deserializes these into
|
||||
* objects, so a bare array of names fails its parser ("expected '{'").
|
||||
*/
|
||||
AdditionalImages: SavedImage[]
|
||||
Club: Club
|
||||
ClubId: number
|
||||
CoownerPermissions: ClubPermission
|
||||
CustomTags: string[]
|
||||
MemberPermissions: ClubPermission
|
||||
ModeratorPermissions: ClubPermission
|
||||
MyMembershipType: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the club-details view for a caller. `MyMembershipType` is the caller's own
|
||||
* membership (0 = none, e.g. a signed-out viewer). Additional images (set via
|
||||
* `/additionalimage/{index}`) and custom tags (set via `modifydetails`) both come off
|
||||
* the club's blob.
|
||||
*/
|
||||
export async function getClubDetails(
|
||||
db: D1Database,
|
||||
club: Club,
|
||||
accountId: number | null
|
||||
): Promise<ClubDetails> {
|
||||
return {
|
||||
AdditionalImages: await getClubGallery(db, club.ClubId),
|
||||
Club: club,
|
||||
ClubId: club.ClubId,
|
||||
CoownerPermissions: clubPermission(club.ClubId, ClubMembershipType.Coowner, {
|
||||
ApproveMember: true,
|
||||
BanUnban: true,
|
||||
CreateEvent: true,
|
||||
EditDetails: true,
|
||||
EditPermissionSettings: true,
|
||||
PostAnnouncement: true,
|
||||
}),
|
||||
CustomTags: await getClubCustomTags(db, club.ClubId),
|
||||
MemberPermissions: clubPermission(club.ClubId, ClubMembershipType.Member),
|
||||
ModeratorPermissions: clubPermission(club.ClubId, ClubMembershipType.Moderator, {
|
||||
ApproveMember: true,
|
||||
BanUnban: true,
|
||||
}),
|
||||
MyMembershipType: accountId === null ? 0 : await getMembership(db, club.ClubId, accountId),
|
||||
}
|
||||
}
|
||||
|
||||
/** A club announcement (mirror of the Go `ClubAnnouncement`). */
|
||||
export interface ClubAnnouncement {
|
||||
AnnouncementId: number
|
||||
ClubId: number
|
||||
AccountId: number
|
||||
Title: string
|
||||
Body: string
|
||||
ImageName: string
|
||||
Meta: string
|
||||
CreatedAt: string | null
|
||||
}
|
||||
|
||||
/** A club's announcements, newest first. An unknown club simply has none. */
|
||||
export async function getClubAnnouncements(
|
||||
db: D1Database,
|
||||
clubId: number
|
||||
): Promise<ClubAnnouncement[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT announcement_id, club_id, account_id, title, body, image_name, meta, created_at
|
||||
FROM club_announcement
|
||||
WHERE club_id = ?1
|
||||
ORDER BY created_at DESC, announcement_id DESC`
|
||||
)
|
||||
.bind(clubId)
|
||||
.all<{
|
||||
announcement_id: number
|
||||
club_id: number
|
||||
account_id: number
|
||||
title: string
|
||||
body: string
|
||||
image_name: string
|
||||
meta: string
|
||||
created_at: string | null
|
||||
}>()
|
||||
|
||||
return results.map((r) => ({
|
||||
AnnouncementId: r.announcement_id,
|
||||
ClubId: r.club_id,
|
||||
AccountId: r.account_id,
|
||||
Title: r.title,
|
||||
Body: r.body,
|
||||
ImageName: r.image_name,
|
||||
Meta: r.meta,
|
||||
CreatedAt: r.created_at,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Post an announcement to a club, returning its new id. */
|
||||
export async function createClubAnnouncement(
|
||||
db: D1Database,
|
||||
clubId: number,
|
||||
accountId: number,
|
||||
fields: { title?: string; body?: string; imageName?: string; meta?: string }
|
||||
): Promise<number> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO club_announcement (club_id, account_id, title, body, image_name, meta, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
RETURNING announcement_id`
|
||||
)
|
||||
.bind(
|
||||
clubId,
|
||||
accountId,
|
||||
fields.title ?? '',
|
||||
fields.body ?? '',
|
||||
fields.imageName ?? '',
|
||||
fields.meta ?? '',
|
||||
new Date().toISOString()
|
||||
)
|
||||
.first<{ announcement_id: number }>()
|
||||
return row?.announcement_id ?? 0
|
||||
}
|
||||
|
||||
/** What club search answers: the page of clubs plus the total that matched. */
|
||||
export interface ClubSearchResult {
|
||||
Clubs: Club[]
|
||||
ContinuationToken: null
|
||||
TotalClubs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Club search (`/club/search`). Public, non-subscription clubs only. `category` is an
|
||||
* exact (case-insensitive) match, `query` a substring of the name or description.
|
||||
* `sort`: 1 = newest first, 2 = by name, anything else (including the client's 0) =
|
||||
* biggest first, then newest. `count` caps the page — out-of-range values fall back to
|
||||
* 30, as the reference does. `TotalClubs` is the full match count, not the page size.
|
||||
*/
|
||||
export async function searchClubs(
|
||||
db: D1Database,
|
||||
category: string,
|
||||
query: string,
|
||||
sort: string | undefined,
|
||||
count: number
|
||||
): Promise<ClubSearchResult> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM club
|
||||
WHERE visibility = ?1
|
||||
AND json_extract(data, '$.ClubType') != ?2`
|
||||
)
|
||||
.bind(ClubVisibility.Public, SUBSCRIPTION_CLUB_TYPE)
|
||||
.all<ClubRow>()
|
||||
|
||||
const stored = results.map((r) => JSON.parse(r.data) as StoredClub)
|
||||
const term = query.trim().toLowerCase()
|
||||
const wanted = category.trim().toLowerCase()
|
||||
|
||||
const matched = stored.filter((club) => {
|
||||
if (wanted !== '' && club.Category.toLowerCase() !== wanted) return false
|
||||
if (term === '') return true
|
||||
return club.Name.toLowerCase().includes(term) || club.Description.toLowerCase().includes(term)
|
||||
})
|
||||
|
||||
const byNewest = (a: StoredClub, b: StoredClub) => b.CreatedAt.localeCompare(a.CreatedAt)
|
||||
matched.sort((a, b) => {
|
||||
if (sort === '1') return byNewest(a, b)
|
||||
if (sort === '2') return a.Name.localeCompare(b.Name)
|
||||
return b.MemberCount - a.MemberCount || byNewest(a, b)
|
||||
})
|
||||
|
||||
return {
|
||||
Clubs: matched.slice(0, count).map(toDto),
|
||||
ContinuationToken: null,
|
||||
TotalClubs: matched.length,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The player's "home club" — the one whose clubhouse they spawn into. It's a field
|
||||
* on the *account* row (owned by the `auth` worker, on the same shared database, the
|
||||
* way the `api` worker writes the account's profile image), not on the club: one
|
||||
* home club per player.
|
||||
*
|
||||
* Returns null when they haven't set one, when the club is gone, or when it has no
|
||||
* clubhouse room — a home club with nowhere to go isn't usable, and the reference
|
||||
* 404s all three cases identically.
|
||||
*/
|
||||
export async function getHomeClub(db: D1Database, accountId: number): Promise<Club | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
"SELECT json_extract(data, '$.homeClubId') AS clubId FROM account WHERE account_id = ?1"
|
||||
)
|
||||
.bind(accountId)
|
||||
.first<{ clubId: number | null }>()
|
||||
if (row?.clubId == null) return null
|
||||
|
||||
const club = await getClub(db, row.clubId)
|
||||
// `== null` catches a club row that predates the field (undefined), not just an
|
||||
// explicit null — either way it has no clubhouse to spawn into.
|
||||
if (club === null || club.ClubhouseRoomId == null) return null
|
||||
return club
|
||||
}
|
||||
|
||||
/** Point the player's home club at `clubId` (stored on their account row). */
|
||||
export async function setHomeClub(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
clubId: number
|
||||
): Promise<void> {
|
||||
await db
|
||||
// CAST to INTEGER — see syncMemberCount: a bound JS number lands as a REAL, so this
|
||||
// would otherwise store `"homeClubId":7.0`.
|
||||
.prepare(
|
||||
"UPDATE account SET data = json_set(data, '$.homeClubId', CAST(?2 AS INTEGER)) WHERE account_id = ?1"
|
||||
)
|
||||
.bind(accountId, clubId)
|
||||
.run()
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the player's home club (the field is removed from their account row, not set
|
||||
* to 0 — `getHomeClub` reads a missing field as "no home club"). Idempotent.
|
||||
*/
|
||||
export async function clearHomeClub(db: D1Database, accountId: number): Promise<void> {
|
||||
await db
|
||||
.prepare("UPDATE account SET data = json_remove(data, '$.homeClubId') WHERE account_id = ?1")
|
||||
.bind(accountId)
|
||||
.run()
|
||||
}
|
||||
|
||||
/** A club membership row, as the members list serves it (mirror of the Go `ClubMember`). */
|
||||
export interface ClubMember {
|
||||
ClubMemberId: number
|
||||
ClubId: number
|
||||
AccountId: number
|
||||
MembershipType: number
|
||||
CreatedAt: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* A club's members (`/club/:id/members`). `membershipType` filters to exactly that
|
||||
* tier when given — note it's an exact match, not a threshold, so `30` lists only
|
||||
* co-owners (not the creator above them). `sortBy` picks the order: 1 = by account
|
||||
* id, 2 = oldest membership first, anything else = the default, highest tier first
|
||||
* then oldest. An unknown club has no members, so it's an empty list, not a 404.
|
||||
*/
|
||||
export async function getClubMembers(
|
||||
db: D1Database,
|
||||
clubId: number,
|
||||
membershipType: number | undefined,
|
||||
sortBy: string | undefined
|
||||
): Promise<ClubMember[]> {
|
||||
const order =
|
||||
sortBy === '1'
|
||||
? 'account_id ASC'
|
||||
: sortBy === '2'
|
||||
? 'created_at ASC'
|
||||
: 'membership_type DESC, created_at ASC'
|
||||
const filter = membershipType === undefined ? '' : 'AND membership_type = ?2'
|
||||
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT club_member_id, club_id, account_id, membership_type, created_at
|
||||
FROM club_member
|
||||
WHERE club_id = ?1 ${filter}
|
||||
ORDER BY ${order}`
|
||||
)
|
||||
.bind(...(membershipType === undefined ? [clubId] : [clubId, membershipType]))
|
||||
.all<{
|
||||
club_member_id: number
|
||||
club_id: number
|
||||
account_id: number
|
||||
membership_type: number
|
||||
created_at: string | null
|
||||
}>()
|
||||
|
||||
return results.map((r) => ({
|
||||
ClubMemberId: r.club_member_id,
|
||||
ClubId: r.club_id,
|
||||
AccountId: r.account_id,
|
||||
MembershipType: r.membership_type,
|
||||
CreatedAt: r.created_at,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Fields `modifydetails` can change. Anything left undefined keeps its stored value. */
|
||||
export interface ClubPatch {
|
||||
name?: string
|
||||
description?: string
|
||||
category?: string
|
||||
visibility?: number
|
||||
joinability?: number
|
||||
allowJuniors?: boolean
|
||||
mainImageName?: string
|
||||
minLevel?: number
|
||||
/** Replaces the club's tags wholesale when present; absent leaves them alone. */
|
||||
customTags?: string[]
|
||||
/** The club's clubhouse room; `null` clears it (undefined leaves it alone). */
|
||||
clubhouseRoomId?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an edit to a club's details (`modifydetails`). Only the keys present on the
|
||||
* patch change. Custom tags are replaced as a set — trimmed, de-duplicated
|
||||
* case-insensitively, first spelling wins. Returns the updated club, or null when
|
||||
* there's no such club.
|
||||
*/
|
||||
export async function updateClub(
|
||||
db: D1Database,
|
||||
clubId: number,
|
||||
patch: ClubPatch
|
||||
): Promise<Club | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT data FROM club WHERE club_id = ?1')
|
||||
.bind(clubId)
|
||||
.first<ClubRow>()
|
||||
if (row === null) return null
|
||||
const stored = JSON.parse(row.data) as StoredClub
|
||||
|
||||
const updated: StoredClub = {
|
||||
...stored,
|
||||
Name: patch.name ?? stored.Name,
|
||||
Description: patch.description ?? stored.Description,
|
||||
Category: patch.category ?? stored.Category,
|
||||
Visibility: patch.visibility ?? stored.Visibility,
|
||||
Joinability: patch.joinability ?? stored.Joinability,
|
||||
AllowJuniors: patch.allowJuniors ?? stored.AllowJuniors,
|
||||
MainImageName: patch.mainImageName ?? stored.MainImageName,
|
||||
MinLevel: patch.minLevel ?? stored.MinLevel,
|
||||
CustomTags: patch.customTags === undefined ? stored.CustomTags : dedupeTags(patch.customTags),
|
||||
// `null` clears the clubhouse, so this can't collapse to `??`.
|
||||
ClubhouseRoomId:
|
||||
patch.clubhouseRoomId === undefined ? stored.ClubhouseRoomId : patch.clubhouseRoomId,
|
||||
}
|
||||
await db
|
||||
.prepare('UPDATE club SET data = ?1 WHERE club_id = ?2')
|
||||
.bind(JSON.stringify(updated), clubId)
|
||||
.run()
|
||||
return toDto(updated)
|
||||
}
|
||||
|
||||
/** Trim, drop blanks, and de-duplicate tags case-insensitively (first spelling wins). */
|
||||
function dedupeTags(tags: string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
for (const raw of tags) {
|
||||
const tag = raw.trim()
|
||||
if (tag === '' || seen.has(tag.toLowerCase())) continue
|
||||
seen.add(tag.toLowerCase())
|
||||
out.push(tag)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** A club's gallery image names, in order (stored on the blob; `[]` when it has none). */
|
||||
export async function getClubAdditionalImages(db: D1Database, clubId: number): Promise<string[]> {
|
||||
const row = await db
|
||||
.prepare('SELECT data FROM club WHERE club_id = ?1')
|
||||
.bind(clubId)
|
||||
.first<ClubRow>()
|
||||
return row === null ? [] : ((JSON.parse(row.data) as StoredClub).AdditionalImages ?? [])
|
||||
}
|
||||
|
||||
/**
|
||||
* A club's gallery as the client reads it: the image record behind each name, in
|
||||
* order. A name whose metadata row is missing falls back to a placeholder record so
|
||||
* the picture still renders.
|
||||
*/
|
||||
export async function getClubGallery(db: D1Database, clubId: number): Promise<SavedImage[]> {
|
||||
const names = await getClubAdditionalImages(db, clubId)
|
||||
if (names.length === 0) return []
|
||||
const records = await getSavedImagesByNames(db, names)
|
||||
return names.map((name) => records.get(name) ?? placeholderSavedImage(name))
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or remove, with an empty `imageName`) one of a club's gallery images. The list
|
||||
* stays packed: removing an image shifts the ones after it up, and setting an index
|
||||
* past the end appends rather than leaving a gap. Returns null when the club doesn't
|
||||
* exist; the caller validates the index is in range.
|
||||
*/
|
||||
export async function setClubAdditionalImage(
|
||||
db: D1Database,
|
||||
clubId: number,
|
||||
index: number,
|
||||
imageName: string
|
||||
): Promise<Club | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT data FROM club WHERE club_id = ?1')
|
||||
.bind(clubId)
|
||||
.first<ClubRow>()
|
||||
if (row === null) return null
|
||||
const stored = JSON.parse(row.data) as StoredClub
|
||||
|
||||
const images = [...(stored.AdditionalImages ?? [])]
|
||||
if (imageName === '') {
|
||||
// Removing past the end is a no-op, not an error: the image is already gone.
|
||||
if (index < images.length) images.splice(index, 1)
|
||||
} else if (index < images.length) {
|
||||
images[index] = imageName
|
||||
} else if (images.length < MAX_ADDITIONAL_IMAGES) {
|
||||
images.push(imageName)
|
||||
}
|
||||
|
||||
const updated: StoredClub = { ...stored, AdditionalImages: images }
|
||||
await db
|
||||
.prepare('UPDATE club SET data = ?1 WHERE club_id = ?2')
|
||||
.bind(JSON.stringify(updated), clubId)
|
||||
.run()
|
||||
return toDto(updated)
|
||||
}
|
||||
|
||||
/** A club's custom tags (stored on the blob; empty when it has none). */
|
||||
export async function getClubCustomTags(db: D1Database, clubId: number): Promise<string[]> {
|
||||
const row = await db
|
||||
.prepare('SELECT data FROM club WHERE club_id = ?1')
|
||||
.bind(clubId)
|
||||
.first<ClubRow>()
|
||||
return row === null ? [] : ((JSON.parse(row.data) as StoredClub).CustomTags ?? [])
|
||||
}
|
||||
|
||||
/** Look up a single club by its ClubId. */
|
||||
export async function getClub(db: D1Database, clubId: number): Promise<Club | null> {
|
||||
return parseOne(
|
||||
await db.prepare('SELECT data FROM club WHERE club_id = ?1').bind(clubId).first<ClubRow>()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a club and everything hanging off it — its memberships and announcements —
|
||||
* and clear it from the home club of anyone who'd set it. Returns false when there
|
||||
* was no such club. Batched so a half-deleted club can't be left behind.
|
||||
*/
|
||||
export async function deleteClub(db: D1Database, clubId: number): Promise<boolean> {
|
||||
if ((await getClub(db, clubId)) === null) return false
|
||||
await db.batch([
|
||||
db.prepare('DELETE FROM club_member WHERE club_id = ?1').bind(clubId),
|
||||
db.prepare('DELETE FROM club_announcement WHERE club_id = ?1').bind(clubId),
|
||||
// The account table belongs to the auth worker; a dangling homeClubId already
|
||||
// reads as "no home club" (getHomeClub), but leaving it would point at whatever
|
||||
// club later reuses the id.
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE account SET data = json_remove(data, '$.homeClubId')
|
||||
WHERE json_extract(data, '$.homeClubId') = ?1`
|
||||
)
|
||||
.bind(clubId),
|
||||
db.prepare('DELETE FROM club WHERE club_id = ?1').bind(clubId),
|
||||
])
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscription clubs (`ClubType` 1) are a creator's paid-subscriber club, not a
|
||||
* club you browse or list among your own — they're excluded from the "my clubs"
|
||||
* lists (the client reaches them through the `/subscription/*` endpoints instead).
|
||||
*/
|
||||
const SUBSCRIPTION_CLUB_TYPE = 1
|
||||
|
||||
/**
|
||||
* How many clubs an account has made, for the per-account club cap. Subscription
|
||||
* clubs don't count — they're provisioned for a creator's subscribers rather than
|
||||
* made by hand, so they shouldn't eat a slot.
|
||||
*/
|
||||
export async function countClubsByCreator(db: D1Database, accountId: number): Promise<number> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM club
|
||||
WHERE creator_account_id = ?1
|
||||
AND json_extract(data, '$.ClubType') != ?2`
|
||||
)
|
||||
.bind(accountId, SUBSCRIPTION_CLUB_TYPE)
|
||||
.first<{ n: number }>()
|
||||
return row?.n ?? 0
|
||||
}
|
||||
|
||||
/** All clubs created by an account (GetMyCreatedClubs), oldest first. */
|
||||
export async function getClubsByCreator(db: D1Database, accountId: number): Promise<Club[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM club
|
||||
WHERE creator_account_id = ?1
|
||||
AND json_extract(data, '$.ClubType') != ?2
|
||||
ORDER BY json_extract(data, '$.CreatedAt') ASC`
|
||||
)
|
||||
.bind(accountId, SUBSCRIPTION_CLUB_TYPE)
|
||||
.all<ClubRow>()
|
||||
return parseAll(results)
|
||||
}
|
||||
|
||||
/**
|
||||
* All clubs an account is an actual member of (GetMyMembershipClubs), oldest club
|
||||
* first. Only memberships at/above `Member` count — pending requests, denied
|
||||
* requests, and bans are excluded. Joins `club_member` to `club`, so a membership
|
||||
* whose club is gone is simply absent.
|
||||
*/
|
||||
export async function getClubsByMember(db: D1Database, accountId: number): Promise<Club[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT c.data AS data
|
||||
FROM club_member m
|
||||
JOIN club c ON c.club_id = m.club_id
|
||||
WHERE m.account_id = ?1 AND m.membership_type >= ?2
|
||||
AND json_extract(c.data, '$.ClubType') != ?3
|
||||
ORDER BY json_extract(c.data, '$.CreatedAt') ASC`
|
||||
)
|
||||
.bind(accountId, MEMBER_THRESHOLD, SUBSCRIPTION_CLUB_TYPE)
|
||||
.all<ClubRow>()
|
||||
return parseAll(results)
|
||||
}
|
||||
|
||||
/** Whether an account is an actual member of a club (Member tier or above). */
|
||||
export async function isClubMember(
|
||||
db: D1Database,
|
||||
clubId: number,
|
||||
accountId: number
|
||||
): Promise<boolean> {
|
||||
return (await getMembership(db, clubId, accountId)) >= MEMBER_THRESHOLD
|
||||
}
|
||||
|
||||
/**
|
||||
* Have `accountId` join a club. On an Open club they become a `Member` immediately;
|
||||
* on an InviteOnly/AskToJoin club the join is recorded as `PendingRequested` (an
|
||||
* approval flow, not yet a member). Idempotent for anyone already a member, and a
|
||||
* no-op for a banned account. Returns the club with its refreshed MemberCount, or
|
||||
* null when the club doesn't exist.
|
||||
*/
|
||||
export async function joinClub(
|
||||
db: D1Database,
|
||||
clubId: number,
|
||||
accountId: number
|
||||
): Promise<Club | null> {
|
||||
const club = await getClub(db, clubId)
|
||||
if (!club) return null
|
||||
|
||||
const current = await getMembership(db, clubId, accountId)
|
||||
// A ban can't be shed by re-joining, and an existing member/pending stays as-is.
|
||||
if (current === ClubMembershipType.Banned || current >= MEMBER_THRESHOLD) {
|
||||
return club
|
||||
}
|
||||
const next =
|
||||
club.Joinability === ClubJoinability.Open
|
||||
? ClubMembershipType.Member
|
||||
: ClubMembershipType.PendingRequested
|
||||
await setMembership(db, clubId, accountId, next)
|
||||
|
||||
const count = await syncMemberCount(db, clubId)
|
||||
return { ...club, MemberCount: count }
|
||||
}
|
||||
|
||||
/**
|
||||
* How a request to join resolved. `joined` is an Open club (no approval needed),
|
||||
* `requested` an AskToJoin club (now PendingRequested), `alreadyPending` a repeat
|
||||
* request, `alreadyMember` someone who's already in. `inviteOnly` and `banned` are
|
||||
* refusals — the caller can't get in this way.
|
||||
*/
|
||||
export type JoinRequestResult =
|
||||
'joined' | 'requested' | 'alreadyPending' | 'alreadyMember' | 'inviteOnly' | 'banned'
|
||||
|
||||
/**
|
||||
* Ask to join a club. Unlike `joinClub` this honours the club's Joinability strictly:
|
||||
* an InviteOnly club can only be entered through an invite, so a request is refused
|
||||
* rather than parked as pending. Returns the outcome plus the club with its refreshed
|
||||
* MemberCount, or null when the club doesn't exist.
|
||||
*/
|
||||
export async function requestToJoinClub(
|
||||
db: D1Database,
|
||||
clubId: number,
|
||||
accountId: number
|
||||
): Promise<{ result: JoinRequestResult; club: Club } | null> {
|
||||
const club = await getClub(db, clubId)
|
||||
if (!club) return null
|
||||
|
||||
const current = await getMembership(db, clubId, accountId)
|
||||
// A ban can't be shed by asking again, and existing members/requests stay as-is.
|
||||
if (current === ClubMembershipType.Banned) return { result: 'banned', club }
|
||||
if (current >= MEMBER_THRESHOLD) return { result: 'alreadyMember', club }
|
||||
if (current === ClubMembershipType.PendingRequested) return { result: 'alreadyPending', club }
|
||||
|
||||
if (club.Joinability === ClubJoinability.InviteOnly) return { result: 'inviteOnly', club }
|
||||
|
||||
const open = club.Joinability === ClubJoinability.Open
|
||||
await setMembership(
|
||||
db,
|
||||
clubId,
|
||||
accountId,
|
||||
open ? ClubMembershipType.Member : ClubMembershipType.PendingRequested
|
||||
)
|
||||
|
||||
const count = await syncMemberCount(db, clubId)
|
||||
return { result: open ? 'joined' : 'requested', club: { ...club, MemberCount: count } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove `accountId`'s membership of a club (idempotent). A ban is preserved — you
|
||||
* can't clear it by leaving — but any member/pending row is dropped. Returns the
|
||||
* outcome plus the club with its refreshed MemberCount, or null when the club doesn't
|
||||
* exist. The club itself is left in place even when the last member leaves.
|
||||
*
|
||||
* The creator can't leave: a club with no owner has no one who can administer it, and
|
||||
* there's no ownership transfer, so they have to delete the club instead. `creator`
|
||||
* reports that refusal, with the club unchanged.
|
||||
*/
|
||||
export async function leaveClub(
|
||||
db: D1Database,
|
||||
clubId: number,
|
||||
accountId: number
|
||||
): Promise<{ result: 'left' | 'creator'; club: Club } | null> {
|
||||
const club = await getClub(db, clubId)
|
||||
if (!club) return null
|
||||
|
||||
const current = await getMembership(db, clubId, accountId)
|
||||
if (current === ClubMembershipType.Creator) return { result: 'creator', club }
|
||||
|
||||
await db
|
||||
.prepare(
|
||||
'DELETE FROM club_member WHERE club_id = ?1 AND account_id = ?2 AND membership_type <> ?3'
|
||||
)
|
||||
.bind(clubId, accountId, ClubMembershipType.Banned)
|
||||
.run()
|
||||
const count = await syncMemberCount(db, clubId)
|
||||
return { result: 'left', club: { ...club, MemberCount: count } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Set an account's membership tier in a club — the invite / role-assignment write
|
||||
* behind `PUT /club/:id/members/invite`. Upserts the `club_member` row to
|
||||
* `membershipType` (adding the account when it wasn't a member, and overriding a prior
|
||||
* tier or ban), then refreshes the club's MemberCount. Returns the club with its fresh
|
||||
* count, or null when the club is gone. The caller is responsible for checking that the
|
||||
* tier is one it may grant and that the target isn't the club's Creator.
|
||||
*/
|
||||
export async function setMemberType(
|
||||
db: D1Database,
|
||||
clubId: number,
|
||||
accountId: number,
|
||||
membershipType: ClubMembershipType
|
||||
): Promise<Club | null> {
|
||||
const club = await getClub(db, clubId)
|
||||
if (!club) return null
|
||||
await setMembership(db, clubId, accountId, membershipType)
|
||||
const count = await syncMemberCount(db, clubId)
|
||||
return { ...club, MemberCount: count }
|
||||
}
|
||||
@@ -2,9 +2,6 @@ import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import {
|
||||
clearHomeClub,
|
||||
ClubJoinability,
|
||||
@@ -22,16 +19,22 @@ import {
|
||||
getClubsByMember,
|
||||
getHomeClub,
|
||||
getMembership,
|
||||
glyphLength,
|
||||
joinClub,
|
||||
leaveClub,
|
||||
MAX_ADDITIONAL_IMAGES,
|
||||
MAX_CLUB_DESCRIPTION_LENGTH,
|
||||
MAX_CLUB_NAME_LENGTH,
|
||||
requestToJoinClub,
|
||||
searchClubs,
|
||||
setClubAdditionalImage,
|
||||
setHomeClub,
|
||||
setMemberType,
|
||||
updateClub,
|
||||
} from './clubs-db'
|
||||
} from '@repo/domain'
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import {
|
||||
AnnouncementIdEnvelope,
|
||||
AnnouncementRequest,
|
||||
@@ -101,8 +104,6 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
||||
*/
|
||||
const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10
|
||||
|
||||
/** Longest a club name may be (the reference's MaxNameLength). */
|
||||
const MAX_CLUB_NAME_LENGTH = 16
|
||||
|
||||
/**
|
||||
* The tiers `members/invite` may grant — the real member roles only. Creator (100) is
|
||||
@@ -665,6 +666,14 @@ const app = new Hono<App>()
|
||||
if ([...name].length > MAX_CLUB_NAME_LENGTH) {
|
||||
return clubError(c, `Club names can be at most ${MAX_CLUB_NAME_LENGTH} characters.`)
|
||||
}
|
||||
// Counted in code points like the name above, so an emoji-heavy description is
|
||||
// measured the way a player sees it rather than by UTF-16 units.
|
||||
if (glyphLength(description) > MAX_CLUB_DESCRIPTION_LENGTH) {
|
||||
return clubError(
|
||||
c,
|
||||
`Club descriptions can be at most ${MAX_CLUB_DESCRIPTION_LENGTH} characters.`
|
||||
)
|
||||
}
|
||||
// The per-account cap, checked after the cheap validations so a rejected name
|
||||
// costs no extra D1 read.
|
||||
const maxClubs = intVar(c.env.MAX_CLUBS_PER_ACCOUNT, DEFAULT_MAX_CLUBS_PER_ACCOUNT)
|
||||
@@ -778,9 +787,19 @@ const app = new Hono<App>()
|
||||
}
|
||||
}
|
||||
|
||||
// Same absent-means-unchanged rule as the name, so a club with no description
|
||||
// isn't forced to grow one just to be edited.
|
||||
const description = field('description') || undefined
|
||||
if (description !== undefined && glyphLength(description) > MAX_CLUB_DESCRIPTION_LENGTH) {
|
||||
return clubError(
|
||||
c,
|
||||
`Club descriptions can be at most ${MAX_CLUB_DESCRIPTION_LENGTH} characters.`
|
||||
)
|
||||
}
|
||||
|
||||
const updated = await updateClub(c.env.DB, clubId, {
|
||||
name,
|
||||
description: field('description') || undefined,
|
||||
description,
|
||||
category: field('category')?.trim() || undefined,
|
||||
visibility: parseVisibility(field('visibility')),
|
||||
joinability: parseJoinability(field('joinability')),
|
||||
|
||||
@@ -65,7 +65,7 @@ export const EmptyObject = z.object({})
|
||||
*/
|
||||
export const ClubDto = z.object({
|
||||
ClubId: z.int(),
|
||||
Name: z.string().describe('At most 16 characters; letters, digits and basic punctuation'),
|
||||
Name: z.string().describe('At most 40 characters; letters, digits and basic punctuation'),
|
||||
Description: z.string(),
|
||||
Category: z.string().describe('One of the /club/categoryTags values; defaults to Social'),
|
||||
Visibility: z.int().describe('ClubVisibility: 0 = Private, 1 = Public'),
|
||||
@@ -277,8 +277,8 @@ export const ChatDisabledResponse = z.boolean()
|
||||
export const CreateClubRequest = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.describe('Required; at most 16 characters, letters/digits/basic punctuation only'),
|
||||
description: z.string().optional(),
|
||||
.describe('Required; at most 40 characters, letters/digits/basic punctuation only'),
|
||||
description: z.string().optional().describe('At most 512 characters'),
|
||||
category: z.string().optional().describe('Defaults to Social when unset'),
|
||||
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
|
||||
joinability: z
|
||||
@@ -292,8 +292,11 @@ export const CreateClubRequest = z.object({
|
||||
|
||||
/** `PUT /club/:clubId/modifydetails` (and `/modify`) form body. */
|
||||
export const ModifyClubRequest = z.object({
|
||||
name: z.string().optional().describe('Empty means unchanged, not "clear it"'),
|
||||
description: z.string().optional().describe('Empty means unchanged'),
|
||||
name: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('At most 40 characters. Empty means unchanged, not "clear it"'),
|
||||
description: z.string().optional().describe('At most 512 characters. Empty means unchanged'),
|
||||
category: z.string().optional(),
|
||||
visibility: z.string().optional().describe('By name (`Public`/`Private`) or number'),
|
||||
joinability: z
|
||||
|
||||
@@ -4,7 +4,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import '../../clubs.app'
|
||||
|
||||
import { SCHEMA_DDL } from '../../clubs-db'
|
||||
import { CLUB_SCHEMA_DDL } from '@repo/domain'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
@@ -18,7 +18,7 @@ beforeAll(async () => {
|
||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-signing-key')
|
||||
// Build the club / club_member tables (mirrors the migration).
|
||||
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of CLUB_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Accounts table (owned by the auth worker) — a player's home club is a field on
|
||||
// their account row, so /club/home/me reads and writes it here.
|
||||
@@ -241,9 +241,16 @@ describe('clubs endpoints', () => {
|
||||
expect(emoji.status).toBe(400)
|
||||
expect(await emoji.json()).toMatchObject({ success: false, value: null })
|
||||
|
||||
// Names cap at 16 characters.
|
||||
expect((await create({ name: 'a'.repeat(17) })).status).toBe(400)
|
||||
expect((await create({ name: 'a'.repeat(16) })).status).toBe(200)
|
||||
// Names cap at 40 characters.
|
||||
expect((await create({ name: 'a'.repeat(41) })).status).toBe(400)
|
||||
expect((await create({ name: 'a'.repeat(40) })).status).toBe(200)
|
||||
|
||||
// Descriptions cap at 512. Counted in code points, so an emoji-heavy one isn't
|
||||
// refused at half the length a player can see (the description has no charset rule
|
||||
// — only the name does).
|
||||
expect((await create({ name: 'DescTooLong', description: 'd'.repeat(513) })).status).toBe(400)
|
||||
expect((await create({ name: 'DescAtLimit', description: 'd'.repeat(512) })).status).toBe(200)
|
||||
expect((await create({ name: 'DescEmoji', description: '🎉'.repeat(512) })).status).toBe(200)
|
||||
|
||||
// Basic punctuation is allowed.
|
||||
expect((await create({ name: "Bob's Club (2)" })).status).toBe(200)
|
||||
|
||||
@@ -6,12 +6,20 @@ A Cloudflare Workers application using Hono
|
||||
|
||||
- `GET /purchase/v1/hasspentmoney` — whether the player has ever spent money;
|
||||
`false`.
|
||||
- `POST /purchase/v1/initiatepurchase` — begins a purchase, answering
|
||||
`{ "transactionId": 1234567890 }`. Nothing is charged and no transaction is
|
||||
recorded, so the id is a fixed placeholder and the posted body is ignored.
|
||||
- `GET /api/catalog/v1/all` — the purchasable SKU catalog (token packs, special
|
||||
offers), served from the bundled `static/catalog-v1-all.json`. The client's
|
||||
`?onlyAvailableSkus=true` is accepted and ignored: the bundled catalog already
|
||||
contains only available SKUs.
|
||||
- `GET /purchasecampaign/allcurrent/v2` — current purchase campaigns
|
||||
(limited-time offers/promos); `[]` (none active).
|
||||
- `GET /reminder/currentTokenBundles/v2` — token-bundle purchase reminders (the
|
||||
"buy more tokens" nudge); `[]` (none to show).
|
||||
- `GET /openapi.json` — the generated OpenAPI 3.1 spec for the routes above.
|
||||
Descriptive only; nothing is validated against it. Also aggregated into the
|
||||
docs UI on `www` at `/docs`.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -16,8 +16,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@standard-community/standard-json": "0.3.5",
|
||||
"@standard-community/standard-openapi": "0.2.9",
|
||||
"hono": "4.12.27",
|
||||
"workers-tagged-logger": "1.0.1"
|
||||
"hono-openapi": "1.3.1",
|
||||
"openapi-types": "12.1.3",
|
||||
"workers-tagged-logger": "1.0.1",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vitest-pool-workers": "0.16.20",
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
|
||||
import catalog from '../static/catalog-v1-all.json'
|
||||
import {
|
||||
BareBoolean,
|
||||
boolQuery,
|
||||
CatalogSku,
|
||||
HealthResponse,
|
||||
InitiatePurchaseRequest,
|
||||
InitiatePurchaseResponse,
|
||||
json,
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
} from './openapi'
|
||||
|
||||
import type { App } from './context'
|
||||
|
||||
@@ -11,6 +23,14 @@ import type { App } from './context'
|
||||
* Commerce routes. The `commerce` prefix maps to this worker's subdomain, so
|
||||
* method routes are served bare.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The transaction id every purchase initiation answers with. Real money never changes
|
||||
* hands here and nothing is persisted, so the client only needs a well-formed handle to
|
||||
* carry through the rest of its store flow.
|
||||
*/
|
||||
const PLACEHOLDER_TRANSACTION_ID = 1234567890
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
@@ -25,24 +45,131 @@ const app = new Hono<App>()
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
.get('/', (c) => c.json({ service: 'commerce', status: 'ok' }))
|
||||
.get(
|
||||
'/',
|
||||
describeRoute({
|
||||
tags: ['Service'],
|
||||
summary: 'Health check',
|
||||
description: 'Liveness probe for the commerce worker. No auth.',
|
||||
responses: { 200: json(HealthResponse, 'Service is up') },
|
||||
}),
|
||||
(c) => c.json({ service: 'commerce', status: 'ok' })
|
||||
)
|
||||
|
||||
// Whether the player has ever spent money. A 404 here makes the client treat
|
||||
// it as an error, so we return `false` (no purchases).
|
||||
.get('/purchase/v1/hasspentmoney', (c) => c.json(false))
|
||||
.get(
|
||||
'/purchase/v1/hasspentmoney',
|
||||
describeRoute({
|
||||
tags: ['Purchase'],
|
||||
summary: 'Whether the player has ever spent money',
|
||||
description: [
|
||||
'Always `false` — nobody buys anything on this server. A 404 here makes the client',
|
||||
'treat the call as an error, so the answer is the bare boolean rather than nothing.',
|
||||
].join(' '),
|
||||
responses: { 200: json(BareBoolean, 'Always false (no purchases)') },
|
||||
}),
|
||||
(c) => c.json(false)
|
||||
)
|
||||
|
||||
// Begin a purchase. The client asks for a transaction handle before it takes the
|
||||
// player to the platform store; nothing is charged or recorded here, so the id is a
|
||||
// fixed placeholder and the posted body is ignored.
|
||||
.post(
|
||||
'/purchase/v1/initiatepurchase',
|
||||
describeRoute({
|
||||
tags: ['Purchase'],
|
||||
summary: 'Begin a purchase',
|
||||
description: [
|
||||
'Hands the client the transaction handle it carries through the rest of the store',
|
||||
'flow. Nothing is charged and no transaction is recorded, so the id is a fixed',
|
||||
'placeholder and the posted body is accepted and ignored — an absent or unparseable',
|
||||
'body is a 200, not a 400.',
|
||||
].join(' '),
|
||||
requestBody: jsonBody(InitiatePurchaseRequest, 'The purchase the player confirmed'),
|
||||
responses: { 200: json(InitiatePurchaseResponse, 'The (placeholder) transaction id') },
|
||||
}),
|
||||
(c) => c.json({ transactionId: PLACEHOLDER_TRANSACTION_ID })
|
||||
)
|
||||
|
||||
// The purchasable SKU catalog (token packs, special offers), served from the
|
||||
// bundled static JSON. The client passes `?onlyAvailableSkus=true`; the bundled
|
||||
// catalog is already only the available SKUs, so the param doesn't change the
|
||||
// response.
|
||||
.get('/api/catalog/v1/all', (c) => c.json(catalog))
|
||||
.get(
|
||||
'/api/catalog/v1/all',
|
||||
describeRoute({
|
||||
tags: ['Catalog'],
|
||||
summary: 'The purchasable SKU catalog',
|
||||
description: [
|
||||
'The token packs, bundles and special offers the store shows, served from the bundled',
|
||||
'static catalog. The client’s `onlyAvailableSkus` is accepted and ignored: the bundled',
|
||||
'catalog already contains only available SKUs.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
boolQuery('onlyAvailableSkus', 'Accepted and ignored — the catalog is already filtered'),
|
||||
],
|
||||
responses: { 200: json(CatalogSku.array(), 'Every available SKU') },
|
||||
}),
|
||||
(c) => c.json(catalog)
|
||||
)
|
||||
|
||||
// Current purchase campaigns (limited-time offers/promos). None exist, and
|
||||
// an empty list is the client's "no active campaigns" state.
|
||||
.get('/purchasecampaign/allcurrent/v2', (c) => c.json([]))
|
||||
.get(
|
||||
'/purchasecampaign/allcurrent/v2',
|
||||
describeRoute({
|
||||
tags: ['Purchase'],
|
||||
summary: 'Current purchase campaigns',
|
||||
description: [
|
||||
'Limited-time offers and promos. Always `[]` — none exist, and an empty list is the',
|
||||
'client’s “no active campaigns” state.',
|
||||
].join(' '),
|
||||
responses: { 200: json(JsonArray, 'Always empty (no active campaigns)') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// Token-bundle purchase reminders (the "buy more tokens" nudge). None to show,
|
||||
// and an empty list is the client's "no reminders" state.
|
||||
.get('/reminder/currentTokenBundles/v2', (c) => c.json([]))
|
||||
.get(
|
||||
'/reminder/currentTokenBundles/v2',
|
||||
describeRoute({
|
||||
tags: ['Purchase'],
|
||||
summary: 'Token-bundle purchase reminders',
|
||||
description: [
|
||||
'The “buy more tokens” nudges. Always `[]` — there are none to show, and an empty list',
|
||||
'is the client’s “no reminders” state.',
|
||||
].join(' '),
|
||||
responses: { 200: json(JsonArray, 'Always empty (no reminders)') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// 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(
|
||||
'/openapi.json',
|
||||
describeRoute({ hide: true }),
|
||||
withCleanSpec(
|
||||
openAPIRouteHandler(app, {
|
||||
documentation: {
|
||||
info: {
|
||||
title: 'recflare commerce',
|
||||
version: '1.0.0',
|
||||
description: [
|
||||
'The store surface for recflare, a private-server reimplementation of the Rec Room',
|
||||
'backend: the SKU catalog the client shows and the purchase calls it makes around it.',
|
||||
'',
|
||||
'No money moves here. There is no store integration and no purchase storage, so the',
|
||||
'catalog is a bundled static asset, the campaign and reminder feeds are empty, and a',
|
||||
'purchase initiation answers with a placeholder transaction id.',
|
||||
].join('\n'),
|
||||
},
|
||||
servers: [{ url: 'https://commerce.recflare.net', description: 'Production' }],
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
export default app
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { resolver } from 'hono-openapi'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { OpenAPIV3_1 } from 'openapi-types'
|
||||
|
||||
/**
|
||||
* OpenAPI schemas for the commerce worker.
|
||||
*
|
||||
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to
|
||||
* generate the spec and are never wired into `hono-openapi`'s `validator()`. Same
|
||||
* rationale as the auth/accounts/econ/match/playersettings workers: a reverse-engineered
|
||||
* protocol, lenient handlers, no runtime validation.
|
||||
*
|
||||
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
|
||||
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist
|
||||
* into `components.schemas`, leaving a dangling reference. Leaving meta off makes every
|
||||
* schema inline, which renders correctly in any tool.
|
||||
*/
|
||||
|
||||
/** Emit a zod schema as an `application/json` response body. */
|
||||
export function json(schema: z.ZodType, description: string) {
|
||||
return { description, content: { 'application/json': { schema: resolver(schema) } } }
|
||||
}
|
||||
|
||||
function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
|
||||
const { $schema: _$schema, additionalProperties: _extra, ...jsonSchema } = z.toJSONSchema(schema)
|
||||
return jsonSchema as OpenAPIV3_1.SchemaObject
|
||||
}
|
||||
|
||||
/** An `application/json` request body. */
|
||||
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
|
||||
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
|
||||
}
|
||||
|
||||
/** An optional boolean query parameter. */
|
||||
export function boolQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||
return { name, in: 'query', required: false, description, schema: { type: 'boolean' } }
|
||||
}
|
||||
|
||||
// ---- Loose shapes ----------------------------------------------------------
|
||||
|
||||
/** An opaque JSON object — a body whose fields haven't been reversed yet. */
|
||||
export const JsonObject = z.record(z.string(), z.unknown())
|
||||
/** An opaque JSON array (an empty-list stub). */
|
||||
export const JsonArray = z.array(z.unknown())
|
||||
|
||||
/** A bare JSON boolean — `hasspentmoney` answers `false` with no envelope. */
|
||||
export const BareBoolean = z.boolean()
|
||||
|
||||
// ---- Service ---------------------------------------------------------------
|
||||
|
||||
/** `GET /` — the root health check. */
|
||||
export const HealthResponse = z.object({
|
||||
service: z.literal('commerce'),
|
||||
status: z.literal('ok'),
|
||||
})
|
||||
|
||||
// ---- Catalog ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The per-SKU `data` blob. `giftDropIds` are the drops granted when the SKU is redeemed
|
||||
* (empty for the bundles, which grant their contents directly); `message` is the label the
|
||||
* store shows on the purchase.
|
||||
*/
|
||||
export const CatalogSkuData = z.object({
|
||||
giftDropIds: z.array(z.int()),
|
||||
message: z.string(),
|
||||
subscriptionPurchase: z
|
||||
.unknown()
|
||||
.optional()
|
||||
.describe('Present only on the subscription SKU; its shape is not reversed yet'),
|
||||
})
|
||||
|
||||
/**
|
||||
* One purchasable SKU from `GET /api/catalog/v1/all` — a token pack, a bundle or a
|
||||
* special offer. `price` is in cents on the store the client is running against, and the
|
||||
* per-store id fields are only present where that SKU ships on that store, so all of them
|
||||
* are optional except the Oculus/Apple/Google ids the reference catalog always carries.
|
||||
*/
|
||||
export const CatalogSku = z.object({
|
||||
skuId: z.int(),
|
||||
name: z.string(),
|
||||
description: z.string().describe('Often an empty string for token packs'),
|
||||
imageName: z.string().describe('The store tile image; the img worker serves it by name'),
|
||||
price: z.int().describe('Store price in cents, e.g. 99 = $0.99'),
|
||||
oculusSkuId: z.string(),
|
||||
appleProductId: z.string(),
|
||||
googlePlaySkuId: z.string(),
|
||||
picoSkuId: z.string().optional(),
|
||||
xboxProductId: z.string().optional(),
|
||||
xboxStoreId: z.string().optional(),
|
||||
psnProductLabel: z.string().optional(),
|
||||
psnEntitlementLabel: z.string().optional(),
|
||||
nintendoSkuId: z.string().optional(),
|
||||
isSingleUse: z.boolean(),
|
||||
shouldAppearInTokenStore: z.boolean(),
|
||||
dataSchemaVersion: z.int(),
|
||||
data: CatalogSkuData,
|
||||
})
|
||||
|
||||
// ---- Purchase --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `POST /purchase/v1/initiatepurchase` body — what the client sends when the player
|
||||
* confirms a purchase (the SKU and the store it is being bought on). Accepted and
|
||||
* ignored: the field names have not been reversed yet, and nothing here talks to a store.
|
||||
*/
|
||||
export const InitiatePurchaseRequest = JsonObject.describe(
|
||||
'The client’s purchase-initiation payload; accepted and ignored'
|
||||
)
|
||||
|
||||
/**
|
||||
* `POST /purchase/v1/initiatepurchase` — the handle the client carries through the rest
|
||||
* of the store flow. Nothing is persisted, so this is a fixed placeholder id.
|
||||
*/
|
||||
export const InitiatePurchaseResponse = z.object({
|
||||
transactionId: z.int().describe('Placeholder — no transaction is recorded'),
|
||||
})
|
||||
@@ -18,6 +18,22 @@ describe('commerce endpoints', () => {
|
||||
expect(await res.json()).toBe(false)
|
||||
})
|
||||
|
||||
it('POST /purchase/v1/initiatepurchase returns a transaction id', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/purchase/v1/initiatepurchase`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ skuId: 178, platform: 'Standalone' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ transactionId: 1234567890 })
|
||||
})
|
||||
|
||||
it('POST /purchase/v1/initiatepurchase ignores the body entirely', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/purchase/v1/initiatepurchase`, { method: 'POST' })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ transactionId: 1234567890 })
|
||||
})
|
||||
|
||||
it('GET /api/catalog/v1/all serves the SKU catalog', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/api/catalog/v1/all?onlyAvailableSkus=true`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -38,4 +54,45 @@ describe('commerce endpoints', () => {
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
it('GET /openapi.json documents every route', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
const spec = (await res.json()) as {
|
||||
openapi: string
|
||||
paths: Record<string, Record<string, { summary?: string }>>
|
||||
}
|
||||
expect(spec.openapi).toMatch(/^3\.1/)
|
||||
|
||||
// The spec route hides itself.
|
||||
expect(spec.paths['/openapi.json']).toBeUndefined()
|
||||
|
||||
// Every route the worker serves is described. This is the drift guard: adding a
|
||||
// route without a describeRoute() block fails here rather than silently shipping
|
||||
// an incomplete spec.
|
||||
const documented = new Set(
|
||||
Object.entries(spec.paths).flatMap(([path, ops]) =>
|
||||
Object.keys(ops).map((method) => `${method.toUpperCase()} ${path}`)
|
||||
)
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'GET /',
|
||||
'GET /api/catalog/v1/all',
|
||||
'GET /purchase/v1/hasspentmoney',
|
||||
'GET /purchasecampaign/allcurrent/v2',
|
||||
'GET /reminder/currentTokenBundles/v2',
|
||||
'POST /purchase/v1/initiatepurchase',
|
||||
])
|
||||
|
||||
// Every operation carries a summary — a path present but undescribed is not
|
||||
// documentation.
|
||||
for (const ops of Object.values(spec.paths)) {
|
||||
for (const op of Object.values(ops)) expect(op.summary).toBeTruthy()
|
||||
}
|
||||
|
||||
// Schemas are inlined rather than $ref'd into components: a `.meta({ id })`'d
|
||||
// schema used in a response emits a $ref this hono-openapi + zod v4 setup does
|
||||
// not always hoist, leaving a dangling reference.
|
||||
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
+363
-17
@@ -4,14 +4,15 @@ Economy Worker served on the `econ` subdomain (`econ.recflare.net`). Hosts the
|
||||
avatar/economy endpoints the game client calls on the `econ` service (distinct from the
|
||||
main `api` worker, which also serves many of them — the client may call either host).
|
||||
|
||||
Balances, inventory, consumables, saved outfits, avatars and gift boxes are D1-backed;
|
||||
storefront catalogs are static assets (`static/storefronts/sf{N}.json`) served via the
|
||||
ASSETS binding. Several routes are still empty-list stubs.
|
||||
Balances, inventory, consumables, saved outfits, avatars, gift boxes, weekly-challenge
|
||||
progress and game-reward eligibility are D1-backed; storefront catalogs and the weekly-challenge rotation are static
|
||||
assets (`static/`), the storefronts served via the ASSETS binding. Several routes are still
|
||||
empty-list stubs.
|
||||
|
||||
## Routes
|
||||
|
||||
`✓` = auth-gated (validates the Bearer JWT from the `auth` worker; empty-body 401 when
|
||||
missing/invalid).
|
||||
missing/invalid). `~` = optional auth: served to anyone, personalised for a valid bearer.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
| -------- | ---------------------------------------------------- | ---- | --------------------------------------- |
|
||||
@@ -42,13 +43,13 @@ missing/invalid).
|
||||
| GET | `/api/storefronts/v3/giftdropstore/:id` | | Gift-drop storefront catalog |
|
||||
| POST | `/api/storefronts/v2/buyItem` | ✓ | Buy a storefront item |
|
||||
| GET | `/api/storefronts/v1/adcarouselitems` | | Ad-carousel items (static) |
|
||||
| GET | `/api/challenge/v2/getCurrent` | | Current weekly challenge (static) |
|
||||
| POST | `/api/challenge/v2/updateProgress` | | Report challenge progress (stub) |
|
||||
| GET | `/api/challenge/v2/getCurrent` | ~ | Weekly rotation + the caller's progress |
|
||||
| POST | `/api/challenge/v2/updateProgress` | ✓ | Report challenge progress |
|
||||
| GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) |
|
||||
| POST | `/api/gamerewards/v1/request` | | Request a game reward (stub `[]`) |
|
||||
| POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 5 XP + gift box |
|
||||
| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) |
|
||||
| GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) |
|
||||
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | | Subscription lookup (both null) |
|
||||
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | ~ | Gold year for `developer`s, else `{}` |
|
||||
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
|
||||
|
||||
The app runs with `strict: false`, so trailing-slash variants match (the client posts
|
||||
@@ -70,9 +71,9 @@ The core flow. The client posts the storefront/item ids, the currency, and the
|
||||
2. rejects a stale price (`409`) — this stops a stale or tampered client buying at a
|
||||
price the catalog no longer offers;
|
||||
3. debits the buyer **atomically** (`400` on insufficient balance);
|
||||
4. grants the drop — an avatar item into the `inventory` table (own-once), a consumable
|
||||
into the `consumable` table (each buy stacks a new instance); currency/xp drops
|
||||
aren't granted yet;
|
||||
4. grants the drop — an avatar item into the `inventory` table (own-once), equipment into
|
||||
`equipment`, a consumable into `consumable` (each buy stacks a new instance), or, for a
|
||||
query drop, whatever the roll lands on (below); currency/xp drops aren't granted yet;
|
||||
5. returns a **gift box** and pushes a `StorefrontBalanceUpdate` over the socket.
|
||||
|
||||
Two things are easy to get wrong:
|
||||
@@ -86,6 +87,50 @@ Two things are easy to get wrong:
|
||||
A `Gift` block routes the item (and box) to another player, but the caller always pays.
|
||||
A self-buy or anonymous gift is attributed to the "Coach" system account (id 1).
|
||||
|
||||
## Query drops — the loot boxes (`IsQuery`)
|
||||
|
||||
A gift-drop with `IsQuery: true` is not an item, it is a **roll**: all of its item fields
|
||||
(`AvatarItemDesc`, `EquipmentModificationGuid`, `ConsumableItemDesc`) are empty on purpose,
|
||||
and what the player gets is picked at grant time. sf2's tooltip states the rule outright —
|
||||
_"A random 4-star item that you don't have."_ Eight ship in the catalogs, two families of
|
||||
the same ladder:
|
||||
|
||||
| sf2 "Star Boxes" (`ItemSetId` 44, `Unique`) | Rarity | sf3 "Random box" family |
|
||||
| ------------------------------------------- | ------ | ----------------------- |
|
||||
| — | 0 | Common Random box |
|
||||
| 2-Star Unique Box | 10 | Uncommon Random box |
|
||||
| 3-Star Unique Box | 20 | Rare Random box |
|
||||
| 4-Star Unique Box | 30 | Epic Random box |
|
||||
| — | 50 | Legendary Random box |
|
||||
|
||||
That table is the **star ↔ rarity ladder** (`STAR_RARITY` in `econ.app.ts`): sf2's three
|
||||
boxes pin 2/3/4 → 10/20/30 by carrying both their name and their `QueryRedirectRarity`, and
|
||||
sf3's five-name ladder fills in the ends. It's the same tier list twice, so read a rarity
|
||||
number in either dialect.
|
||||
|
||||
`rollQueryDrop` resolves one inside `grantGiftDrop`, so both faucets — a purchase and the
|
||||
weekly gift — hand over a real item rather than an unopenable box:
|
||||
|
||||
- **The pool is sf3**, the general store (`ROLL_STOREFRONT_TYPE`). It's the only catalog
|
||||
with a real pool at every tier (1161 items against 8–40 in the themed ones), it's where
|
||||
the Random box family itself sells, and "a random 4-star item" means the item universe,
|
||||
not whichever seasonal shelf the box came off.
|
||||
- **Filtered to what the player doesn't own**, which is the `Unique` promise and the only
|
||||
reading of "an item you don't have" that means anything.
|
||||
- **Avatar items and equipment only.** Other query drops are excluded (a box that rolls a
|
||||
box), and so are consumables: they stack, so "don't have" never becomes false and they'd
|
||||
crowd out the real prizes.
|
||||
- **`avatarItemsOnly` narrows it to worn items**, dropping equipment skins from the pool.
|
||||
Level-up boxes use it; storefront boxes don't, since "a random 4-star item" means both.
|
||||
- **`QueryRedirectRarity` wins over `Rarity`** when present — sf2 carries both and they
|
||||
agree; sf3's boxes carry only `Rarity`.
|
||||
- **An empty pool grants nothing** (logged `query gift-drop rolled nothing`) — an owner of
|
||||
every 4-star item still gets the box, just nothing in it.
|
||||
- **`buyItem` answers with the ROLLED item, not the box.** The client draws the purchase
|
||||
from `BalanceUpdates[0].Data[0]`, and a query drop's own item fields are all empty — echo
|
||||
those and the player sees an empty box for a purchase that actually granted something. The
|
||||
stored box was always correct; only the response was wrong.
|
||||
|
||||
## Consume envelopes
|
||||
|
||||
Both consume routes (`/gifts/consume`, `/consumables/consume`) always answer HTTP 200
|
||||
@@ -95,11 +140,301 @@ 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`)
|
||||
|
||||
Served by `GET /api/challenge/v2/getCurrent` (with each challenge's per-player `Complete`
|
||||
stamped in — see Progress below). The server never evaluates the rules: the client reads
|
||||
the rule tree in each challenge's `Config`, watches its own gameplay, and posts the tree
|
||||
back to `/api/challenge/v2/updateProgress` with its verdict. So this file is the entire
|
||||
definition of a week's challenges — ids, display strings, matching rules and the reward
|
||||
preview.
|
||||
|
||||
Everything below was read off reference data (one captured live rotation), not a spec.
|
||||
Field meanings marked _(inferred)_ are read from how the values line up with the strings
|
||||
the client renders; the rest are pinned by the data itself. The file itself is edited
|
||||
freely as rotations change — the examples here are the captured week, so expect the shipped
|
||||
rotation to differ.
|
||||
|
||||
### Top level
|
||||
|
||||
| Field | Example | Notes |
|
||||
| ---------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ChallengeMapId` | `17` | Id of the rotation as a whole ("map" of challenges). Echoed back on `updateProgress`; bump it when you publish a new week. |
|
||||
| `CompletedRequired` | `false` | _(inferred)_ All-or-nothing: `true` makes the `Gift` need every challenge, `false` the three-of-five threshold below. |
|
||||
| `StartAt` / `EndAt` | `2026-03-25T21:00:00` | The window, 7 days apart, **no timezone suffix** — unlike `ServerTime`. Treat as UTC. |
|
||||
| `ServerTime` | `2026-03-31T14:42:54.2754728Z` | .NET round-trip timestamp (7-digit fraction, `Z`). The client dates the countdown off this, so it is **frozen** — see below. |
|
||||
| `Challenges` | array | The week's challenges, rendered in order. |
|
||||
| `Gift` | object | The reward preview for finishing the set. |
|
||||
| `FallbackGiftName` | `"4-Star Box"` | Shown when the client can't resolve `Gift` into a name. |
|
||||
| `ChallengeThemeString` | a designer quote | Free text carried through from the captured rotation; a theme note, not a rendered UI string as far as we can tell. |
|
||||
|
||||
**The frozen clock:** `ServerTime` (Mar 31) sits _inside_ `StartAt`…`EndAt` (Mar 25 → Apr 1),
|
||||
about a day before the end, and the file is static — so the client always sees an active
|
||||
rotation with a ~1-day countdown rather than an expired one. If you edit the window, move
|
||||
`ServerTime` inside the new one too, or the challenges may render as already over.
|
||||
|
||||
### A challenge entry
|
||||
|
||||
| Field | Notes |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ChallengeId` | Unique within the rotation, not sequential (`37, 38, 44, 49, 63`). Posted back on `updateProgress`. |
|
||||
| `Name` | Internal slug, never displayed — and **not authoritative**: `63` is named `Complete3SpillwayGames` but its `Config` and description are Clearcut. Trust `Config`, not the name. |
|
||||
| `Config` | The rule tree, as an **escaped JSON string** (not a nested object). See below. |
|
||||
| `Description` | The one-line goal, e.g. `"Complete 10 games in ^Paintball"`. |
|
||||
| `Tooltip` | The longer hint under it. |
|
||||
| `Complete` | Per-player state, so always `false` in the file — `getCurrent` overwrites it per caller from `challenge_status`. |
|
||||
|
||||
`^Token` in `Description`/`Tooltip` is a client-side room link: the client resolves the
|
||||
token to a room and renders a tappable name. Subrooms use a dotted path
|
||||
(`^Paintball.Clearcut`). It is optional decoration, not markup the client requires — the
|
||||
same rotation writes both `"Complete 10 games in ^Paintball"` and, plainly,
|
||||
`"Complete 3 games of Paintball: Clear Cut"`.
|
||||
|
||||
### The `Config` rule tree
|
||||
|
||||
An escaped JSON string holding a tree of nodes, each with a numeric type in `ct`: a
|
||||
**Match** (`ct: 0`, `wc` is a list of predicates that must all hold for one game result) or
|
||||
a **Counter** (`ct: 1`, `ctc` is the child node to count and `t` the target). Leaves match a
|
||||
scene allow-list (`ct: 7`, subroom `UnitySceneId`s) or a session variable (`ct: 9`, e.g.
|
||||
`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.
|
||||
|
||||
### The `Gift` block
|
||||
|
||||
Same item vocabulary as a storefront `GiftDrop` (`AvatarItemDesc` — a comma-separated list
|
||||
of avatar-item guids, `AvatarItemType`, `ConsumableItemDesc`, `EquipmentPrefabName`,
|
||||
`EquipmentModificationGuid`) plus `Xp`, `Level` and `StorefrontType`, but two fields are
|
||||
**renamed**: a storefront's `Context`/`Rarity` are `GiftContext`/`GiftRarity` here. Don't
|
||||
feed one shape to the other's reader.
|
||||
|
||||
`EquipmentModificationGuid` is the Rec Room packed guid — 22-char URL-safe base64 of the 16
|
||||
guid bytes in .NET little-endian order, padding stripped (`g5u0weNLmkCLeUXFUVn74Q` →
|
||||
`c1b49b83-4be3-409a-8b79-45c55159fbe1`). The reward is identified by prefab + that guid,
|
||||
_not_ by `GiftDropId`: this block's `GiftDropId` is `3994`, while the same skin sells in
|
||||
`sf3.json` as `2121` ("Camera Skin (Comic)").
|
||||
|
||||
**Granted when the set is finished** — see below. The grant path is `buyItem`'s, so the
|
||||
block is translated into a storefront gift-drop first (`toChallengeGiftDrop`); the renamed
|
||||
`GiftContext`/`GiftRarity` are exactly what that translation is for.
|
||||
|
||||
The block carries no display strings and a `GiftRarity` of `0` for an item that sells at
|
||||
rarity `5`, so both are taken from the catalog entry selling the same item (matched on
|
||||
equipment guid / avatar desc) — the reward reads as "Camera Skin (Comic)", not as the box it
|
||||
might have arrived in. An explicit `FriendlyName`/`Tooltip` on the block wins over the
|
||||
catalog if a rotation we publish sets them; neither is present in the captured one.
|
||||
|
||||
**`FallbackGiftName` is the other half of the reward, not just a label.** "4-Star Box" is
|
||||
what the player gets _instead_ when they already own the item — the real game phrased it
|
||||
"…or a 4-Star Box!" — so it is granted as a query drop (a roll) at the tier its star count
|
||||
names, via the ladder in the query-drop section. Renaming it to `3-Star Box` retunes the
|
||||
consolation tier with no code change; a name that doesn't parse falls back to 4 stars.
|
||||
|
||||
### Winning the gift (`challenge_gift`)
|
||||
|
||||
There is no claim endpoint and the client never asks: the reward is handed out from the
|
||||
`updateProgress` call that reaches the threshold. Every completing report on the **live**
|
||||
rotation re-reads the caller's completions and, once enough of `weekly-challenge.json`'s
|
||||
challenges are there, grants the `Gift` the way a purchase grants a drop — the item into
|
||||
`inventory`/`equipment`/`consumable`, plus a gift box (message
|
||||
`Weekly challenge complete!`) the player finds in `GET /api/avatar/v2/gifts`.
|
||||
|
||||
**Three of five, not five of five** (`CHALLENGES_REQUIRED_FOR_GIFT`). A week publishes five
|
||||
challenges and the gift is for playing most of them, so the two a player can't reach — a
|
||||
quest they don't own, a mode they don't like — don't sink the whole week. The count is of
|
||||
challenges the rotation still **publishes**: a live client can report an id an edited
|
||||
rotation no longer lists, and three of those shouldn't buy a gift nobody worked for. A
|
||||
rotation publishing fewer than three can only ask for what it has.
|
||||
|
||||
**The item, or a roll.** If the player already owns the `Gift`'s item — likely, since the
|
||||
rotation's reward is one fixed item that sells in the store — they get the
|
||||
`FallbackGiftName` box instead, rolled at its star tier. Finishing the week can't be worth
|
||||
nothing. A `Gift` block carrying no ownable item at all (no avatar desc, no equipment guid)
|
||||
counts as "already owned", so a rotation whose reward is _only_ a box is written by leaving
|
||||
the block empty and naming the tier.
|
||||
|
||||
- **`challenge_gift` makes it happen once.** One row per (account, rotation); the row's
|
||||
existence _is_ the grant. The client keeps reporting after the set is finished, so the
|
||||
insert is the gate: `ON CONFLICT … DO NOTHING … RETURNING` claims it in one statement, and
|
||||
a second report returns no row and grants nothing.
|
||||
- **Claim first, grant second** — at-most-once. If the grant then fails the reward is lost
|
||||
rather than doubled; it's logged (`failed to grant weekly challenge gift`) and re-granted
|
||||
by hand if it ever happens. A faucet that sticks is easier to spot than one that leaks.
|
||||
- **The response is unchanged; the socket carries the news.** `updateProgress` answers the
|
||||
same four fields whether or not a gift was won, and a `GiftPackageReceivedImmediate` (31)
|
||||
frame goes out over the hub with the box — that's what pops the reward panel the moment
|
||||
the set is finished, instead of the player finding it on the next read of the gifts list.
|
||||
The payload is the reference server's field-for-field (`Id`, `FromGiftDropId: 0`,
|
||||
`FromPlayerId`, the item fields, `Platform`/`PlatformsToSpawnOn: -1`, `BalanceType: -2`,
|
||||
`Message`), and it names the **rolled** item when the fallback box is what was granted.
|
||||
`Immediate` (31) rather than `GiftPackageReceived` (30) is what the reference sends for a
|
||||
box the server hands over unasked; the sender is Coach (1). Best-effort — a hub failure is
|
||||
logged and swallowed, since the gift is already granted and stored.
|
||||
- **`CompletedRequired: true` makes the rotation all-or-nothing** — the threshold becomes
|
||||
every published challenge. That reading of the flag is still _inferred_ (it is `false` in
|
||||
the captured rotation, which is the partial default), but it's the one its name and the
|
||||
three-of-five rule agree on.
|
||||
- **`Xp`/`Level` on the block are ignored**, as on a purchase — same gap, and both are `0`
|
||||
in the captured rotation.
|
||||
- **A report against an old rotation never wins anything**, and an empty `Challenges` array
|
||||
earns nothing (its threshold clamps to zero, which every player would otherwise meet
|
||||
without playing).
|
||||
- **Players already past the threshold when this shipped still get it**: the client
|
||||
re-reports completed challenges, and the first such report is a completing report.
|
||||
|
||||
### Progress (`challenge_status`)
|
||||
|
||||
`POST /api/challenge/v2/updateProgress` (auth-gated) upserts one row per (account,
|
||||
challenge) into `challenge_status`, and `getCurrent` reads them back to stamp `Complete`.
|
||||
The body is `{ ChallengeMapId, ChallengeId, Config, Complete }` with the ids as **strings**
|
||||
and `Complete` as .NET's `"True"`/`"False"` — capitalized, so `Boolean(body.Complete)` reads
|
||||
"not complete" as complete (`parseBool` handles both spellings and a real JSON `true`).
|
||||
|
||||
Only the completion is stored. `Config` is the catalog's own rule tree plus the client's
|
||||
running count, so a per-player copy would just be a staler duplicate of static data — it is
|
||||
echoed back untouched but never persisted. The response is the four posted fields, except
|
||||
`Complete` is the **stored** value rather than the posted one, because:
|
||||
|
||||
- **Completion latches within a rotation.** The client reports repeatedly, and a later
|
||||
report saying "not complete" (a fresh session, a retry arriving out of order) must not
|
||||
un-finish something already finished.
|
||||
- **A new rotation resets the row.** Challenge ids are only unique within a rotation, so
|
||||
the same id in a later week would otherwise start out already complete. A report whose
|
||||
`ChallengeMapId` differs from the stored one replaces the row instead of latching; reads
|
||||
are scoped to the rotation for the same reason.
|
||||
|
||||
`getCurrent`'s auth is **optional** — an unauthenticated caller gets the static rotation
|
||||
with every `Complete` false rather than a 401, since the rotation is public and a failure
|
||||
on this route can stall the client's load. The overlay rebuilds the response object rather
|
||||
than stamping the imported JSON in place: that import is module state shared across every
|
||||
request an isolate serves, so mutating it would leak one player's completions to the next
|
||||
caller.
|
||||
|
||||
## Game rewards (`reward_status`)
|
||||
|
||||
The client asks for a reward whenever it thinks one is due, posting a form body of the type
|
||||
and the message to show for it:
|
||||
|
||||
```
|
||||
rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day
|
||||
rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Soccer
|
||||
```
|
||||
|
||||
Since the client asks rather than the server offering, whether a reward is actually **owed**
|
||||
is decided here, from `reward_status` — one row per (account, reward type, gift context)
|
||||
holding the last claim and a count. One claim per type per activity per hour
|
||||
(`REWARD_COOLDOWN_MS`), flat for every type despite what a name like `FirstActivityOfDay`
|
||||
suggests; per-type windows would be a map keyed by type.
|
||||
|
||||
- **The claim is one SQL statement** (`ON CONFLICT … DO UPDATE … WHERE`). The client fires
|
||||
these off right after a match, so two can land together; a read-then-write would let both
|
||||
see the same stale `granted_at` and pay out twice.
|
||||
- **A rejected claim leaves `granted_at` alone.** If an on-cooldown ask pushed the timestamp
|
||||
forward, a client that retries in a loop would never become eligible.
|
||||
- **`giftContext` (the activity, e.g. `Soccer`) is part of the key** — the "first activity of
|
||||
the day" is per activity, so a player who moves from Soccer to Paintball is owed another
|
||||
reward while a second Soccer match inside the hour is not.
|
||||
- **A contextless ask keys on `''`, not NULL.** SQLite allows — and does not dedupe — NULLs
|
||||
in a non-INTEGER primary key, so a NULL context would insert a fresh row on every ask
|
||||
instead of hitting the conflict, and the cooldown would never apply. Migration
|
||||
`0013_reward_status_gift_context.sql` rebuilds the table (SQLite can't add a column to a
|
||||
primary key) and lands the pre-existing rows on that same `''` bucket, so cooldowns from
|
||||
before it keep counting.
|
||||
|
||||
**What a claim pays: 5 XP, in a gift box.** The XP (`GAME_REWARD_XP`) is banked in
|
||||
`progression` and the box is the wrapper the client shows for it — no item, every item field
|
||||
empty, `GiftContext` 50 (`GameRewards`). The box wears the `Message` the client posted
|
||||
(`First Game of the Day`), and a `GiftPackageReceivedImmediate` frame goes out with it, the
|
||||
same push the weekly-challenge gift uses. XP is banked **before** the box is created, so a
|
||||
failure can't leave a box promising XP nobody was credited.
|
||||
|
||||
- **One flat amount for every reward type**, matching the one flat cooldown they share.
|
||||
Pricing `FirstActivityOfDay` differently from `PostGameActivity` is a map keyed by type,
|
||||
the same shape the per-type cooldown would take.
|
||||
- **Deliberately smaller than a level.** The first level costs 10 XP, so a single action
|
||||
can't be a level-up — it takes two rewards to reach level 2, and the early levels are paced
|
||||
by the hourly cooldown rather than cleared in one match.
|
||||
- **The response stays `[]`.** It's what the client already accepts, and the reward is
|
||||
delivered as a box, so there's nothing to put in the body. The reference answers its own
|
||||
(different) flow with `{ error, success, value: null }`, not a list of rewards.
|
||||
- **An on-cooldown ask pays nothing** — no XP, no box, no frame. That's the whole point of
|
||||
getting eligibility right first: a client that retries in a loop must not mint boxes.
|
||||
|
||||
**Progression (`progression`) is shared.** `econ` writes it here; `api` reads it back for
|
||||
`GET /api/players/v{1,2}/progression/…`. It lives in `@repo/domain` for that reason, the
|
||||
same split as gift boxes. A player with no row reads as level 1 / 0 XP, so a GET never
|
||||
inserts.
|
||||
|
||||
**Levelling spends the XP.** `xp` is progress into the current level, not a lifetime total:
|
||||
`addXp` adds the grant, then walks the ladder in `LEVEL_REQUIRED_XP`, subtracting each
|
||||
level's cost while it's covered — so a big enough grant can cross several levels at once.
|
||||
The ladder steps 10 → 20 → 45 → 115 → 360 → 1080 every ten levels and stops at 50, so the
|
||||
first level costs 10 XP and the last costs a hundred times that.
|
||||
|
||||
That table is copied from the `LevelProgressionMaps` the client is served in
|
||||
`apps/api/static/api-config-v2.json`, and **both sides have to agree** or the bar fills to a
|
||||
different mark than the level-up fires at; an `api` test asserts they stay identical.
|
||||
|
||||
It is also the real game's curve, checked against Rec Room's own published level chart —
|
||||
cumulative XP to finish a level: 170 by 10, 620 by 20, 1,770 by 30, 5,370 by 40, 16,170 by 50. Nearly flat to level 20, then a knee at 30–40 and a steep climb to the cap; a third of
|
||||
the whole grind sits in the last ten levels. A test pins those milestones, since per-level
|
||||
costs are easy to edit one at a time and hard to eyeball as a curve.
|
||||
|
||||
**Every level pays out a reward**, from Rec Room's published level-reward table
|
||||
(`LEVEL_REWARDS` in `@repo/domain`) — per level, not per band:
|
||||
|
||||
| Levels | Reward |
|
||||
| ---------------- | ------------------------------------------ |
|
||||
| 1, 3, 5, 6, 7, 9 | Consumable |
|
||||
| 2, 4, 8, 10 – 21 | 2-Star Clothing (rarity 10) |
|
||||
| 22 – 30 | 3-Star on even levels, 2-Star between |
|
||||
| 31 – 39 | 3-Star, with 4-Star at 31 and 35 |
|
||||
| 40 – 49 | 4-Star Clothing (rarity 30) |
|
||||
| 50 | 5-Star Clothing (rarity 50) — the only one |
|
||||
|
||||
**One reward per level crossed** — a grant spanning several levels pays each of them. In
|
||||
practice a 5 XP game reward crosses at most one, so the second reward a fresh player claims
|
||||
hands over two boxes: the XP reward itself and the 2-Star Clothing for reaching level 2. Each
|
||||
arrives as a gift box announced like any other (`Level 2!`).
|
||||
|
||||
- **"Clothing" is why the roll passes `avatarItemsOnly`** — the prize has to be something the
|
||||
player can wear and be seen in, never an equipment skin for a weapon they may not own.
|
||||
- **Consumable levels don't roll a rarity.** The table names no star tier for them, and
|
||||
consumables stack, so there's no ownership filter either — a second Confetti Cannon is a
|
||||
fine prize. It's picked as a concrete drop rather than through the query path.
|
||||
- **This table is not the served config's `GiftRarity`.** That one is a coarse per-band tier
|
||||
(flat 10 to level 14, 20 to 39, 30 to 49, 50 at the cap) with no notion of consumables, and
|
||||
the two disagree — level 15 is 2-Star in the published table and 20 in the config. We grant
|
||||
from the published table; the config is left as captured, so the drift test asserts only
|
||||
the XP costs. If the client previews an upcoming reward from `GiftRarity`, aligning the two
|
||||
is an edit to the static config.
|
||||
- The reference server carries the config data and never reads it: granting anything for a
|
||||
level is ours.
|
||||
|
||||
**The client is told, or it shows nothing.** A grant pushes `PlayerProgressionLevelUpdate`
|
||||
(`{ PlayerId, Level, XP }`) — without it the bar sits still until something else refreshes
|
||||
it, which is what "levelling does nothing" looks like from the game. `api`'s
|
||||
`GET /api/players/v1/progression/:id` pushes the same frame on read, as the reference does,
|
||||
so a client that just connected gets its bar right.
|
||||
|
||||
**Not ported:** the reference's `request` doesn't grant at all — it offers **three** drops,
|
||||
pushes a `RewardSelectionReceived` frame and waits for `POST /api/gamerewards/v1/select` to
|
||||
grant the one the player picked. We grant on request instead, so there is no selection state
|
||||
and no `/select`. It also caps activity XP per day (`daily_xp_ledgers`); the hourly cooldown
|
||||
is our cap.
|
||||
|
||||
`GET /api/gamerewards/v1/pending` stays `[]`: with rewards claimed on request, nothing sits
|
||||
waiting to be collected.
|
||||
|
||||
## Bindings
|
||||
|
||||
| Binding | Type | Notes |
|
||||
| ---------------------------- | -------------- | -------------------------------------------------------- |
|
||||
| `DB` | D1 | Shared `recflare` database — balances, inventory, etc. |
|
||||
| ---------------------------- | -------------- | ---------------------------------------------------------- |
|
||||
| `DB` | D1 | Shared `recflare` database — balances, inventory, XP, etc. |
|
||||
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
|
||||
| `ASSETS` | static assets | Serves `sf{N}.json` storefront catalogs |
|
||||
| `RECFLARE_NOTIFICATIONS_HUB` | Durable Object | Cross-worker RPC to the `notify` worker's hub |
|
||||
@@ -109,8 +444,19 @@ Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no cod
|
||||
|
||||
## Known gaps
|
||||
|
||||
- Gifting to another player grants the item and box but does not notify the recipient.
|
||||
- `buyItem` grants avatar-item and consumable drops; currency/xp drops aren't granted.
|
||||
- Gifting to another player grants the item and box but does not notify the recipient — the
|
||||
reference sends `GiftPackageReceivedImmediate` there too (`buy.go`, when the body carries
|
||||
a `Gift`), and `pushGiftReceived` is now sitting right there to do it.
|
||||
- `buyItem` grants avatar-item, equipment, consumable and query (box) drops; currency/xp
|
||||
drops aren't granted.
|
||||
- A query drop rolls uniformly across the tier and can't run at a rarity sf3 doesn't
|
||||
publish; per-item weighting and a multi-catalog pool would both need a manifest of the
|
||||
storefronts, which the ASSETS binding can't enumerate.
|
||||
- Consumables are granted and listed but never spent by gameplay, so `Count` only grows.
|
||||
- Several routes (room keys, wishlist, equipment, room consumables/currencies, game
|
||||
rewards) are empty-list stubs pending their own stores.
|
||||
- Several routes (room keys, wishlist, equipment, room consumables/currencies) are
|
||||
empty-list stubs pending their own stores.
|
||||
- Game rewards pay a flat 5 XP; there is no daily XP cap beyond the hourly cooldown (the
|
||||
reference caps activity XP per day in `daily_xp_ledgers`).
|
||||
- The level-reward table and the served config's `GiftRarity` disagree in places (see the
|
||||
level section); we grant from the table and leave the config as captured, so a client that
|
||||
previews an upcoming reward would preview the config's answer, not ours.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Owned inventions, owned by the `econ` worker. One row per (account, invention): the
|
||||
-- inventions a player has bought from the invention store. Written at purchase time by
|
||||
-- `/api/storefronts/v2/buyInvention`, which also uses it to reject a re-buy. Ownership
|
||||
-- is boolean (you own an invention or you don't), so the pair is the primary key and a
|
||||
-- second purchase is a no-op rather than a duplicate row.
|
||||
--
|
||||
-- The invention itself lives in the `invention` table, whose schema/migrations the `api`
|
||||
-- worker owns (apps/api/migrations/0002_invention.sql) on this same `recflare` database;
|
||||
-- only the id is stored here. Creators are NOT listed here — an invention's creator owns
|
||||
-- it by virtue of `CreatorPlayerId`, and never buys their own. Kept in sync with
|
||||
-- INVENTORY_INVENTION_SCHEMA_DDL in src/inventory-invention-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_invention (
|
||||
account_id INTEGER NOT NULL,
|
||||
invention_id INTEGER NOT NULL,
|
||||
acquired_at TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, invention_id)
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Weekly-challenge progress, owned by the `econ` worker. One row per (account,
|
||||
-- challenge): the client evaluates a challenge's rule tree locally and posts its verdict
|
||||
-- to `/api/challenge/v2/updateProgress`, which upserts here; `/api/challenge/v2/getCurrent`
|
||||
-- reads the rows back to stamp each challenge's per-player `Complete`.
|
||||
--
|
||||
-- Only the completion flag is stored. The `Config` rule tree posted alongside it is the
|
||||
-- challenge's definition (static/weekly-challenge.json, identical for every player) plus
|
||||
-- the client's running count in `cc`; the server evaluates none of it, so a per-player copy
|
||||
-- would just be a staler duplicate of the catalog.
|
||||
--
|
||||
-- `challenge_map_id` is the rotation the report belongs to. It is not part of the key, but
|
||||
-- it scopes reads and resets the row when a challenge id comes back in a later rotation:
|
||||
-- ids are only unique within one. Kept in sync with CHALLENGE_STATUS_SCHEMA_DDL in
|
||||
-- src/challenge-db.ts.
|
||||
|
||||
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,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, challenge_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_challenge_status_account_map ON challenge_status (account_id, challenge_map_id);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Game-reward eligibility, owned by the `econ` worker. One row per (account, reward type):
|
||||
-- the client asks for a reward whenever it thinks one is due (`POST
|
||||
-- /api/gamerewards/v1/request` with `rewardType`/`Message`), so this table is what decides
|
||||
-- whether one is actually owed and keeps a repeat ask from paying out twice.
|
||||
--
|
||||
-- `granted_at` is when the type was last claimed and `grant_count` how many times it has
|
||||
-- been; the claim is a conditional upsert, so the check and the write are one atomic
|
||||
-- statement (the client can fire two requests at once after a match).
|
||||
--
|
||||
-- The reward TYPE is the whole key. The client also sends a `giftContext` (the activity,
|
||||
-- e.g. `Soccer`), deliberately not keyed on: one cooldown per type, shared across
|
||||
-- activities. Kept in sync with REWARD_STATUS_SCHEMA_DDL in src/reward-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reward_status (
|
||||
account_id INTEGER NOT NULL,
|
||||
reward_type TEXT NOT NULL,
|
||||
granted_at TEXT NOT NULL,
|
||||
grant_count INTEGER NOT NULL,
|
||||
PRIMARY KEY (account_id, reward_type)
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Weekly-challenge gift grants, owned by the `econ` worker. One row per (account,
|
||||
-- rotation), written when the last challenge of a rotation is reported complete on
|
||||
-- `/api/challenge/v2/updateProgress` and the rotation's `Gift` is handed out.
|
||||
--
|
||||
-- The table exists only to make that grant happen ONCE. The client reports progress
|
||||
-- repeatedly, so every report that arrives with the set already finished would otherwise
|
||||
-- mint another copy of the reward; the insert is the gate, and it conflicts on the second
|
||||
-- report instead of paying out again.
|
||||
--
|
||||
-- Keyed by rotation as well as account so a new week's set can be finished and rewarded on
|
||||
-- its own — `challenge_map_id` is the rotation, matching `challenge_status`. There is no
|
||||
-- `granted` flag: the row's existence IS the grant. Kept in sync with
|
||||
-- CHALLENGE_GIFT_SCHEMA_DDL in src/challenge-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS challenge_gift (
|
||||
account_id INTEGER NOT NULL,
|
||||
challenge_map_id INTEGER NOT NULL,
|
||||
granted_at TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, challenge_map_id)
|
||||
);
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Player progression (level + XP), owned by the `econ` worker as the writer, but shared:
|
||||
-- `econ` pays XP out (game rewards) and `api` reads it back for
|
||||
-- `GET /api/players/v{1,2}/progression/…`, so the helpers live in @repo/domain rather than
|
||||
-- in either worker. Same split as `received_gift`.
|
||||
--
|
||||
-- One row per account, created on the first grant. A missing row means "nothing earned
|
||||
-- yet", which is the level-1/0-XP default the progression endpoints already served — so
|
||||
-- reads fall back to it instead of inserting on a GET.
|
||||
--
|
||||
-- `level` is stored rather than derived: the reference server levels a player up by
|
||||
-- subtracting the tier's RequiredXp from the running XP, using thresholds from a config we
|
||||
-- don't have (configv2.json's LevelProgressionMaps). Until those numbers exist XP
|
||||
-- accumulates and everyone stays level 1; the column is here so turning the curve on later
|
||||
-- is a write, not a migration. Kept in sync with PROGRESSION_SCHEMA_DDL in
|
||||
-- packages/domain/src/progression-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS progression (
|
||||
account_id INTEGER PRIMARY KEY,
|
||||
level INTEGER NOT NULL DEFAULT 1,
|
||||
xp INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Widen the game-reward cooldown key to include the activity the reward came from.
|
||||
--
|
||||
-- The client posts a `giftContext` alongside the type (`rewardType=PostGameActivity&
|
||||
-- giftContext=Soccer`), which migration 0010 deliberately dropped: one cooldown per type,
|
||||
-- shared across activities. That means the first activity of the day pays once no matter
|
||||
-- how many different activities a player runs. Keying on (type, context) instead gives
|
||||
-- each activity its own cooldown, so a different activity pays again while the same one
|
||||
-- stays on cooldown.
|
||||
--
|
||||
-- SQLite can't add a column to a primary key, so the table is rebuilt and the rows copied
|
||||
-- across. Existing rows have no context and take `''` — NOT the NULL that would read more
|
||||
-- naturally, because SQLite allows (and does not dedupe) NULLs in a non-INTEGER primary
|
||||
-- key, which would let the upsert insert a second unkeyed row instead of updating the
|
||||
-- first and pay out every time. Asks that carry no `giftContext` land on that same `''`
|
||||
-- bucket, so a pre-migration cooldown keeps counting.
|
||||
|
||||
CREATE TABLE reward_status_new (
|
||||
account_id INTEGER NOT NULL,
|
||||
reward_type TEXT NOT NULL,
|
||||
gift_context TEXT NOT NULL,
|
||||
granted_at TEXT NOT NULL,
|
||||
grant_count INTEGER NOT NULL,
|
||||
PRIMARY KEY (account_id, reward_type, gift_context)
|
||||
);
|
||||
|
||||
INSERT INTO reward_status_new (account_id, reward_type, gift_context, granted_at, grant_count)
|
||||
SELECT account_id, reward_type, '', granted_at, grant_count FROM reward_status;
|
||||
|
||||
DROP TABLE reward_status;
|
||||
|
||||
ALTER TABLE reward_status_new RENAME TO reward_status;
|
||||
@@ -1,3 +1,5 @@
|
||||
import { BalancePlatform } from '../../notify/src/notification-payloads'
|
||||
|
||||
/**
|
||||
* Currency balances on the shared `recflare` D1 database.
|
||||
*
|
||||
@@ -13,7 +15,8 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* The currencies the client knows about (its `CurrencyType` enum). The client sends
|
||||
* The currencies the client knows about (its `CurrencyType` enum, obfuscated
|
||||
* `GKPEKOLBBJL` — which lists every member below except `RoomInventoryItem`). The client sends
|
||||
* these ints in the balance/storefront paths — `/api/storefronts/v4/balance/2` is
|
||||
* RecCenterTokens — so the values are fixed by the client, not by us.
|
||||
*
|
||||
@@ -90,10 +93,23 @@ export function startingBalances(
|
||||
}
|
||||
|
||||
/**
|
||||
* `Platform` in the client's balance DTO. -2 is "all platforms" — we don't track
|
||||
* per-platform wallets (real RecNet did, for platform-purchased tokens).
|
||||
* The ONE balance bucket this server uses: `NonPurchasedNotUsableInP2P` (-2).
|
||||
*
|
||||
* The client keys a balance by `(CurrencyType, Platform)` and shows the SUM of the buckets,
|
||||
* so which Platform a balance is reported under is not cosmetic — it is the bucket's
|
||||
* identity. Everything we hand out is minted rather than bought, and we track no
|
||||
* per-platform wallets (real RecNet did, for tokens paid for on each store), so one
|
||||
* account-wide bucket per currency answers for all of them.
|
||||
*
|
||||
* Every surface that names the bucket must name THIS one: the balance DTO's `Platform`, the
|
||||
* `BalanceType` the storefront HTTP bodies echo, and the `Platform` on every
|
||||
* `StorefrontBalance*` socket frame. Naming a second one there invents a balance the client
|
||||
* adds to the real total — see the frame rule in econ.app.ts.
|
||||
*
|
||||
* The enum itself lives in the notify worker's `notification-payloads.ts`, recovered from
|
||||
* the client's decoder, rather than being duplicated here.
|
||||
*/
|
||||
export const ALL_PLATFORMS = -2
|
||||
export const ALL_PLATFORMS: BalancePlatform = BalancePlatform.NonPurchasedNotUsableInP2P
|
||||
|
||||
/** Schema DDL (mirror of migrations 0001_balance.sql) — also used to build the table in tests. */
|
||||
export const BALANCE_SCHEMA_DDL: string[] = [
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* 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`.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations 0009_challenge_status.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,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, challenge_id)
|
||||
)`,
|
||||
]
|
||||
|
||||
/** One challenge's progress as the client reports it. */
|
||||
export interface ChallengeProgress {
|
||||
challengeMapId: number
|
||||
challengeId: number
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a progress report and return the completion 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`.
|
||||
*
|
||||
* 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
|
||||
* statement overwrites it.
|
||||
*/
|
||||
export async function recordChallengeProgress(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
progress: ChallengeProgress
|
||||
): Promise<boolean> {
|
||||
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)
|
||||
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,
|
||||
challenge_map_id = excluded.challenge_map_id,
|
||||
updated_at = excluded.updated_at
|
||||
RETURNING complete`
|
||||
)
|
||||
.bind(
|
||||
accountId,
|
||||
progress.challengeId,
|
||||
progress.challengeMapId,
|
||||
progress.complete ? 1 : 0,
|
||||
new Date().toISOString()
|
||||
)
|
||||
.first<{ complete: number }>()
|
||||
return row?.complete === 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
export async function getCompletedChallengeIds(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
challengeMapId: number
|
||||
): Promise<Set<number>> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT challenge_id FROM challenge_status
|
||||
WHERE account_id = ?1 AND challenge_map_id = ?2 AND complete = 1`
|
||||
)
|
||||
.bind(accountId, challengeMapId)
|
||||
.all<{ challenge_id: number }>()
|
||||
return new Set(results.map((r) => r.challenge_id))
|
||||
}
|
||||
|
||||
/** Schema DDL (mirror of migrations 0011_challenge_gift.sql) — also builds the table in tests. */
|
||||
export const CHALLENGE_GIFT_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS challenge_gift (
|
||||
account_id INTEGER NOT NULL,
|
||||
challenge_map_id INTEGER NOT NULL,
|
||||
granted_at TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, challenge_map_id)
|
||||
)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* Take the one gift a rotation owes a player, returning whether this call is the one that
|
||||
* got it — `false` means it was already handed out and the caller must grant nothing.
|
||||
*
|
||||
* The client keeps reporting progress after the set is finished, so "has this been paid?"
|
||||
* has to be asked and answered in ONE statement: a read-then-insert would let two reports
|
||||
* that land together both see no row and both pay out. `ON CONFLICT … DO NOTHING` with
|
||||
* `RETURNING` gives us that — the second insert matches the existing row, writes nothing
|
||||
* and returns nothing.
|
||||
*
|
||||
* The gate is deliberately at-most-once: the row is claimed BEFORE the items are granted,
|
||||
* so a failure mid-grant loses the reward rather than risking a second one. It is a faucet,
|
||||
* and a stuck one is easier to notice and re-grant by hand than a leaking one.
|
||||
*/
|
||||
export async function claimChallengeGift(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
challengeMapId: number,
|
||||
now: Date = new Date()
|
||||
): Promise<boolean> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO challenge_gift (account_id, challenge_map_id, granted_at)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT (account_id, challenge_map_id) DO NOTHING
|
||||
RETURNING granted_at`
|
||||
)
|
||||
.bind(accountId, challengeMapId, now.toISOString())
|
||||
.first<{ granted_at: string }>()
|
||||
return row !== null
|
||||
}
|
||||
+1227
-103
@@ -2,12 +2,29 @@ import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { consumeGift, createGift, getGift, getPendingGifts } from '@repo/domain'
|
||||
import {
|
||||
addXp,
|
||||
consumeGift,
|
||||
createGift,
|
||||
getGift,
|
||||
getPendingGifts,
|
||||
grantInvention,
|
||||
levelReward,
|
||||
levelsReached,
|
||||
ownsInvention,
|
||||
} from '@repo/domain'
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
import { validateAndGetAccountId, validateAndGetRoles } 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.
|
||||
// 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'
|
||||
// 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
|
||||
// wrong or renamed key (see the `Platform`/`BalanceType` trap) fails the build here.
|
||||
import { BalanceAddType } from '../../notify/src/notification-payloads'
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
import adCarouselItems from '../static/ad-carousel-items.json'
|
||||
import defaultAvatarItems from '../static/default-avatar-items.json'
|
||||
@@ -17,11 +34,19 @@ import weeklyChallenge from '../static/weekly-challenge.json'
|
||||
import { getAvatar, setAvatar } from './avatar-db'
|
||||
import {
|
||||
ALL_PLATFORMS,
|
||||
creditCurrency,
|
||||
CurrencyType,
|
||||
DEFAULT_STARTING_TOKENS,
|
||||
ensureStartingBalances,
|
||||
getBalance,
|
||||
isSpendable,
|
||||
spendCurrency,
|
||||
} from './balance-db'
|
||||
import {
|
||||
claimChallengeGift,
|
||||
getCompletedChallengeIds,
|
||||
recordChallengeProgress,
|
||||
} from './challenge-db'
|
||||
import {
|
||||
consumeConsumable,
|
||||
countConsumable,
|
||||
@@ -34,6 +59,7 @@ import {
|
||||
AUTHED,
|
||||
AvatarV2Dto,
|
||||
BalanceEntry,
|
||||
BuyInventionResponse,
|
||||
BuyItemRequest,
|
||||
BuyItemResponse,
|
||||
ChallengeProgressRequest,
|
||||
@@ -45,20 +71,29 @@ import {
|
||||
EquipmentUpdateRequest,
|
||||
ErrorResponse,
|
||||
form,
|
||||
GameRewardRequest,
|
||||
json,
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
JsonObject,
|
||||
OpaqueJsonBody,
|
||||
OPTIONAL_AUTHED,
|
||||
SaveOutfitRequest,
|
||||
SaveOutfitV4Response,
|
||||
SubscriptionResponse,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
UpdateObjectiveRequest,
|
||||
UpdateObjectiveResponse,
|
||||
} from './openapi'
|
||||
import { getOutfits, setOutfit } from './outfit-db'
|
||||
import { claimReward } from './reward-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { GiftContent, StoredGift } from '@repo/domain'
|
||||
import type { GiftContent, Progression, StoredGift, XpGrant } from '@repo/domain'
|
||||
import type {
|
||||
BalanceResponsePayload,
|
||||
PurchaseBalanceModificationPayload,
|
||||
} from '../../notify/src/notification-payloads'
|
||||
import type { Avatar } from './avatar-db'
|
||||
import type { ConsumeResult } from './consumables-db'
|
||||
import type { App } from './context'
|
||||
@@ -69,7 +104,8 @@ import type { Outfit } from './outfit-db'
|
||||
/**
|
||||
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
|
||||
* the `econ` service (these are separate from the main `api` worker). Balances,
|
||||
* inventory, consumables, saved outfits, avatars and gift boxes are D1-backed;
|
||||
* inventory (avatar items, equipment, bought inventions), consumables, saved outfits,
|
||||
* avatars, gift boxes, weekly-challenge progress and game-reward eligibility are D1-backed;
|
||||
* storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS
|
||||
* binding. Some routes are still empty-list stubs (room keys, wishlist, …).
|
||||
*
|
||||
@@ -84,11 +120,31 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
||||
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
}
|
||||
|
||||
/**
|
||||
* 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}.
|
||||
*/
|
||||
async function authedRoles(c: Context<App>): Promise<string[] | null> {
|
||||
return validateAndGetRoles(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
}
|
||||
|
||||
/** Results.Unauthorized() equivalent — 401 with empty body. */
|
||||
function unauthorized(c: Context<App>) {
|
||||
return c.body(null, 401)
|
||||
}
|
||||
|
||||
/**
|
||||
* A boolean the client may send either as a JSON `true` or as .NET's `bool.ToString()`
|
||||
* output — `"True"`/`"False"`, capitalized. `Boolean(value)` is a trap here: the string
|
||||
* `"False"` is truthy, so a client reporting "not complete" would read as complete.
|
||||
* Anything unrecognised (missing, `null`, `""`) is false.
|
||||
*/
|
||||
function parseBool(value: string | boolean | undefined): boolean {
|
||||
return typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared parse/validate/store for the save-outfit routes (v3 and v4). Persists the
|
||||
* posted outfit into its `Slot` verbatim and returns the stored `Outfit`; on the
|
||||
@@ -184,13 +240,43 @@ async function pushConsumableAdded(
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a StorefrontBalanceUpdate to a player after their balance changes, mirroring the
|
||||
* reference's
|
||||
* `HubSendToPlayer(accountID, NotifFrame(StorefrontBalanceUpdate, {Balance, CurrencyType, BalanceType}))`.
|
||||
* The client applies it to the shown balance so a purchase debit reflects immediately,
|
||||
* without waiting for a `GET /balance` re-fetch. `Balance` is the resulting total in that
|
||||
* currency (not the delta), `BalanceType` is -2 (account-wide, all platforms). Best-effort:
|
||||
* a hub failure is logged and swallowed, since the balance change has already committed.
|
||||
* THE BALANCE-FRAME RULE, which both balance bugs came from getting wrong.
|
||||
*
|
||||
* The client holds a balance PER `(CurrencyType, Platform)` bucket and shows the SUM of the
|
||||
* buckets. Every `StorefrontBalance*` frame is an absolute SET of the one bucket it names —
|
||||
* not a change to apply — so:
|
||||
*
|
||||
* 1. `Balance` is the RESULTING TOTAL. Sending the change sets the bucket TO that change.
|
||||
* 2. The bucket key on the wire is `Platform`. The client's property is called
|
||||
* `BalanceType` but carries a `[DataMember]` rename, and its decoder drops unknown
|
||||
* members in silence — so a frame that says `BalanceType` lands in `Platform` 0,
|
||||
* `SteamPurchased`, and creates a SECOND bucket that is added to the real one forever.
|
||||
* 3. That bucket must be the same one `GET /api/storefronts/v4/balance/:type` reports,
|
||||
* `ALL_PLATFORMS`. One account-wide bucket per currency is the whole model here; a
|
||||
* frame naming any other Platform is a phantom balance, not a per-store nicety.
|
||||
*
|
||||
* Both live bugs were rule 2 or 3, and both looked like the frame being "additive":
|
||||
* - A player who earned 250 on 10,000 read 20,250 — `BalanceType: -2` was dropped, so the
|
||||
* total landed in a phantom Steam bucket beside the real one.
|
||||
* - A player who spent 900 of 17,500 read 34,100, then 33,200 once the purchase response's
|
||||
* -900 reached the real bucket — same phantom bucket, this time from `Platform: RecNet`.
|
||||
* Neither was additivity: the totals were right, the bucket was wrong. Frames as specified
|
||||
* here are idempotent, so re-sending one or racing a `GET /balance` cannot drift the total.
|
||||
*
|
||||
* See apps/notify/src/notification-payloads.ts for the payload shapes this is recovered
|
||||
* from — the interfaces there type these calls, so a wrong key is now a build error.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Push a StorefrontBalanceUpdate (61) — "your balance in this bucket is now X" — after a
|
||||
* player's balance changes for a reason that is not their own purchase. `balance` is their
|
||||
* resulting TOTAL in that currency, per the rule above.
|
||||
*
|
||||
* A player who is reading the HTTP response for the same change gets this too: it sets the
|
||||
* bucket to the same total the body reports, so the two agree rather than compound. Pushing
|
||||
* it is what saves them a `GET /balance` re-fetch.
|
||||
*
|
||||
* Best-effort: a hub failure is logged and swallowed, since the change has already committed.
|
||||
*/
|
||||
async function pushBalanceUpdate(
|
||||
c: Context<App>,
|
||||
@@ -198,15 +284,19 @@ async function pushBalanceUpdate(
|
||||
currencyType: number,
|
||||
balance: number
|
||||
): Promise<void> {
|
||||
// `satisfies` rather than a type annotation: the hub takes a Record<string, unknown>, and
|
||||
// an interface (unlike an inferred object type) has no implicit index signature to match
|
||||
// it. This still checks every key against the shape the client's decoder parses.
|
||||
const payload = {
|
||||
Balance: balance,
|
||||
CurrencyType: currencyType,
|
||||
Platform: ALL_PLATFORMS,
|
||||
} satisfies BalanceResponsePayload
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
accountId,
|
||||
NotificationType.StorefrontBalanceUpdate,
|
||||
{
|
||||
Balance: balance,
|
||||
CurrencyType: currencyType,
|
||||
BalanceType: ALL_PLATFORMS,
|
||||
}
|
||||
payload
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push StorefrontBalanceUpdate notification', {
|
||||
@@ -216,6 +306,99 @@ async function pushBalanceUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a StorefrontBalancePurchase (62) — the frame the reference sends when the balance
|
||||
* moved because the player BOUGHT something, as opposed to the plain update above. Same
|
||||
* absolute-set semantics: `balance` is the resulting total.
|
||||
*
|
||||
* `Delta` (the negated price) and `BalanceAddType` are display/telemetry only — the client
|
||||
* logs them and then stores `Balance` outright, so a correct `Delta` beside a stale
|
||||
* `Balance` still leaves the player's balance wrong. `Platform` is `ALL_PLATFORMS`, NOT
|
||||
* `RecNetPurchased`: it has to name the bucket `GET /balance` reports, and sending RecNet
|
||||
* here is exactly what doubled a buyer's tokens on screen. Best-effort, as above.
|
||||
*/
|
||||
async function pushBalancePurchase(
|
||||
c: Context<App>,
|
||||
accountId: number,
|
||||
currencyType: number,
|
||||
delta: number,
|
||||
balance: number
|
||||
): Promise<void> {
|
||||
const payload = {
|
||||
BalanceAddType: BalanceAddType.CommercePurchase,
|
||||
Delta: delta,
|
||||
Balance: balance,
|
||||
Platform: ALL_PLATFORMS,
|
||||
CurrencyType: currencyType,
|
||||
} satisfies PurchaseBalanceModificationPayload
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
accountId,
|
||||
NotificationType.StorefrontBalancePurchase,
|
||||
payload
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push StorefrontBalancePurchase notification', {
|
||||
accountId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** The operator-granted role that comes with a complimentary subscription. */
|
||||
const DEVELOPER_ROLE = 'developer'
|
||||
|
||||
/** `SubscriptionLevel.Gold`. 1 is Platinum. */
|
||||
const SUBSCRIPTION_LEVEL_GOLD = 0
|
||||
|
||||
/** `SubscriptionPeriod.Year`. 0 is Month, 2 ThreeMonth, 3 SixMonth. */
|
||||
const SUBSCRIPTION_PERIOD_YEAR = 1
|
||||
|
||||
/**
|
||||
* `PlatformType.All` (-1) — the subscription belongs to no single store, which is the honest
|
||||
* answer when no store sold it. The rest of the enum: 0 Steam, 1 Oculus, 2 PlayStation,
|
||||
* 3 Xbox, 4 RecNet, 5 IOS, 6 GooglePlay, 7 Standalone, 8 Pico.
|
||||
*/
|
||||
const SUBSCRIPTION_PLATFORM_ALL = -1
|
||||
|
||||
/** The id every reported subscription carries — a placeholder, since none is stored. */
|
||||
const STUB_SUBSCRIPTION_ID = 1
|
||||
|
||||
/**
|
||||
* The complimentary subscription a `developer` account reports — Rec Room Plus, which the
|
||||
* client's API calls a `CampusCard`.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* `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) {
|
||||
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.
|
||||
const expires = new Date(now)
|
||||
expires.setUTCFullYear(expires.getUTCFullYear() + 1)
|
||||
return {
|
||||
SubscriptionId: STUB_SUBSCRIPTION_ID,
|
||||
RecNetPlayerId: accountId,
|
||||
PlatformType: SUBSCRIPTION_PLATFORM_ALL,
|
||||
PlatformId: '',
|
||||
PlatformPurchaseId: '',
|
||||
Level: SUBSCRIPTION_LEVEL_GOLD,
|
||||
Period: SUBSCRIPTION_PERIOD_YEAR,
|
||||
ExpirationDate: expires.toISOString(),
|
||||
IsAutoRenewing: true,
|
||||
CreatedAt: now.toISOString(),
|
||||
ModifiedAt: now.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a stored avatar into the public render subset returned by
|
||||
* `GET /api/avatar/v2/:id` — the fields needed to draw another player's avatar
|
||||
@@ -249,6 +432,26 @@ interface StoreGiftDrop {
|
||||
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
|
||||
* rarity they don't already own (see {@link rollQueryDrop}). sf2's "Star Boxes" set and
|
||||
* sf3's "Random box" family are the two that ship; sf2's tooltip says it outright — "A
|
||||
* random 4-star item that you don't have."
|
||||
*/
|
||||
IsQuery?: boolean
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
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?: number
|
||||
}
|
||||
interface StorePrice {
|
||||
CurrencyType: number
|
||||
@@ -335,7 +538,7 @@ function toGiftContent(
|
||||
AvatarItemType: giftDrop.AvatarItemType,
|
||||
CurrencyType: giftDrop.CurrencyType,
|
||||
Currency: giftDrop.Currency,
|
||||
Xp: 0,
|
||||
Xp: giftDrop.Xp ?? 0,
|
||||
PackageType: 0,
|
||||
Message: message,
|
||||
EquipmentPrefabName: giftDrop.EquipmentPrefabName,
|
||||
@@ -347,6 +550,616 @@ function toGiftContent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a GiftPackageReceivedImmediate notification for a gift box the player didn't ask
|
||||
* for, mirroring the reference's
|
||||
* `HubSendToPlayer(accountID, NotifFrame(GiftPackageReceivedImmediate, {...}))` — the
|
||||
* client pops the "you got something" panel from it instead of waiting for the next read of
|
||||
* `GET /api/avatar/v2/gifts`.
|
||||
*
|
||||
* The payload is the reference's field-for-field: the stored box's contents plus its `Id`,
|
||||
* a `FromGiftDropId` of 0 (the reference never populates it either) and the
|
||||
* platform/balance constants. `Xp` is the drop's, so a game reward's box announces the XP it
|
||||
* paid; `Level` is 0, since nothing levels a player up yet.
|
||||
*
|
||||
* "Immediate" (31) rather than GiftPackageReceived (30) is what the reference sends for a
|
||||
* box handed over by the server: a purchase gifted to another player, an admin token grant,
|
||||
* a report reward. This is the same case — the player is being handed a box they never
|
||||
* clicked for. Best-effort: a hub failure is logged and swallowed, since the gift itself is
|
||||
* already granted and stored.
|
||||
*/
|
||||
async function pushGiftReceived(
|
||||
c: Context<App>,
|
||||
accountId: number,
|
||||
gift: GrantedGift,
|
||||
message: string,
|
||||
fromPlayerId: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
accountId,
|
||||
NotificationType.GiftPackageReceivedImmediate,
|
||||
{
|
||||
Id: gift.id,
|
||||
FromGiftDropId: 0,
|
||||
FromPlayerId: fromPlayerId,
|
||||
ConsumableItemDesc: gift.drop.ConsumableItemDesc,
|
||||
AvatarItemDesc: gift.drop.AvatarItemDesc,
|
||||
AvatarItemType: gift.drop.AvatarItemType ?? 0,
|
||||
EquipmentPrefabName: gift.drop.EquipmentPrefabName,
|
||||
EquipmentModificationGuid: gift.drop.EquipmentModificationGuid,
|
||||
CurrencyType: gift.drop.CurrencyType,
|
||||
Currency: gift.drop.Currency,
|
||||
Xp: gift.drop.Xp ?? 0,
|
||||
Level: 0,
|
||||
Platform: -1,
|
||||
PlatformsToSpawnOn: -1,
|
||||
BalanceType: ALL_PLATFORMS,
|
||||
GiftContext: gift.drop.Context,
|
||||
GiftRarity: gift.drop.Rarity,
|
||||
Message: message,
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push GiftPackageReceivedImmediate notification', {
|
||||
accountId,
|
||||
giftId: gift.id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a PlayerProgressionLevelUpdate so the client's level bar moves when XP lands, instead
|
||||
* of waiting for its next progression read. `XP` is the progress into the current level (the
|
||||
* ladder spends the rest on the level-ups), which is what the bar draws against the
|
||||
* `LevelProgressionMaps` the client is served.
|
||||
*
|
||||
* Best-effort: the XP is already banked, so a hub failure costs a bar animation, not the
|
||||
* reward.
|
||||
*/
|
||||
async function pushProgressionUpdate(
|
||||
c: Context<App>,
|
||||
accountId: number,
|
||||
progression: Progression
|
||||
): Promise<void> {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
accountId,
|
||||
NotificationType.PlayerProgressionLevelUpdate,
|
||||
{ PlayerId: progression.PlayerId, Level: progression.Level, XP: progression.XP }
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push PlayerProgressionLevelUpdate notification', {
|
||||
accountId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog a query drop rolls from: sf3, the general store. It is the only catalog with
|
||||
* a real pool at every rarity (1161 items against 8–40 in the themed ones), it's where the
|
||||
* "Random box" family itself sells, and a box promising "a random 4-star item" plainly
|
||||
* means the whole item universe rather than whichever seasonal shelf it was bought from.
|
||||
*/
|
||||
const ROLL_STOREFRONT_TYPE = 3
|
||||
|
||||
/** Every item in the roll catalog, or `[]` if it can't be read (a roll then yields nothing). */
|
||||
async function loadRollCatalog(c: Context<App>): Promise<StoreItem[]> {
|
||||
const res = await c.env.ASSETS.fetch(new URL(`/sf${ROLL_STOREFRONT_TYPE}.json`, c.req.url))
|
||||
if (!res.ok) return []
|
||||
const storefront = (await res.json()) as Storefront
|
||||
return storefront.StoreItems
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the player already owns what a drop carries — the question a query drop's "an
|
||||
* item that you don't have" turns on, and the one that decides whether the weekly gift
|
||||
* hands over its item or rolls the fallback box instead.
|
||||
*
|
||||
* Ownership is boolean for avatar items and equipment, which is what makes "already have
|
||||
* it" meaningful. A drop carrying neither (a consumable, a currency drop, an empty query
|
||||
* box) counts as owned: there is nothing ownable to hand over, so callers offering a
|
||||
* fallback should take it.
|
||||
*/
|
||||
async function ownsGiftDrop(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
giftDrop: StoreGiftDrop
|
||||
): Promise<boolean> {
|
||||
if (typeof giftDrop.AvatarItemDesc === 'string' && giftDrop.AvatarItemDesc !== '') {
|
||||
const owned = await getInventory(db, accountId)
|
||||
return owned.some((item) => item.AvatarItemDesc === giftDrop.AvatarItemDesc)
|
||||
}
|
||||
if (
|
||||
typeof giftDrop.EquipmentModificationGuid === 'string' &&
|
||||
giftDrop.EquipmentModificationGuid !== ''
|
||||
) {
|
||||
const owned = await getEquipment(db, accountId)
|
||||
return owned.some((eq) => eq.ModificationGuid === giftDrop.EquipmentModificationGuid)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** How a query drop is rolled — what it may land on, and whose catalog copy to use. */
|
||||
interface RollOptions {
|
||||
/**
|
||||
* Restrict the roll to avatar items, leaving equipment skins out of the pool. Off by
|
||||
* default: a bought box says "a random item", and the catalog's own boxes mean both.
|
||||
*/
|
||||
avatarItemsOnly?: boolean
|
||||
/**
|
||||
* The roll catalog, when the caller has already read it — it's the big one (sf3), and a
|
||||
* caller granting several boxes at once shouldn't re-read it per box.
|
||||
*/
|
||||
rollCatalog?: StoreItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll a query drop: pick, uniformly at random, one item of `rarity` from the roll catalog
|
||||
* that the player doesn't already own. Returns null when the pool is empty — an unreadable
|
||||
* catalog, a rarity nothing is published at, or a player who owns every item of that tier.
|
||||
*
|
||||
* The pool is deliberately narrow. Other query drops are excluded (a box that rolls a box
|
||||
* would either loop or hand over an unopenable one), and so is everything that isn't an
|
||||
* avatar item or a piece of equipment: "an item you don't have" only means anything for
|
||||
* things owned once, and consumables stack, so a consumable would be rollable forever and
|
||||
* would crowd out the real prizes.
|
||||
*
|
||||
* `avatarItemsOnly` narrows it further to things worn on the avatar, leaving equipment
|
||||
* skins out — a level-up prize should be something the player can see on themselves, not a
|
||||
* skin for a weapon they may not own. It also skips the equipment read entirely, since
|
||||
* nothing in the pool can match it.
|
||||
*/
|
||||
async function rollQueryDrop(
|
||||
c: Context<App>,
|
||||
accountId: number,
|
||||
rarity: number,
|
||||
options: RollOptions = {}
|
||||
): Promise<StoreGiftDrop | null> {
|
||||
const [catalog, ownedItems, ownedEquipment] = await Promise.all([
|
||||
options.rollCatalog ?? loadRollCatalog(c),
|
||||
getInventory(c.env.DB, accountId),
|
||||
options.avatarItemsOnly === true ? [] : getEquipment(c.env.DB, accountId),
|
||||
])
|
||||
const haveItem = new Set(ownedItems.map((item) => item.AvatarItemDesc))
|
||||
const haveEquipment = new Set(ownedEquipment.map((eq) => eq.ModificationGuid))
|
||||
const pool = catalog.filter(({ GiftDrop: drop }) => {
|
||||
if (drop.IsQuery === true || drop.Rarity !== rarity) return false
|
||||
if (typeof drop.AvatarItemDesc === 'string' && drop.AvatarItemDesc !== '') {
|
||||
return !haveItem.has(drop.AvatarItemDesc)
|
||||
}
|
||||
if (options.avatarItemsOnly === true) return false
|
||||
if (
|
||||
typeof drop.EquipmentModificationGuid === 'string' &&
|
||||
drop.EquipmentModificationGuid !== ''
|
||||
) {
|
||||
return !haveEquipment.has(drop.EquipmentModificationGuid)
|
||||
}
|
||||
return false
|
||||
})
|
||||
const rolled = pool[Math.floor(Math.random() * pool.length)]
|
||||
return rolled?.GiftDrop ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* A gift box that was just created, and the drop it ended up holding. The drop is the
|
||||
* RESOLVED one — what a query drop rolled, not the box that promised it — so a caller
|
||||
* announcing the gift names the item the player actually won.
|
||||
*/
|
||||
interface GrantedGift {
|
||||
id: number
|
||||
drop: StoreGiftDrop
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a random consumable from the roll catalog — the reward the published level table
|
||||
* hands out for the early levels.
|
||||
*
|
||||
* Unlike a clothing roll this one has no rarity and no ownership filter: the table names no
|
||||
* star tier for a consumable, and consumables STACK, so "one you don't have" is meaningless
|
||||
* (a second Confetti Cannon is a fine prize). Returns a concrete drop rather than a query
|
||||
* one, so the grant path just grants it.
|
||||
*/
|
||||
function rollConsumableDrop(catalog: StoreItem[]): StoreGiftDrop | null {
|
||||
const pool = catalog.filter(
|
||||
({ GiftDrop: drop }) =>
|
||||
drop.IsQuery !== true &&
|
||||
typeof drop.ConsumableItemDesc === 'string' &&
|
||||
drop.ConsumableItemDesc !== ''
|
||||
)
|
||||
return pool[Math.floor(Math.random() * pool.length)]?.GiftDrop ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand a gift-drop to a player: grant whatever it turns out to carry (an avatar item, an
|
||||
* equipment skin, a consumable, or none of these — currency/xp drops aren't granted yet)
|
||||
* and create the gift box that renders it.
|
||||
*
|
||||
* A query drop is ROLLED here first, so what gets granted — and what the box shows — is the
|
||||
* item the player actually won, not the box that promised it. A roll with nothing left to
|
||||
* give falls through with the box itself, which grants nothing: no worse than not rolling,
|
||||
* and the warning says which rarity ran dry.
|
||||
*
|
||||
* Both faucets share this — a storefront purchase and the weekly-challenge reward — so a
|
||||
* drop lands in a player's inventory the same way whichever one it came from. The item is
|
||||
* granted here, not when the box is opened: consuming a box only deletes the row.
|
||||
*/
|
||||
async function grantGiftDrop(
|
||||
c: Context<App>,
|
||||
accountId: number,
|
||||
drop: StoreGiftDrop,
|
||||
message: string,
|
||||
options: RollOptions = {}
|
||||
): Promise<GrantedGift> {
|
||||
let giftDrop = drop
|
||||
if (drop.IsQuery === true) {
|
||||
const rarity = drop.QueryRedirectRarity ?? drop.Rarity
|
||||
const rolled = await rollQueryDrop(c, accountId, rarity, options)
|
||||
if (rolled === null) {
|
||||
logger.warn('query gift-drop rolled nothing', {
|
||||
accountId,
|
||||
rarity,
|
||||
friendlyName: drop.FriendlyName,
|
||||
})
|
||||
} else {
|
||||
giftDrop = rolled
|
||||
}
|
||||
}
|
||||
const db = c.env.DB
|
||||
if (typeof giftDrop.AvatarItemDesc === 'string' && giftDrop.AvatarItemDesc !== '') {
|
||||
await grantItem(db, accountId, toAvatarItem(giftDrop))
|
||||
}
|
||||
if (
|
||||
typeof giftDrop.EquipmentModificationGuid === 'string' &&
|
||||
giftDrop.EquipmentModificationGuid !== ''
|
||||
) {
|
||||
await grantEquipment(db, accountId, toEquipment(giftDrop))
|
||||
}
|
||||
const isConsumable =
|
||||
typeof giftDrop.ConsumableItemDesc === 'string' && giftDrop.ConsumableItemDesc !== ''
|
||||
const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT : 0
|
||||
// Capture the granted consumable's row id and the player's pre-existing count so the
|
||||
// gift box can carry them — gift-consume fires ConsumableMappingAdded from these.
|
||||
let consumableMappingId = 0
|
||||
let consumablePreExisting = 0
|
||||
if (isConsumable) {
|
||||
consumablePreExisting = await countConsumable(db, accountId, giftDrop.ConsumableItemDesc)
|
||||
consumableMappingId = await grantConsumable(
|
||||
db,
|
||||
accountId,
|
||||
giftDrop.ConsumableItemDesc,
|
||||
consumableCount
|
||||
)
|
||||
}
|
||||
const { id } = await createGift(
|
||||
db,
|
||||
accountId,
|
||||
toGiftContent(giftDrop, message, consumableCount, consumableMappingId, consumablePreExisting)
|
||||
)
|
||||
return { id, drop: giftDrop }
|
||||
}
|
||||
|
||||
/**
|
||||
* XP paid for a claimed game reward. One flat amount for every reward type, matching the
|
||||
* one flat cooldown they share — "First Game of the Day" and "Activity completed!" are the
|
||||
* same size of pat on the back until there's reason to price them apart.
|
||||
*
|
||||
* Deliberately smaller than the 10 XP the first level costs: a single action shouldn't be a
|
||||
* level-up, let alone two of them. At 5 it takes two rewards to reach level 2, and the early
|
||||
* levels are paced by the hourly cooldown rather than cleared in one match.
|
||||
*/
|
||||
const GAME_REWARD_XP = 5
|
||||
|
||||
/**
|
||||
* `GiftContext.GameRewards` — what the box says it came from, so the client files it under
|
||||
* gameplay rewards rather than a purchase or a player's gift. (`51` is the tokens variant,
|
||||
* for when a reward pays currency instead of XP.)
|
||||
*/
|
||||
const GIFT_CONTEXT_GAME_REWARDS = 50
|
||||
|
||||
/** Shown on the box when the client asks for a reward without saying what to call it. */
|
||||
const DEFAULT_GAME_REWARD_MESSAGE = 'Reward earned!'
|
||||
|
||||
/**
|
||||
* The gift-drop a claimed game reward hands over: XP in a box, no item. Every item field is
|
||||
* empty on purpose — this is not a purchase and not a roll, so `grantGiftDrop` grants
|
||||
* nothing into the inventory and only creates the box. The XP is banked in `progression`;
|
||||
* the copy here is what the box and its notification display.
|
||||
*/
|
||||
function toGameRewardDrop(): StoreGiftDrop {
|
||||
return {
|
||||
FriendlyName: '',
|
||||
Tooltip: '',
|
||||
ConsumableItemDesc: '',
|
||||
AvatarItemDesc: '',
|
||||
AvatarItemType: null,
|
||||
EquipmentPrefabName: '',
|
||||
EquipmentModificationGuid: '',
|
||||
Rarity: 0,
|
||||
Context: GIFT_CONTEXT_GAME_REWARDS,
|
||||
Currency: 0,
|
||||
CurrencyType: 0,
|
||||
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
|
||||
* has to be something the player can wear and be seen in — never an equipment skin for a
|
||||
* weapon they may not own. This is the one roll that narrows the pool that far.
|
||||
*/
|
||||
function toLevelUpDrop(rarity: number): StoreGiftDrop {
|
||||
return {
|
||||
FriendlyName: '',
|
||||
Tooltip: '',
|
||||
ConsumableItemDesc: '',
|
||||
AvatarItemDesc: '',
|
||||
AvatarItemType: null,
|
||||
EquipmentPrefabName: '',
|
||||
EquipmentModificationGuid: '',
|
||||
Rarity: rarity,
|
||||
Context: GIFT_CONTEXT_GAME_REWARDS,
|
||||
Currency: 0,
|
||||
CurrencyType: 0,
|
||||
IsQuery: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand over the rewards a run of level-ups earned — ONE PER LEVEL crossed, since the
|
||||
* published table names a reward for every level and a single grant can cross several (a
|
||||
* large enough grant could clear the first three levels at 10 XP each). Each arrives as a
|
||||
* gift box, announced like any other unasked-for gift.
|
||||
*
|
||||
* Which reward is per level, not per tier: the early levels pay CONSUMABLES and the rest pay
|
||||
* clothing at a rising star rating. The catalog is read once and shared across the boxes.
|
||||
* Best-effort as a whole: the XP is banked and the levels are already stored, so a failed
|
||||
* roll costs a prize, not the level.
|
||||
*/
|
||||
async function grantLevelUpGifts(
|
||||
c: Context<App>,
|
||||
accountId: number,
|
||||
grant: XpGrant
|
||||
): Promise<void> {
|
||||
const levels = levelsReached(grant)
|
||||
if (levels.length === 0) return
|
||||
try {
|
||||
const rollCatalog = await loadRollCatalog(c)
|
||||
for (const level of levels) {
|
||||
const reward = levelReward(level)
|
||||
if (reward === null) continue
|
||||
const message = `Level ${level}!`
|
||||
// A consumable is rolled to a concrete drop up front; clothing rides the query path,
|
||||
// which rolls it against what the player already owns.
|
||||
const drop =
|
||||
reward.kind === 'consumable'
|
||||
? rollConsumableDrop(rollCatalog)
|
||||
: toLevelUpDrop(reward.rarity)
|
||||
if (drop === null) {
|
||||
logger.warn('level up reward rolled nothing', { accountId, level, kind: reward.kind })
|
||||
continue
|
||||
}
|
||||
const granted = await grantGiftDrop(c, accountId, drop, message, {
|
||||
avatarItemsOnly: reward.kind === 'clothing',
|
||||
rollCatalog,
|
||||
})
|
||||
await pushGiftReceived(c, accountId, granted, message, COACH_ACCOUNT_ID)
|
||||
logger.info('level up gift granted', {
|
||||
accountId,
|
||||
level,
|
||||
kind: reward.kind,
|
||||
rarity: reward.kind === 'clothing' ? reward.rarity : null,
|
||||
giftId: granted.id,
|
||||
avatarItemDesc: granted.drop.AvatarItemDesc,
|
||||
consumableItemDesc: granted.drop.ConsumableItemDesc,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('failed to grant level up gift', {
|
||||
accountId,
|
||||
levels,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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!'
|
||||
|
||||
/**
|
||||
* The star rating → `Rarity` ladder, indexed by stars - 1. Pinned by sf2's "Star Boxes"
|
||||
* item set, whose three members name their own tier and carry the rarity they roll at:
|
||||
* 2-Star → 10, 3-Star → 20, 4-Star → 30. The ends are extrapolated from sf3's parallel
|
||||
* "Random box" family (Common 0, Uncommon 10, Rare 20, Epic 30, Legendary 50), which is the
|
||||
* same ladder under the other naming.
|
||||
*/
|
||||
const STAR_RARITY = [0, 10, 20, 30, 50]
|
||||
|
||||
/** The tier a "4-Star Box" rolls at, used when a rotation's fallback name doesn't parse. */
|
||||
const DEFAULT_FALLBACK_STARS = 4
|
||||
|
||||
/**
|
||||
* The rarity the rotation's `FallbackGiftName` promises, read off the leading star count
|
||||
* ("4-Star Box" → 30). That string is the whole specification of the consolation prize —
|
||||
* 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])
|
||||
return STAR_RARITY[stars - 1] ?? STAR_RARITY[DEFAULT_FALLBACK_STARS - 1] ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate the rotation's `Gift` block into the storefront gift-drop shape the grant path
|
||||
* reads. The renamed fields are the whole point — feeding one shape to the other's reader
|
||||
* silently drops the rarity and context.
|
||||
*
|
||||
* The reward carries no price, so `Currency`/`CurrencyType` are zero: the box shows an
|
||||
* item, not a payout. Display strings come from the block when it carries them; a block
|
||||
* that doesn't (the captured rotation names neither) borrows them from the catalog entry
|
||||
* 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
|
||||
const sold = catalog.find(
|
||||
({ GiftDrop: drop }) =>
|
||||
(gift.EquipmentModificationGuid !== '' &&
|
||||
drop.EquipmentModificationGuid === gift.EquipmentModificationGuid) ||
|
||||
(gift.AvatarItemDesc !== '' && drop.AvatarItemDesc === gift.AvatarItemDesc)
|
||||
)?.GiftDrop
|
||||
return {
|
||||
FriendlyName: gift.FriendlyName ?? sold?.FriendlyName ?? weeklyChallenge.FallbackGiftName,
|
||||
Tooltip: gift.Tooltip ?? sold?.Tooltip ?? '',
|
||||
ConsumableItemDesc: gift.ConsumableItemDesc,
|
||||
AvatarItemDesc: gift.AvatarItemDesc,
|
||||
AvatarItemType: gift.AvatarItemType,
|
||||
EquipmentPrefabName: gift.EquipmentPrefabName,
|
||||
EquipmentModificationGuid: gift.EquipmentModificationGuid,
|
||||
// The block's own `GiftRarity` is 0 in the captured rotation even though the item it
|
||||
// names sells at rarity 5, so the catalog's rarity wins where there is one.
|
||||
Rarity: sold?.Rarity ?? gift.GiftRarity,
|
||||
Context: gift.GiftContext,
|
||||
Currency: 0,
|
||||
CurrencyType: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The consolation box: a query drop at the rarity `FallbackGiftName` promises, named after
|
||||
* 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 {
|
||||
return {
|
||||
FriendlyName: weeklyChallenge.FallbackGiftName,
|
||||
Tooltip: '',
|
||||
ConsumableItemDesc: '',
|
||||
AvatarItemDesc: '',
|
||||
AvatarItemType: null,
|
||||
EquipmentPrefabName: '',
|
||||
EquipmentModificationGuid: '',
|
||||
Rarity: fallbackGiftRarity(),
|
||||
Context: (weeklyChallenge.Gift as ChallengeGift).GiftContext,
|
||||
Currency: 0,
|
||||
CurrencyType: 0,
|
||||
IsQuery: true,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How many of a rotation's challenges earn its gift. A week presents five and asks for
|
||||
* three: the reward is for playing most of the week's set, not for clearing all of it, so
|
||||
* the two a player can't reach (a quest they don't own, a mode they don't like) don't sink
|
||||
* the whole week.
|
||||
*/
|
||||
const CHALLENGES_REQUIRED_FOR_GIFT = 3
|
||||
|
||||
/**
|
||||
* How many completions this rotation's gift needs. `CompletedRequired` makes the set
|
||||
* 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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Award the rotation's `Gift` if this player has just earned it, doing nothing otherwise.
|
||||
* Called after each completing progress report, since `updateProgress` is the only place a
|
||||
* challenge is ever finished — there is no separate claim endpoint, and the client never
|
||||
* asks for this reward.
|
||||
*
|
||||
* Earning it takes {@link challengesRequiredForGift} of the rotation's challenges, counted
|
||||
* from `challenge_status`. Only challenges the rotation still publishes count: a report can
|
||||
* carry an id this week's set no longer lists (an edited rotation under a live client), and
|
||||
* three of those shouldn't buy a gift the player never worked for.
|
||||
*
|
||||
* What lands is the `Gift` block's item — or, if the player already owns it, the box named
|
||||
* by `FallbackGiftName`, which rolls something they don't have at that tier. Finishing the
|
||||
* week can't be worth nothing, and the rotation's reward is one fixed item that plenty of
|
||||
* players will have bought already.
|
||||
*
|
||||
* A grant that throws is swallowed: the client is reporting gameplay progress, and failing
|
||||
* that report (which it would then retry with the same completion) is worse than missing
|
||||
* the reward — the claim row is already taken, so the miss is permanent but visible in the
|
||||
* logs. An empty rotation earns nothing: its threshold clamps to zero, which every player
|
||||
* would otherwise meet without playing.
|
||||
*/
|
||||
async function awardChallengeGift(c: Context<App>, accountId: number): Promise<void> {
|
||||
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
|
||||
// Claim first: this is what stops the next report paying out a second time.
|
||||
const claimed = await claimChallengeGift(c.env.DB, accountId, weeklyChallenge.ChallengeMapId)
|
||||
if (!claimed) return
|
||||
const catalog = await loadRollCatalog(c)
|
||||
const reward = toChallengeGiftDrop(catalog)
|
||||
const duplicate = await ownsGiftDrop(c.env.DB, accountId, reward)
|
||||
const granted = await grantGiftDrop(
|
||||
c,
|
||||
accountId,
|
||||
duplicate ? toChallengeFallbackDrop() : reward,
|
||||
CHALLENGE_GIFT_MESSAGE,
|
||||
{ rollCatalog: catalog }
|
||||
)
|
||||
// Nobody asked for this box, so the client has no reason to re-read the gifts list:
|
||||
// the notification is what makes the reward show up at the moment the set is finished.
|
||||
// From "Coach", the same system sender a self-buy is attributed to — the rotation is
|
||||
// the server handing something over, not another player.
|
||||
await pushGiftReceived(c, accountId, granted, CHALLENGE_GIFT_MESSAGE, COACH_ACCOUNT_ID)
|
||||
logger.info('weekly challenge gift granted', {
|
||||
accountId,
|
||||
challengeMapId: weeklyChallenge.ChallengeMapId,
|
||||
giftId: granted.id,
|
||||
fallbackRoll: duplicate,
|
||||
challengesComplete: done,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('failed to grant weekly challenge gift', {
|
||||
accountId,
|
||||
challengeMapId: weeklyChallenge.ChallengeMapId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A concise `describeRoute` spec for a route that serves an opaque JSON array — either
|
||||
* a static catalog served verbatim or an empty-list stub. `auth` adds the bearer
|
||||
@@ -477,6 +1290,37 @@ const app = new Hono<App>({ strict: false })
|
||||
(c) => c.json([])
|
||||
)
|
||||
|
||||
// Report one objective's progress. The client posts the whole objective as it now
|
||||
// sees it (Index/Group identify it within `myprogress`) and reads back the state of
|
||||
// the GROUP that objective belongs to — camelCase here, unlike the PascalCase body it
|
||||
// posted. Stubbed: with no objectives store yet we persist nothing, echo the group
|
||||
// back and never complete it, so the reward-claim flow isn't triggered. `clearedAt`
|
||||
// is the clear time, which for a group we didn't clear is just now.
|
||||
.post(
|
||||
'/api/objectives/v1/updateobjective',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Report objective progress',
|
||||
description: [
|
||||
'Stubbed: with no objectives store we persist nothing and never complete a group.',
|
||||
'Echoes `Group` back as camelCase `group` with `isCompleted: false` so the client',
|
||||
'gets a well-formed body.',
|
||||
].join(' '),
|
||||
requestBody: jsonBody(UpdateObjectiveRequest, 'The objective as the client now sees it'),
|
||||
responses: { 200: json(UpdateObjectiveResponse, 'The echoed group, never completed') },
|
||||
}),
|
||||
async (c) => {
|
||||
const body = await c.req
|
||||
.json<{ Group?: string | number }>()
|
||||
.catch(() => ({}) as Record<string, never>)
|
||||
return c.json({
|
||||
group: Number(body.Group) || 0,
|
||||
isCompleted: false,
|
||||
clearedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// The player's avatar, stored as a JSON blob on their account row. Falls back
|
||||
// to the default outfit when they haven't saved one — the client's parser NREs
|
||||
// on an empty OutfitSelections (real RecNet never returns one).
|
||||
@@ -996,7 +1840,9 @@ const app = new Hono<App>({ strict: false })
|
||||
'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',
|
||||
'price), not the new total. Pushes a StorefrontBalanceUpdate socket notification.',
|
||||
'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).',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(BuyItemRequest, 'The item, currency, price, and optional Gift'),
|
||||
@@ -1072,62 +1918,32 @@ const app = new Hono<App>({ strict: false })
|
||||
)
|
||||
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
|
||||
|
||||
// Grant the item to the recipient. A gift-drop carries an avatar item, a consumable,
|
||||
// an equipment skin, or none of these (currency/xp drops aren't granted yet); grant
|
||||
// whichever it actually has.
|
||||
if (typeof item.GiftDrop.AvatarItemDesc === 'string' && item.GiftDrop.AvatarItemDesc !== '') {
|
||||
await grantItem(c.env.DB, receiverId, toAvatarItem(item.GiftDrop))
|
||||
}
|
||||
if (
|
||||
typeof item.GiftDrop.EquipmentModificationGuid === 'string' &&
|
||||
item.GiftDrop.EquipmentModificationGuid !== ''
|
||||
) {
|
||||
await grantEquipment(c.env.DB, receiverId, toEquipment(item.GiftDrop))
|
||||
}
|
||||
const isConsumable =
|
||||
typeof item.GiftDrop.ConsumableItemDesc === 'string' &&
|
||||
item.GiftDrop.ConsumableItemDesc !== ''
|
||||
const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT : 0
|
||||
// Capture the granted consumable's row id and the player's pre-existing count so
|
||||
// the gift box can carry them — gift-consume fires ConsumableMappingAdded from these.
|
||||
let consumableMappingId = 0
|
||||
let consumablePreExisting = 0
|
||||
if (isConsumable) {
|
||||
consumablePreExisting = await countConsumable(
|
||||
c.env.DB,
|
||||
// Grant the item to the recipient, with the gift box that renders it. A box (an
|
||||
// `IsQuery` drop, e.g. sf2's "4-Star Unique Box") rolls its prize in here, and
|
||||
// `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 { id: giftId, drop: granted } = await grantGiftDrop(
|
||||
c,
|
||||
receiverId,
|
||||
item.GiftDrop.ConsumableItemDesc
|
||||
)
|
||||
consumableMappingId = await grantConsumable(
|
||||
c.env.DB,
|
||||
receiverId,
|
||||
item.GiftDrop.ConsumableItemDesc,
|
||||
consumableCount
|
||||
)
|
||||
}
|
||||
const { id: giftId } = await createGift(
|
||||
c.env.DB,
|
||||
receiverId,
|
||||
toGiftContent(
|
||||
item.GiftDrop,
|
||||
message,
|
||||
consumableCount,
|
||||
consumableMappingId,
|
||||
consumablePreExisting
|
||||
)
|
||||
message
|
||||
)
|
||||
|
||||
// Push the buyer's new (reduced) balance over the socket so their client updates the
|
||||
// shown total immediately — the buyer (`id`) is who was debited, in the currency they
|
||||
// spent. Best-effort; the HTTP response still carries the change either way.
|
||||
// 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
|
||||
// SETS the account-wide bucket to the resulting total read back from D1, so it agrees
|
||||
// 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 pushBalanceUpdate(c, id, currencyType as number, newBalance)
|
||||
await pushBalancePurchase(c, id, currencyType as number, -price.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
|
||||
// `GET /balance/:type`); `BalanceType` is -2 (account-wide, all platforms). The Data
|
||||
// entry is the gift-drop the client received — it carries no FriendlyName or
|
||||
// consumable count (the count is a getUnlocked concept; each box is one instance).
|
||||
// entry is the gift-drop the client RECEIVED — the rolled item for a query box, the
|
||||
// bought drop otherwise — and it carries no FriendlyName or consumable count (the
|
||||
// count is a getUnlocked concept; each box is one instance).
|
||||
return c.json({
|
||||
BalanceUpdates: [
|
||||
{
|
||||
@@ -1136,22 +1952,22 @@ const app = new Hono<App>({ strict: false })
|
||||
{
|
||||
Id: giftId,
|
||||
FromPlayerId: fromPlayerId,
|
||||
ConsumableItemDesc: item.GiftDrop.ConsumableItemDesc,
|
||||
AvatarItemDesc: item.GiftDrop.AvatarItemDesc,
|
||||
AvatarItemType: item.GiftDrop.AvatarItemType ?? 0,
|
||||
EquipmentPrefabName: item.GiftDrop.EquipmentPrefabName,
|
||||
EquipmentModificationGuid: item.GiftDrop.EquipmentModificationGuid,
|
||||
CurrencyType: item.GiftDrop.CurrencyType,
|
||||
Currency: item.GiftDrop.Currency,
|
||||
Xp: 0,
|
||||
ConsumableItemDesc: granted.ConsumableItemDesc,
|
||||
AvatarItemDesc: granted.AvatarItemDesc,
|
||||
AvatarItemType: granted.AvatarItemType ?? 0,
|
||||
EquipmentPrefabName: granted.EquipmentPrefabName,
|
||||
EquipmentModificationGuid: granted.EquipmentModificationGuid,
|
||||
CurrencyType: granted.CurrencyType,
|
||||
Currency: granted.Currency,
|
||||
Xp: granted.Xp ?? 0,
|
||||
Level: 0,
|
||||
Platform: -1,
|
||||
PlatformsToSpawnOn: -1,
|
||||
BalanceType: ALL_PLATFORMS,
|
||||
GiftContext: Number.isInteger(gift?.GiftContext)
|
||||
? (gift?.GiftContext as number)
|
||||
: item.GiftDrop.Context,
|
||||
GiftRarity: item.GiftDrop.Rarity,
|
||||
: granted.Context,
|
||||
GiftRarity: granted.Rarity,
|
||||
Message: message,
|
||||
},
|
||||
],
|
||||
@@ -1164,6 +1980,165 @@ 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.
|
||||
.get(
|
||||
'/api/storefronts/v2/buyInvention',
|
||||
describeRoute({
|
||||
tags: ['Storefront'],
|
||||
summary: 'Buy an invention',
|
||||
description: [
|
||||
'Looks the invention up by id, confirms the client’s `requestedPrice` still matches',
|
||||
'its stored `Price`, debits the buyer and pays the creator that price in',
|
||||
'RecCenterTokens (a free invention moves nothing), records ownership in',
|
||||
'`inventory_invention`, and returns the invention alongside the buyer’s resulting',
|
||||
'balance. When tokens moved, both players get a socket push carrying their RESULTING',
|
||||
'total — the buyer a StorefrontBalancePurchase, the CREATOR a StorefrontBalanceUpdate —',
|
||||
'which sets the account-wide bucket their client shows, agreeing with this body.',
|
||||
'A GET because that is how the client sends it.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'inventionId',
|
||||
in: 'query',
|
||||
required: true,
|
||||
description: 'Invention id; missing or non-numeric is 400',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
{
|
||||
name: 'requestedPrice',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'The price the client rendered; a mismatch is 409. Defaults to 0',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(BuyInventionResponse, 'The purchase result (invention + balance)'),
|
||||
400: json(
|
||||
ErrorResponse,
|
||||
'Missing/non-numeric 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 inventionId = Number.parseInt(c.req.query('inventionId') ?? '', 10)
|
||||
if (Number.isNaN(inventionId)) return c.json({ error: 'inventionId is required' }, 400)
|
||||
// Absent/non-numeric requestedPrice reads as 0, which only matches a free invention —
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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,
|
||||
BalanceType: ALL_PLATFORMS,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
BalanceUpdates: [{ UpdateResponse: 0, Data: invention }],
|
||||
},
|
||||
// The same `{ Status, Invention, InventionVersion }` envelope the invention
|
||||
// save/read endpoints serve — the client re-renders the invention from it.
|
||||
InventionResponse: toSaveResult(invention),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Storefront ad-carousel items. Served from the bundled static JSON — one
|
||||
// placeholder banner with no purchasable items until real promo data exists.
|
||||
.get(
|
||||
@@ -1172,51 +2147,104 @@ const app = new Hono<App>({ strict: false })
|
||||
(c) => c.json(adCarouselItems)
|
||||
)
|
||||
|
||||
// Current weekly challenge. Served from the bundled static JSON until
|
||||
// per-rotation challenge data is wired up.
|
||||
// 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.
|
||||
.get(
|
||||
'/api/challenge/v2/getCurrent',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Current weekly challenge',
|
||||
description: 'Served from the bundled static challenge until per-rotation data is wired up.',
|
||||
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.',
|
||||
].join(' '),
|
||||
security: OPTIONAL_AUTHED,
|
||||
responses: { 200: json(JsonObject, 'The current weekly challenge') },
|
||||
}),
|
||||
(c) => c.json(weeklyChallenge)
|
||||
async (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.
|
||||
return c.json({
|
||||
...weeklyChallenge,
|
||||
Challenges: weeklyChallenge.Challenges.map((challenge) => ({
|
||||
...challenge,
|
||||
Complete: complete.has(challenge.ChallengeId),
|
||||
})),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Report progress on a weekly challenge. 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`. Stubbed: with no challenge-
|
||||
// progress DB yet we persist nothing and never mark a challenge complete (so the
|
||||
// gift flow isn't triggered). Echo the identifying fields back with Complete=false
|
||||
// so the client gets a well-formed, non-null body to deserialize.
|
||||
// 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.
|
||||
.post(
|
||||
'/api/challenge/v2/updateProgress',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Report weekly-challenge progress',
|
||||
description: [
|
||||
'Stubbed: with no challenge-progress store we persist nothing and never mark a',
|
||||
'challenge complete. Echoes the identifying fields back with `Complete: false` so the',
|
||||
'client gets a well-formed body.',
|
||||
'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.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(ChallengeProgressRequest, 'Challenge ids + the evaluated rule tree'),
|
||||
responses: { 200: json(ChallengeProgressResponse, 'Echoed fields, Complete false') },
|
||||
responses: {
|
||||
200: json(ChallengeProgressResponse, 'Echoed fields with the stored completion'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const body = await c.req
|
||||
.json<{
|
||||
ChallengeMapId?: string | number
|
||||
ChallengeId?: string | number
|
||||
Config?: string
|
||||
Complete?: string | boolean
|
||||
}>()
|
||||
.catch(() => ({}) as Record<string, never>)
|
||||
const challengeMapId = Number(body.ChallengeMapId) || 0
|
||||
const challengeId = Number(body.ChallengeId) || 0
|
||||
// Nothing to key a row on — echo the body back rather than writing a (0, 0) row.
|
||||
const complete =
|
||||
challengeId === 0
|
||||
? parseBool(body.Complete)
|
||||
: await recordChallengeProgress(c.env.DB, id, {
|
||||
challengeMapId,
|
||||
challengeId,
|
||||
complete: parseBool(body.Complete),
|
||||
})
|
||||
// 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
|
||||
// an unfinished challenge means the set isn't either, so neither is worth a read.
|
||||
// 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) {
|
||||
await awardChallengeGift(c, id)
|
||||
}
|
||||
return c.json({
|
||||
ChallengeMapId: Number(body.ChallengeMapId) || 0,
|
||||
ChallengeId: Number(body.ChallengeId) || 0,
|
||||
ChallengeMapId: challengeMapId,
|
||||
ChallengeId: challengeId,
|
||||
Config: typeof body.Config === 'string' ? body.Config : '',
|
||||
Complete: false,
|
||||
Complete: complete,
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1226,13 +2254,83 @@ const app = new Hono<App>({ strict: false })
|
||||
c.json([])
|
||||
)
|
||||
|
||||
// Request a game reward (client posts `rewardType`/`Message`, e.g.
|
||||
// FirstActivityOfDay). Stubbed: with no reward DB yet we grant nothing and return an
|
||||
// empty list of rewards — matching the `pending` shape so the client deserializes it.
|
||||
// Request a game reward. [Authorize]. The client asks whenever it thinks one is due,
|
||||
// posting the type and the message to show for it (`rewardType=FirstActivityOfDay&
|
||||
// Message=First Game of the Day`, or `rewardType=PostGameActivity&Message=Activity
|
||||
// completed!&giftContext=Soccer`) — so whether a reward is actually OWED is decided
|
||||
// here, from `reward_status`: one claim per type per activity per hour, atomically.
|
||||
//
|
||||
// A claim pays GAME_REWARD_XP into `progression` and hands over a gift box carrying that
|
||||
// XP, announced with the same GiftPackageReceivedImmediate frame the weekly gift uses —
|
||||
// the client posted the message to show, so the box wears it. An on-cooldown ask changes
|
||||
// nothing and pays nothing.
|
||||
//
|
||||
// The response stays `[]` either way. It is what the client already accepts, and the box
|
||||
// is how a reward is delivered, so there is no captured shape to put the payout in — the
|
||||
// reference answers its own (different, selection-based) flow with a success envelope,
|
||||
// not a list of rewards.
|
||||
//
|
||||
// `giftContext` (the activity, e.g. `Soccer`) is part of the cooldown key: the first
|
||||
// activity of the day is per ACTIVITY, so a player who moves from Soccer to Paintball is
|
||||
// owed another reward while a second Soccer match inside the hour is not. An ask that
|
||||
// sends no context keys on `''`.
|
||||
.post(
|
||||
'/api/gamerewards/v1/request',
|
||||
listRoute('Request a game reward', 'Stubbed — grants nothing, returns []'),
|
||||
(c) => c.json([])
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Request a game reward',
|
||||
description: [
|
||||
'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 `[]`.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(GameRewardRequest, 'The reward type and its display message'),
|
||||
responses: {
|
||||
200: json(JsonArray, 'The rewards granted — always [] while the payload is stubbed'),
|
||||
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 rewardType = typeof body.rewardType === 'string' ? body.rewardType : ''
|
||||
// No type, nothing to gate: don't write a row keyed on an empty string.
|
||||
if (rewardType === '') return c.json([])
|
||||
const giftContext = typeof body.giftContext === 'string' ? body.giftContext : ''
|
||||
const claimed = await claimReward(c.env.DB, id, rewardType, giftContext)
|
||||
// On cooldown: nothing was claimed, so nothing is paid and nothing is announced.
|
||||
if (claimed === null) return c.json([])
|
||||
const message =
|
||||
typeof body.Message === 'string' && body.Message !== ''
|
||||
? body.Message
|
||||
: DEFAULT_GAME_REWARD_MESSAGE
|
||||
// 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)
|
||||
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)
|
||||
// …and every level crossed is worth a box of its own tier.
|
||||
await grantLevelUpGifts(c, id, { progression, levelsGained })
|
||||
logger.info('game reward claimed', {
|
||||
accountId: id,
|
||||
rewardType,
|
||||
giftContext,
|
||||
grantCount: claimed,
|
||||
message,
|
||||
xp: GAME_REWARD_XP,
|
||||
level: progression.Level,
|
||||
levelsGained,
|
||||
levelXp: progression.XP,
|
||||
giftId: granted.id,
|
||||
})
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
|
||||
// The player's room keys. Returns "[]".
|
||||
@@ -1244,16 +2342,42 @@ const app = new Hono<App>({ strict: false })
|
||||
c.json([])
|
||||
)
|
||||
|
||||
// Subscription lookup. Returns both fields null with no auth.
|
||||
// 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`.
|
||||
//
|
||||
// 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.
|
||||
.post(
|
||||
'/api/CampusCard/v1/UpdateAndGetSubscription',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Subscription lookup',
|
||||
description: 'No subscriptions yet — both fields null. No auth.',
|
||||
responses: { 200: json(SubscriptionResponse, 'Both fields null') },
|
||||
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.',
|
||||
].join(' '),
|
||||
responses: {
|
||||
200: json(SubscriptionResponse, 'The subscription, or `{}` for no subscription'),
|
||||
},
|
||||
}),
|
||||
(c) => c.json({ subscription: null, platformAccountSubscribedPlayerId: null })
|
||||
async (c) => {
|
||||
const roles = await authedRoles(c)
|
||||
if (!roles?.includes(DEVELOPER_ROLE)) return c.json({})
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.json({})
|
||||
return c.json({
|
||||
Subscription: developerSubscription(id),
|
||||
PlatformAccountSubscribedPlayerId: null,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// The generated spec. Documentation only — no request is validated against it (see
|
||||
|
||||
+122
-8
@@ -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: [] }]
|
||||
|
||||
/**
|
||||
* Optional bearer JWT — the empty requirement object makes "no credentials" a valid
|
||||
* alternative. For routes that serve public data but personalise it for a known caller
|
||||
* (the weekly challenge's per-player `Complete`) instead of 401ing.
|
||||
*/
|
||||
export const OPTIONAL_AUTHED: OpenAPIV3_1.SecurityRequirementObject[] = [{}, { bearerAuth: [] }]
|
||||
|
||||
// ---- Loose shapes ----------------------------------------------------------
|
||||
// Several routes serve opaque static catalogs (avatar items, the weekly challenge) or
|
||||
// empty-list stubs. Modelling every catalog field adds noise without value, so these
|
||||
@@ -98,18 +105,65 @@ export const CustomAvatarItemsResponse = z.object({
|
||||
TotalResults: z.int(),
|
||||
})
|
||||
|
||||
/** `POST /api/CampusCard/v1/UpdateAndGetSubscription` — both fields null (no subs yet). */
|
||||
export const SubscriptionResponse = z.object({
|
||||
subscription: z.null(),
|
||||
platformAccountSubscribedPlayerId: z.null(),
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const SubscriptionDto = z.object({
|
||||
SubscriptionId: z.int().describe('Placeholder — no subscription is stored'),
|
||||
RecNetPlayerId: z.int().describe('The subscribed player: the caller'),
|
||||
PlatformType: z
|
||||
.int()
|
||||
.nullable()
|
||||
.describe(
|
||||
'Which store sold it: -1 All, 0 Steam, 1 Oculus, 2 PlayStation, 3 Xbox, 4 RecNet, ' +
|
||||
'5 IOS, 6 GooglePlay, 7 Standalone, 8 Pico. -1 here — no store did'
|
||||
),
|
||||
PlatformId: z.string().describe('Empty — no store account behind it'),
|
||||
PlatformPurchaseId: z.string().describe('Empty — nothing was purchased'),
|
||||
Level: z.int().describe('0 Gold, 1 Platinum'),
|
||||
Period: z.int().describe('0 Month, 1 Year, 2 ThreeMonth, 3 SixMonth'),
|
||||
ExpirationDate: z.string().describe('ISO 8601 UTC; a year out, recomputed per call'),
|
||||
IsAutoRenewing: z.boolean(),
|
||||
CreatedAt: z.string(),
|
||||
ModifiedAt: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/CampusCard/v1/UpdateAndGetSubscription` — the caller's subscription, or `{}`
|
||||
* when they have none (which is everyone without the `developer` role). `{}` rather than a
|
||||
* `Subscription: null` envelope: an absent key is how the client reads "not subscribed".
|
||||
*/
|
||||
export const SubscriptionResponse = z.union([
|
||||
z.object({
|
||||
Subscription: SubscriptionDto,
|
||||
PlatformAccountSubscribedPlayerId: z
|
||||
.null()
|
||||
.describe('The platform account holding the sub, when it is shared. Never set here'),
|
||||
}),
|
||||
z.object({}).describe('`{}` — no subscription'),
|
||||
])
|
||||
|
||||
/** `POST /api/challenge/v2/updateProgress` — the identifying fields echoed back. */
|
||||
export const ChallengeProgressResponse = z.object({
|
||||
ChallengeMapId: z.int(),
|
||||
ChallengeId: z.int(),
|
||||
Config: z.string(),
|
||||
Complete: z.boolean().describe('Always false — no challenge-progress store yet'),
|
||||
Config: z.string().describe('Echoed back verbatim; not stored'),
|
||||
Complete: z
|
||||
.boolean()
|
||||
.describe('The STORED completion — latches true within a rotation, so it may differ'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/objectives/v1/updateobjective` — the group the objective belongs to, after
|
||||
* the update. camelCase, unlike the PascalCase body the client posts and the PascalCase
|
||||
* `ObjectiveGroups` entries `myprogress` serves — three spellings of the same group.
|
||||
*/
|
||||
export const UpdateObjectiveResponse = z.object({
|
||||
group: z.int().describe('Echoed back from the request'),
|
||||
isCompleted: z.boolean().describe('Always false — no objectives store yet'),
|
||||
clearedAt: z.string().describe('When the group was cleared — now, since nothing persists'),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -130,7 +184,34 @@ export const BuyItemResponse = z.object({
|
||||
BalanceType: z.int().describe('-2 = account-wide'),
|
||||
})
|
||||
|
||||
/** buyItem error body (`{ error }`), returned on 400/404/409. */
|
||||
/**
|
||||
* `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.
|
||||
*/
|
||||
export const BuyInventionResponse = z.object({
|
||||
BalanceUpdateResponse: z.object({
|
||||
Balance: z.int().describe('The resulting balance — NOT the change, unlike buyItem'),
|
||||
BalanceType: z.int().describe('-2 = account-wide'),
|
||||
CurrencyType: z.int().describe('2 = RecCenterTokens'),
|
||||
BalanceUpdates: z.array(
|
||||
z.object({
|
||||
UpdateResponse: z.int(),
|
||||
Data: JsonObject.describe('The bought invention (`RRInvention`)'),
|
||||
})
|
||||
),
|
||||
}),
|
||||
InventionResponse: z
|
||||
.object({
|
||||
Status: z.int(),
|
||||
Invention: JsonObject,
|
||||
InventionVersion: JsonObject,
|
||||
})
|
||||
.describe('The same envelope `POST /api/inventions/v6/save` returns'),
|
||||
})
|
||||
|
||||
/** buyItem / buyInvention error body (`{ error }`), returned on 400/403/404/409. */
|
||||
export const ErrorResponse = z.object({ error: z.string() })
|
||||
|
||||
// ---- Request schemas -------------------------------------------------------
|
||||
@@ -168,7 +249,40 @@ export const ConsumeGiftRequest = z.object({
|
||||
export const ChallengeProgressRequest = z.object({
|
||||
ChallengeMapId: z.union([z.string(), z.int()]).optional(),
|
||||
ChallengeId: z.union([z.string(), z.int()]).optional(),
|
||||
Config: z.string().optional().describe('The client-evaluated rule tree'),
|
||||
Config: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The client-evaluated rule tree, with its running count in `cc`; not stored'),
|
||||
Complete: z
|
||||
.union([z.string(), z.boolean()])
|
||||
.optional()
|
||||
.describe('The client’s verdict — sent as .NET’s `"True"`/`"False"`'),
|
||||
})
|
||||
|
||||
/** `POST /api/gamerewards/v1/request` form body. */
|
||||
export const GameRewardRequest = z.object({
|
||||
rewardType: z
|
||||
.string()
|
||||
.describe('The reward being asked for, e.g. `FirstActivityOfDay`, `PostGameActivity`'),
|
||||
Message: z.string().optional().describe('The message to show for the reward'),
|
||||
giftContext: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The activity it came from, e.g. `Soccer` — part of the cooldown key'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/objectives/v1/updateobjective` JSON body — one objective's state as the
|
||||
* client now sees it. `Index`/`Group` identify it within `myprogress`; the rest is the
|
||||
* progress it wants persisted.
|
||||
*/
|
||||
export const UpdateObjectiveRequest = z.object({
|
||||
Index: z.int().describe('Which objective within the group'),
|
||||
Group: z.int().describe('Which objective group'),
|
||||
Progress: z.int().optional(),
|
||||
VisualProgress: z.int().optional().describe('What the client animates towards'),
|
||||
IsCompleted: z.boolean().optional(),
|
||||
HasClaimedReward: z.boolean().optional(),
|
||||
})
|
||||
|
||||
/** `POST /api/avatar/v3/saved/set` JSON body — an outfit with a target `Slot`. */
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Game-reward eligibility on the shared `recflare` D1 database — one row per (account,
|
||||
* reward type, gift context), written by `POST /api/gamerewards/v1/request`.
|
||||
*
|
||||
* The client asks for a reward whenever it thinks one is due ("First Game of the Day"
|
||||
* after an activity, "Activity completed!" after a match), so the server, not the client,
|
||||
* has to decide whether one is actually owed: this table is what makes a second ask for
|
||||
* the same reward a no-op instead of a second payout.
|
||||
*
|
||||
* The `giftContext` the client sends (the activity, e.g. `Soccer`) is PART of the key: a
|
||||
* cooldown is per (type, activity), so the same activity can't pay twice inside the hour
|
||||
* but a different one can. An ask with no context keys on `''` — see `claimReward` for why
|
||||
* that isn't NULL.
|
||||
*
|
||||
* The `econ` worker owns this table and its migrations
|
||||
* (apps/econ/migrations/0010_reward_status.sql, widened by
|
||||
* apps/econ/migrations/0013_reward_status_gift_context.sql).
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of the migrations above) — also builds the table in tests. */
|
||||
export const REWARD_STATUS_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS reward_status (
|
||||
account_id INTEGER NOT NULL,
|
||||
reward_type TEXT NOT NULL,
|
||||
gift_context TEXT NOT NULL,
|
||||
granted_at TEXT NOT NULL,
|
||||
grant_count INTEGER NOT NULL,
|
||||
PRIMARY KEY (account_id, reward_type, gift_context)
|
||||
)`,
|
||||
]
|
||||
|
||||
/**
|
||||
* How long a player must wait between rewards of the same type in the same activity. One
|
||||
* hour flat, for every type — despite what a name like `FirstActivityOfDay` suggests.
|
||||
* Per-type windows would be a map keyed by reward type; there's one window until a reward
|
||||
* type needs its own.
|
||||
*/
|
||||
export const REWARD_COOLDOWN_MS = 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Claim a reward if the player is due one, returning how many of that type they have now
|
||||
* claimed in that context — or `null` when the cooldown hasn't elapsed and nothing was
|
||||
* claimed.
|
||||
*
|
||||
* `giftContext` defaults to `''` rather than NULL for the contextless ask: SQLite allows
|
||||
* (and does not dedupe) NULLs in a non-INTEGER primary key, so a NULL context would insert
|
||||
* a fresh row on every ask instead of hitting the conflict, and the cooldown would never
|
||||
* apply.
|
||||
*
|
||||
* The check and the claim are ONE statement. The client fires these off after a match, so
|
||||
* two requests can land together; a read-then-write would let both see the same stale
|
||||
* `granted_at` and pay out twice. `ON CONFLICT … DO UPDATE … WHERE` gives us the atomic
|
||||
* version: when the cooldown hasn't elapsed the update is skipped, no row is returned, and
|
||||
* the stored `granted_at` is left alone (so a rejected claim doesn't extend the cooldown).
|
||||
*
|
||||
* `granted_at` holds `toISOString()` output — fixed-width UTC, so the lexical `<=` against
|
||||
* the cutoff is a chronological comparison with no date parsing in SQL.
|
||||
*/
|
||||
export async function claimReward(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
rewardType: string,
|
||||
giftContext = '',
|
||||
now: Date = new Date()
|
||||
): Promise<number | null> {
|
||||
const cutoff = new Date(now.getTime() - REWARD_COOLDOWN_MS).toISOString()
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO reward_status (account_id, reward_type, gift_context, granted_at, grant_count)
|
||||
VALUES (?1, ?2, ?3, ?4, 1)
|
||||
ON CONFLICT (account_id, reward_type, gift_context) DO UPDATE SET
|
||||
granted_at = excluded.granted_at,
|
||||
grant_count = reward_status.grant_count + 1
|
||||
WHERE reward_status.granted_at <= ?5
|
||||
RETURNING grant_count`
|
||||
)
|
||||
.bind(accountId, rewardType, giftContext, now.toISOString(), cutoff)
|
||||
.first<{ grant_count: number }>()
|
||||
return row?.grant_count ?? null
|
||||
}
|
||||
@@ -4,8 +4,23 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
||||
|
||||
import '../../econ.app'
|
||||
|
||||
import { RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain'
|
||||
import {
|
||||
getOwnedInventionIds,
|
||||
getProgression,
|
||||
INVENTORY_INVENTION_SCHEMA_DDL,
|
||||
PROGRESSION_SCHEMA_DDL,
|
||||
RECEIVED_GIFT_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
// 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 { 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'
|
||||
import { SCHEMA_DDL } from '../../avatar-db'
|
||||
import {
|
||||
BALANCE_SCHEMA_DDL,
|
||||
@@ -14,10 +29,12 @@ import {
|
||||
getBalance,
|
||||
spendCurrency,
|
||||
} from '../../balance-db'
|
||||
import { CHALLENGE_GIFT_SCHEMA_DDL, CHALLENGE_STATUS_SCHEMA_DDL } from '../../challenge-db'
|
||||
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
|
||||
import { EQUIPMENT_SCHEMA_DDL } from '../../equipment-db'
|
||||
import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db'
|
||||
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
|
||||
import { OUTFIT_SCHEMA_DDL } from '../../outfit-db'
|
||||
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
@@ -27,6 +44,9 @@ declare module 'cloudflare:test' {
|
||||
|
||||
const ORIGIN = 'https://example.com'
|
||||
|
||||
/** The first challenge of the live rotation — the progress tests report against it. */
|
||||
const CURRENT_CHALLENGE = weeklyChallenge.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.
|
||||
beforeAll(async () => {
|
||||
@@ -35,15 +55,84 @@ beforeAll(async () => {
|
||||
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of BALANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of CHALLENGE_STATUS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of CHALLENGE_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of PROGRESSION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of REWARD_STATUS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of EQUIPMENT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
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()
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
|
||||
.run()
|
||||
for (const invention of SEEDED_INVENTIONS) {
|
||||
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
|
||||
.bind(JSON.stringify(invention))
|
||||
.run()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Inventions the buyInvention tests buy (or fail to buy). Only the fields that path
|
||||
* reads are meaningful — id, creator, published flag and price — but the record is
|
||||
* shaped like a real stored `RRInvention` so the response envelope is realistic.
|
||||
*/
|
||||
function invention(
|
||||
inventionId: number,
|
||||
overrides: { CreatorPlayerId?: number; IsPublished?: boolean; Price?: number } = {}
|
||||
) {
|
||||
return {
|
||||
InventionId: inventionId,
|
||||
ReplicationId: `replication-${inventionId}`,
|
||||
CreatorPlayerId: 999,
|
||||
Name: `Invention ${inventionId}`,
|
||||
Description: 'A test invention',
|
||||
ImageName: '',
|
||||
CurrentVersionNumber: 1,
|
||||
CurrentVersion: {
|
||||
InventionId: inventionId,
|
||||
ReplicationId: `version-${inventionId}`,
|
||||
VersionNumber: 1,
|
||||
BlobName: `invention-${inventionId}.inv`,
|
||||
BlobHash: null,
|
||||
InstantiationCost: 0,
|
||||
LightsCost: 0,
|
||||
ChipsCost: 0,
|
||||
CloudVariablesCost: 0,
|
||||
AICost: 0,
|
||||
},
|
||||
Accessibility: 0,
|
||||
IsPublished: true,
|
||||
IsFeatured: false,
|
||||
ModifiedAt: '2026-01-01T00:00:00.000Z',
|
||||
CreatedAt: '2026-01-01T00:00:00.000Z',
|
||||
FirstPublishedAt: '2026-01-01T00:00:00.000Z',
|
||||
CreationRoomId: 0,
|
||||
NumPlayersHaveUsedInRoom: 0,
|
||||
NumDownloads: 0,
|
||||
CheerCount: 0,
|
||||
CreatorPermission: 100,
|
||||
GeneralPermission: 20,
|
||||
IsAGInvention: false,
|
||||
IsCertifiedInvention: false,
|
||||
Price: 0,
|
||||
AllowTrial: true,
|
||||
HideFromPlayer: false,
|
||||
ReferencedInventions: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const SEEDED_INVENTIONS = [
|
||||
invention(8), // free, published, someone else's — the sellable one
|
||||
invention(9, { Price: 250 }), // priced: buying it pays creator 999 250 tokens
|
||||
invention(10, { IsPublished: false }), // a draft, not on sale even at 0
|
||||
invention(11, { CreatorPlayerId: 60 }), // account 60's own invention
|
||||
]
|
||||
|
||||
/**
|
||||
* A real outfit as the client posts it to /api/avatar/v3/saved/set — kept verbatim
|
||||
* (including the JSON-in-a-string OutfitSelectionsV2/FaceFeatures fields) so the
|
||||
@@ -73,10 +162,17 @@ function b64url(input: ArrayBuffer | string): string {
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
async function bearer(sub = '42'): Promise<Record<string, string>> {
|
||||
/**
|
||||
* A bearer token for `sub`. `roles` becomes the `role` claim the auth worker stamps from an
|
||||
* 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>> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const claims =
|
||||
roles === undefined ? { sub, exp: now + 3600 } : { sub, exp: now + 3600, role: roles }
|
||||
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
||||
JSON.stringify({ sub, exp: now + 3600 })
|
||||
JSON.stringify(claims)
|
||||
)}`
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
@@ -270,6 +366,37 @@ describe('econ endpoints', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('POST /api/objectives/v1/updateobjective echoes the group, never completed', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/updateobjective`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
Index: 2,
|
||||
Group: 3,
|
||||
Progress: 1,
|
||||
VisualProgress: 0,
|
||||
IsCompleted: true,
|
||||
HasClaimedReward: false,
|
||||
}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { group: number; isCompleted: boolean; clearedAt: string }
|
||||
expect(body.group).toBe(3)
|
||||
expect(body.isCompleted).toBe(false)
|
||||
expect(Number.isNaN(Date.parse(body.clearedAt))).toBe(false)
|
||||
})
|
||||
|
||||
test('POST /api/objectives/v1/updateobjective tolerates a non-JSON body', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/updateobjective`, {
|
||||
method: 'POST',
|
||||
body: 'not json',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { group: number; isCompleted: boolean }
|
||||
expect(body.group).toBe(0)
|
||||
expect(body.isCompleted).toBe(false)
|
||||
})
|
||||
|
||||
test('GET /api/checklist/v1/current 401s without a token, returns [] with one', async () => {
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`)
|
||||
expect(anon.status).toBe(401)
|
||||
@@ -629,6 +756,7 @@ describe('econ endpoints', () => {
|
||||
|
||||
test('POST /api/storefronts/v2/buyItem debits, grants the item, and hands back a gift box', async () => {
|
||||
// Account 20: fresh, so its first balance touch grants the 10000 default.
|
||||
await drainFrames()
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('20')), 'Content-Type': 'application/json' },
|
||||
@@ -656,6 +784,28 @@ describe('econ endpoints', () => {
|
||||
expect(gift.AvatarItemDesc).not.toBe('')
|
||||
expect(gift.Id).toBeGreaterThan(0)
|
||||
|
||||
// A purchase pushes StorefrontBalancePurchase, which SETS one (CurrencyType, Platform)
|
||||
// bucket to an absolute value: `Balance` is the resulting total (10000 - 450) and `Delta`
|
||||
// is display-only. The bucket key is `Platform`, and it MUST be the -2 the balance
|
||||
// endpoint reports below — the client sums its buckets, so a frame naming any other
|
||||
// platform (or spelling the key `BalanceType`, which the client's decoder drops) invents
|
||||
// a second balance beside the real one. That is what showed a live player 34,100 tokens
|
||||
// after spending 900 of 17,500, then 33,200 once the body's -900 landed.
|
||||
expect(await drainFrames()).toEqual([
|
||||
{
|
||||
accountId: 20,
|
||||
notificationType: NotificationType.StorefrontBalancePurchase,
|
||||
payload: {
|
||||
// 1400 = CommercePurchase; -2 = NonPurchasedNotUsableInP2P, the only bucket we use.
|
||||
BalanceAddType: 1400,
|
||||
Delta: -450,
|
||||
Balance: 9550,
|
||||
Platform: -2,
|
||||
CurrencyType: 2,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
// The balance endpoint reflects the debit (this is the resulting total, 10000 - 450).
|
||||
const bal = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`, {
|
||||
headers: await bearer('20'),
|
||||
@@ -920,6 +1070,162 @@ describe('econ endpoints', () => {
|
||||
expect(list.every((i) => i.FriendlyName !== 'Bowtie (White)')).toBe(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* The StorefrontBalanceUpdate (and other) frames the worker has pushed since the last
|
||||
* drain, read back off the stub hub in vitest.config.ts. Notification sends are
|
||||
* best-effort — the worker logs and swallows a hub failure — so this is the only way a
|
||||
* test sees what was actually pushed.
|
||||
*/
|
||||
const drainFrames = async (): Promise<
|
||||
Array<{ accountId: number; notificationType: number; payload: Record<string, unknown> }>
|
||||
> =>
|
||||
(
|
||||
env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') as unknown as {
|
||||
drainFrames(): Promise<
|
||||
Array<{ accountId: number; notificationType: number; payload: Record<string, unknown> }>
|
||||
>
|
||||
}
|
||||
).drainFrames()
|
||||
|
||||
// buyInvention is a GET with query params — that is how the client sends it.
|
||||
const buyInvention = async (sub: string, inventionId: number, requestedPrice = 0) =>
|
||||
exports.default.fetch(
|
||||
`${ORIGIN}/api/storefronts/v2/buyInvention?inventionId=${inventionId}&requestedPrice=${requestedPrice}`,
|
||||
{ headers: await bearer(sub) }
|
||||
)
|
||||
|
||||
test('GET /api/storefronts/v2/buyInvention 401s without a token', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/storefronts/v2/buyInvention?inventionId=8&requestedPrice=0`
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('GET /api/storefronts/v2/buyInvention records ownership of a free invention', async () => {
|
||||
const res = await buyInvention('50', 8)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
BalanceUpdateResponse: {
|
||||
Balance: number
|
||||
BalanceType: number
|
||||
CurrencyType: number
|
||||
BalanceUpdates: Array<{ UpdateResponse: number; Data: { InventionId: number } }>
|
||||
}
|
||||
InventionResponse: {
|
||||
Status: number
|
||||
Invention: { InventionId: number; Name: string }
|
||||
InventionVersion: { InventionId: number; VersionNumber: number }
|
||||
}
|
||||
}
|
||||
// Nothing was debited, so `Balance` is the resulting total — the untouched starting
|
||||
// grant — not a change, unlike buyItem's.
|
||||
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS)
|
||||
expect(body.BalanceUpdateResponse.CurrencyType).toBe(CurrencyType.RecCenterTokens)
|
||||
expect(body.BalanceUpdateResponse.BalanceType).toBe(-2)
|
||||
expect(body.BalanceUpdateResponse.BalanceUpdates[0].Data.InventionId).toBe(8)
|
||||
expect(body.InventionResponse.Status).toBe(0)
|
||||
expect(body.InventionResponse.Invention.Name).toBe('Invention 8')
|
||||
expect(body.InventionResponse.InventionVersion.VersionNumber).toBe(1)
|
||||
|
||||
expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8])
|
||||
|
||||
// Owning an invention is boolean: buying it again is a conflict, not a second row.
|
||||
expect((await buyInvention('50', 8)).status).toBe(409)
|
||||
expect(await getOwnedInventionIds(env.DB, 50)).toEqual([8])
|
||||
})
|
||||
|
||||
test('GET /api/storefronts/v2/buyInvention pays the creator the buyer’s tokens', async () => {
|
||||
// Invention 9 costs 250 and was made by account 999. Buying it moves 250 tokens from
|
||||
// the buyer to that creator — no house cut, so the two sides are equal and opposite.
|
||||
await drainFrames()
|
||||
const res = await buyInvention('51', 9, 250)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { BalanceUpdateResponse: { Balance: number } }
|
||||
// `Balance` is the buyer's RESULTING total, so it already has the debit in it.
|
||||
expect(body.BalanceUpdateResponse.Balance).toBe(DEFAULT_STARTING_TOKENS - 250)
|
||||
expect(
|
||||
await getBalance(env.DB, 51, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||
).toBe(DEFAULT_STARTING_TOKENS - 250)
|
||||
// The creator had never touched their balance: they keep their starting grant AND get
|
||||
// paid, rather than the payout standing in for the grant.
|
||||
expect(
|
||||
await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||
).toBe(DEFAULT_STARTING_TOKENS + 250)
|
||||
expect(await getOwnedInventionIds(env.DB, 51)).toEqual([9])
|
||||
|
||||
// Both sides get a frame carrying their RESULTING TOTAL, into the same -2 bucket the
|
||||
// balance endpoint reports — a StorefrontBalance* push SETS that bucket, so sending the
|
||||
// change (250 / -250) would set their whole balance to it. The creator sold, so theirs is
|
||||
// a plain update; the buyer bought, so theirs is a purchase frame with a display-only
|
||||
// `Delta`. Note the key is `Platform`: the client renames `BalanceType` away and drops it.
|
||||
expect(await drainFrames()).toEqual([
|
||||
{
|
||||
accountId: 999,
|
||||
notificationType: NotificationType.StorefrontBalanceUpdate,
|
||||
payload: {
|
||||
Balance: DEFAULT_STARTING_TOKENS + 250,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
Platform: -2,
|
||||
},
|
||||
},
|
||||
{
|
||||
accountId: 51,
|
||||
notificationType: NotificationType.StorefrontBalancePurchase,
|
||||
payload: {
|
||||
BalanceAddType: 1400,
|
||||
Delta: -250,
|
||||
Balance: DEFAULT_STARTING_TOKENS - 250,
|
||||
Platform: -2,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('GET /api/storefronts/v2/buyInvention rejects a stale price and an unaffordable one', async () => {
|
||||
// Sending 0 for the 250-token invention 9 is a stale (or tampered) price.
|
||||
expect((await buyInvention('53', 9, 0)).status).toBe(409)
|
||||
|
||||
// Account 54 can't afford it: nothing is debited, nobody is paid, nothing is owned.
|
||||
await spendCurrency(
|
||||
env.DB,
|
||||
54,
|
||||
CurrencyType.RecCenterTokens,
|
||||
DEFAULT_STARTING_TOKENS,
|
||||
DEFAULT_STARTING_TOKENS
|
||||
)
|
||||
const creatorBefore = await getBalance(
|
||||
env.DB,
|
||||
999,
|
||||
CurrencyType.RecCenterTokens,
|
||||
DEFAULT_STARTING_TOKENS
|
||||
)
|
||||
expect((await buyInvention('54', 9, 250)).status).toBe(400)
|
||||
expect(
|
||||
await getBalance(env.DB, 54, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||
).toBe(0)
|
||||
expect(
|
||||
await getBalance(env.DB, 999, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||
).toBe(creatorBefore)
|
||||
expect(await getOwnedInventionIds(env.DB, 53)).toEqual([])
|
||||
expect(await getOwnedInventionIds(env.DB, 54)).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/storefronts/v2/buyInvention rejects drafts, self-buys and unknown ids', async () => {
|
||||
// Unpublished — a draft is not on sale, free or not.
|
||||
expect((await buyInvention('52', 10)).status).toBe(403)
|
||||
// Account 60 created invention 11; a creator already owns it.
|
||||
expect((await buyInvention('60', 11)).status).toBe(400)
|
||||
expect((await buyInvention('52', 9999)).status).toBe(404)
|
||||
// Missing/non-numeric inventionId.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyInvention`, {
|
||||
headers: await bearer('52'),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(await getOwnedInventionIds(env.DB, 52)).toEqual([])
|
||||
expect(await getOwnedInventionIds(env.DB, 60)).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=..`).
|
||||
@@ -1081,36 +1387,554 @@ describe('econ endpoints', () => {
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /api/challenge/v2/updateProgress echoes the challenge, never complete (stub)', async () => {
|
||||
const config =
|
||||
'{"ct":1,"ipc":false,"ctc":[{"ct":0,"ipc":false,"wc":[{"ct":6,"vs":[2]},{"ct":7,"vs":[{"l":"a673712c-877f-4749-b69a-4a4c6310d545"}]}]}],"t":5,"cc":1}'
|
||||
test('POST /api/challenge/v2/updateProgress echoes the challenge and its stored completion', async () => {
|
||||
// Post the live rotation's own challenge and rule tree — what the client actually
|
||||
// sends — so editing static/weekly-challenge.json can't quietly stale this test.
|
||||
const challenge = CURRENT_CHALLENGE
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: { ...(await bearer('70')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ChallengeMapId: '17',
|
||||
ChallengeId: '49',
|
||||
Config: config,
|
||||
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
|
||||
ChallengeId: String(challenge.ChallengeId),
|
||||
Config: challenge.Config,
|
||||
// .NET's bool.ToString() — the capitalized string, which `Boolean("False")`
|
||||
// would read as complete.
|
||||
Complete: 'False',
|
||||
}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
ChallengeMapId: 17,
|
||||
ChallengeId: 49,
|
||||
Config: config,
|
||||
ChallengeMapId: weeklyChallenge.ChallengeMapId,
|
||||
ChallengeId: challenge.ChallengeId,
|
||||
Config: challenge.Config,
|
||||
Complete: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/gamerewards/v1/request returns [] (stub)', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
|
||||
test('POST /api/challenge/v2/updateProgress is 401 without a token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ChallengeMapId: '17', ChallengeId: '49', Complete: 'True' }),
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('a completed challenge persists and getCurrent stamps it for that player only', async () => {
|
||||
const completedId = CURRENT_CHALLENGE.ChallengeId
|
||||
const bearerHeaders = await bearer('71')
|
||||
const posted = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
|
||||
method: 'POST',
|
||||
headers: { ...bearerHeaders, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
|
||||
ChallengeId: completedId,
|
||||
Complete: 'True',
|
||||
}),
|
||||
})
|
||||
expect(posted.status).toBe(200)
|
||||
|
||||
const mine = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`, {
|
||||
headers: bearerHeaders,
|
||||
})
|
||||
const body = (await mine.json()) as {
|
||||
Challenges: Array<{ ChallengeId: number; Complete: boolean }>
|
||||
}
|
||||
// Only the reported one is stamped; the rest of the rotation is untouched.
|
||||
expect(body.Challenges.filter((ch) => ch.Complete).map((ch) => ch.ChallengeId)).toEqual([
|
||||
completedId,
|
||||
])
|
||||
|
||||
// A different player, and an anonymous caller, still see the static catalog.
|
||||
const other = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`, {
|
||||
headers: await bearer('72'),
|
||||
})
|
||||
const otherBody = (await other.json()) as { Challenges: Array<{ Complete: boolean }> }
|
||||
expect(otherBody.Challenges.some((ch) => ch.Complete)).toBe(false)
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
|
||||
const anonBody = (await anon.json()) as { Challenges: Array<{ Complete: boolean }> }
|
||||
expect(anonBody.Challenges.some((ch) => ch.Complete)).toBe(false)
|
||||
})
|
||||
|
||||
test('completion latches within a rotation but resets on a new one', async () => {
|
||||
const headers = { ...(await bearer('73')), 'Content-Type': 'application/json' }
|
||||
// A challenge id of its own, so this says nothing about the live rotation.
|
||||
const post = (ChallengeMapId: string, Complete: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ ChallengeMapId, ChallengeId: '9001', Complete }),
|
||||
})
|
||||
const completeOf = async (res: Response) =>
|
||||
((await res.json()) as { Complete: boolean }).Complete
|
||||
|
||||
expect(await completeOf(await post('17', 'True'))).toBe(true)
|
||||
// A later report that says "not complete" must not un-finish it.
|
||||
expect(await completeOf(await post('17', 'False'))).toBe(true)
|
||||
// …but the same challenge id in the NEXT rotation starts over.
|
||||
expect(await completeOf(await post('18', 'False'))).toBe(false)
|
||||
expect(await completeOf(await post('18', 'True'))).toBe(true)
|
||||
})
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
/** 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 report = (challengeId: number) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
|
||||
ChallengeId: String(challengeId),
|
||||
Complete: 'True',
|
||||
}),
|
||||
})
|
||||
return { ids, report }
|
||||
}
|
||||
|
||||
/** 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`, {
|
||||
headers: await bearer(sub),
|
||||
})
|
||||
return (await res.json()) as Array<{
|
||||
Id: number
|
||||
Message: string
|
||||
EquipmentModificationGuid: string
|
||||
AvatarItemDesc: string
|
||||
ConsumableItemDesc: string
|
||||
GiftRarity: number
|
||||
}>
|
||||
}
|
||||
|
||||
test('completing enough of the rotation grants its gift, once', async () => {
|
||||
// The live rotation, so this follows whatever static/weekly-challenge.json holds.
|
||||
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)
|
||||
for (const id of ids.slice(0, REQUIRED_FOR_GIFT - 1)) {
|
||||
expect((await report(id)).status).toBe(200)
|
||||
}
|
||||
// One short of the threshold — the gift isn't due yet, even though challenges remain
|
||||
// unfinished either way.
|
||||
expect(await giftBoxes('74')).toEqual([])
|
||||
await drainFrames()
|
||||
|
||||
expect((await report(ids[REQUIRED_FOR_GIFT - 1] ?? 0)).status).toBe(200)
|
||||
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)
|
||||
|
||||
// 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).
|
||||
const frames = await drainFrames()
|
||||
expect(frames).toHaveLength(1)
|
||||
expect(frames[0]?.accountId).toBe(74)
|
||||
expect(frames[0]?.notificationType).toBe(NotificationType.GiftPackageReceivedImmediate)
|
||||
expect(frames[0]?.payload).toEqual({
|
||||
Id: won[0]?.Id,
|
||||
FromGiftDropId: 0,
|
||||
FromPlayerId: 1,
|
||||
ConsumableItemDesc: '',
|
||||
AvatarItemDesc: weeklyChallenge.Gift.AvatarItemDesc,
|
||||
AvatarItemType: weeklyChallenge.Gift.AvatarItemType,
|
||||
EquipmentPrefabName: weeklyChallenge.Gift.EquipmentPrefabName,
|
||||
EquipmentModificationGuid: weeklyChallenge.Gift.EquipmentModificationGuid,
|
||||
CurrencyType: 0,
|
||||
Currency: 0,
|
||||
Xp: 0,
|
||||
Level: 0,
|
||||
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,
|
||||
Message: 'Weekly challenge complete!',
|
||||
})
|
||||
|
||||
// The reward is the item, not the box: it lands in the inventory unopened.
|
||||
const unlocked = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`, {
|
||||
headers: await bearer('74'),
|
||||
})
|
||||
const owned = (await unlocked.json()) as Array<{ ModificationGuid: string }>
|
||||
expect(owned.map((e) => e.ModificationGuid)).toContain(
|
||||
weeklyChallenge.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.
|
||||
for (const id of ids) expect((await report(id)).status).toBe(200)
|
||||
expect(await giftBoxes('74')).toHaveLength(1)
|
||||
})
|
||||
|
||||
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.
|
||||
await grantEquipment(env.DB, 75, {
|
||||
ModificationGuid: weeklyChallenge.Gift.EquipmentModificationGuid,
|
||||
PrefabName: weeklyChallenge.Gift.EquipmentPrefabName,
|
||||
FriendlyName: 'Camera Skin (Comic)',
|
||||
Tooltip: '',
|
||||
Rarity: 5,
|
||||
PlatformMask: -1,
|
||||
Favorited: false,
|
||||
})
|
||||
|
||||
const { ids, report } = await finishTheRotation('75')
|
||||
for (const id of ids.slice(0, REQUIRED_FOR_GIFT - 1)) {
|
||||
expect((await report(id)).status).toBe(200)
|
||||
}
|
||||
await drainFrames()
|
||||
expect((await report(ids[REQUIRED_FOR_GIFT - 1] ?? 0)).status).toBe(200)
|
||||
|
||||
const won = await giftBoxes('75')
|
||||
expect(won).toHaveLength(1)
|
||||
// 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?.GiftRarity).toBe(30)
|
||||
expect(
|
||||
(rolled?.AvatarItemDesc ?? '') !== '' || (rolled?.EquipmentModificationGuid ?? '') !== ''
|
||||
).toBe(true)
|
||||
|
||||
// The frame announces what was ROLLED, not the box that promised it — so the client
|
||||
// pops the item they actually won.
|
||||
const frames = await drainFrames()
|
||||
expect(frames).toHaveLength(1)
|
||||
expect(frames[0]?.notificationType).toBe(NotificationType.GiftPackageReceivedImmediate)
|
||||
expect(frames[0]?.payload).toMatchObject({
|
||||
Id: rolled?.Id,
|
||||
FromPlayerId: 1,
|
||||
GiftRarity: 30,
|
||||
AvatarItemDesc: rolled?.AvatarItemDesc,
|
||||
EquipmentModificationGuid: rolled?.EquipmentModificationGuid,
|
||||
Message: 'Weekly challenge complete!',
|
||||
})
|
||||
})
|
||||
|
||||
test('buying a query drop rolls a real item into the buyer’s inventory', async () => {
|
||||
// sf2's "4-Star Unique Box" (539) — an `IsQuery` drop with no item fields of its own,
|
||||
// which before the roll existed debited the buyer and granted nothing.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('76')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
StorefrontType: 2,
|
||||
PurchasableItemId: 539,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
RequestedPrice: 800,
|
||||
}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
// The RESPONSE describes what the roll landed on, not the box that was bought: the
|
||||
// client draws the purchase from this entry, and the box's own fields are all empty.
|
||||
const bought = (await res.json()) as {
|
||||
BalanceUpdates: Array<{
|
||||
Data: Array<{
|
||||
AvatarItemDesc: string
|
||||
EquipmentModificationGuid: string
|
||||
GiftRarity: number
|
||||
}>
|
||||
}>
|
||||
}
|
||||
const entry = bought.BalanceUpdates[0]?.Data[0]
|
||||
expect(entry?.GiftRarity).toBe(30)
|
||||
expect(`${entry?.AvatarItemDesc ?? ''}${entry?.EquipmentModificationGuid ?? ''}`).not.toBe('')
|
||||
|
||||
const boxes = await giftBoxes('76')
|
||||
expect(boxes).toHaveLength(1)
|
||||
// The box shows what was rolled — a real 4-star item, not the empty box drop.
|
||||
expect(boxes[0]?.GiftRarity).toBe(30)
|
||||
expect(entry?.AvatarItemDesc).toBe(boxes[0]?.AvatarItemDesc)
|
||||
const key = (box?: { AvatarItemDesc: string; EquipmentModificationGuid: string }) =>
|
||||
`${box?.AvatarItemDesc ?? ''}|${box?.EquipmentModificationGuid ?? ''}`
|
||||
expect(key(boxes[0])).not.toBe('|')
|
||||
|
||||
// …and it is already in their inventory, unopened box or not.
|
||||
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
||||
headers: await bearer('76'),
|
||||
})
|
||||
const owned = (await items.json()) as Array<{ AvatarItemDesc: string }>
|
||||
if ((boxes[0]?.AvatarItemDesc ?? '') !== '') {
|
||||
expect(owned.map((i) => i.AvatarItemDesc)).toContain(boxes[0]?.AvatarItemDesc)
|
||||
}
|
||||
|
||||
// A second box can't roll the same prize: "an item that you don't have" excludes what
|
||||
// the first roll just granted. Two draws from a 244-item pool could collide by chance,
|
||||
// so this only holds because the pool is filtered by ownership.
|
||||
const second = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('76')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
StorefrontType: 2,
|
||||
PurchasableItemId: 539,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
RequestedPrice: 800,
|
||||
}),
|
||||
})
|
||||
expect(second.status).toBe(200)
|
||||
const after = await giftBoxes('76')
|
||||
expect(after).toHaveLength(2)
|
||||
expect(key(after[0])).not.toBe(key(after[1]))
|
||||
})
|
||||
|
||||
test('buying sf3’s Uncommon Random box answers with the rolled item', async () => {
|
||||
// The purchase that came back as an empty box: an sf3 query drop, rolled out of the very
|
||||
// catalog it sells in.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('77')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
StorefrontType: 3,
|
||||
PurchasableItemId: 2455,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
RequestedPrice: 200,
|
||||
CouponConsumablePlayerMappingId: null,
|
||||
Gift: null,
|
||||
}),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
BalanceUpdates: Array<{
|
||||
Data: Array<{ Id: number; AvatarItemDesc: string; GiftRarity: number }>
|
||||
}>
|
||||
}
|
||||
const entry = body.BalanceUpdates[0]?.Data[0]
|
||||
// Uncommon: rarity 10, and a real item rather than the box's empty fields.
|
||||
expect(entry?.GiftRarity).toBe(10)
|
||||
expect(entry?.AvatarItemDesc).not.toBe('')
|
||||
|
||||
const boxes = await giftBoxes('77')
|
||||
expect(boxes).toHaveLength(1)
|
||||
expect(boxes[0]?.Id).toBe(entry?.Id)
|
||||
expect(boxes[0]?.AvatarItemDesc).toBe(entry?.AvatarItemDesc)
|
||||
})
|
||||
|
||||
test('POST /api/gamerewards/v1/request claims once an hour per reward type and activity', async () => {
|
||||
const headers = {
|
||||
...(await bearer('80')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}
|
||||
const request = (body: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
const statusOf = (rewardType: string, giftContext = '') =>
|
||||
env.DB.prepare(
|
||||
`SELECT granted_at, grant_count FROM reward_status
|
||||
WHERE account_id = 80 AND reward_type = ?1 AND gift_context = ?2`
|
||||
)
|
||||
.bind(rewardType, giftContext)
|
||||
.first<{ granted_at: string; grant_count: number }>()
|
||||
|
||||
// A claim answers the empty list the client accepts — the reward rides in a gift box.
|
||||
const first = await request(
|
||||
'rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day'
|
||||
)
|
||||
expect(first.status).toBe(200)
|
||||
expect(await first.json()).toEqual([])
|
||||
const claimed = await statusOf('FirstActivityOfDay')
|
||||
expect(claimed?.grant_count).toBe(1)
|
||||
|
||||
// Asking again inside the hour claims nothing — and must not push the cooldown out,
|
||||
// or a client that retries in a loop would never become eligible.
|
||||
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
|
||||
expect(await statusOf('FirstActivityOfDay')).toEqual(claimed)
|
||||
|
||||
// A different type has its own cooldown — and so does each `giftContext` within a type:
|
||||
// Soccer and Paintball are separate rows that each claim once.
|
||||
expect(
|
||||
(
|
||||
await request(
|
||||
'rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Soccer'
|
||||
)
|
||||
).status
|
||||
).toBe(200)
|
||||
expect((await statusOf('PostGameActivity', 'Soccer'))?.grant_count).toBe(1)
|
||||
expect(
|
||||
(
|
||||
await request(
|
||||
'rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Paintball'
|
||||
)
|
||||
).status
|
||||
).toBe(200)
|
||||
expect((await statusOf('PostGameActivity', 'Paintball'))?.grant_count).toBe(1)
|
||||
|
||||
// …but the same activity again inside the hour claims nothing.
|
||||
const soccer = await statusOf('PostGameActivity', 'Soccer')
|
||||
expect((await request('rewardType=PostGameActivity&giftContext=Soccer')).status).toBe(200)
|
||||
expect(await statusOf('PostGameActivity', 'Soccer')).toEqual(soccer)
|
||||
|
||||
// A contextless ask is its own bucket (`''`), not a wildcard over the two above.
|
||||
expect((await request('rewardType=PostGameActivity&Message=no%20context')).status).toBe(200)
|
||||
expect((await statusOf('PostGameActivity'))?.grant_count).toBe(1)
|
||||
expect((await request('rewardType=PostGameActivity&Message=again')).status).toBe(200)
|
||||
expect((await statusOf('PostGameActivity'))?.grant_count).toBe(1)
|
||||
|
||||
// Once the hour has passed, the same type claims again.
|
||||
await env.DB.prepare(
|
||||
"UPDATE reward_status SET granted_at = ?1 WHERE account_id = 80 AND reward_type = 'FirstActivityOfDay'"
|
||||
)
|
||||
.bind(new Date(Date.now() - 61 * 60 * 1000).toISOString())
|
||||
.run()
|
||||
expect((await request('rewardType=FirstActivityOfDay&Message=tomorrow')).status).toBe(200)
|
||||
expect((await statusOf('FirstActivityOfDay'))?.grant_count).toBe(2)
|
||||
})
|
||||
|
||||
test('a claimed game reward pays XP into a gift box, and announces it', async () => {
|
||||
const request = async (body: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer('82')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
})
|
||||
/** Age the cooldown so the next ask is eligible again. */
|
||||
const passAnHour = () =>
|
||||
env.DB.prepare(
|
||||
"UPDATE reward_status SET granted_at = ?1 WHERE account_id = 82 AND reward_type = 'FirstActivityOfDay'"
|
||||
)
|
||||
.bind(new Date(Date.now() - 61 * 60 * 1000).toISOString())
|
||||
.run()
|
||||
|
||||
await drainFrames()
|
||||
expect((await getProgression(env.DB, 82)).XP).toBe(0)
|
||||
const res = await request('rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
|
||||
// 5 XP is deliberately less than the 10 the first level costs, so one action moves the
|
||||
// bar without levelling anyone up.
|
||||
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
|
||||
|
||||
// One box: the XP reward itself, carrying the message the client asked to show and no
|
||||
// item — a game reward is not an item.
|
||||
const first = await giftBoxes('82')
|
||||
expect(first).toHaveLength(1)
|
||||
expect(first[0]).toMatchObject({
|
||||
Xp: 5,
|
||||
Message: 'First Game of the Day',
|
||||
AvatarItemDesc: '',
|
||||
EquipmentModificationGuid: '',
|
||||
ConsumableItemDesc: '',
|
||||
})
|
||||
|
||||
// The box, then the bar — no level-up box, since no level was crossed.
|
||||
const frames = await drainFrames()
|
||||
expect(frames.map((f) => f.notificationType)).toEqual([
|
||||
NotificationType.GiftPackageReceivedImmediate,
|
||||
NotificationType.PlayerProgressionLevelUpdate,
|
||||
])
|
||||
expect(frames[0]?.accountId).toBe(82)
|
||||
expect(frames[0]?.payload).toMatchObject({
|
||||
Id: first[0]?.Id,
|
||||
FromPlayerId: 1,
|
||||
Xp: 5,
|
||||
// GiftContext.GameRewards — the box came from gameplay, not a purchase.
|
||||
GiftContext: 50,
|
||||
Message: 'First Game of the Day',
|
||||
})
|
||||
expect(frames[1]?.payload).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
|
||||
|
||||
// An on-cooldown ask pays nothing: no more boxes, no frames, no more XP.
|
||||
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
|
||||
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
|
||||
expect(await giftBoxes('82')).toHaveLength(1)
|
||||
expect(await drainFrames()).toEqual([])
|
||||
|
||||
// A SECOND reward completes the 10 XP level 1 costs — two actions per early level, which
|
||||
// is the pacing the smaller grant buys.
|
||||
await passAnHour()
|
||||
expect((await request('rewardType=FirstActivityOfDay&Message=Second')).status).toBe(200)
|
||||
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 2, XP: 0 })
|
||||
|
||||
// …and level 2 pays 2-Star Clothing per the published table: an AVATAR ITEM, never an
|
||||
// equipment skin, which is what the avatar-only roll is for.
|
||||
const afterLevel2 = await giftBoxes('82')
|
||||
expect(afterLevel2).toHaveLength(3)
|
||||
const clothingBox = afterLevel2[2]
|
||||
expect(clothingBox?.Message).toBe('Level 2!')
|
||||
expect(clothingBox?.AvatarItemDesc).not.toBe('')
|
||||
expect(clothingBox?.EquipmentModificationGuid).toBe('')
|
||||
expect(clothingBox?.ConsumableItemDesc).toBe('')
|
||||
expect(clothingBox?.GiftRarity).toBe(10)
|
||||
|
||||
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
||||
headers: await bearer('82'),
|
||||
})
|
||||
const owned = (await items.json()) as Array<{ AvatarItemDesc: string }>
|
||||
expect(owned.map((i) => i.AvatarItemDesc)).toContain(clothingBox?.AvatarItemDesc)
|
||||
expect((await drainFrames()).map((f) => f.notificationType)).toEqual([
|
||||
NotificationType.GiftPackageReceivedImmediate,
|
||||
NotificationType.PlayerProgressionLevelUpdate,
|
||||
NotificationType.GiftPackageReceivedImmediate,
|
||||
])
|
||||
|
||||
// Two more rewards reach level 3, which the table pays as a CONSUMABLE rather than
|
||||
// clothing — rolled without a rarity, since the table names none for them.
|
||||
for (const message of ['Third', 'Fourth']) {
|
||||
await passAnHour()
|
||||
expect((await request(`rewardType=FirstActivityOfDay&Message=${message}`)).status).toBe(200)
|
||||
}
|
||||
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 3, XP: 0 })
|
||||
|
||||
const afterLevel3 = await giftBoxes('82')
|
||||
const consumableBox = afterLevel3[afterLevel3.length - 1]
|
||||
expect(consumableBox?.Message).toBe('Level 3!')
|
||||
expect(consumableBox?.ConsumableItemDesc).not.toBe('')
|
||||
expect(consumableBox?.AvatarItemDesc).toBe('')
|
||||
expect(consumableBox?.EquipmentModificationGuid).toBe('')
|
||||
|
||||
const consumables = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, {
|
||||
headers: await bearer('82'),
|
||||
})
|
||||
const held = (await consumables.json()) as Array<{ ConsumableItemDesc: string }>
|
||||
expect(held.map((cons) => cons.ConsumableItemDesc)).toContain(consumableBox?.ConsumableItemDesc)
|
||||
})
|
||||
|
||||
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',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
expect(anon.status).toBe(401)
|
||||
|
||||
// No reward type: nothing to gate, so no row keyed on an empty string.
|
||||
const typeless = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer('81')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: 'Message=First%20Game%20of%20the%20Day',
|
||||
})
|
||||
expect(typeless.status).toBe(200)
|
||||
expect(await typeless.json()).toEqual([])
|
||||
const rows = await env.DB.prepare(
|
||||
'SELECT COUNT(*) AS count FROM reward_status WHERE account_id = 81'
|
||||
).first<{ count: number }>()
|
||||
expect(rows?.count).toBe(0)
|
||||
})
|
||||
|
||||
test('GET /api/roomkeys/v1/mine returns []', async () => {
|
||||
@@ -1119,18 +1943,53 @@ describe('econ endpoints', () => {
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /api/CampusCard/v1/UpdateAndGetSubscription returns null fields', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/CampusCard/v1/UpdateAndGetSubscription`,
|
||||
{
|
||||
const getSubscription = async (headers: Record<string, string> = {}) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/CampusCard/v1/UpdateAndGetSubscription`, {
|
||||
method: 'POST',
|
||||
}
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
subscription: null,
|
||||
platformAccountSubscribedPlayerId: null,
|
||||
headers,
|
||||
})
|
||||
|
||||
test('POST /api/CampusCard/v1/UpdateAndGetSubscription gives a developer a Gold year', async () => {
|
||||
const res = await getSubscription(await bearer('205', ['gameClient', 'developer']))
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
Subscription: Record<string, unknown>
|
||||
PlatformAccountSubscribedPlayerId: null
|
||||
}
|
||||
expect(body.PlatformAccountSubscribedPlayerId).toBeNull()
|
||||
expect(body.Subscription).toMatchObject({
|
||||
SubscriptionId: 1,
|
||||
// The subscribed player is the caller, not a fixed id.
|
||||
RecNetPlayerId: 205,
|
||||
// -1 All: no store sold this. 0 = Gold (1 is Platinum), 1 = Year.
|
||||
PlatformType: -1,
|
||||
PlatformId: '',
|
||||
PlatformPurchaseId: '',
|
||||
Level: 0,
|
||||
Period: 1,
|
||||
IsAutoRenewing: true,
|
||||
})
|
||||
|
||||
// The subscription runs a year from the call rather than to a hard-coded date, so it
|
||||
// cannot lapse on a day nobody is expecting.
|
||||
const created = new Date(body.Subscription.CreatedAt as string)
|
||||
const expires = new Date(body.Subscription.ExpirationDate as string)
|
||||
expect(body.Subscription.ModifiedAt).toBe(body.Subscription.CreatedAt)
|
||||
expect(expires.getTime()).toBeGreaterThan(Date.now())
|
||||
expect(expires.getUTCFullYear()).toBe(created.getUTCFullYear() + 1)
|
||||
expect(expires.getUTCMonth()).toBe(created.getUTCMonth())
|
||||
expect(expires.getUTCDate()).toBe(created.getUTCDate())
|
||||
})
|
||||
|
||||
test('POST /api/CampusCard/v1/UpdateAndGetSubscription is {} without the developer role', async () => {
|
||||
// A plain player's token: valid, but no elevated role.
|
||||
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({})
|
||||
// No token: "not subscribed" rather than 401, so a loading client isn't stalled.
|
||||
const anon = await getSubscription()
|
||||
expect(anon.status).toBe(200)
|
||||
expect(await anon.json()).toEqual({})
|
||||
})
|
||||
|
||||
test('unknown path returns 404', async () => {
|
||||
@@ -1182,6 +2041,7 @@ describe('econ endpoints', () => {
|
||||
'GET /api/roomkeys/v1/mine',
|
||||
'GET /api/roomkeys/v1/room',
|
||||
'GET /api/storefronts/v1/adcarouselitems',
|
||||
'GET /api/storefronts/v2/buyInvention',
|
||||
'GET /api/storefronts/v3/giftdropstore/{id}',
|
||||
'GET /api/storefronts/v4/balance/{currencyType}',
|
||||
'GET /econ/customAvatarItems/v1/owned',
|
||||
@@ -1194,6 +2054,7 @@ describe('econ endpoints', () => {
|
||||
'POST /api/consumables/v1/consume',
|
||||
'POST /api/gamerewards/v1/request',
|
||||
'POST /api/objectives/v1/cleargroup',
|
||||
'POST /api/objectives/v1/updateobjective',
|
||||
'POST /api/storefronts/v2/buyItem',
|
||||
'PUT /api/equipment/v1/update',
|
||||
])
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{
|
||||
"ChallengeId": 37,
|
||||
"Name": "CompleteJT",
|
||||
"Config": "{\"ct\":0,\"ipc\":false,\"wc\":[{\"ct\":6,\"vs\":[2]},{\"ct\":9,\"vs\":[true],\"v\":\"won\"},{\"ct\":7,\"vs\":[{\"l\":\"acc06e66-c2d0-4361-b0cd-46246a4c455c\"}]}]}",
|
||||
"Config": "{\"ct\":1,\"c\":true,\"ipc\":false,\"ctc\":[{\"ct\":0,\"ipc\":false,\"wc\":[{\"ct\":6,\"vs\":[2]},{\"ct\":7,\"vs\":[{\"l\":\"6d5eea4b-f069-4ed0-9916-0e2f07df0d03\"},{\"l\":\"4078dfed-24bb-4db7-863f-578ba48d726b\"}]}]}],\"t\":1,\"cc\":1}",
|
||||
"Description": "Complete ^TheRiseOfJumbotron quest",
|
||||
"Tooltip": "Vanquish Jumbotron in the Rise of Jumbotron quest!",
|
||||
"Complete": false
|
||||
@@ -60,5 +60,5 @@
|
||||
"GiftRarity": 0
|
||||
},
|
||||
"FallbackGiftName": "4-Star Box",
|
||||
"ChallengeThemeString": "\"do like \"kapow\"-like its a punch to the face that we're doing weekly challenges\" - fexlar"
|
||||
"ChallengeThemeString": ""
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ export default defineConfig({
|
||||
// isolated test, so provide a minimal stub exposing the same NotificationsHub
|
||||
// RPC surface — enough for the runtime to start and for notification sends to
|
||||
// no-op.
|
||||
//
|
||||
// The stub RECORDS what it was sent (`drainFrames`) rather than discarding it.
|
||||
// Pushes are best-effort and swallow their own errors, so a frame carrying the
|
||||
// wrong payload is otherwise invisible here — which is exactly how
|
||||
// StorefrontBalanceUpdate shipped with the resulting total in a field the
|
||||
// client adds to what it is already showing.
|
||||
workers: [
|
||||
{
|
||||
name: 'notify',
|
||||
@@ -24,8 +30,18 @@ export default defineConfig({
|
||||
script: `
|
||||
import { DurableObject } from 'cloudflare:workers'
|
||||
export class NotificationsHub extends DurableObject {
|
||||
async notifyPlayer() { return { delivered: 0, queued: true } }
|
||||
frames = []
|
||||
async notifyPlayer(accountId, notificationType, payload) {
|
||||
this.frames.push({ accountId, notificationType, payload })
|
||||
return { delivered: 0, queued: true }
|
||||
}
|
||||
async broadcast() { return { delivered: 0 } }
|
||||
/** Everything pushed since the last call, then forget it. */
|
||||
async drainFrames() {
|
||||
const drained = this.frames
|
||||
this.frames = []
|
||||
return drained
|
||||
}
|
||||
}
|
||||
export default { fetch() { return new Response('ok') } }
|
||||
`,
|
||||
|
||||
+24
-5
@@ -13,14 +13,33 @@ key:
|
||||
back to the bundled `static/DefaultProfileImage.jpg` asset (served `200` via
|
||||
the `ASSETS` binding), so clients always get a valid image. The fallback also
|
||||
honours `?sig=p1` and returns a `Content-Signature` header.
|
||||
- `GET /<key>?sig=p1` — same, but the response body is RSA-SHA1 signed and the
|
||||
signature returned in a `Content-Signature: key-id=KEY:RSA:p1.rec.net; data=<base64>`
|
||||
header. The client uses this to verify image integrity. Signing buffers the
|
||||
whole object.
|
||||
- `GET /<key>?sig=p1` — same, plus a
|
||||
`Content-Signature: key-id=KEY:RSA:p1.rec.net; data=<base64>` header. By default
|
||||
that value is a **placeholder, not a real signature** — see below.
|
||||
|
||||
The bucket is bound in the Worker as `env.IMAGES` (see `wrangler.jsonc`).
|
||||
|
||||
## Response signing key
|
||||
## Response signing
|
||||
|
||||
The client requires a `Content-Signature` header to be present when it asks for
|
||||
`?sig=p1`, but it never verifies the value. Signing for real is this worker's
|
||||
dominant CPU cost: it has to buffer the whole object into the isolate rather than
|
||||
streaming it out of R2, then hash the full body with SHA-1 and run an RSA-2048
|
||||
private-key operation — on every request the edge cache misses.
|
||||
|
||||
So by default (`IMG_SIGNING_ENABLED: false` in `wrangler.jsonc`) the header is
|
||||
filled with a placeholder derived from the object key: FNV-1a seeds an xorshift32
|
||||
PRNG that emits 256 bytes, the length of a real RSA-2048 signature, so the value
|
||||
is structurally indistinguishable to the client's parser and stable for a given
|
||||
key. It costs no body access, so untransformed images keep streaming.
|
||||
|
||||
Set the var to `true` for genuine RSA-SHA1 signatures over the returned bytes.
|
||||
Note this is a **placeholder, not a downgrade of a security control** — nothing
|
||||
in the system authenticates images either way. Turn it on before relying on the
|
||||
header for integrity. (Resizes buffer regardless — the Photon codec needs the
|
||||
whole image.)
|
||||
|
||||
### Signing key
|
||||
|
||||
`?sig=p1` signs with the RSA-2048 key in `env.IMG_SIGNING_KEY` (PKCS8 DER,
|
||||
base64). `wrangler.jsonc` ships an **insecure dev key** for local dev / tests;
|
||||
|
||||
@@ -6,6 +6,12 @@ export type Env = SharedHonoEnv & {
|
||||
DB: D1Database
|
||||
/** R2 bucket holding the served image objects, keyed by filename. */
|
||||
IMAGES: R2Bucket
|
||||
/**
|
||||
* Shared `recflare-cdn` bucket. Only its `image/` prefix is read here: images
|
||||
* uploaded through the `storage` worker are stored extensionless under
|
||||
* `image/<date>/<uuid>` and requested from this worker by the bare name.
|
||||
*/
|
||||
CDN_ASSETS: R2Bucket
|
||||
/** Static assets (fallback images) served from `static/`. */
|
||||
ASSETS: Fetcher
|
||||
/**
|
||||
@@ -13,6 +19,14 @@ export type Env = SharedHonoEnv & {
|
||||
* requested with `?sig=p1`. Optional — when absent, responses are unsigned.
|
||||
*/
|
||||
IMG_SIGNING_KEY?: string
|
||||
/**
|
||||
* Feature flag for REAL response signing. `?sig=p1` always returns a
|
||||
* `Content-Signature` header, but only when this is true is the value an
|
||||
* actual RSA-SHA1 signature over the body; when false (the default) it is a
|
||||
* cheap placeholder derived from the object key, which keeps the response on
|
||||
* the streaming path. See `stubSignature()` in `img.app.ts`.
|
||||
*/
|
||||
IMG_SIGNING_ENABLED?: boolean
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* Image-metadata storage on the shared `recflare` D1 database. Each image is a
|
||||
* single JSON blob in the `data` column; queryable fields (Id, ImageName,
|
||||
* PlayerId, RoomId) are SQLite generated (virtual) columns extracted from that
|
||||
* JSON — the same JSON-blob pattern the rooms/accounts tables use.
|
||||
*
|
||||
* The `img` worker owns this schema/migration (migrations/0001_images.sql, applied
|
||||
* with its own `migrations_table` so it doesn't clash with the other workers'
|
||||
* migrations on the shared database). The `api` worker writes a row on upload and
|
||||
* reads it back, keeping its own copy of these helpers in sync.
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0001_image.sql, sans any seed rows). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS image (
|
||||
data TEXT NOT NULL,
|
||||
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Id')) VIRTUAL,
|
||||
image_name TEXT GENERATED ALWAYS AS (json_extract(data, '$.ImageName')) VIRTUAL,
|
||||
player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerId')) VIRTUAL,
|
||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_image_id ON image (id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_image_name ON image (image_name)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_player_id ON image (player_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_room_id ON image (room_id)`,
|
||||
// A player's interaction with a saved image — one row per (player, image). Only
|
||||
// `cheered` for now; named generically so other per-user interactions (e.g.
|
||||
// favorited) can be added as columns. The `api` worker writes it (cheer endpoints)
|
||||
// and keeps the image's denormalized `CheerCount` in sync from it.
|
||||
`CREATE TABLE IF NOT EXISTS image_interaction (
|
||||
player_id INTEGER NOT NULL,
|
||||
saved_image_id INTEGER NOT NULL,
|
||||
cheered INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT,
|
||||
PRIMARY KEY (player_id, saved_image_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_image_interaction_image ON image_interaction (saved_image_id)`,
|
||||
]
|
||||
|
||||
/** A stored image record (the client-facing SavedImage shape). */
|
||||
export interface SavedImage {
|
||||
Id: number
|
||||
Type: number
|
||||
Accessibility: number
|
||||
AccessibilityLocked: boolean
|
||||
ImageName: string
|
||||
Description: string | null
|
||||
PlayerId: number
|
||||
TaggedPlayerIds: number[]
|
||||
RoomId: number | null
|
||||
PlayerEventId: number | null
|
||||
CreatedAt: string
|
||||
CheerCount: number
|
||||
CommentCount: number
|
||||
}
|
||||
|
||||
interface ImageRow {
|
||||
data: string
|
||||
}
|
||||
|
||||
/** Fields supplied at upload time (from `imgMeta`); everything else defaults. */
|
||||
export interface NewImage {
|
||||
imageName: string
|
||||
playerId: number
|
||||
type?: number
|
||||
accessibility?: number
|
||||
roomId?: number | null
|
||||
description?: string | null
|
||||
taggedPlayerIds?: number[]
|
||||
playerEventId?: number | null
|
||||
}
|
||||
|
||||
/** Insert a new image record for an upload, returning the stored row. */
|
||||
export async function createImage(db: D1Database, input: NewImage): Promise<SavedImage> {
|
||||
// Sequential id: one past the current max (the table starts empty).
|
||||
const row = await db
|
||||
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM image')
|
||||
.first<{ next: number }>()
|
||||
const image: SavedImage = {
|
||||
Id: row?.next ?? 1,
|
||||
Type: input.type ?? 1,
|
||||
Accessibility: input.accessibility ?? 1,
|
||||
AccessibilityLocked: false,
|
||||
ImageName: input.imageName,
|
||||
Description: input.description ?? null,
|
||||
PlayerId: input.playerId,
|
||||
TaggedPlayerIds: input.taggedPlayerIds ?? [],
|
||||
RoomId: input.roomId ?? null,
|
||||
PlayerEventId: input.playerEventId ?? null,
|
||||
CreatedAt: new Date().toISOString(),
|
||||
CheerCount: 0,
|
||||
CommentCount: 0,
|
||||
}
|
||||
await db.prepare('INSERT INTO image (data) VALUES (?1)').bind(JSON.stringify(image)).run()
|
||||
return image
|
||||
}
|
||||
|
||||
/** Look up an image record by its ImageName (the R2 key / filename), or null. */
|
||||
export async function getImageByName(db: D1Database, name: string): Promise<SavedImage | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT data FROM image WHERE image_name = ?1')
|
||||
.bind(name)
|
||||
.first<ImageRow>()
|
||||
return row ? (JSON.parse(row.data) as SavedImage) : null
|
||||
}
|
||||
+177
-31
@@ -3,7 +3,7 @@ import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { withCleanSpec, withNotFound, withOnError, writeContentRange } from '@repo/hono-helpers'
|
||||
|
||||
import { imageBytes, json, ServiceStatus } from './openapi'
|
||||
|
||||
@@ -15,6 +15,9 @@ const SIGNATURE_KEY_ID = 'KEY:RSA:p1.rec.net'
|
||||
/** Static asset served (200) when the requested key is missing from R2. */
|
||||
const FALLBACK_ASSET_PATH = '/DefaultProfileImage.jpg'
|
||||
|
||||
/** Prefix extensionless keys resolve under in the shared `recflare-cdn` bucket. */
|
||||
const CDN_IMAGE_PREFIX = 'image/'
|
||||
|
||||
/**
|
||||
* Cache-Control for served images. Uploaded images are immutable once written,
|
||||
* so cache for a year and mark `immutable` so browsers never revalidate. A new
|
||||
@@ -101,6 +104,23 @@ function resizeImage(input: Uint8Array, transform: Transform): Uint8Array {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which bucket (and under which key) a requested path resolves in.
|
||||
*
|
||||
* Every object the `api` worker writes to `recflare-img` keeps a file extension
|
||||
* (`.jpg` is forced when the upload has none), so an extensionless key can only be
|
||||
* a `storage` upload: FileType 3 lands in the shared `recflare-cdn` bucket as
|
||||
* `image/<date>/<uuid>` and the client references it by the bare `<date>/<uuid>`
|
||||
* name it got back. That makes the extension a reliable discriminator —
|
||||
* `/2028-06-01/<uuid>` here is `recflare-cdn`'s `image/2028-06-01/<uuid>`.
|
||||
*/
|
||||
function resolveObject(env: Env, key: string): { bucket: R2Bucket; objectKey: string } {
|
||||
const filename = key.slice(key.lastIndexOf('/') + 1)
|
||||
return filename.includes('.')
|
||||
? { bucket: env.IMAGES, objectKey: key }
|
||||
: { bucket: env.CDN_ASSETS, objectKey: CDN_IMAGE_PREFIX + key }
|
||||
}
|
||||
|
||||
// Import the signing key once per isolate. The key material is constant for the
|
||||
// lifetime of the Worker, so caching the promise is safe.
|
||||
let signingKey: Promise<CryptoKey | null> | undefined
|
||||
@@ -132,17 +152,88 @@ async function signImage(env: Env, bytes: BufferSource): Promise<string | null>
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
/** Length of an RSA-2048 signature, matched by the placeholder below. */
|
||||
const SIGNATURE_BYTES = 256
|
||||
|
||||
/**
|
||||
* A placeholder `Content-Signature` value derived from the object key.
|
||||
*
|
||||
* The client requires the header to be PRESENT when it asks for `?sig=p1` — it
|
||||
* does not check the value — and a real signature is this worker's dominant CPU
|
||||
* cost, so by default we fabricate one. Being a pure function of the key it needs
|
||||
* no access to the body, which is the whole point: the response still streams out
|
||||
* of R2 instead of being buffered into the isolate to be hashed.
|
||||
*
|
||||
* FNV-1a over the key seeds an xorshift32 PRNG that fills a full RSA-2048-length
|
||||
* signature, so the value looks structurally right and is stable for a given key
|
||||
* (a cached response and a fresh one agree). It is NOT verifiable: turn on
|
||||
* `IMG_SIGNING_ENABLED` if anything ever needs to check it.
|
||||
*/
|
||||
function stubSignature(key: string): string {
|
||||
let state = 0x811c9dc5
|
||||
for (let i = 0; i < key.length; i++) {
|
||||
state = Math.imul(state ^ key.charCodeAt(i), 0x01000193) >>> 0
|
||||
}
|
||||
// xorshift32 is a fixed point at zero; the FNV basis makes this unreachable in
|
||||
// practice, but a degenerate all-zero signature is worth ruling out outright.
|
||||
if (state === 0) state = 0x811c9dc5
|
||||
|
||||
let binary = ''
|
||||
for (let i = 0; i < SIGNATURE_BYTES; i++) {
|
||||
state = (state ^ (state << 13)) >>> 0
|
||||
state = state ^ (state >>> 17)
|
||||
state = (state ^ (state << 5)) >>> 0
|
||||
binary += String.fromCharCode(state & 0xff)
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
/**
|
||||
* How this request's `Content-Signature` header gets produced.
|
||||
*
|
||||
* - `none` — no `?sig=p1` was asked for; no header.
|
||||
* - `stub` — the value is a pure function of the object key and is already
|
||||
* computed, so the body never has to be read. The default.
|
||||
* - `rsa` — a real RSA-SHA1 signature over the bytes actually returned, which
|
||||
* forces the whole body through the isolate.
|
||||
*/
|
||||
type Signing = { mode: 'none' } | { mode: 'stub'; value: string } | { mode: 'rsa' }
|
||||
|
||||
function resolveSigning(env: Env, sig: string | undefined, key: string): Signing {
|
||||
if (sig !== 'p1') return { mode: 'none' }
|
||||
if (env.IMG_SIGNING_ENABLED === true) return { mode: 'rsa' }
|
||||
return { mode: 'stub', value: stubSignature(key) }
|
||||
}
|
||||
|
||||
function signatureHeader(value: string): string {
|
||||
return `key-id=${SIGNATURE_KEY_ID}; data=${value}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the key-derived placeholder signature, if that's the mode in play. Called
|
||||
* before the body is touched — a stub never forces buffering.
|
||||
*/
|
||||
function applyStubSignature(headers: Headers, signing: Signing): void {
|
||||
if (signing.mode === 'stub') headers.set('content-signature', signatureHeader(signing.value))
|
||||
}
|
||||
|
||||
/** Whether serving this response requires the full body in the isolate. */
|
||||
function needsBody(transform: Transform | null, signing: Signing): boolean {
|
||||
return transform !== null || signing.mode === 'rsa'
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the full image bytes and prepared response `headers`, optionally resize
|
||||
* (Photon) and/or RSA-SHA1 sign (`?sig=p1`) before returning the `Response`.
|
||||
* Both operations need the whole body, so callers buffer before calling this.
|
||||
* (Photon) and/or RSA-SHA1 sign before returning the `Response`. Both operations
|
||||
* need the whole body, so callers buffer before calling this. A `stub` signature
|
||||
* is already on `headers` by this point.
|
||||
*/
|
||||
async function finalizeImage(
|
||||
env: Env,
|
||||
bytes: ArrayBuffer,
|
||||
headers: Headers,
|
||||
transform: Transform | null,
|
||||
wantsSignature: boolean
|
||||
signing: Signing
|
||||
): Promise<Response> {
|
||||
let body: BufferSource = bytes
|
||||
if (transform) {
|
||||
@@ -152,11 +243,9 @@ async function finalizeImage(
|
||||
headers.delete('etag')
|
||||
}
|
||||
|
||||
if (wantsSignature) {
|
||||
if (signing.mode === 'rsa') {
|
||||
const signature = await signImage(env, body)
|
||||
if (signature) {
|
||||
headers.set('content-signature', `key-id=${SIGNATURE_KEY_ID}; data=${signature}`)
|
||||
}
|
||||
if (signature) headers.set('content-signature', signatureHeader(signature))
|
||||
}
|
||||
|
||||
return new Response(body, { headers })
|
||||
@@ -164,23 +253,25 @@ async function finalizeImage(
|
||||
|
||||
/**
|
||||
* Serve a static asset `Response` with our standard cache headers, honouring
|
||||
* `?width`/`?height` (resize) and `?sig=p1` (signing). Either requires the full
|
||||
* body, so the asset is buffered; otherwise it is streamed through untouched.
|
||||
* `?width`/`?height` (resize) and `?sig=p1` (signing). A transform or a real
|
||||
* signature requires the full body, so the asset is buffered; otherwise it is
|
||||
* streamed through untouched.
|
||||
*/
|
||||
async function serveStaticAsset(
|
||||
env: Env,
|
||||
asset: Response,
|
||||
transform: Transform | null,
|
||||
wantsSignature: boolean
|
||||
signing: Signing
|
||||
): Promise<Response> {
|
||||
const headers = new Headers()
|
||||
const contentType = asset.headers.get('content-type')
|
||||
if (contentType) headers.set('content-type', contentType)
|
||||
headers.set('cache-control', CACHE_CONTROL)
|
||||
applyStubSignature(headers, signing)
|
||||
|
||||
if (transform || wantsSignature) {
|
||||
if (needsBody(transform, signing)) {
|
||||
const bytes = await asset.arrayBuffer()
|
||||
return finalizeImage(env, bytes, headers, transform, wantsSignature)
|
||||
return finalizeImage(env, bytes, headers, transform, signing)
|
||||
}
|
||||
|
||||
return new Response(asset.body, { headers })
|
||||
@@ -231,11 +322,14 @@ app.get(
|
||||
description: [
|
||||
'Image hosting for recflare, a private-server reimplementation of the Rec Room',
|
||||
'backend. Serves every image the client renders — profile photos, room thumbnails,',
|
||||
'club banners and the photo feed — from an R2 bucket, with bundled static assets',
|
||||
'club banners and the photo feed — out of R2, with bundled static assets',
|
||||
'(`static/`) taking precedence over the bucket and `DefaultProfileImage.jpg` served',
|
||||
'as the fallback when a key is missing. Optional on-the-fly center-crop and resize',
|
||||
'run through the Photon WASM codec; `?sig=p1` adds the RSA-SHA1 `Content-Signature`',
|
||||
'header the client verifies against `KEY:RSA:p1.rec.net`.',
|
||||
'as the fallback when a key is missing. Keys with an extension come from the',
|
||||
'`recflare-img` bucket; extensionless ones are `storage` uploads and come from the',
|
||||
'shared `recflare-cdn` bucket under its `image/` prefix. Optional center-crop and resize',
|
||||
'run through the Photon WASM codec; `?sig=p1` adds the `Content-Signature` header',
|
||||
'the client expects against `KEY:RSA:p1.rec.net` — a key-derived placeholder',
|
||||
'unless the `IMG_SIGNING_ENABLED` flag turns on real RSA-SHA1 signing.',
|
||||
'',
|
||||
'Note that this worker only serves bytes: the image metadata the client lists (the',
|
||||
'`SavedImage` records behind `/api/images/...`) lives in the `api` worker, which',
|
||||
@@ -266,6 +360,12 @@ app.get(
|
||||
'the same key; when neither exists the bundled `DefaultProfileImage.jpg` is served',
|
||||
'with a 200 rather than a 404, so the client never renders a broken image.',
|
||||
'',
|
||||
'Which bucket the key resolves in depends on its extension. A key with one (always',
|
||||
'the case for an `api` image upload) comes from `recflare-img`. A key WITHOUT one is',
|
||||
'a `storage` upload and comes from the shared `recflare-cdn` bucket under its',
|
||||
'`image/` prefix, so `/2028-06-01/<uuid>` here serves `image/2028-06-01/<uuid>`',
|
||||
'there.',
|
||||
'',
|
||||
'Responses carry `Cache-Control: public, max-age=31536000, immutable` — an uploaded',
|
||||
'image is never rewritten in place, a new image gets a new key.',
|
||||
'',
|
||||
@@ -273,6 +373,12 @@ app.get(
|
||||
'return JPEG with no `ETag` (the source etag no longer describes the body), and the',
|
||||
'`If-None-Match` precondition is skipped. An out-of-range or non-integer dimension is',
|
||||
'ignored and the original is served — never an error.',
|
||||
'',
|
||||
'A `Range` is honoured (206) only on the untouched stream, which is the only response',
|
||||
'that advertises `Accept-Ranges`. A transform decodes the whole image and a real',
|
||||
'signature covers the whole body, so those serve the entire result and ignore the',
|
||||
'header. Where a range does apply, a `bytes=` request is never answered with a bare',
|
||||
'200: the `Content-Range` always states which bytes the body holds.',
|
||||
].join('\n'),
|
||||
parameters: [
|
||||
{
|
||||
@@ -315,10 +421,13 @@ app.get(
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: [
|
||||
'`p1` RSA-SHA1 signs the response body and returns it as',
|
||||
'`Content-Signature: key-id=KEY:RSA:p1.rec.net; data=<base64>`. Signed over the',
|
||||
'bytes actually returned, i.e. the resized body when a transform applies. Omitted',
|
||||
'when the worker has no `IMG_SIGNING_KEY`.',
|
||||
'`p1` returns a `Content-Signature: key-id=KEY:RSA:p1.rec.net; data=<base64>`',
|
||||
'header. By default `data` is a PLACEHOLDER derived from the object key, not a',
|
||||
'real signature — the client requires the header to be present but does not',
|
||||
'verify it, and signing for real costs the streaming fast path. Set',
|
||||
'`IMG_SIGNING_ENABLED` for a true RSA-SHA1 signature over the bytes actually',
|
||||
'returned (i.e. the resized body when a transform applies); that also needs an',
|
||||
'`IMG_SIGNING_KEY`, without which the header is omitted entirely.',
|
||||
].join(' '),
|
||||
schema: { type: 'string', enum: ['p1'] },
|
||||
},
|
||||
@@ -330,9 +439,22 @@ app.get(
|
||||
'Conditional request against the R2 object etag. Ignored when a transform is requested.',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'Range',
|
||||
in: 'header',
|
||||
required: false,
|
||||
description: [
|
||||
'A single byte range, parsed by R2 itself. Honoured with a 206 on the untouched',
|
||||
'stream only — ignored when a transform or a real signature applies, since both',
|
||||
'need the whole image. A `bytes=` value never yields a bare 200: the',
|
||||
'`Content-Range` names the bytes enclosed even where that is all of them.',
|
||||
].join(' '),
|
||||
schema: { type: 'string', example: 'bytes=0-1023' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: imageBytes('The image bytes (or the DefaultProfileImage.jpg fallback)'),
|
||||
206: imageBytes('A byte range of the stored image, when the request carried a `Range`'),
|
||||
304: { description: 'If-None-Match matched the stored object etag; no body' },
|
||||
400: { description: 'The key contained `..`; no body' },
|
||||
},
|
||||
@@ -341,7 +463,11 @@ app.get(
|
||||
const key = c.req.param('key')
|
||||
if (key.includes('..')) return c.body(null, 400)
|
||||
|
||||
const wantsSignature = c.req.query('sig') === 'p1'
|
||||
// `?sig=p1` always answers with a Content-Signature header — the client needs
|
||||
// one to be there — but by default the value is a cheap placeholder derived
|
||||
// from the key rather than a real RSA-SHA1 signature over the body. See
|
||||
// stubSignature(); IMG_SIGNING_ENABLED switches back to real signing.
|
||||
const signing = resolveSigning(c.env, c.req.query('sig'), key)
|
||||
const transform = parseTransform(
|
||||
c.req.query('width'),
|
||||
c.req.query('height'),
|
||||
@@ -353,23 +479,30 @@ app.get(
|
||||
// that always win over whatever, if anything, is in the bucket.
|
||||
const staticAsset = await c.env.ASSETS.fetch(new URL(`/${key}`, c.req.url))
|
||||
if (staticAsset.ok) {
|
||||
return serveStaticAsset(c.env, staticAsset, transform, wantsSignature)
|
||||
return serveStaticAsset(c.env, staticAsset, transform, signing)
|
||||
}
|
||||
|
||||
// Conditional requests only make sense for the untransformed object: a
|
||||
// resized response carries no etag, so the client can never send a matching
|
||||
// one. Skip the precondition when a transform is requested.
|
||||
const ifNoneMatch = transform ? undefined : c.req.header('if-none-match')?.replace(/"/g, '')
|
||||
const object = await c.env.IMAGES.get(
|
||||
key,
|
||||
ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : undefined
|
||||
)
|
||||
const { bucket, objectKey } = resolveObject(c.env, key)
|
||||
// A `Range` applies only to the untouched stream. Resizing decodes the whole image
|
||||
// and an RSA signature covers the whole body, so a ranged read there would produce
|
||||
// bytes that are not the range asked for — ask R2 for the range only when we are
|
||||
// going to hand its bytes straight back. R2 parses the header itself; see
|
||||
// writeContentRange() below for why it is never answered with a bare 200.
|
||||
const range = needsBody(transform, signing) ? undefined : c.req.raw.headers
|
||||
const object = await bucket.get(objectKey, {
|
||||
...(ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : {}),
|
||||
...(range ? { range } : {}),
|
||||
})
|
||||
if (!object) {
|
||||
// Missing from both static and R2 → serve the bundled DefaultProfileImage.jpg
|
||||
// static asset so clients still get a valid image instead of a 404. Honour
|
||||
// `?sig=p1` the same way so signed clients can verify the fallback.
|
||||
// `?sig=p1` the same way so the fallback is signed like any other image.
|
||||
const asset = await c.env.ASSETS.fetch(new URL(FALLBACK_ASSET_PATH, c.req.url))
|
||||
return serveStaticAsset(c.env, asset, transform, wantsSignature)
|
||||
return serveStaticAsset(c.env, asset, transform, signing)
|
||||
}
|
||||
|
||||
const headers = new Headers()
|
||||
@@ -380,9 +513,22 @@ app.get(
|
||||
// Precondition matched (If-None-Match) → R2 returns no body.
|
||||
if (!('body' in object)) return new Response(null, { status: 304, headers })
|
||||
|
||||
if (transform || wantsSignature) {
|
||||
// Set after the 304 above so both signing modes behave alike: the header only
|
||||
// ever rides a response that actually carries bytes.
|
||||
applyStubSignature(headers, signing)
|
||||
|
||||
if (needsBody(transform, signing)) {
|
||||
const bytes = await object.arrayBuffer()
|
||||
return finalizeImage(c.env, bytes, headers, transform, wantsSignature)
|
||||
return finalizeImage(c.env, bytes, headers, transform, signing)
|
||||
}
|
||||
|
||||
// Only the untouched stream can honour a range, so only it advertises the fact.
|
||||
// The transformed and static-asset paths above serve the whole thing regardless,
|
||||
// which is the legal answer to a range you cannot honour — but claiming
|
||||
// `accept-ranges` there would invite a client to expect otherwise.
|
||||
headers.set('accept-ranges', 'bytes')
|
||||
if (writeContentRange(headers, c.req.raw.headers, object)) {
|
||||
return new Response(object.body, { status: 206, headers })
|
||||
}
|
||||
|
||||
return new Response(object.body, { headers })
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { PhotonImage } from '@cf-wasm/photon'
|
||||
import { env, SELF } from 'cloudflare:test'
|
||||
import { createExecutionContext, env, SELF, waitOnExecutionContext } from 'cloudflare:test'
|
||||
import { beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
import '../../img.app'
|
||||
import app from '../../img.app'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
@@ -34,10 +34,29 @@ const PUBLIC_SPKI_B64 =
|
||||
// bucket path rather than a static asset.
|
||||
const R2_KEY = 'user-photo.jpg'
|
||||
|
||||
// An extensionless name, as returned by the `storage` worker for a FileType 3
|
||||
// upload — served from `recflare-cdn` under `image/`, not `recflare-img`.
|
||||
const CDN_NAME = '2028-06-01/12345-67890-12345'
|
||||
|
||||
/**
|
||||
* Fetch with real signing turned OFF — the deployed default. `vitest.config.ts`
|
||||
* binds `IMG_SIGNING_ENABLED` on so the RSA path stays covered, so the placeholder
|
||||
* path has to drive the app directly with an overridden env.
|
||||
*/
|
||||
async function unsignedFetch(url: string): Promise<Response> {
|
||||
const ctx = createExecutionContext()
|
||||
const res = await app.fetch(new Request(url), { ...env, IMG_SIGNING_ENABLED: false }, ctx)
|
||||
await waitOnExecutionContext(ctx)
|
||||
return res
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await env.IMAGES.put(R2_KEY, IMAGE_BYTES, {
|
||||
httpMetadata: { contentType: 'image/jpeg' },
|
||||
})
|
||||
await env.CDN_ASSETS.put(`image/${CDN_NAME}`, IMAGE_BYTES, {
|
||||
httpMetadata: { contentType: 'image/jpeg' },
|
||||
})
|
||||
// Seed R2 with a key that ALSO exists in `static/` to prove static wins.
|
||||
await env.IMAGES.put('3DCharades.jpg', IMAGE_BYTES, {
|
||||
httpMetadata: { contentType: 'image/jpeg' },
|
||||
@@ -58,6 +77,38 @@ describe('img endpoints', () => {
|
||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES)
|
||||
})
|
||||
|
||||
it('serves an extensionless key from the cdn bucket under image/', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/${CDN_NAME}`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toBe('image/jpeg')
|
||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES)
|
||||
})
|
||||
|
||||
it('does not look for an extensionless key in the image bucket', async () => {
|
||||
// Same bare name seeded into `recflare-img` instead: extensionless keys only
|
||||
// ever resolve against `recflare-cdn`, so this falls through to the default.
|
||||
await env.IMAGES.put('2028-06-02/only-in-img', IMAGE_BYTES)
|
||||
const res = await SELF.fetch(`${ORIGIN}/2028-06-02/only-in-img`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = new Uint8Array(await res.arrayBuffer())
|
||||
expect(body.length).toBeGreaterThan(IMAGE_BYTES.length)
|
||||
})
|
||||
|
||||
it('resizes an extensionless cdn image', async () => {
|
||||
// Exercises the transform path against the cdn bucket, not just the stream-through.
|
||||
// Needs a decodable JPEG, so reuse a bundled static asset's bytes.
|
||||
const real = await (await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)).arrayBuffer()
|
||||
await env.CDN_ASSETS.put('image/2028-06-03/real-photo', real, {
|
||||
httpMetadata: { contentType: 'image/jpeg' },
|
||||
})
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/2028-06-03/real-photo?width=128`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toBe('image/jpeg')
|
||||
expect(res.headers.get('etag')).toBeNull()
|
||||
expect(jpegSize(new Uint8Array(await res.arrayBuffer())).width).toBe(128)
|
||||
})
|
||||
|
||||
it('serves a static asset in preference to an R2 object of the same key', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -89,6 +140,62 @@ describe('img endpoints', () => {
|
||||
expect(res.status).toBe(304)
|
||||
})
|
||||
|
||||
it('honors a Range request on the stored image with a 206', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/${R2_KEY}`, { headers: { Range: 'bytes=2-4' } })
|
||||
expect(res.status).toBe(206)
|
||||
expect(res.headers.get('content-range')).toBe('bytes 2-4/8')
|
||||
expect(res.headers.get('accept-ranges')).toBe('bytes')
|
||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES.slice(2, 5))
|
||||
})
|
||||
|
||||
// R2 resolves a range it cannot parse or satisfy to the WHOLE object rather than
|
||||
// failing. Handing that back as a bare 200 is the shape that corrupts a chunked
|
||||
// download — the client wrote a whole file where it expected a slice — so every one
|
||||
// of these still states what the body holds.
|
||||
it('never answers a bytes range with a whole-object 200', async () => {
|
||||
for (const range of ['bytes=100-200', 'bytes=abc', 'bytes=0-1,3-4', 'bytes=0-7']) {
|
||||
const res = await SELF.fetch(`${ORIGIN}/${R2_KEY}`, { headers: { Range: range } })
|
||||
expect(res.status, range).toBe(206)
|
||||
expect(res.headers.get('content-range'), range).toBe('bytes 0-7/8')
|
||||
}
|
||||
|
||||
// A unit other than bytes must be ignored outright (RFC 9110), not answered with
|
||||
// a byte-denominated Content-Range.
|
||||
const other = await SELF.fetch(`${ORIGIN}/${R2_KEY}`, { headers: { Range: 'items=0-1' } })
|
||||
expect(other.status).toBe(200)
|
||||
expect(other.headers.get('content-range')).toBeNull()
|
||||
})
|
||||
|
||||
// A resize decodes the whole image, so there is no meaningful slice of the source to
|
||||
// read — the range is ignored and the whole transformed result served, which is the
|
||||
// legal answer. What it must NOT do is claim a 206 over bytes it rebuilt. Runs against
|
||||
// the R2 path (a decodable JPEG borrowed from `static/`), since that is the one that
|
||||
// has a range to suppress; the static-asset path is never handed one at all.
|
||||
it('ignores a Range when a transform rebuilds the body', async () => {
|
||||
const real = await (await SELF.fetch(`${ORIGIN}/3DCharades.jpg`)).arrayBuffer()
|
||||
await env.IMAGES.put('ranged-transform.jpg', real, {
|
||||
httpMetadata: { contentType: 'image/jpeg' },
|
||||
})
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/ranged-transform.jpg?width=128`, {
|
||||
headers: { Range: 'bytes=0-9' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-range')).toBeNull()
|
||||
expect(res.headers.get('accept-ranges')).toBeNull()
|
||||
expect(jpegSize(new Uint8Array(await res.arrayBuffer())).width).toBe(128)
|
||||
|
||||
// Same for a real RSA signature, which covers the whole body (the test env binds
|
||||
// IMG_SIGNING_ENABLED on, so `?sig=p1` takes the signing path rather than the stub).
|
||||
const signed = await SELF.fetch(`${ORIGIN}/ranged-transform.jpg?sig=p1`, {
|
||||
headers: { Range: 'bytes=0-9' },
|
||||
})
|
||||
expect(signed.status).toBe(200)
|
||||
expect(signed.headers.get('content-range')).toBeNull()
|
||||
expect(signed.headers.get('content-signature')).toContain('key-id=KEY:RSA:p1.rec.net')
|
||||
expect(new Uint8Array(await signed.arrayBuffer()).byteLength).toBe(real.byteLength)
|
||||
})
|
||||
|
||||
it('serves the DefaultProfileImage.jpg fallback for a missing image', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/missing.png`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -149,6 +256,48 @@ describe('img endpoints', () => {
|
||||
expect(res.headers.get('content-signature')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns a placeholder signature when IMG_SIGNING_ENABLED is off', async () => {
|
||||
// The deployed default (see wrangler.jsonc). The header must still be there —
|
||||
// the client requires it — but the value is derived from the key, so the body
|
||||
// is neither buffered nor hashed and streams straight out of R2.
|
||||
const res = await unsignedFetch(`${ORIGIN}/${R2_KEY}?sig=p1`)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const header = res.headers.get('content-signature')
|
||||
expect(header).toMatch(/^key-id=KEY:RSA:p1\.rec\.net; data=/)
|
||||
// Same shape as a real RSA-2048 signature, so the client's parser sees no
|
||||
// difference between the two modes.
|
||||
const signature = Uint8Array.from(atob(header!.split('data=')[1]), (ch) => ch.charCodeAt(0))
|
||||
expect(signature.length).toBe(256)
|
||||
expect(signature.some((b) => b !== 0)).toBe(true)
|
||||
|
||||
// Still on the streaming path: the source etag survives and the bytes are the
|
||||
// stored object, untouched.
|
||||
expect(res.headers.get('etag')).toBeTruthy()
|
||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(IMAGE_BYTES)
|
||||
})
|
||||
|
||||
it('derives the placeholder signature from the key, stably', async () => {
|
||||
const sigFor = async (path: string) =>
|
||||
(await unsignedFetch(`${ORIGIN}/${path}?sig=p1`)).headers.get('content-signature')
|
||||
|
||||
// Stable for a key, so a cached response and a fresh one agree...
|
||||
expect(await sigFor(R2_KEY)).toBe(await sigFor(R2_KEY))
|
||||
// ...and distinct across keys, so it isn't a single hardcoded constant.
|
||||
expect(await sigFor(R2_KEY)).not.toBe(await sigFor(CDN_NAME))
|
||||
})
|
||||
|
||||
it('signs the fallback and resized bodies with a placeholder too', async () => {
|
||||
// The fallback (missing key) and the transform path both go through
|
||||
// serveStaticAsset/finalizeImage — the header must survive both.
|
||||
const fallback = await unsignedFetch(`${ORIGIN}/missing.png?sig=p1`)
|
||||
expect(fallback.headers.get('content-signature')).toMatch(/^key-id=KEY:RSA:p1\.rec\.net; /)
|
||||
|
||||
const resized = await unsignedFetch(`${ORIGIN}/RecCenter.jpg?width=512&sig=p1`)
|
||||
expect(resized.headers.get('content-signature')).toMatch(/^key-id=KEY:RSA:p1\.rec\.net; /)
|
||||
expect(jpegSize(new Uint8Array(await resized.arrayBuffer())).width).toBe(512)
|
||||
})
|
||||
|
||||
it('resizes a static asset to ?width, preserving aspect ratio', async () => {
|
||||
const full = new Uint8Array(await (await SELF.fetch(`${ORIGIN}/RecCenter.jpg`)).arrayBuffer())
|
||||
const original = jpegSize(full)
|
||||
|
||||
@@ -8,6 +8,10 @@ export default defineConfig({
|
||||
miniflare: {
|
||||
bindings: {
|
||||
ENVIRONMENT: 'VITEST',
|
||||
// Signing is off in `wrangler.jsonc`; turn it on here so the `?sig=p1`
|
||||
// path stays covered. The flag-off behaviour is tested by calling the
|
||||
// app directly with an overridden env.
|
||||
IMG_SIGNING_ENABLED: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
+17
-1
@@ -9,7 +9,7 @@
|
||||
// so image requests still hit R2/signing; assets are only fetched explicitly
|
||||
// via the ASSETS binding.
|
||||
"cache": {
|
||||
"enabled": true
|
||||
"enabled": false
|
||||
},
|
||||
"assets": {
|
||||
"directory": "./static",
|
||||
@@ -17,10 +17,18 @@
|
||||
"run_worker_first": true
|
||||
},
|
||||
// Images are stored as objects in an R2 bucket and streamed back by key.
|
||||
// `recflare-cdn` (owned by the `cdn` worker, written by `storage`) is bound
|
||||
// alongside it: uploads posted to `storage` as FileType 3 land under its
|
||||
// `image/` prefix with no extension, and the client asks THIS worker for them
|
||||
// by the bare name — see the extensionless-key branch in src/img.app.ts.
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "IMAGES",
|
||||
"bucket_name": "recflare-img"
|
||||
},
|
||||
{
|
||||
"binding": "CDN_ASSETS",
|
||||
"bucket_name": "recflare-cdn"
|
||||
}
|
||||
],
|
||||
// Shared `recflare` D1 — the `img` worker owns the `images` metadata table
|
||||
@@ -46,6 +54,14 @@
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
"SENTRY_RELEASE": "unknown", // overridden during deployment
|
||||
// Feature flag for REAL `?sig=p1` signing. OFF: the client only needs a
|
||||
// Content-Signature header to EXIST, and never checks it, while signing for
|
||||
// real buffers the whole object into the isolate instead of streaming it from
|
||||
// R2 and pays a SHA-1 over the full body plus an RSA-2048 private-key op on
|
||||
// every edge-cache miss. So the header is filled with a placeholder derived
|
||||
// from the object key (see stubSignature in src/img.app.ts). Flip to true if
|
||||
// anything ever needs to verify it.
|
||||
"IMG_SIGNING_ENABLED": false,
|
||||
// RSA-2048 private key (PKCS8 DER, base64) used to sign image responses
|
||||
// requested with ?sig=p1. This is an INSECURE DEV KEY committed for local
|
||||
// dev / tests — override in production with `wrangler secret put IMG_SIGNING_KEY`.
|
||||
|
||||
+31
-3
@@ -7,7 +7,7 @@ instances and presence all live in the shared `recflare` D1 database.
|
||||
## Routes
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
| ------ | ------------------------------------ | ---- | ------------------------------------------------ |
|
||||
| ------ | ------------------------------------ | ---- | ----------------------------------------------------- |
|
||||
| POST | `/player/login` | | Login ack (no-op; must not touch presence) |
|
||||
| POST | `/player/exclusivelogin` | | Exclusive-login ack (no-op) → `{ errorCode: 0 }` |
|
||||
| POST | `/player/logout` | ✓\* | Clear presence (except the Orientation seed) |
|
||||
@@ -15,6 +15,8 @@ instances and presence all live in the shared `recflare` D1 database.
|
||||
| GET | `/player?id=1&id=2,3` | | Batch player presence lookup |
|
||||
| POST | `/player/heartbeat` | ✓ | Presence heartbeat (JSON body) |
|
||||
| PUT | `/player/statusvisibility` | ✓\* | Set status visibility |
|
||||
| GET | `/player/avoidjuniors` | ✓ | The player's "avoid juniors" setting → `true`/`false` |
|
||||
| PUT | `/player/avoidjuniors` | ✓ | Set it (`avoidJuniors=True`) → the resulting value |
|
||||
| POST | `/goto/room/:room` | ✓ | Go to a room (`dormroom` → personal dorm) |
|
||||
| POST | `/matchmake/none` | | Preserve current instance, else dorm |
|
||||
| POST | `/matchmake/room/:roomId/:subRoomId` | ✓ | Matchmake into a specific subroom |
|
||||
@@ -91,15 +93,41 @@ Several behaviours are load-bearing and reverse-engineered from the client:
|
||||
solo Orientation room) and only falls back to the dorm when the player has none.
|
||||
`goto/none` always goes to the dorm.
|
||||
|
||||
### Switching a room out (`ROOM_REDIRECTS`)
|
||||
|
||||
An operator can substitute one room for another at matchmake time — the way to replace a
|
||||
stock RRO room, typically the Rec Center (room 2), with a room of their own without
|
||||
touching the client. The knob is `RECFLARE_ROOM_REDIRECTS` in the root `.env` (see
|
||||
`.env.example`), comma-separated `<fromRoomId>=<to>` pairs where `<to>` is a room id or
|
||||
name: `2=MyHub`, or `2=100,3=MyHub`.
|
||||
|
||||
Substitution happens where a matchmake resolves a named room, so it covers every route
|
||||
that names one — the two- and three-segment room matchmakes and a club's clubhouse — and
|
||||
everything downstream (the ban check, presence, the visit count) sees only the room
|
||||
actually entered. Matching is on the resolved room id, so asking by name (`RecCenter`)
|
||||
substitutes the same as asking by id.
|
||||
|
||||
- **A requested subroom is dropped** when a substitution fires: the id addresses a subroom
|
||||
of the room the client asked for, so entry falls back to the substitute's default one.
|
||||
- **One hop only** — `2=3,3=2` swaps the two rooms rather than looping.
|
||||
- **An unresolvable target leaves the original room in place** (logged), so a typo doesn't
|
||||
make a room unreachable.
|
||||
- **Following a friend and joining a specific instance are unaffected** — those enter a
|
||||
live instance, which is already in whichever room it was created in.
|
||||
|
||||
## Bindings
|
||||
|
||||
| Binding | Type | Notes |
|
||||
| ------------ | ------------- | ------------------------------------------------------------ |
|
||||
| -------------------------- | ------------- | ------------------------------------------------------------ |
|
||||
| `DB` | D1 | Shared `recflare` database — rooms, room instances, presence |
|
||||
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
|
||||
| `RECFLARE_PLAYER_SETTINGS` | KV | The `playersettings` map — `/player/avoidjuniors` |
|
||||
|
||||
The `presence` and `room_instance` tables are owned/migrated by the `rooms` worker;
|
||||
this worker has no migrations of its own.
|
||||
this worker has no migrations of its own. The settings KV is owned by the
|
||||
`playersettings` worker; this worker touches exactly one key in it, the "avoid juniors"
|
||||
preference, and its write merges (as that worker's own PUT does) so the rest of the
|
||||
player's settings survive.
|
||||
|
||||
## Known gaps
|
||||
|
||||
|
||||
@@ -20,6 +20,36 @@ export type Env = SharedHonoEnv & {
|
||||
* player. Used by `POST /invite` to deliver the game-invite message to the invitee.
|
||||
*/
|
||||
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
||||
/**
|
||||
* The per-player settings map the `playersettings` worker owns (`player:<id>` → JSON
|
||||
* `{ key: value }`). Read-only here, and only by `GET /player/avoidjuniors`: the
|
||||
* "avoid juniors" preference is a matchmaking question the client asks this worker,
|
||||
* but it is stored with the rest of the player's settings, not in presence.
|
||||
*/
|
||||
RECFLARE_PLAYER_SETTINGS: KVNamespace
|
||||
/**
|
||||
* Room substitutions applied at matchmake time, as comma-separated `<fromRoomId>=<to>`
|
||||
* pairs — e.g. `2=100` or `2=MyHub,3=100` — where `from` is the room id the client
|
||||
* asks for and `to` is the room it actually enters (id or room name). Optional; unset
|
||||
* means every matchmake enters the room it asked for.
|
||||
*
|
||||
* The point of it is swapping out a stock RRO room for a custom one: `2=MyHub` sends
|
||||
* everyone who matchmakes into the Rec Center (room 2) to `MyHub` instead, without
|
||||
* touching the client. See `roomRedirects` in match.app.ts.
|
||||
*/
|
||||
ROOM_REDIRECTS?: string
|
||||
/**
|
||||
* Which linked arms a ban is enforced through, as a comma-separated list out of `ip`
|
||||
* and `platform` — or `off` for neither. Unset means BOTH: a ban reaches the accounts
|
||||
* that share a proven platform identity or an IP with the banned one, which is what
|
||||
* stops an evader simply making a new account.
|
||||
*
|
||||
* The `ip` arm is coarse (households, NAT, campus and carrier networks share one
|
||||
* address), so `platform` alone is the setting for a server whose players share
|
||||
* networks. Whatever this says, a ban always applies to the account it was handed to.
|
||||
* Read through `banEvasionMatch`; the `auth` worker reads the same knob.
|
||||
*/
|
||||
BAN_EVASION_MATCH?: string
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
+649
-43
@@ -3,9 +3,11 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
Accessibility,
|
||||
areFriends,
|
||||
canManageRoom,
|
||||
createRoomInstance,
|
||||
deleteEmptyRoomInstances,
|
||||
deleteExpiredPresence,
|
||||
deletePresence,
|
||||
GAME_VERSION,
|
||||
@@ -21,22 +23,36 @@ import {
|
||||
getRoomByName,
|
||||
getRoomInstance,
|
||||
getRoomInstancesByRoom,
|
||||
getRoomInstanceSummariesByRoom,
|
||||
isClubMember,
|
||||
isPlayerBannedFromRoom,
|
||||
MatchmakingErrorCode,
|
||||
MessageType,
|
||||
recordRoomVisit,
|
||||
refreshInstanceFullness,
|
||||
RoomInstanceType,
|
||||
setPresence,
|
||||
setRoomInstanceInProgress,
|
||||
setRoomInstancePrivate,
|
||||
subRoomDataBlob,
|
||||
} from '@repo/domain'
|
||||
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
// The account-wide ban lives on a `report` row, whose table the api worker owns; its
|
||||
// db module is plain D1 queries with no runtime deps, so it imports cleanly here (the
|
||||
// same way econ reads api's inventions-db).
|
||||
import { banEvasionMatch, resolveBan } from '../../api/src/bans-db'
|
||||
// The player-event tables are the api worker's too (same plain-D1 shape as bans-db):
|
||||
// `/matchmake/event` needs the event's room and the caller's invite row.
|
||||
import { getEventById, getEventResponse } from '../../api/src/events-db'
|
||||
// Value import of the notify worker's NotificationType enum (its bundle has no runtime
|
||||
// deps), so /invite sends a typed MessageReceived frame instead of a magic number.
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
import {
|
||||
AUTHED,
|
||||
AvoidJuniorsRequest,
|
||||
AvoidJuniorsResponse,
|
||||
EMPTY_OK,
|
||||
ExclusiveLoginResponse,
|
||||
form,
|
||||
@@ -50,6 +66,7 @@ import {
|
||||
NotifyDisconnectRequest,
|
||||
PlayerDto,
|
||||
RoomInstanceDto,
|
||||
RoomInstanceSummaryDto,
|
||||
StatusVisibilityRequest,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from './openapi'
|
||||
@@ -122,6 +139,110 @@ function unauthorized(c: Context<App>) {
|
||||
return c.body(null, 401)
|
||||
}
|
||||
|
||||
/**
|
||||
* The "avoid juniors" preference, spelled the way the client posts it — the key a NEW
|
||||
* setting is written under, and the one every stored spelling is matched against.
|
||||
*
|
||||
* The player's settings are a free-form `{ key: value }` bag written by the client through
|
||||
* the `playersettings` worker, and the exact spelling it writes this key under is
|
||||
* reverse-engineered, so the lookup is case- and separator-insensitive (`avoidJuniors`,
|
||||
* `AvoidJuniors`, `AVOID_JUNIORS` all resolve to this one preference) rather than betting on
|
||||
* one casing and silently reading false forever if it's wrong. The write then overwrites
|
||||
* whichever spelling is already there, so a player never ends up with two keys for the one
|
||||
* preference — which would make the read depend on their order in the map.
|
||||
*/
|
||||
const AVOID_JUNIORS_KEY = 'avoidJuniors'
|
||||
|
||||
/** Lowercase and drop separators, so keys compare on their letters alone. */
|
||||
function normalizeSettingKey(key: string): string {
|
||||
return key.toLowerCase().replaceAll(/[^a-z0-9]/g, '')
|
||||
}
|
||||
|
||||
/** The player's existing spelling of the setting key, if their map has one. */
|
||||
function findAvoidJuniorsKey(stored: Record<string, unknown>): string | undefined {
|
||||
const wanted = normalizeSettingKey(AVOID_JUNIORS_KEY)
|
||||
return Object.keys(stored).find((key) => normalizeSettingKey(key) === wanted)
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings values are strings, so a boolean arrives as `True`/`false`/`1`/`0` (the client
|
||||
* isn't consistent about which). `undefined` for anything unrecognized, which the read and
|
||||
* the write treat differently: a stored value that won't parse is a false preference, but a
|
||||
* posted one that won't parse is a body worth ignoring rather than a write of `false`.
|
||||
*/
|
||||
function parseSettingBool(value: unknown): boolean | undefined {
|
||||
if (typeof value === 'boolean') return value
|
||||
switch (String(value).trim().toLowerCase()) {
|
||||
case 'true':
|
||||
case '1':
|
||||
case 'yes':
|
||||
return true
|
||||
case 'false':
|
||||
case '0':
|
||||
case 'no':
|
||||
return false
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** The player's settings map from the KV the `playersettings` worker owns. */
|
||||
async function getPlayerSettings(
|
||||
env: Env,
|
||||
accountId: number
|
||||
): Promise<Record<string, string> | null> {
|
||||
return env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||
`player:${accountId}`,
|
||||
'json'
|
||||
).catch(() => null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a player's "avoid juniors" preference. Absent settings, an absent key, and an
|
||||
* unparseable value are all false: the client asks this before matchmaking, so a read that
|
||||
* can't answer must not keep a player out of rooms.
|
||||
*/
|
||||
async function readAvoidJuniors(env: Env, accountId: number): Promise<boolean> {
|
||||
const stored = await getPlayerSettings(env, accountId)
|
||||
if (!stored) return false
|
||||
|
||||
const key = findAvoidJuniorsKey(stored)
|
||||
return key === undefined ? false : (parseSettingBool(stored[key]) ?? false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a player's "avoid juniors" preference back into their settings map.
|
||||
*
|
||||
* The write MERGES, exactly as the `playersettings` worker's own PUT does: the map holds
|
||||
* every setting the player has (OOBE state, tutorial mask, …), so storing this one on its
|
||||
* own would wipe the rest. Read-modify-write on KV isn't atomic, but the same is true of
|
||||
* the settings worker, and two writers racing over one player's own settings means that
|
||||
* player toggling two options in the same instant.
|
||||
*/
|
||||
async function writeAvoidJuniors(env: Env, accountId: number, value: boolean): Promise<void> {
|
||||
const stored = (await getPlayerSettings(env, accountId)) ?? {}
|
||||
const merged: Record<string, string> = { ...stored }
|
||||
merged[findAvoidJuniorsKey(merged) ?? AVOID_JUNIORS_KEY] = value ? 'True' : 'False'
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(`player:${accountId}`, JSON.stringify(merged))
|
||||
}
|
||||
|
||||
/**
|
||||
* The posted preference, out of a form (`avoidJuniors=True`, what the client sends) or a
|
||||
* JSON body. The field name is matched the same loose way the stored key is, so the casing
|
||||
* the client picks can't silently miss. `undefined` when the body carries no readable
|
||||
* value — the caller leaves the setting alone rather than writing a guess.
|
||||
*/
|
||||
async function readAvoidJuniorsBody(c: Context<App>): Promise<boolean | undefined> {
|
||||
const contentType = c.req.header('content-type') ?? ''
|
||||
const body = contentType.includes('application/json')
|
||||
? await c.req.json<unknown>().catch(() => null)
|
||||
: await c.req.parseBody().catch(() => null)
|
||||
if (body === null || typeof body !== 'object') return undefined
|
||||
|
||||
const key = findAvoidJuniorsKey(body as Record<string, unknown>)
|
||||
return key === undefined ? undefined : parseSettingBool((body as Record<string, unknown>)[key])
|
||||
}
|
||||
|
||||
/** A synthesized room instance (same shape for dorm and other rooms). */
|
||||
type RoomInstance = ReturnType<typeof roomInstanceFromRoom>
|
||||
|
||||
@@ -229,7 +350,8 @@ async function notifyFriendsPresence(c: Context<App>, playerId: number): Promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the room instance the player just matchmade into, preserving status.
|
||||
* Store the room instance the player just matchmade into, preserving status, and count
|
||||
* the visit against the room.
|
||||
*
|
||||
* With no live presence to carry forward (the player's first matchmake after login,
|
||||
* or one after their presence lapsed) the device fields would otherwise default —
|
||||
@@ -254,6 +376,22 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
|
||||
// and the heartbeat can keep verifying against it.
|
||||
loginLock: prev?.loginLock,
|
||||
})
|
||||
|
||||
// Count the visit. Every matchmake route funnels through here with the instance the
|
||||
// player landed in, and a matchmake is the only way into a room, so this is the one
|
||||
// place a visit can be recorded once — whether they got here by room id, by subroom,
|
||||
// by following a friend, from a club's clubhouse, or into their own dorm. Bumps the
|
||||
// room's `visits` column, which is served as `Stats.VisitCount`. Best-effort: a
|
||||
// counter is not worth failing the matchmake over.
|
||||
try {
|
||||
await recordRoomVisit(c.env.DB, roomInstance.roomId)
|
||||
} catch (err) {
|
||||
logger.error('failed to record room visit', {
|
||||
roomId: roomInstance.roomId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
// Keep the destination instance's is_full flag in sync with live presence (the
|
||||
// player's own presence, just written, is counted). Then re-evaluate the
|
||||
// instance they left — its head-count dropped — so a full room frees up when
|
||||
@@ -270,8 +408,25 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
|
||||
await notifyFriendsPresence(c, id)
|
||||
}
|
||||
|
||||
/** MatchmakingErrorCode.NoSuchRoom — returned when a room isn't in the DB. */
|
||||
const NO_SUCH_ROOM = 20
|
||||
/** Returned when a room isn't in the DB — and for every other opaque refusal. */
|
||||
const NO_SUCH_ROOM = MatchmakingErrorCode.NoSuchRoom
|
||||
|
||||
/**
|
||||
* "You are banned from this room". Unlike the opaque NoSuchRoom every other refusal
|
||||
* answers, a banned player is told why: they already know the room exists, so there's
|
||||
* nothing to hide, and the client can say so instead of showing a room that
|
||||
* mysteriously fails to load.
|
||||
*/
|
||||
const BANNED_FROM_ROOM = MatchmakingErrorCode.BannedFromRoom
|
||||
|
||||
/**
|
||||
* "This event isn't open to you" — the refusal on a private event the caller wasn't
|
||||
* invited to. Told plainly rather than hidden behind the opaque NoSuchRoom: a player
|
||||
* reaching this already holds the event id from somewhere that showed it to them, so
|
||||
* the only thing withholding the reason buys is a room that fails to load for no
|
||||
* visible reason.
|
||||
*/
|
||||
const EVENT_IS_PRIVATE = MatchmakingErrorCode.EventIsPrivate
|
||||
|
||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||
const HUB_INSTANCE = 'global'
|
||||
@@ -476,25 +631,112 @@ async function inviteParty(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The outcome of resolving a room to join: the instance, or the `errorCode` to answer
|
||||
* with. Kept as a pair rather than a bare null so callers can tell a room that isn't
|
||||
* there (NoSuchRoom) from one the caller is banned from — those answer different codes.
|
||||
*/
|
||||
type ResolvedInstance =
|
||||
| { instance: RoomInstance; errorCode: MatchmakingErrorCode.Success }
|
||||
| { instance: null; errorCode: MatchmakingErrorCode }
|
||||
|
||||
/**
|
||||
* The operator's room substitutions, parsed from the `ROOM_REDIRECTS` var: a map of
|
||||
* the room id the client asks for to the room it actually enters (id or room name).
|
||||
* The var is comma-separated `<fromRoomId>=<to>` pairs, e.g. `2=MyHub,3=100`.
|
||||
*
|
||||
* Keyed on the source's numeric id rather than the path segment because the client can
|
||||
* matchmake by either id or name (`/matchmake/room/2` and `/matchmake/room/RecCenter`
|
||||
* are the same room), so the substitution is matched against the room D1 resolved —
|
||||
* one entry then covers both spellings. Unparseable pairs are skipped rather than
|
||||
* failing the matchmake: a typo in the knob must not take room entry down.
|
||||
*/
|
||||
function roomRedirects(env: Env): Map<number, string> {
|
||||
const map = new Map<number, string>()
|
||||
if (typeof env.ROOM_REDIRECTS !== 'string') return map
|
||||
for (const pair of env.ROOM_REDIRECTS.split(',')) {
|
||||
const eq = pair.indexOf('=')
|
||||
if (eq === -1) continue
|
||||
const from = Number(pair.slice(0, eq).trim())
|
||||
const to = pair.slice(eq + 1).trim()
|
||||
if (!Number.isInteger(from) || to === '') continue
|
||||
map.set(from, to)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the operator's `ROOM_REDIRECTS` substitution to a room the client asked for.
|
||||
* Answers the room to actually enter, plus the subroom to enter it by.
|
||||
*
|
||||
* A substituted room drops the requested subroom: the id the client sent addresses a
|
||||
* subroom of the room it *asked* for, and the same number in the target room is a
|
||||
* different place entirely (or nothing at all), so entry falls back to the target's
|
||||
* default subroom. Substitution is a single hop — `2=3,3=2` swaps the two rooms rather
|
||||
* than looping — and an unresolvable target leaves the original room in place, so a
|
||||
* typo'd knob degrades to "no substitution" instead of a dead hub.
|
||||
*/
|
||||
async function substituteRoom(
|
||||
c: Context<App>,
|
||||
room: Room,
|
||||
subRoomId?: number
|
||||
): Promise<{ room: Room; subRoomId?: number }> {
|
||||
const fromId = typeof room.RoomId === 'number' ? room.RoomId : NaN
|
||||
const to = roomRedirects(c.env).get(fromId)
|
||||
if (to === undefined) return { room, subRoomId }
|
||||
|
||||
const toId = Number.parseInt(to, 10)
|
||||
const target = Number.isNaN(toId)
|
||||
? await getRoomByName(c.env.DB, to)
|
||||
: await getRoomById(c.env.DB, toId)
|
||||
if (!target) {
|
||||
logger.warn('room redirect target not found; entering the requested room', {
|
||||
roomId: fromId,
|
||||
target: to,
|
||||
})
|
||||
return { room, subRoomId }
|
||||
}
|
||||
|
||||
logger.info('room redirected', { roomId: fromId, target: to })
|
||||
return { room: target, subRoomId: undefined }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a room by `:room` path segment (numeric id or name) from D1, then find a
|
||||
* joinable instance of it (public matchmakes reuse one via the `room_instance`
|
||||
* table) or create a new one. Returns null when the room isn't found.
|
||||
* table) or create a new one. A null instance carries the error code to answer:
|
||||
* NoSuchRoom when the room isn't in the DB, BannedFromRoom when the caller is banned.
|
||||
*
|
||||
* Every matchmake that names a room lands here, so this is also where the operator's
|
||||
* room substitutions apply (`ROOM_REDIRECTS`) — everything downstream, from the ban
|
||||
* check to presence and the visit count, sees only the room actually entered.
|
||||
*/
|
||||
async function resolveRoomInstance(
|
||||
c: Context<App>,
|
||||
roomKey: string,
|
||||
isPrivate: boolean,
|
||||
ownerId: number,
|
||||
subRoomId?: number
|
||||
): Promise<RoomInstance | null> {
|
||||
requestedSubRoomId?: number
|
||||
): Promise<ResolvedInstance> {
|
||||
const id = Number.parseInt(roomKey, 10)
|
||||
const room = Number.isNaN(id)
|
||||
const requested = Number.isNaN(id)
|
||||
? await getRoomByName(c.env.DB, roomKey)
|
||||
: await getRoomById(c.env.DB, id)
|
||||
if (!room) return null
|
||||
if (!requested) return { instance: null, errorCode: NO_SUCH_ROOM }
|
||||
|
||||
const { room, subRoomId } = await substituteRoom(c, requested, requestedSubRoomId)
|
||||
|
||||
const f = instanceFieldsFromRoom(room, subRoomId)
|
||||
|
||||
// A banned player never gets an instance. This is the whole enforcement of a room
|
||||
// ban: the Photon room id only ever reaches a player through a matchmake, so
|
||||
// refusing here means they have no coordinates to join or interact with. Handled
|
||||
// before any instance is created or reused so a ban can't spawn one.
|
||||
if (await isPlayerBannedFromRoom(c.env.DB, f.roomId, ownerId)) {
|
||||
logger.info('matchmake refused: player banned from room', { roomId: f.roomId, ownerId })
|
||||
return { instance: null, errorCode: BANNED_FROM_ROOM }
|
||||
}
|
||||
|
||||
// Never place the player back into the instance they're already in: the client
|
||||
// keys the room transition off a changing `roomInstanceId`, so re-matchmaking into
|
||||
// your current instance (e.g. the only public instance of a room you're already in)
|
||||
@@ -525,13 +767,16 @@ async function resolveRoomInstance(
|
||||
roomInstanceType: f.roomInstanceType,
|
||||
})
|
||||
}
|
||||
return roomInstanceFromRoom(
|
||||
return {
|
||||
instance: roomInstanceFromRoom(
|
||||
room,
|
||||
isPrivate,
|
||||
instance.roomInstanceId,
|
||||
instance.photonRoomId,
|
||||
f.subRoomId
|
||||
)
|
||||
),
|
||||
errorCode: MatchmakingErrorCode.Success,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -574,6 +819,45 @@ const app = new Hono<App>()
|
||||
})(c, next)
|
||||
)
|
||||
|
||||
// A banned player goes nowhere. Room bans are per-room and checked per route (they
|
||||
// depend on which room you're entering); a BAN isn't about a room at all, so it's
|
||||
// enforced once here, across every matchmake — by room, by subroom, by instance, into
|
||||
// a club's clubhouse, following a friend, and into their own dorm. A gate rather than
|
||||
// six copies of the same check: a route added later inherits it, and there is no
|
||||
// matchmake left that hands a banned player Photon coordinates.
|
||||
//
|
||||
// `resolveBan` matches the caller's own account AND the accounts they share a proven
|
||||
// platform identity or an IP with, so a ban survives the evader making a new account
|
||||
// (see bans-db.ts; the operator narrows the linked arms with BAN_EVASION_MATCH). The
|
||||
// arm that matched is logged, because "banned" and "shares a network with somebody
|
||||
// banned" are very different things to be looking at in a log.
|
||||
//
|
||||
// It answers the same BannedFromRoom the room bans do. The code is per-room in name
|
||||
// only — it's the one refusal the client renders as "you are banned" instead of a room
|
||||
// that mysteriously fails to load, and it's what the enum offers.
|
||||
//
|
||||
// Unauthenticated requests fall through untouched: the route's own `authedId` answers
|
||||
// 401, which mustn't turn into "banned" just because the token was missing.
|
||||
.use('/matchmake/*', async (c, next) => {
|
||||
const id = await authedId(c)
|
||||
if (id !== null) {
|
||||
const match = await resolveBan(c.env.DB, id, {
|
||||
identity: { ip: c.req.header('cf-connecting-ip') },
|
||||
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
|
||||
})
|
||||
if (match) {
|
||||
logger.info('matchmake refused: player banned', {
|
||||
accountId: id,
|
||||
via: match.via,
|
||||
bannedAccountId: match.bannedAccountId,
|
||||
path: c.req.path,
|
||||
})
|
||||
return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null })
|
||||
}
|
||||
}
|
||||
await next()
|
||||
})
|
||||
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
@@ -843,6 +1127,68 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// The caller's "avoid juniors" preference. It's asked of this worker because it's a
|
||||
// matchmaking question, but it isn't matchmaking state: the setting is written by the
|
||||
// client through the `playersettings` worker, so this reads that worker's KV map
|
||||
// directly (read-only) rather than keeping a second copy of the same toggle here.
|
||||
.get(
|
||||
'/player/avoidjuniors',
|
||||
describeRoute({
|
||||
tags: ['Player settings'],
|
||||
summary: 'The player’s “avoid juniors” preference',
|
||||
description: [
|
||||
'Whether the authenticated player asked to be kept away from junior accounts, read',
|
||||
'from their settings map in the `playersettings` KV. The body is a bare JSON boolean',
|
||||
'(`true`/`false`), not an envelope. A player who never set it reads `false`.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(AvoidJuniorsResponse, 'The preference; `false` when never set'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
return c.json(await readAvoidJuniors(c.env, id))
|
||||
}
|
||||
)
|
||||
|
||||
// Set the preference. Answers the RESULTING value rather than an empty ack, the way the
|
||||
// GET does — the client has just changed a toggle it renders, and a body it can read
|
||||
// back can't disagree with what was stored.
|
||||
.put(
|
||||
'/player/avoidjuniors',
|
||||
describeRoute({
|
||||
tags: ['Player settings'],
|
||||
summary: 'Set the player’s “avoid juniors” preference',
|
||||
description: [
|
||||
'Stores the posted preference in the authenticated player’s settings map (the',
|
||||
'`playersettings` KV) and answers the resulting value as a bare JSON boolean. The',
|
||||
'write merges, so the player’s other settings are left alone. A body with no readable',
|
||||
'`avoidJuniors` value leaves the setting as it was and answers the stored value — a',
|
||||
'no-op 200, not a 400.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(AvoidJuniorsRequest, 'The preference to store'),
|
||||
responses: {
|
||||
200: json(AvoidJuniorsResponse, 'The preference now stored'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const posted = await readAvoidJuniorsBody(c)
|
||||
if (posted === undefined) return c.json(await readAvoidJuniors(c.env, id))
|
||||
|
||||
await writeAvoidJuniors(c.env, id, posted)
|
||||
return c.json(posted)
|
||||
}
|
||||
)
|
||||
|
||||
// ---- Room navigation -----------------------------------------------------
|
||||
// Each matchmake persists the resulting instance as the player's presence so the
|
||||
// heartbeat can replay it (keeping client presence in sync).
|
||||
@@ -859,7 +1205,8 @@ const app = new Hono<App>()
|
||||
description: [
|
||||
'Looks the club up, checks the caller is a member of it, and places them into an',
|
||||
'instance of its clubhouse room. Returns errorCode 20 with a null instance when the',
|
||||
'club is unknown, has no clubhouse set, or the caller isn’t a member.',
|
||||
'club is unknown, has no clubhouse set, or the caller isn’t a member — and errorCode',
|
||||
'55 when they are banned from the clubhouse room.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(JoinModeRequest, 'Optional JoinMode'),
|
||||
@@ -875,7 +1222,7 @@ const app = new Hono<App>()
|
||||
responses: {
|
||||
200: json(
|
||||
MatchmakeResponse,
|
||||
'The clubhouse instance (or errorCode 20 with null when it can’t be entered)'
|
||||
'The clubhouse instance (or a null instance with errorCode 20 / 55 when it can’t be entered)'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
@@ -895,18 +1242,100 @@ const app = new Hono<App>()
|
||||
}
|
||||
|
||||
const joinMode = await readJoinMode(c)
|
||||
const instance = await resolveRoomInstance(
|
||||
const { instance, errorCode } = await resolveRoomInstance(
|
||||
c,
|
||||
String(club.clubhouseRoomId),
|
||||
joinMode === 2,
|
||||
id
|
||||
)
|
||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||
if (!instance) return c.json({ errorCode, roomInstance: null })
|
||||
await enterRoom(c, id, instance)
|
||||
return c.json({ errorCode: 0, roomInstance: instance })
|
||||
}
|
||||
)
|
||||
|
||||
// Matchmake into a player event (`/matchmake/event/{playerEventId}`) — the "join" on
|
||||
// an event. The event names the room (and optionally the subroom) to enter, so this
|
||||
// is a room matchmake behind an access check on the EVENT.
|
||||
.post(
|
||||
'/matchmake/event/:eventId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Navigation'],
|
||||
summary: 'Matchmake into a player event',
|
||||
description: [
|
||||
'Places the caller into an instance of the event’s room — its subroom too, when the',
|
||||
'event pins one. Who may join: anyone, if the event is Public (1) or Unlisted (2),',
|
||||
'since unlisted only keeps an event out of the listings rather than closing it; and',
|
||||
'otherwise only the event’s creator or a player who has been invited to it (any',
|
||||
'`event_attendee` row, whatever their answer — being able to decline and change your',
|
||||
'mind is the point). Everyone else gets errorCode 35 (EventIsPrivate) with a null',
|
||||
'instance; an unknown event is the opaque errorCode 20, and 55 when the caller is',
|
||||
'banned from the room the event runs in.',
|
||||
'',
|
||||
'The event’s start and end times are NOT enforced — the reference has codes for',
|
||||
'both (4 EventNotStarted, 5 EventAlreadyFinished) but nothing here has been observed',
|
||||
'sending them, and locking a creator out of their own room before the hour would be',
|
||||
'worse than letting people in early.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(MatchmakeRoomRequest, 'Optional JoinMode and AdditionalPlayerIds'),
|
||||
parameters: [
|
||||
{
|
||||
name: 'eventId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Player event id (digits only)',
|
||||
schema: { type: 'string', pattern: '^[0-9]+$' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(
|
||||
MatchmakeResponse,
|
||||
'The event’s instance (or a null instance with errorCode 20 / 35 / 55)'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const eventId = Number.parseInt(c.req.param('eventId'), 10)
|
||||
const event = await getEventById(c.env.DB, eventId)
|
||||
// Opaque, like the club path: an unknown event and one the caller can't see
|
||||
// shouldn't be distinguishable by probing ids.
|
||||
if (event === null) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||
|
||||
const open =
|
||||
event.Accessibility === Accessibility.Public ||
|
||||
event.Accessibility === Accessibility.Unlisted
|
||||
// An `event_attendee` row is the invite: bulkInvite writes one, and so does
|
||||
// responding, so anyone who was invited or answered passes. The creator is checked
|
||||
// separately so an event whose creator deleted their own response still lets them in.
|
||||
if (
|
||||
!open &&
|
||||
event.CreatorPlayerId !== id &&
|
||||
(await getEventResponse(c.env.DB, eventId, id)) === null
|
||||
) {
|
||||
logger.info('matchmake refused: not invited to private event', { eventId, id })
|
||||
return c.json({ errorCode: EVENT_IS_PRIVATE, roomInstance: null })
|
||||
}
|
||||
|
||||
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
|
||||
const { instance, errorCode } = await resolveRoomInstance(
|
||||
c,
|
||||
String(event.RoomId),
|
||||
joinMode === 2,
|
||||
id,
|
||||
event.SubRoomId ?? undefined
|
||||
)
|
||||
if (!instance) return c.json({ errorCode, roomInstance: null })
|
||||
await enterRoom(c, id, instance)
|
||||
await inviteParty(c, id, additionalPlayerIds, instance)
|
||||
return c.json({ errorCode: 0, roomInstance: instance })
|
||||
}
|
||||
)
|
||||
|
||||
// Follow a friend into the room they're in (`/matchmake/player/{playerId}`). Friends
|
||||
// ONLY — the caller must be a mutual friend of the target, or it's refused; otherwise
|
||||
// anyone could read a player's presence and warp to them. Reads the friend's current
|
||||
@@ -925,7 +1354,9 @@ const app = new Hono<App>()
|
||||
'from the target’s stored presence. FRIENDS ONLY: the caller must be a mutual friend',
|
||||
'of the target (otherwise anyone could read a player’s presence and warp to them).',
|
||||
'Returns errorCode 20 with a null instance when the target isn’t a friend, is the',
|
||||
'caller themselves, or isn’t currently in a room.',
|
||||
'caller themselves, or isn’t currently in a room, and errorCode 55 when the caller is',
|
||||
'banned from the room the friend is in — this path hands out join coordinates without',
|
||||
'going through the room resolver, so it carries its own ban check.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
@@ -940,7 +1371,7 @@ const app = new Hono<App>()
|
||||
responses: {
|
||||
200: json(
|
||||
MatchmakeResponse,
|
||||
'The friend’s instance (or errorCode 20 with null when it can’t be joined)'
|
||||
'The friend’s instance (or a null instance with errorCode 20 / 55 when it can’t be joined)'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
@@ -961,6 +1392,14 @@ const app = new Hono<App>()
|
||||
const instance = targetPresence?.roomInstance ?? null
|
||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||
|
||||
// This path hands out a Photon room id without going through
|
||||
// resolveRoomInstance, so the room's bans have to be checked here too —
|
||||
// otherwise following a friend in is a way around a ban.
|
||||
if (await isPlayerBannedFromRoom(c.env.DB, instance.roomId, id)) {
|
||||
logger.info('follow refused: player banned from room', { roomId: instance.roomId, id })
|
||||
return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null })
|
||||
}
|
||||
|
||||
// Join that same instance (same id + Photon room) and store it as the caller's
|
||||
// presence, so the heartbeat replays it and their own friend fan-out fires.
|
||||
await enterRoom(c, id, instance)
|
||||
@@ -968,6 +1407,93 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// Join one SPECIFIC live instance by id (`/matchmake/instance/{roomInstanceId}`) —
|
||||
// the action behind the owner's instance listing (`GET /room/{roomId}/instances`),
|
||||
// where they pick a session of their room and drop into it. Unlike every other
|
||||
// matchmake this targets a fixed instance: nothing is reused, nothing is created,
|
||||
// and a full or in-progress instance is still entered (moderating a full instance
|
||||
// is the point). OWNER-ONLY, gated with the same creator-or-co-owner check as the
|
||||
// listing — the Photon room id is the join coordinate, so an open version of this
|
||||
// would let anyone warp into any private session by guessing an id. Registered
|
||||
// before the `/matchmake/room/…` routes so `instance` isn't read as a room name.
|
||||
.post(
|
||||
'/matchmake/instance/:instanceId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Navigation'],
|
||||
summary: 'Join a specific instance (owner only)',
|
||||
description: [
|
||||
'Places the caller into one specific live instance of their own room, picked by id',
|
||||
'from the owner’s instance listing. Gated to the room’s creator or a co-owner.',
|
||||
'Unlike the other matchmakes this never reuses or creates an instance, and enters',
|
||||
'even a full or in-progress one. Returns errorCode 20 with a null instance when the',
|
||||
'instance or its room is gone, or the caller doesn’t manage that room; errorCode 55',
|
||||
'when banned.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'instanceId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Room instance id (digits only)',
|
||||
schema: { type: 'string', pattern: '^[0-9]+$' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(
|
||||
MatchmakeResponse,
|
||||
'The instance (or a null instance with errorCode 20 / 55 when it can’t be joined)'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const instanceId = Number.parseInt(c.req.param('instanceId'), 10)
|
||||
const stored = await getRoomInstance(c.env.DB, instanceId)
|
||||
// One opaque refusal for "no such instance", "no such room" and "not yours":
|
||||
// a distinct code for the last would confirm which instance ids are live.
|
||||
if (!stored) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||
const room = await getRoomById(c.env.DB, stored.roomId)
|
||||
if (!room) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||
if (!canManageRoom(room, id)) {
|
||||
logger.info('instance matchmake refused: not the room’s owner', {
|
||||
roomInstanceId: instanceId,
|
||||
roomId: stored.roomId,
|
||||
accountId: id,
|
||||
})
|
||||
return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||
}
|
||||
|
||||
// Like the follow-a-friend path, this hands out a Photon room id without going
|
||||
// through resolveRoomInstance, so the room's bans are checked here too. An owner
|
||||
// can't ban themselves out of their own room in practice, but a co-owner can be
|
||||
// banned, and a ban must beat every route that yields join coordinates.
|
||||
if (await isPlayerBannedFromRoom(c.env.DB, stored.roomId, id)) {
|
||||
logger.info('instance matchmake refused: player banned from room', {
|
||||
roomId: stored.roomId,
|
||||
id,
|
||||
})
|
||||
return c.json({ errorCode: BANNED_FROM_ROOM, roomInstance: null })
|
||||
}
|
||||
|
||||
// Rebuild the wire instance from the room (fresh scene + published save) keyed to
|
||||
// this instance's own id and Photon room, so the owner lands in exactly the
|
||||
// session they picked rather than a new one alongside it.
|
||||
const instance = roomInstanceFromRoom(
|
||||
room,
|
||||
stored.isPrivate,
|
||||
stored.roomInstanceId,
|
||||
stored.photonRoomId,
|
||||
stored.subRoomId
|
||||
)
|
||||
await enterRoom(c, id, instance)
|
||||
return c.json({ errorCode: 0, roomInstance: instance })
|
||||
}
|
||||
)
|
||||
|
||||
// Matchmake into a specific subroom of a room (`/matchmake/room/{roomId}/{subRoomId}`
|
||||
// — the client uses this to enter a room's other scenes). The subroom decides the
|
||||
// scene the client loads and which instances are joinable, so it must be carried
|
||||
@@ -994,7 +1520,10 @@ const app = new Hono<App>()
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'),
|
||||
200: json(
|
||||
MatchmakeResponse,
|
||||
'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned)'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
@@ -1003,14 +1532,14 @@ const app = new Hono<App>()
|
||||
if (id === null) return unauthorized(c)
|
||||
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
|
||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||
const instance = await resolveRoomInstance(
|
||||
const { instance, errorCode } = await resolveRoomInstance(
|
||||
c,
|
||||
c.req.param('roomId'),
|
||||
joinMode === 2,
|
||||
id,
|
||||
subRoomId
|
||||
)
|
||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||
if (!instance) return c.json({ errorCode, roomInstance: null })
|
||||
await enterRoom(c, id, instance)
|
||||
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
|
||||
await inviteParty(c, id, additionalPlayerIds, instance)
|
||||
@@ -1033,7 +1562,10 @@ const app = new Hono<App>()
|
||||
requestBody: form(MatchmakeRoomRequest, 'Optional JoinMode and AdditionalPlayerIds'),
|
||||
parameters: [{ name: 'roomId', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
responses: {
|
||||
200: json(MatchmakeResponse, 'The instance (or errorCode 20 with null on unknown room)'),
|
||||
200: json(
|
||||
MatchmakeResponse,
|
||||
'The instance (or a null instance with errorCode 20 on an unknown room, 55 when banned)'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
@@ -1041,8 +1573,13 @@ const app = new Hono<App>()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const { joinMode, additionalPlayerIds } = await readMatchmakeBody(c)
|
||||
const instance = await resolveRoomInstance(c, c.req.param('roomId'), joinMode === 2, id)
|
||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||
const { instance, errorCode } = await resolveRoomInstance(
|
||||
c,
|
||||
c.req.param('roomId'),
|
||||
joinMode === 2,
|
||||
id
|
||||
)
|
||||
if (!instance) return c.json({ errorCode, roomInstance: null })
|
||||
await enterRoom(c, id, instance)
|
||||
// Pull the caller's party (AdditionalPlayerIds) into the instance they landed in.
|
||||
await inviteParty(c, id, additionalPlayerIds, instance)
|
||||
@@ -1057,11 +1594,12 @@ const app = new Hono<App>()
|
||||
description: [
|
||||
'Single-segment matchmake into the caller’s personal dorm, stored as presence. The',
|
||||
'client only ever calls this with the `dorm` keyword — real rooms go through',
|
||||
'`/matchmake/room/:roomId`.',
|
||||
'`/matchmake/room/:roomId`. Returns errorCode 55 with a null instance when the',
|
||||
'account is banned: a ban keeps a player out of their own dorm too.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(MatchmakeResponse, 'The player’s personal dorm instance'),
|
||||
200: json(MatchmakeResponse, 'The dorm instance (or a null instance with errorCode 55)'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
@@ -1165,16 +1703,20 @@ const app = new Hono<App>()
|
||||
(c) => c.body(null, 200)
|
||||
)
|
||||
|
||||
// The room owner flips the instance's in-progress flag once the session starts
|
||||
// (e.g. a game round begins). Body is a form post: `inProgress=True|False`.
|
||||
// The instance's in-progress flag, flipped when a session starts (e.g. a game round
|
||||
// begins). Deliberately NOT owner-gated, unlike the other room-instance mutations:
|
||||
// this is set by whoever in the room starts the game, not by the room's owner — a
|
||||
// gate here would break game starts for everyone else. Body is a form post:
|
||||
// `inProgress=True|False`.
|
||||
.put(
|
||||
'/roominstance/:id/inprogress',
|
||||
describeRoute({
|
||||
tags: ['Room instance'],
|
||||
summary: 'Set instance in-progress flag',
|
||||
description: [
|
||||
'The room owner flips the instance’s in-progress flag when a session starts (e.g. a',
|
||||
'round begins). Body is `inProgress=True|False`.',
|
||||
'Flips the instance’s in-progress flag when a session starts (e.g. a round begins).',
|
||||
'Set by whoever in the room starts the game — any authenticated player, not just the',
|
||||
'room’s owner. Body is `inProgress=True|False`.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(InProgressRequest, 'The inProgress flag'),
|
||||
@@ -1202,18 +1744,72 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// Close a live instance to strangers (`/roominstance/{id}/markprivate`) — the owner
|
||||
// makes the session they're running private, so public matchmaking stops feeding new
|
||||
// players into it (getJoinableInstance only reuses non-private instances). Everyone
|
||||
// already inside stays put; this shuts the door rather than clearing the room.
|
||||
// OWNER-ONLY (same creator-or-co-owner gate as the instance listing): whether a
|
||||
// session is open is the room owner's call, not a passer-by's. Generic empty ack.
|
||||
.post(
|
||||
'/roominstance/:id/markprivate',
|
||||
describeRoute({
|
||||
tags: ['Room instance'],
|
||||
summary: 'Mark an instance private (owner only)',
|
||||
description: [
|
||||
'Marks a live instance private, so public matchmaking stops placing new players',
|
||||
'into it. Players already inside are unaffected. Auth-gated and gated to the',
|
||||
'instance’s room’s creator or a co-owner (403 otherwise). Empty ack.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Room instance id',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: EMPTY_OK,
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
|
||||
404: { description: 'Non-numeric id or no such instance (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const instanceId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (Number.isNaN(instanceId)) return c.body(null, 404)
|
||||
|
||||
const stored = await getRoomInstance(c.env.DB, instanceId)
|
||||
if (!stored) return c.body(null, 404)
|
||||
const room = await getRoomById(c.env.DB, stored.roomId)
|
||||
if (!room || !canManageRoom(room, id)) return c.body(null, 403)
|
||||
|
||||
await setRoomInstancePrivate(c.env.DB, instanceId, true)
|
||||
return c.body(null, 200)
|
||||
}
|
||||
)
|
||||
|
||||
// The room's live instances — the owner's view of active sessions of their room.
|
||||
// Auth-gated (401) and owner/co-owner-only (403): the caller must be the room's
|
||||
// creator or hold a Creator/CoOwner role on it. Unknown room → 404. Returns the
|
||||
// bare RoomInstance DTO array (empty when the room has no live instances).
|
||||
// creator or hold a Creator/CoOwner role on it. Unknown room → 404. Returns a
|
||||
// summary per instance (empty when the room has no live instances) — id, subroom,
|
||||
// fullness, creation time and who's currently in it — not the client's
|
||||
// RoomInstance DTO: this is a management listing, so it answers "who's in there"
|
||||
// and withholds the connection details of a session the owner isn't joining.
|
||||
.get(
|
||||
'/room/:roomId{[0-9]+}/instances',
|
||||
describeRoute({
|
||||
tags: ['Room instance'],
|
||||
summary: 'A room’s live instances',
|
||||
description: [
|
||||
'The owner’s view of active sessions of their room. Auth-gated and gated to the',
|
||||
'room’s creator or a co-owner (403 otherwise). Unknown room → 404.',
|
||||
'The owner’s view of active sessions of their room — each instance with the',
|
||||
'players currently in it. Auth-gated and gated to the room’s creator or a',
|
||||
'co-owner (403 otherwise). Unknown room → 404.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
@@ -1226,7 +1822,7 @@ const app = new Hono<App>()
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(RoomInstanceDto.array(), 'Live instances (empty when none)'),
|
||||
200: json(RoomInstanceSummaryDto.array(), 'Live instances (empty when none)'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
|
||||
404: { description: 'No such room (empty body)' },
|
||||
@@ -1243,7 +1839,7 @@ const app = new Hono<App>()
|
||||
// same owner-or-co-owner gate the rooms worker uses for room-admin actions.
|
||||
if (!canManageRoom(room, id)) return c.body(null, 403)
|
||||
|
||||
return c.json(await getRoomInstancesByRoom(c.env.DB, roomId))
|
||||
return c.json(await getRoomInstanceSummariesByRoom(c.env.DB, roomId))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1273,24 +1869,33 @@ const app = new Hono<App>()
|
||||
)
|
||||
|
||||
/**
|
||||
* Cron: sweep presence that has aged past its TTL. Reads already ignore expired rows,
|
||||
* so this isn't about correctness of `/player` — it's that a player who crashed or
|
||||
* hard-quit never matchmakes out of their instance, so nothing recomputes that
|
||||
* instance's fullness and it can stay flagged full (and unjoinable) with nobody in it.
|
||||
* Recompute the instances the expiring rows point at, *then* delete: the sweep is the
|
||||
* only thing that notices those departures. Fullness is recomputed after the delete so
|
||||
* the head-count no longer sees them.
|
||||
* Cron: sweep presence that has aged past its TTL, then the instances left empty.
|
||||
*
|
||||
* The presence purge isn't about correctness of `/player` — reads already ignore
|
||||
* expired rows. It's that a player who crashed or hard-quit never matchmakes out of
|
||||
* their instance, so nothing recomputes that instance's fullness and it can stay
|
||||
* flagged full (and unjoinable) with nobody in it. Note the instances the expiring
|
||||
* rows point at *before* deleting: the sweep is the only thing that notices those
|
||||
* departures.
|
||||
*
|
||||
* Emptying an instance is what makes it garbage — nothing ever reuses it, and a
|
||||
* joiner handed one would land alone in a Photon room everyone left — so the empty
|
||||
* sweep runs next. It reads presence without consulting expiry, so it depends on
|
||||
* running after the purge above: this order is what makes a lapsed row count as a
|
||||
* departure. Fullness is recomputed last, so it works from the final head-count and
|
||||
* skips (returns null for) the instances just deleted.
|
||||
*/
|
||||
async function sweepExpiredPresence(env: Env): Promise<void> {
|
||||
const staleInstanceIds = await getExpiredPresenceInstanceIds(env.DB)
|
||||
const removed = await deleteExpiredPresence(env.DB)
|
||||
const emptyInstanceIds = await deleteEmptyRoomInstances(env.DB)
|
||||
for (const instanceId of staleInstanceIds) {
|
||||
await refreshInstanceFullness(env.DB, instanceId)
|
||||
}
|
||||
// The tagged logger is request-scoped (its middleware never runs for a cron), so
|
||||
// log plainly here — Workers observability picks it up either way.
|
||||
console.log(
|
||||
`presence sweep: removed ${removed} expired rows, refreshed ${staleInstanceIds.length} instances`
|
||||
`presence sweep: removed ${removed} expired rows, deleted ${emptyInstanceIds.length} empty instances, refreshed ${staleInstanceIds.length} instances`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1311,7 +1916,8 @@ app.get(
|
||||
'Room backend. Rooms and room instances are D1-backed (matchmaking finds or creates a',
|
||||
'`room_instance` per session); presence — the instance each player is currently in —',
|
||||
'lives in the shared `presence` table and expires on a TTL. A cron sweep clears',
|
||||
'expired presence and frees up instances a crashed player never left.',
|
||||
'expired presence, frees up instances a crashed player never left, and deletes',
|
||||
'instances nobody is standing in any more.',
|
||||
].join('\n'),
|
||||
},
|
||||
servers: [{ url: 'https://match.recflare.net', description: 'Production' }],
|
||||
|
||||
@@ -95,6 +95,22 @@ export const RoomInstanceDto = z.object({
|
||||
EncryptVoiceChat: z.boolean(),
|
||||
})
|
||||
|
||||
/**
|
||||
* One live instance in the owner's management listing (`GET /room/:roomId/instances`).
|
||||
* Not the client `RoomInstanceDto`: it carries who's in there and drops the connection
|
||||
* details (photon ids, data blob, room code) of a session the owner isn't in.
|
||||
*/
|
||||
export const RoomInstanceSummaryDto = z.object({
|
||||
roomInstanceId: z.int(),
|
||||
roomId: z.int(),
|
||||
subRoomId: z.int().describe('Which subroom (scene) of the room this instance is'),
|
||||
isFull: z.boolean(),
|
||||
createdAt: z.string().describe('ISO 8601 UTC, stamped when the instance was created'),
|
||||
playerIds: z
|
||||
.array(z.int())
|
||||
.describe('Accounts currently in the instance (live presence); empty when nobody is'),
|
||||
})
|
||||
|
||||
/**
|
||||
* A player's presence as the client reads it (`GET /player`, `POST /player/heartbeat`).
|
||||
* `isOnline` means "has a live (unexpired) presence row", NOT "is in a room" — a player
|
||||
@@ -128,10 +144,29 @@ export const PlayerDto = z.object({
|
||||
* a non-zero code (e.g. 20 NoSuchRoom) comes with `roomInstance: null`.
|
||||
*/
|
||||
export const MatchmakeResponse = z.object({
|
||||
errorCode: z.int().describe('0 = success; 20 = NoSuchRoom'),
|
||||
errorCode: z
|
||||
.int()
|
||||
.describe('0 = success; 20 = NoSuchRoom; 55 = banned from the room (the one non-opaque code)'),
|
||||
roomInstance: RoomInstanceDto.nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /player/avoidjuniors` — a BARE JSON boolean (`true`/`false`), not an envelope and
|
||||
* not a `{ value }` wrapper. The whole body is the preference.
|
||||
*/
|
||||
export const AvoidJuniorsResponse = z
|
||||
.boolean()
|
||||
.describe('Whether the player asked to be kept away from junior accounts')
|
||||
|
||||
/**
|
||||
* `PUT /player/avoidjuniors` form body. The client posts `avoidJuniors=True`; the field is
|
||||
* matched case-insensitively and `True`/`false`/`1`/`0`/`yes`/`no` all parse, since neither
|
||||
* the casing nor the spelling of the boolean is guaranteed across the client's surfaces.
|
||||
*/
|
||||
export const AvoidJuniorsRequest = z.object({
|
||||
avoidJuniors: z.string().describe('`True`/`False` (also `1`/`0`, `yes`/`no`)'),
|
||||
})
|
||||
|
||||
/** `POST /player/exclusivelogin` — a bare error code. */
|
||||
export const ExclusiveLoginResponse = z.object({ errorCode: z.int().describe('Always 0') })
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
||||
import {
|
||||
countPlayersInInstance,
|
||||
createRoomInstance,
|
||||
EMPTY_INSTANCE_GRACE_SECONDS,
|
||||
GAME_VERSION,
|
||||
getRoomInstance,
|
||||
PRESENCE_SCHEMA_DDL,
|
||||
@@ -20,6 +21,13 @@ import {
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
import { SCHEMA_DDL as EVENTS_SCHEMA_DDL } from '../../../../api/src/events-db'
|
||||
import {
|
||||
banFromReport,
|
||||
createReport,
|
||||
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
|
||||
} from '../../../../api/src/reports-db'
|
||||
import { PLATFORM_SCHEMA_DDL } from '../../../../auth/src/platform-db'
|
||||
import { scheduled } from '../../match.app'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
@@ -143,6 +151,49 @@ beforeAll(async () => {
|
||||
insertMember.bind(5, 120, 100),
|
||||
])
|
||||
|
||||
// Player-event tables (owned by the api worker) — matchmake/event reads the event
|
||||
// for its room and the caller's invite row for access.
|
||||
for (const stmt of EVENTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
const insertEvent = env.DB.prepare('INSERT OR IGNORE INTO event (data) VALUES (?1)')
|
||||
const event = (id: number, accessibility: number, extra?: Record<string, unknown>) =>
|
||||
JSON.stringify({
|
||||
PlayerEventId: id,
|
||||
CreatorPlayerId: 300,
|
||||
ImageName: null,
|
||||
RoomId: 2,
|
||||
SubRoomId: null,
|
||||
ClubId: null,
|
||||
Name: `Event ${id}`,
|
||||
Description: '',
|
||||
StartTime: '2020-11-29T22:00:00Z',
|
||||
EndTime: '2020-11-29T23:00:00Z',
|
||||
AttendeeCount: 1,
|
||||
State: 0,
|
||||
Accessibility: accessibility,
|
||||
IsMultiInstance: false,
|
||||
SupportMultiInstanceRoomChat: false,
|
||||
DefaultBroadcastPermissions: 0,
|
||||
CanRequestBroadcastPermissions: 0,
|
||||
...extra,
|
||||
})
|
||||
await env.DB.batch([
|
||||
insertEvent.bind(event(8, 0)), // private
|
||||
insertEvent.bind(event(9, 1)), // public
|
||||
insertEvent.bind(event(10, 2)), // unlisted — listings only, still joinable
|
||||
// A private one in the two-subroom room, pinning the SECOND subroom.
|
||||
insertEvent.bind(event(11, 0, { RoomId: 77, SubRoomId: 35 })),
|
||||
])
|
||||
const insertAttendee = env.DB.prepare(
|
||||
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
|
||||
VALUES (?1, ?2, ?3, '2020-11-29T21:00:00Z')`
|
||||
)
|
||||
await env.DB.batch([
|
||||
insertAttendee.bind(8, 300, 0), // the creator, Going from create
|
||||
insertAttendee.bind(8, 301, 0), // invited
|
||||
insertAttendee.bind(8, 302, 2), // invited, but declined — still allowed in
|
||||
insertAttendee.bind(11, 301, 0),
|
||||
])
|
||||
|
||||
// Relationship table (owned by the api worker) — matchmake reads it to push a
|
||||
// presence update to the player's friends. Seed friendships for player 9700.
|
||||
await env.DB.prepare(
|
||||
@@ -161,8 +212,24 @@ beforeAll(async () => {
|
||||
insertRel.bind(9702, 9700, 3), // friends (9702 requested) — friend is the requester
|
||||
insertRel.bind(9700, 9703, 1), // pending request out — 9703 is NOT a friend
|
||||
])
|
||||
|
||||
// Report table (owned by the api worker) — an account-wide ban is a report row with
|
||||
// `banned` set, and every matchmake is refused for a player who has one.
|
||||
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Platform identity links (owned by the auth worker) — a ban also reaches the
|
||||
// accounts sharing a proven identity with the banned one.
|
||||
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
/**
|
||||
* Ban a player account-wide the way a moderator would: file a report against them and
|
||||
* convert it. `banExpires` null is a permanent ban.
|
||||
*/
|
||||
async function banAccount(playerId: number, banExpires: string | null = null): Promise<void> {
|
||||
const row = await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: playerId })
|
||||
await banFromReport(env.DB, row.id, { banExpires })
|
||||
}
|
||||
|
||||
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
|
||||
// match worker's validation accepts it. Kept inline to avoid a cross-package
|
||||
// import.
|
||||
@@ -256,6 +323,145 @@ describe('public endpoints', () => {
|
||||
expect(players[0]).toMatchObject({ playerId: 1, isOnline: true, appVersion: GAME_VERSION })
|
||||
})
|
||||
|
||||
// The "avoid juniors" preference lives in the playersettings KV map, not in presence.
|
||||
// The body is a BARE boolean — the client reads the whole body as the value.
|
||||
describe('GET /player/avoidjuniors', () => {
|
||||
const settings = async (playerId: number, map: Record<string, string>) =>
|
||||
env.RECFLARE_PLAYER_SETTINGS.put(`player:${playerId}`, JSON.stringify(map))
|
||||
|
||||
const read = async (playerId: number) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
|
||||
headers: await bearer(String(playerId)),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
test('reads the stored setting', async () => {
|
||||
await settings(3100, { avoidJuniors: 'True', 'Recroom.OOBE': '77' })
|
||||
expect(await read(3100)).toBe(true)
|
||||
|
||||
await settings(3101, { avoidJuniors: 'False' })
|
||||
expect(await read(3101)).toBe(false)
|
||||
})
|
||||
|
||||
test('the key match ignores casing and separators', async () => {
|
||||
await settings(3102, { AVOID_JUNIORS: '1' })
|
||||
expect(await read(3102)).toBe(true)
|
||||
|
||||
await settings(3103, { avoidjuniors: 'yes' })
|
||||
expect(await read(3103)).toBe(true)
|
||||
})
|
||||
|
||||
// A player who never touched the setting, and one whose value is junk, both read
|
||||
// false — the read gates matchmaking, so it must not fail closed.
|
||||
test('defaults to false when unset or unparseable', async () => {
|
||||
expect(await read(3104)).toBe(false)
|
||||
|
||||
await settings(3105, { 'Recroom.OOBE': '77' })
|
||||
expect(await read(3105)).toBe(false)
|
||||
|
||||
await settings(3106, { avoidJuniors: 'maybe' })
|
||||
expect(await read(3106)).toBe(false)
|
||||
})
|
||||
|
||||
test('is auth-gated', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /player/avoidjuniors', () => {
|
||||
const stored = async (playerId: number) =>
|
||||
env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(`player:${playerId}`, 'json')
|
||||
|
||||
const write = async (playerId: number, body: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...(await bearer(String(playerId))),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
const read = async (playerId: number) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
|
||||
headers: await bearer(String(playerId)),
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// The body the client posts. The response is the resulting value, and the GET agrees.
|
||||
test('stores the posted preference and answers it', async () => {
|
||||
expect(await write(3200, 'avoidJuniors=True')).toBe(true)
|
||||
expect(await read(3200)).toBe(true)
|
||||
|
||||
expect(await write(3200, 'avoidJuniors=False')).toBe(false)
|
||||
expect(await read(3200)).toBe(false)
|
||||
})
|
||||
|
||||
// The map holds every setting the player has, so the write must not replace it.
|
||||
test('merges into the player’s other settings', async () => {
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(
|
||||
'player:3201',
|
||||
JSON.stringify({ 'Recroom.OOBE': '77', TUTORIAL_COMPLETE_MASK: '11' })
|
||||
)
|
||||
await write(3201, 'avoidJuniors=True')
|
||||
expect(await stored(3201)).toEqual({
|
||||
'Recroom.OOBE': '77',
|
||||
TUTORIAL_COMPLETE_MASK: '11',
|
||||
avoidJuniors: 'True',
|
||||
})
|
||||
})
|
||||
|
||||
// Whichever spelling the player's map already carries is the one overwritten —
|
||||
// two keys for one preference would make the read depend on their order.
|
||||
test('overwrites an existing key rather than adding a second one', async () => {
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(
|
||||
'player:3202',
|
||||
JSON.stringify({ AVOID_JUNIORS: 'True' })
|
||||
)
|
||||
expect(await write(3202, 'avoidJuniors=False')).toBe(false)
|
||||
expect(await stored(3202)).toEqual({ AVOID_JUNIORS: 'False' })
|
||||
})
|
||||
|
||||
test('accepts a JSON body', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...(await bearer('3203')),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ avoidJuniors: true }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(true)
|
||||
expect(await read(3203)).toBe(true)
|
||||
})
|
||||
|
||||
// An unreadable body leaves the stored setting alone and answers it — a no-op 200,
|
||||
// not a 400 and not a write of `false`.
|
||||
test('a body with no readable value is a no-op', async () => {
|
||||
await write(3204, 'avoidJuniors=True')
|
||||
expect(await write(3204, 'avoidJuniors=maybe')).toBe(true)
|
||||
expect(await write(3204, '')).toBe(true)
|
||||
expect(await stored(3204)).toEqual({ avoidJuniors: 'True' })
|
||||
})
|
||||
|
||||
test('is auth-gated', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/avoidjuniors`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'avoidJuniors=True',
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /matchmake/room/:roomId resolves the room scene from D1', async () => {
|
||||
const headers = await bearer('88')
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
|
||||
@@ -277,6 +483,46 @@ describe('public endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('a matchmake counts a visit against the room', async () => {
|
||||
const visits = async (roomId: number): Promise<number> =>
|
||||
(await env.DB.prepare('SELECT visits FROM room WHERE room_id = ?1')
|
||||
.bind(roomId)
|
||||
.first<{ visits: number }>())!.visits
|
||||
const enter = async (path: string, player: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(player)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({ JoinMode: '2' }).toString(),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
}
|
||||
|
||||
// Counted per matchmake, whichever route got the player there — the two-segment
|
||||
// room form and the subroom form both land in room 77.
|
||||
const before = await visits(77)
|
||||
await enter('/matchmake/room/77', '94')
|
||||
expect(await visits(77)).toBe(before + 1)
|
||||
await enter('/matchmake/room/77/35', '95')
|
||||
expect(await visits(77)).toBe(before + 2)
|
||||
|
||||
// Same player entering again is another visit (VisitCount is visits, not visitors),
|
||||
// and it's the entered room that's counted — not every room.
|
||||
const otherBefore = await visits(2)
|
||||
await enter('/matchmake/room/77', '94')
|
||||
expect(await visits(77)).toBe(before + 3)
|
||||
expect(await visits(2)).toBe(otherBefore)
|
||||
|
||||
// A refused matchmake counts nothing: an unknown room has no row to bump.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/99999`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('96'),
|
||||
})
|
||||
expect(((await res.json()) as { errorCode: number }).errorCode).toBe(20)
|
||||
})
|
||||
|
||||
test('POST /matchmake/room/:roomId seeds presence with the account device class', async () => {
|
||||
// A screen player (deviceClass 2, recorded by auth at login) matchmaking with no
|
||||
// live presence: without the account fallback they'd enter the room as deviceClass
|
||||
@@ -451,6 +697,80 @@ describe('public endpoints', () => {
|
||||
expect((await matchmake('/matchmake/club/4')).status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /matchmake/event/:eventId gates a private event on the invite list', async () => {
|
||||
const matchmake = async (path: string, sub?: string) =>
|
||||
exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(sub === undefined ? {} : await bearer(sub)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: 'JoinMode=0',
|
||||
})
|
||||
type Body = {
|
||||
errorCode: number
|
||||
roomInstance: { roomId: number; location: string; roomInstanceId: number } | null
|
||||
}
|
||||
const join = async (path: string, sub?: string) =>
|
||||
(await (await matchmake(path, sub)).json()) as Body
|
||||
|
||||
// An invited player lands in an instance of the event's room (2)...
|
||||
const invited = await join('/matchmake/event/8', '301')
|
||||
expect(invited.errorCode).toBe(0)
|
||||
expect(invited.roomInstance).toMatchObject({ roomId: 2, location: RECCENTER_SCENE })
|
||||
|
||||
// ...recorded as their presence, like any other matchmake.
|
||||
const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
|
||||
.bind(301)
|
||||
.first<{ data: string }>()
|
||||
const presence = JSON.parse(row!.data) as { roomInstance: { roomInstanceId: number } }
|
||||
expect(presence.roomInstance.roomInstanceId).toBe(invited.roomInstance!.roomInstanceId)
|
||||
|
||||
// The creator gets in, and so does someone who was invited and DECLINED — the row
|
||||
// is the invite, whatever the answer.
|
||||
expect((await join('/matchmake/event/8', '300')).errorCode).toBe(0)
|
||||
expect((await join('/matchmake/event/8', '302')).errorCode).toBe(0)
|
||||
|
||||
// A stranger doesn't — and is told why (35 EventIsPrivate), not fobbed off with 20.
|
||||
expect(await join('/matchmake/event/8', '399')).toEqual({
|
||||
errorCode: 35,
|
||||
roomInstance: null,
|
||||
})
|
||||
|
||||
// Public and unlisted are open to anyone: unlisted only keeps an event out of the
|
||||
// listings, it doesn't close it.
|
||||
expect((await join('/matchmake/event/9', '399')).errorCode).toBe(0)
|
||||
expect((await join('/matchmake/event/10', '399')).errorCode).toBe(0)
|
||||
|
||||
// An unknown event is the opaque NoSuchRoom, so ids can't be probed.
|
||||
expect(await join('/matchmake/event/9999', '399')).toEqual({
|
||||
errorCode: 20,
|
||||
roomInstance: null,
|
||||
})
|
||||
|
||||
// Signed out is a 401, not a matchmaking error.
|
||||
expect((await matchmake('/matchmake/event/9')).status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /matchmake/event/:eventId enters the subroom the event pins', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/event/11`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('301')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'JoinMode=0',
|
||||
})
|
||||
const body = (await res.json()) as {
|
||||
errorCode: number
|
||||
roomInstance: { roomId: number; subRoomId: number; location: string } | null
|
||||
}
|
||||
// Room 77's SECOND subroom (35), not its first — the event pins the scene.
|
||||
expect(body.errorCode).toBe(0)
|
||||
expect(body.roomInstance).toMatchObject({
|
||||
roomId: 77,
|
||||
subRoomId: 35,
|
||||
location: SECOND_SUBROOM_SCENE,
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /matchmake/room/:roomId returns NoSuchRoom for an unknown room', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/99999`, {
|
||||
method: 'POST',
|
||||
@@ -460,6 +780,79 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toEqual({ errorCode: 20, roomInstance: null })
|
||||
})
|
||||
|
||||
test('ROOM_REDIRECTS switches a matchmake out to another room', async () => {
|
||||
// `env` is shared by every test in this file, so restore the knob in `finally`.
|
||||
const original = env.ROOM_REDIRECTS
|
||||
const matchmake = async (path: string, player: string) =>
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(player)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
// Private, so each call gets a fresh instance of whatever room it landed in.
|
||||
body: new URLSearchParams({ JoinMode: '2' }).toString(),
|
||||
})
|
||||
).json()) as {
|
||||
errorCode: number
|
||||
roomInstance: { roomId: number; subRoomId: number; location: string; name: string } | null
|
||||
}
|
||||
|
||||
try {
|
||||
env.ROOM_REDIRECTS = '2=MultiRoom'
|
||||
// The room asked for is never entered; the substitute is, scene and all.
|
||||
expect((await matchmake('/matchmake/room/2', '8801')).roomInstance).toMatchObject({
|
||||
roomId: 77,
|
||||
name: '^MultiRoom',
|
||||
location: RECCENTER_SCENE,
|
||||
})
|
||||
// Matched on the resolved room, not the path segment, so the name spelling of the
|
||||
// same room is substituted too.
|
||||
expect((await matchmake('/matchmake/room/RecCenter', '8802')).roomInstance).toMatchObject({
|
||||
roomId: 77,
|
||||
})
|
||||
// The requested subroom is dropped — 35 is a subroom of the substitute, not of the
|
||||
// room asked for — so entry falls back to the substitute's default subroom (34).
|
||||
expect((await matchmake('/matchmake/room/2/35', '8803')).roomInstance).toMatchObject({
|
||||
roomId: 77,
|
||||
subRoomId: 34,
|
||||
location: RECCENTER_SCENE,
|
||||
})
|
||||
// Club 4's clubhouse is room 2, and it resolves through the same path: a
|
||||
// substituted room is substituted wherever a matchmake names it.
|
||||
expect((await matchmake('/matchmake/club/4', '121')).roomInstance).toMatchObject({
|
||||
roomId: 77,
|
||||
})
|
||||
|
||||
// Targeting by id works the same, and substitution is a single hop: 2 and 77
|
||||
// swap rather than bouncing between each other.
|
||||
env.ROOM_REDIRECTS = '2=77,77=2'
|
||||
expect((await matchmake('/matchmake/room/2', '8804')).roomInstance).toMatchObject({
|
||||
roomId: 77,
|
||||
})
|
||||
expect((await matchmake('/matchmake/room/77', '8805')).roomInstance).toMatchObject({
|
||||
roomId: 2,
|
||||
})
|
||||
|
||||
// A target that doesn't resolve leaves the requested room in place — a typo'd
|
||||
// knob must not make the room unreachable.
|
||||
env.ROOM_REDIRECTS = '2=NoSuchRoomHere'
|
||||
expect((await matchmake('/matchmake/room/2', '8806')).roomInstance).toMatchObject({
|
||||
roomId: 2,
|
||||
})
|
||||
|
||||
// Unset: everyone enters the room they asked for.
|
||||
env.ROOM_REDIRECTS = undefined
|
||||
expect((await matchmake('/matchmake/room/2', '8807')).roomInstance).toMatchObject({
|
||||
roomId: 2,
|
||||
name: '^RecCenter',
|
||||
})
|
||||
} finally {
|
||||
env.ROOM_REDIRECTS = original
|
||||
}
|
||||
})
|
||||
|
||||
test('PUT /player/statusvisibility returns 200', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/statusvisibility`, { method: 'PUT' })
|
||||
expect(res.status).toBe(200)
|
||||
@@ -731,14 +1124,15 @@ describe('auth-gated endpoints', () => {
|
||||
expect(await stale.text()).toBe('')
|
||||
})
|
||||
|
||||
// Seed presence directly into D1 with a chosen `expiresAt` (epoch seconds) so the
|
||||
// TTL-refresh branch can be exercised deterministically (independent of timing).
|
||||
const seedPresence = (id: number, expiresAt: number) =>
|
||||
// Seed presence directly into D1 with a chosen instance and `expiresAt` (epoch
|
||||
// seconds), so the TTL branches can be exercised deterministically (independent of
|
||||
// timing) and a player can be planted in an instance without matchmaking there.
|
||||
const seedPresenceInInstance = (id: number, roomInstanceId: number, expiresAt: number) =>
|
||||
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: id,
|
||||
roomInstance: { roomInstanceId: 1000042, roomId: 1 },
|
||||
roomInstance: { roomInstanceId, roomId: 1 },
|
||||
statusVisibility: 0,
|
||||
deviceClass: 0,
|
||||
vrMovementMode: 1,
|
||||
@@ -749,6 +1143,9 @@ describe('auth-gated endpoints', () => {
|
||||
)
|
||||
.run()
|
||||
|
||||
const seedPresence = (id: number, expiresAt: number) =>
|
||||
seedPresenceInInstance(id, 1000042, expiresAt)
|
||||
|
||||
const storedExpiresAt = async (id: number): Promise<number> => {
|
||||
const row = await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
|
||||
.bind(id)
|
||||
@@ -792,24 +1189,9 @@ describe('auth-gated endpoints', () => {
|
||||
|
||||
test('countPlayersInInstance counts live players in a room instance (excludes expired)', async () => {
|
||||
// Three players in instance 1000099 — two live, one expired.
|
||||
const seedInInstance = (id: number, expiresAt: number) =>
|
||||
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: id,
|
||||
roomInstance: { roomInstanceId: 1000099, roomId: 2 },
|
||||
statusVisibility: 0,
|
||||
deviceClass: 0,
|
||||
vrMovementMode: 1,
|
||||
platform: 0,
|
||||
appVersion: GAME_VERSION,
|
||||
expiresAt,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
await seedInInstance(710, nowSeconds() + 800)
|
||||
await seedInInstance(711, nowSeconds() + 800)
|
||||
await seedInInstance(712, nowSeconds() - 10) // already expired → not counted
|
||||
await seedPresenceInInstance(710, 1000099, nowSeconds() + 800)
|
||||
await seedPresenceInInstance(711, 1000099, nowSeconds() + 800)
|
||||
await seedPresenceInInstance(712, 1000099, nowSeconds() - 10) // expired → not counted
|
||||
expect(await countPlayersInInstance(env.DB, 1000099)).toBe(2)
|
||||
expect(await countPlayersInInstance(env.DB, 999999)).toBe(0)
|
||||
})
|
||||
@@ -872,6 +1254,89 @@ describe('auth-gated endpoints', () => {
|
||||
expect((await getRoomInstance(env.DB, solo))?.isFull).toBe(false)
|
||||
})
|
||||
|
||||
// Age an instance past EMPTY_INSTANCE_GRACE_SECONDS by backdating its `createdAt`
|
||||
// (the generated `created_at` column follows the blob), so the empty-instance sweep
|
||||
// can be exercised without waiting out the grace window.
|
||||
const backdateInstance = (id: number, secondsAgo = EMPTY_INSTANCE_GRACE_SECONDS + 60) =>
|
||||
env.DB.prepare(
|
||||
"UPDATE room_instance SET data = json_set(data, '$.createdAt', ?2) WHERE id = ?1"
|
||||
)
|
||||
.bind(id, new Date(Date.now() - secondsAgo * 1000).toISOString())
|
||||
.run()
|
||||
|
||||
const expirePresence = (accountId: number) =>
|
||||
env.DB.prepare(
|
||||
"UPDATE presence SET data = json_set(data, '$.expiresAt', ?2) WHERE account_id = ?1"
|
||||
)
|
||||
.bind(accountId, nowSeconds() - 10)
|
||||
.run()
|
||||
|
||||
test('the cron sweep deletes instances nobody is left standing in', async () => {
|
||||
// Two instances built directly rather than by matchmaking, so neither is one a
|
||||
// previous test's player is still standing in (public matchmakes reuse instances).
|
||||
// One holds a player who crashed out — an expired row the sweep purges first,
|
||||
// leaving the instance empty — the other a live player.
|
||||
const abandoned = await createRoomInstance(env.DB, {
|
||||
ownerAccountId: 830,
|
||||
roomId: 2,
|
||||
photonRoomId: 'abandoned-instance',
|
||||
maxCapacity: 12,
|
||||
})
|
||||
await seedPresenceInInstance(830, abandoned.roomInstanceId, nowSeconds() - 10)
|
||||
const occupied = await createRoomInstance(env.DB, {
|
||||
ownerAccountId: 831,
|
||||
roomId: 2,
|
||||
photonRoomId: 'occupied-instance',
|
||||
maxCapacity: 12,
|
||||
})
|
||||
await seedPresenceInInstance(831, occupied.roomInstanceId, nowSeconds() + 800)
|
||||
await backdateInstance(abandoned.roomInstanceId)
|
||||
await backdateInstance(occupied.roomInstanceId)
|
||||
|
||||
const ctx = createExecutionContext()
|
||||
await scheduled(createScheduledController(), env, ctx)
|
||||
await waitOnExecutionContext(ctx)
|
||||
|
||||
expect(await getRoomInstance(env.DB, abandoned.roomInstanceId)).toBeNull()
|
||||
expect(await getRoomInstance(env.DB, occupied.roomInstanceId)).not.toBeNull()
|
||||
})
|
||||
|
||||
test('the cron sweep spares a freshly created instance nobody has joined yet', async () => {
|
||||
// The instance and its creator's presence are written by the same request but not
|
||||
// atomically — a sweep landing in between must not delete the instance the player
|
||||
// is being handed. `createdAt` is left alone, so it's inside the grace window.
|
||||
const fresh = await createRoomInstance(env.DB, {
|
||||
ownerAccountId: 832,
|
||||
roomId: 2,
|
||||
photonRoomId: 'fresh-instance',
|
||||
maxCapacity: 12,
|
||||
})
|
||||
|
||||
const ctx = createExecutionContext()
|
||||
await scheduled(createScheduledController(), env, ctx)
|
||||
await waitOnExecutionContext(ctx)
|
||||
|
||||
expect(await getRoomInstance(env.DB, fresh.roomInstanceId)).not.toBeNull()
|
||||
})
|
||||
|
||||
test('the cron sweep spares an empty dorm instance', async () => {
|
||||
// A dorm is backed by one persistent instance so its Photon room id survives
|
||||
// re-entry — it sits empty whenever the owner is anywhere else.
|
||||
const headers = await bearer('833')
|
||||
const dorm = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
|
||||
).json()) as { roomInstance: { roomInstanceId: number } }
|
||||
const dormInstanceId = dorm.roomInstance.roomInstanceId
|
||||
await expirePresence(833)
|
||||
await backdateInstance(dormInstanceId)
|
||||
|
||||
const ctx = createExecutionContext()
|
||||
await scheduled(createScheduledController(), env, ctx)
|
||||
await waitOnExecutionContext(ctx)
|
||||
|
||||
expect(await getRoomInstance(env.DB, dormInstanceId)).not.toBeNull()
|
||||
})
|
||||
|
||||
test('player/login and exclusivelogin preserve presence', async () => {
|
||||
const headers = await bearer('9')
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, { method: 'POST', headers })
|
||||
@@ -984,10 +1449,32 @@ describe('auth-gated endpoints', () => {
|
||||
headers: await bearer('42'),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const instances = (await res.json()) as Array<{ roomId: number; roomInstanceId: number }>
|
||||
const instances = (await res.json()) as Array<{
|
||||
roomInstanceId: number
|
||||
roomId: number
|
||||
subRoomId: number
|
||||
isFull: boolean
|
||||
createdAt: string
|
||||
playerIds: number[]
|
||||
}>
|
||||
expect(instances.length).toBeGreaterThanOrEqual(1)
|
||||
expect(instances.every((i) => i.roomId === 3)).toBe(true)
|
||||
|
||||
// The summary projection: id/subroom/fullness/createdAt plus who's in there —
|
||||
// and none of the client DTO's connection fields.
|
||||
const instance = instances.find((i) => i.playerIds.includes(42))
|
||||
expect(instance).toBeDefined()
|
||||
expect(Object.keys(instance!).sort()).toEqual([
|
||||
'createdAt',
|
||||
'isFull',
|
||||
'playerIds',
|
||||
'roomId',
|
||||
'roomInstanceId',
|
||||
'subRoomId',
|
||||
])
|
||||
expect(instance!.isFull).toBe(false)
|
||||
expect(Number.isNaN(Date.parse(instance!.createdAt))).toBe(false)
|
||||
|
||||
// The co-owner (account 43, Role 30) may view the instances too.
|
||||
const coOwner = await exports.default.fetch(`${ORIGIN}/room/3/instances`, {
|
||||
headers: await bearer('43'),
|
||||
@@ -996,6 +1483,144 @@ describe('auth-gated endpoints', () => {
|
||||
expect((await coOwner.json()) as unknown[]).toHaveLength(instances.length)
|
||||
})
|
||||
|
||||
test('POST /matchmake/instance/:id joins that exact instance, owner-only', async () => {
|
||||
// A player with no role on room 3 spins up an instance of it, which the room's
|
||||
// owner should then be able to drop into by id.
|
||||
const spawn = await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('43'),
|
||||
})
|
||||
const spawned = (await spawn.json()) as {
|
||||
roomInstance: { roomInstanceId: number; photonRoomId: string }
|
||||
}
|
||||
const instanceId = spawned.roomInstance.roomInstanceId
|
||||
|
||||
// No token → 401.
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
|
||||
// Authed but not the room's owner or co-owner → the opaque NoSuchRoom refusal,
|
||||
// so instance ids can't be probed for live private sessions.
|
||||
const stranger = await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('999'),
|
||||
})
|
||||
expect(stranger.status).toBe(200)
|
||||
expect(await stranger.json()).toEqual({ errorCode: 20, roomInstance: null })
|
||||
|
||||
// Unknown instance → same refusal.
|
||||
const unknown = await exports.default.fetch(`${ORIGIN}/matchmake/instance/9999999`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('42'),
|
||||
})
|
||||
expect(await unknown.json()).toEqual({ errorCode: 20, roomInstance: null })
|
||||
|
||||
// Park the owner somewhere else first, so this is a real transition.
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('42'),
|
||||
})
|
||||
|
||||
// The owner lands in that exact instance — same id AND same Photon room as the
|
||||
// player already in it, which is what makes it the same session.
|
||||
const joined = await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('42'),
|
||||
})
|
||||
expect(joined.status).toBe(200)
|
||||
const body = (await joined.json()) as {
|
||||
errorCode: number
|
||||
roomInstance: { roomInstanceId: number; photonRoomId: string; roomId: number }
|
||||
}
|
||||
expect(body.errorCode).toBe(0)
|
||||
expect(body.roomInstance.roomInstanceId).toBe(instanceId)
|
||||
expect(body.roomInstance.photonRoomId).toBe(spawned.roomInstance.photonRoomId)
|
||||
expect(body.roomInstance.roomId).toBe(3)
|
||||
|
||||
// It's now the owner's presence, and the listing shows both of them in there.
|
||||
const listed = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/room/3/instances`, { headers: await bearer('42') })
|
||||
).json()) as Array<{ roomInstanceId: number; playerIds: number[] }>
|
||||
const target = listed.find((i) => i.roomInstanceId === instanceId)
|
||||
expect(target?.playerIds).toEqual([42, 43])
|
||||
})
|
||||
|
||||
test('POST /roominstance/:id/markprivate closes the instance, owner-only', async () => {
|
||||
// Room 77 subroom 34 — its own instance, so marking it private can't affect the
|
||||
// instances the other tests matchmake into.
|
||||
const spawn = await exports.default.fetch(`${ORIGIN}/matchmake/room/77/34`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('42'),
|
||||
})
|
||||
const { roomInstance } = (await spawn.json()) as { roomInstance: { roomInstanceId: number } }
|
||||
const instanceId = roomInstance.roomInstanceId
|
||||
|
||||
// No token → 401.
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/roominstance/${instanceId}/markprivate`, {
|
||||
method: 'POST',
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
|
||||
// Unknown instance → 404.
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/roominstance/9999999/markprivate`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('42'),
|
||||
})
|
||||
).status
|
||||
).toBe(404)
|
||||
|
||||
// Room 77 has no creator and no roles, so nobody manages it → 403 even for 42.
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/roominstance/${instanceId}/markprivate`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('42'),
|
||||
})
|
||||
).status
|
||||
).toBe(403)
|
||||
|
||||
// Room 3 is account 42's, so its instances are theirs to close.
|
||||
const owned = await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('43'),
|
||||
})
|
||||
const ownedId = ((await owned.json()) as { roomInstance: { roomInstanceId: number } })
|
||||
.roomInstance.roomInstanceId
|
||||
const marked = await exports.default.fetch(`${ORIGIN}/roominstance/${ownedId}/markprivate`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('42'),
|
||||
})
|
||||
expect(marked.status).toBe(200)
|
||||
expect(await marked.text()).toBe('')
|
||||
|
||||
// Closed to strangers: a public matchmake into room 3 no longer reuses it, so a
|
||||
// new player lands in a different instance.
|
||||
const after = await exports.default.fetch(`${ORIGIN}/matchmake/room/3`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('999'),
|
||||
})
|
||||
const afterId = ((await after.json()) as { roomInstance: { roomInstanceId: number } })
|
||||
.roomInstance.roomInstanceId
|
||||
expect(afterId).not.toBe(ownedId)
|
||||
|
||||
// The player already inside is untouched — this shuts the door, it doesn't clear
|
||||
// the room.
|
||||
const listed = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/room/3/instances`, { headers: await bearer('42') })
|
||||
).json()) as Array<{ roomInstanceId: number; playerIds: number[] }>
|
||||
expect(listed.find((i) => i.roomInstanceId === ownedId)?.playerIds).toContain(43)
|
||||
})
|
||||
|
||||
test('POST /invite pushes a game-invite MessageReceived to the target', async () => {
|
||||
// The notify DO is stubbed to record every notifyPlayer call (see vitest.config).
|
||||
type Sent = {
|
||||
@@ -1241,6 +1866,71 @@ describe('auth-gated endpoints', () => {
|
||||
|
||||
// No token → 401.
|
||||
expect((await follow(9801)).status).toBe(401)
|
||||
|
||||
// A ban on the room blocks the follow too: this path hands out a Photon room id
|
||||
// without going through resolveRoomInstance, so it carries its own ban check —
|
||||
// otherwise following a friend in would be a way around a ban.
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at)
|
||||
VALUES (2, 9800, 0, 1, '2026-01-01T00:00:00.000Z')`
|
||||
).run()
|
||||
try {
|
||||
expect(await (await follow(9801, '9800')).json()).toEqual({
|
||||
errorCode: 55,
|
||||
roomInstance: null,
|
||||
})
|
||||
} finally {
|
||||
await env.DB.prepare(
|
||||
'DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9800'
|
||||
).run()
|
||||
}
|
||||
})
|
||||
|
||||
test('POST /matchmake/room/:roomId refuses a player banned from the room', async () => {
|
||||
const matchmake = async (sub: string) =>
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(sub),
|
||||
})
|
||||
).json()) as { errorCode: number; roomInstance: { roomInstanceId: number } | null }
|
||||
|
||||
// Not banned yet → a normal join.
|
||||
expect((await matchmake('9700')).errorCode).toBe(0)
|
||||
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO room_ban (room_id, banned_player_id, ban_mask, banned_by_account_id, created_at)
|
||||
VALUES (2, 9701, 0, 1, '2026-01-01T00:00:00.000Z')`
|
||||
).run()
|
||||
|
||||
// The ban is the whole enforcement: no instance means no Photon room id, so there
|
||||
// is nothing for the banned player to join. errorCode 55 rather than the opaque
|
||||
// NoSuchRoom every other refusal answers — a banned player already knows the room
|
||||
// exists, so the client can say why. Applies to the subroom path as well.
|
||||
expect(await matchmake('9701')).toEqual({ errorCode: 55, roomInstance: null })
|
||||
const sub = await exports.default.fetch(`${ORIGIN}/matchmake/room/2/2`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('9701'),
|
||||
})
|
||||
expect(await sub.json()).toEqual({ errorCode: 55, roomInstance: null })
|
||||
|
||||
// Refused before any instance is created, and no presence was recorded for them.
|
||||
expect(
|
||||
await env.DB.prepare('SELECT 1 AS hit FROM presence WHERE account_id = 9701').first()
|
||||
).toBeNull()
|
||||
|
||||
// The ban is per-room — another room is unaffected.
|
||||
const other = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/room/77`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('9701'),
|
||||
})
|
||||
).json()) as { errorCode: number }
|
||||
expect(other.errorCode).toBe(0)
|
||||
|
||||
// Lifting the ban lets them in again.
|
||||
await env.DB.prepare('DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9701').run()
|
||||
expect((await matchmake('9701')).errorCode).toBe(0)
|
||||
})
|
||||
|
||||
test('POST /matchmake/room/:id invites AdditionalPlayerIds (party) into the instance', async () => {
|
||||
@@ -1328,12 +2018,15 @@ describe('auth-gated endpoints', () => {
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'GET /player',
|
||||
'GET /player/avoidjuniors',
|
||||
'GET /room/{roomId}/instances',
|
||||
'GET /rooms/requiring/developer',
|
||||
'GET /rooms/requiring/rrplus',
|
||||
'POST /invite',
|
||||
'POST /matchmake/club/{clubId}',
|
||||
'POST /matchmake/dorm',
|
||||
'POST /matchmake/event/{eventId}',
|
||||
'POST /matchmake/instance/{instanceId}',
|
||||
'POST /matchmake/player/{playerId}',
|
||||
'POST /matchmake/room/{roomId}',
|
||||
'POST /matchmake/room/{roomId}/{subRoomId}',
|
||||
@@ -1342,7 +2035,9 @@ describe('auth-gated endpoints', () => {
|
||||
'POST /player/login',
|
||||
'POST /player/logout',
|
||||
'POST /player/notifydisconnect',
|
||||
'POST /roominstance/{id}/markprivate',
|
||||
'POST /roominstance/{id}/reportjoinresult',
|
||||
'PUT /player/avoidjuniors',
|
||||
'PUT /player/gameserverregionpings',
|
||||
'PUT /player/photonregionpings',
|
||||
'PUT /player/statusvisibility',
|
||||
@@ -1356,3 +2051,191 @@ describe('auth-gated endpoints', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// An ACCOUNT ban (a `report` row with `banned` set, owned by the api worker) is not
|
||||
// about any one room, so it is enforced across every matchmake rather than per route —
|
||||
// see the /matchmake/* gate in match.app.ts. It answers the same BannedFromRoom (55) the
|
||||
// per-room bans do, which is the code the client renders as "you are banned".
|
||||
describe('account bans', () => {
|
||||
const matchmake = async (path: string, player: string) =>
|
||||
exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(player),
|
||||
})
|
||||
|
||||
test('every matchmake route is refused for a banned account', async () => {
|
||||
await banAccount(6001)
|
||||
// One live instance of room 2 and one club membership, so each route would
|
||||
// otherwise have somewhere to put them.
|
||||
for (const path of [
|
||||
'/matchmake/room/2',
|
||||
'/matchmake/room/77/34',
|
||||
'/matchmake/dorm',
|
||||
'/matchmake/club/4',
|
||||
'/matchmake/player/9701',
|
||||
'/matchmake/instance/1',
|
||||
]) {
|
||||
const res = await matchmake(path, '6001')
|
||||
expect(res.status, path).toBe(200)
|
||||
expect(await res.json(), path).toEqual({ errorCode: 55, roomInstance: null })
|
||||
}
|
||||
})
|
||||
|
||||
// The refusal is the ban's, not the room's: nothing is entered, so no presence is
|
||||
// written and the player stays where they were (nowhere).
|
||||
test('a refused matchmake leaves no presence behind', async () => {
|
||||
await banAccount(6002)
|
||||
expect((await matchmake('/matchmake/room/2', '6002')).status).toBe(200)
|
||||
|
||||
const player = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/player?id=6002`, { headers: await bearer('6002') })
|
||||
).json()) as Array<{ isOnline: boolean; roomInstance: unknown }>
|
||||
expect(player[0]?.roomInstance ?? null).toBeNull()
|
||||
})
|
||||
|
||||
// A timed ban lifts itself once its expiry passes — nothing clears the flag.
|
||||
test('an expired ban no longer blocks a matchmake', async () => {
|
||||
await banAccount(6003, '2020-01-01T00:00:00.000Z')
|
||||
const res = await matchmake('/matchmake/room/2', '6003')
|
||||
const body = (await res.json()) as { errorCode: number; roomInstance: unknown }
|
||||
expect(body.errorCode).toBe(0)
|
||||
expect(body.roomInstance).not.toBeNull()
|
||||
})
|
||||
|
||||
test('a ban that has not expired yet blocks a matchmake', async () => {
|
||||
await banAccount(6004, new Date(Date.now() + 3_600_000).toISOString())
|
||||
expect(await (await matchmake('/matchmake/room/2', '6004')).json()).toEqual({
|
||||
errorCode: 55,
|
||||
roomInstance: null,
|
||||
})
|
||||
})
|
||||
|
||||
// A report on its own is not a ban — only a moderator converting it is.
|
||||
test('an unbanned report does not block a matchmake', async () => {
|
||||
await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: 6005 })
|
||||
const body = (await (await matchmake('/matchmake/room/2', '6005')).json()) as {
|
||||
errorCode: number
|
||||
}
|
||||
expect(body.errorCode).toBe(0)
|
||||
})
|
||||
|
||||
// Filing the report doesn't touch the reporter, so they still play.
|
||||
test('the reporter is not banned by the report they filed', async () => {
|
||||
await banAccount(6006)
|
||||
const body = (await (await matchmake('/matchmake/room/2', '1')).json()) as { errorCode: number }
|
||||
expect(body.errorCode).toBe(0)
|
||||
})
|
||||
|
||||
// The gate must not turn a missing token into "banned" — that's still a 401.
|
||||
test('an unauthenticated matchmake is still a 401', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, { method: 'POST' })
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
// Only the matchmakes are gated: presence and the rest of the surface keep working,
|
||||
// so a banned player's client isn't left hammering a dead heartbeat.
|
||||
test('the gate does not touch non-matchmake routes', async () => {
|
||||
await banAccount(6007)
|
||||
const res = await exports.default.fetch(`${ORIGIN}/player/heartbeat`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('6007'),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
// The ban follows the player past the account it was written on: a new account sharing a
|
||||
// proven platform identity or an IP with a banned one is refused the same way. See
|
||||
// bans-db.ts in the api worker for the arms and the BAN_EVASION_MATCH knob.
|
||||
describe('ban evasion at matchmake', () => {
|
||||
const matchmake = async (player: string, ip?: string) =>
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/matchmake/room/2`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(player)), ...(ip ? { 'CF-Connecting-IP': ip } : {}) },
|
||||
})
|
||||
).json()) as { errorCode: number; roomInstance: unknown }
|
||||
|
||||
/** Seed an account row carrying the IPs it signed up / last logged in from. */
|
||||
const account = async (id: number, ips: Record<string, string> = {}) => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: id, username: `Player${id}`, ...ips }))
|
||||
.run()
|
||||
}
|
||||
|
||||
const link = async (id: number, platform: number, platformId: string) => {
|
||||
await env.DB.prepare(
|
||||
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
|
||||
VALUES (?1, ?2, ?3, ?4)`
|
||||
)
|
||||
.bind(id, platform, platformId, new Date().toISOString())
|
||||
.run()
|
||||
}
|
||||
|
||||
test('a new account sharing a banned account’s platform identity is refused', async () => {
|
||||
await account(6201)
|
||||
await link(6201, 0, 'steam-evader')
|
||||
await banAccount(6201)
|
||||
// The replacement account: different id, same headset.
|
||||
await account(6202)
|
||||
await link(6202, 0, 'steam-evader')
|
||||
|
||||
expect(await matchmake('6202')).toEqual({ errorCode: 55, roomInstance: null })
|
||||
})
|
||||
|
||||
test('a new account sharing a banned account’s signup IP is refused', async () => {
|
||||
await account(6203, { signupIp: '203.0.113.203' })
|
||||
await banAccount(6203)
|
||||
await account(6204, { signupIp: '203.0.113.203' })
|
||||
|
||||
expect(await matchmake('6204')).toEqual({ errorCode: 55, roomInstance: null })
|
||||
})
|
||||
|
||||
// The address the request arrives from counts too, so an account that has never
|
||||
// logged in from the banned network before is caught on the first matchmake.
|
||||
test('the request’s own IP is matched even when the account has none stored', async () => {
|
||||
await account(6205, { signupIp: '203.0.113.205' })
|
||||
await banAccount(6205)
|
||||
await account(6206)
|
||||
|
||||
expect(await matchmake('6206', '203.0.113.205')).toEqual({ errorCode: 55, roomInstance: null })
|
||||
// From anywhere else, that same account plays.
|
||||
expect((await matchmake('6206', '198.51.100.50')).errorCode).toBe(0)
|
||||
})
|
||||
|
||||
test('an unrelated account is unaffected', async () => {
|
||||
await account(6207, { signupIp: '203.0.113.207' })
|
||||
await banAccount(6207)
|
||||
await account(6208, { signupIp: '198.51.100.208' })
|
||||
await link(6208, 0, 'steam-innocent')
|
||||
|
||||
expect((await matchmake('6208')).errorCode).toBe(0)
|
||||
})
|
||||
|
||||
// BAN_EVASION_MATCH is the operator's answer to the IP arm's false positives: the
|
||||
// housemate of a banned player gets back in, the evader on the same headset does not.
|
||||
test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the direct ban', async () => {
|
||||
const original = env.BAN_EVASION_MATCH
|
||||
await account(6210, { signupIp: '203.0.113.210' })
|
||||
await link(6210, 0, 'steam-knob')
|
||||
await banAccount(6210)
|
||||
await account(6211, { signupIp: '203.0.113.210' }) // housemate
|
||||
await account(6212)
|
||||
await link(6212, 0, 'steam-knob') // same headset
|
||||
|
||||
try {
|
||||
env.BAN_EVASION_MATCH = 'platform'
|
||||
expect((await matchmake('6211')).errorCode).toBe(0)
|
||||
expect(await matchmake('6212')).toEqual({ errorCode: 55, roomInstance: null })
|
||||
// The banned account itself is still refused, whatever the knob says.
|
||||
expect(await matchmake('6210')).toEqual({ errorCode: 55, roomInstance: null })
|
||||
|
||||
env.BAN_EVASION_MATCH = 'off'
|
||||
expect((await matchmake('6211')).errorCode).toBe(0)
|
||||
expect((await matchmake('6212')).errorCode).toBe(0)
|
||||
expect(await matchmake('6210')).toEqual({ errorCode: 55, roomInstance: null })
|
||||
} finally {
|
||||
env.BAN_EVASION_MATCH = original
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+4
-2
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable */
|
||||
// Runtime types generated with workerd@1.20260625.1 2025-09-20 nodejs_compat
|
||||
// Runtime types generated with workerd@1.20260625.1 2026-06-16 nodejs_compat
|
||||
// Begin runtime types
|
||||
/*! *****************************************************************************
|
||||
Copyright (c) Cloudflare. All rights reserved.
|
||||
@@ -420,6 +420,7 @@ interface TestController {
|
||||
interface ExecutionContext<Props = unknown> {
|
||||
waitUntil(promise: Promise<any>): void;
|
||||
passThroughOnException(): void;
|
||||
readonly exports: Cloudflare.Exports;
|
||||
readonly props: Props;
|
||||
cache?: CacheContext;
|
||||
readonly access?: CloudflareAccessContext;
|
||||
@@ -526,6 +527,7 @@ interface DurableObjectClass<_T extends Rpc.DurableObjectBranded | undefined = u
|
||||
}
|
||||
interface DurableObjectState<Props = unknown> {
|
||||
waitUntil(promise: Promise<any>): void;
|
||||
readonly exports: Cloudflare.Exports;
|
||||
readonly props: Props;
|
||||
readonly id: DurableObjectId;
|
||||
readonly storage: DurableObjectStorage;
|
||||
@@ -1643,7 +1645,7 @@ declare class Headers {
|
||||
value: string
|
||||
]>;
|
||||
}
|
||||
type BodyInit = ReadableStream<Uint8Array> | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData;
|
||||
type BodyInit = ReadableStream<Uint8Array> | string | ArrayBuffer | ArrayBufferView | Blob | URLSearchParams | FormData | Iterable<ArrayBuffer | ArrayBufferView> | AsyncIterable<ArrayBuffer | ArrayBufferView>;
|
||||
declare abstract class Body {
|
||||
/* [MDN Reference](https://developer.mozilla.org/docs/Web/API/Request/body) */
|
||||
get body(): ReadableStream | null;
|
||||
|
||||
@@ -15,10 +15,20 @@
|
||||
"database_id": "local"
|
||||
}
|
||||
],
|
||||
// Per-player settings KV, owned by the `playersettings` worker. Read-only here, for
|
||||
// GET /player/avoidjuniors. The "local" id placeholder is replaced with the real id
|
||||
// from RECFLARE_KV at deploy time.
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "RECFLARE_PLAYER_SETTINGS",
|
||||
"id": "local"
|
||||
}
|
||||
],
|
||||
// Presence sweep. Rows expire on their own TTL (15m) and reads already ignore
|
||||
// expired ones, so this is housekeeping: it purges them and recomputes the
|
||||
// fullness of the instances the departed players were in (a crashed player never
|
||||
// matchmakes out, so nothing else notices they left). Every 5 minutes.
|
||||
// expired ones, so this is housekeeping: it purges them, deletes the room
|
||||
// instances left with nobody in them, and recomputes the fullness of the
|
||||
// instances the departed players were in (a crashed player never matchmakes out,
|
||||
// so nothing else notices they left). Every 5 minutes.
|
||||
"triggers": {
|
||||
"crons": ["*/5 * * * *"]
|
||||
},
|
||||
@@ -51,6 +61,10 @@
|
||||
"head_sampling_rate": 1 // 100%
|
||||
}
|
||||
},
|
||||
// The room substitutions (ROOM_REDIRECTS) are deliberately NOT set here. They're
|
||||
// injected at deploy time from the gitignored .env (RECFLARE_ROOM_REDIRECTS, see
|
||||
// .env.example), so swapping a room out never means editing a versioned file. Unset —
|
||||
// the default — means every matchmake enters the room it asked for.
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
"SENTRY_RELEASE": "unknown" // overridden during deployment
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"check:lint": "run-oxlint",
|
||||
"check:types": "run-tsc",
|
||||
"check:workers-types": "run-wrangler-types --check",
|
||||
"deploy:mono": "run-wrangler-deploy",
|
||||
"dev": "run-wrangler-dev",
|
||||
"fix:workers-types": "run-wrangler-types",
|
||||
"test": "run-vitest"
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
||||
// Type-only import (erased at build) of the DO class this worker re-exports from its
|
||||
// entry. The parameter has to be here, not just on `match`'s Env: `scheduled` hands this
|
||||
// worker's superset Env straight to `matchScheduled`, and a bare `DurableObjectNamespace`
|
||||
// is not assignable to the `DurableObjectNamespace<NotificationsHub>` that one declares.
|
||||
import type { NotificationsHub } from '../../notify/src/notifications-hub'
|
||||
|
||||
/**
|
||||
* Union of every mounted worker's bindings.
|
||||
@@ -10,6 +15,19 @@ import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/
|
||||
* each app's narrower `Env`, so the sub-apps type-check unchanged.
|
||||
*/
|
||||
export type Env = SharedHonoEnv & {
|
||||
/**
|
||||
* Base domain this worker answers on, e.g. `rec.example.com` — injected from
|
||||
* `RECFLARE_DOMAIN` by both `run-wrangler-dev` and `run-wrangler-deploy`, with a
|
||||
* placeholder default in `wrangler.jsonc` for tests and an unconfigured checkout.
|
||||
*
|
||||
* Read by the mounted `ns` app to build the service-discovery document — the thing a
|
||||
* client is pointed at — so it has to name the host that actually reaches this worker:
|
||||
* the tunnel/LAN hostname when running it locally, and the apex of the domain when
|
||||
* deployed (`RECFLARE_SUBDOMAINS='{"mono":"@"}'`, `just deploy-mono`). Every service
|
||||
* mounted here is served from a PATH on that one host, so the document says
|
||||
* `https://<domain>/rooms` and nothing else would answer there.
|
||||
*/
|
||||
DOMAIN: string
|
||||
// HS256 JWT signing key (shared Secrets Store). Tokens signed by `auth` verify everywhere.
|
||||
JWT_SECRET: SecretsStoreSecret
|
||||
// Meta (Oculus) app secret, from the same store. Read only by `auth`, to validate a
|
||||
@@ -25,7 +43,7 @@ export type Env = SharedHonoEnv & {
|
||||
RECFLARE_PLAYER_SETTINGS: KVNamespace
|
||||
// Real-time notifications hub. The class is defined in `notify` and re-exported by
|
||||
// this worker's entry so the binding resolves in-process (no `script_name`).
|
||||
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace
|
||||
RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace<NotificationsHub>
|
||||
}
|
||||
|
||||
export type Variables = SharedHonoVariables
|
||||
|
||||
+39
-13
@@ -3,22 +3,31 @@
|
||||
*
|
||||
* Mounts each RecFlare worker inside a single deployable Worker WITHOUT modifying the
|
||||
* originals: every app is imported by relative path and bundled by esbuild at build
|
||||
* time. Production routing mirrors the split deployment — requests are dispatched on
|
||||
* the request's subdomain (`accounts.<domain>` -> the `accounts` app), so the sub-app
|
||||
* paths (and therefore the client contract) are untouched.
|
||||
* time. A request selects its service two ways, and the sub-app paths (and therefore the
|
||||
* client contract) are untouched either way.
|
||||
*
|
||||
* Local dev has no subdomain, so the first path segment selects the service and is
|
||||
* stripped before the request is forwarded, e.g.
|
||||
* http://localhost:8787/accounts/ -> accounts app sees /
|
||||
* http://localhost:8787/match/player/login -> match app sees /player/login
|
||||
* http://localhost:8787/api/api/config/v2 -> api app sees /api/config/v2
|
||||
* By PATH — how this worker is meant to be deployed, at the apex of `DOMAIN`, and the
|
||||
* only way that works in local dev, which has no subdomain. The first path segment names
|
||||
* the service and is stripped before the request is forwarded, e.g.
|
||||
* https://<domain>/accounts/ -> accounts app sees /
|
||||
* https://<domain>/match/player/login -> match app sees /player/login
|
||||
* https://<domain>/api/api/config/v2 -> api app sees /api/config/v2
|
||||
*
|
||||
* By SUBDOMAIN — `accounts.<domain>` -> the `accounts` app, with the path forwarded
|
||||
* unchanged. That mirrors the split deployment, so a client (or a stray DNS record) still
|
||||
* pointed at the per-service hosts keeps working if they're routed here.
|
||||
*
|
||||
* A request with no path (just `/`) that selects no service serves the `ns` discovery
|
||||
* document, so a bare hit to the facade root returns the service map to bootstrap from.
|
||||
* The document is built in the PATH style (`https://<domain>/rooms`, every service on
|
||||
* this one host) — see ENDPOINT_STYLE below — so deploy this worker at the apex of
|
||||
* `DOMAIN` and point the client at nothing else.
|
||||
*
|
||||
* NOT mounted here: `www`, `img`, `econ`. Each binds a static `assets` directory and
|
||||
* Cloudflare allows only one static-assets binding per Worker. Resolve that (serve
|
||||
* their static trees from R2, or keep those three as their own Workers) before adding.
|
||||
* The discovery document still puts them on this host, since a single-service run is the
|
||||
* whole point of this worker — so until they're mounted, their paths 404 here.
|
||||
*/
|
||||
import accounts from '../../accounts/src/accounts.app'
|
||||
import api from '../../api/src/api.app'
|
||||
@@ -67,16 +76,24 @@ const services = {
|
||||
|
||||
type ServiceName = keyof typeof services
|
||||
|
||||
/**
|
||||
* This worker is one host, so its discovery document has to name one host: every service
|
||||
* is advertised as `https://<domain>/<name>`, never `https://<name>.<domain>`. Handed to
|
||||
* the mounted `ns` app, which defaults to the per-host document the split deployment wants.
|
||||
*/
|
||||
const ENDPOINT_STYLE = 'path'
|
||||
|
||||
function resolve(request: Request): { name: ServiceName; request: Request } | undefined {
|
||||
const url = new URL(request.url)
|
||||
|
||||
// Production: dispatch on the leftmost DNS label — accounts.<domain> -> accounts.
|
||||
// The path is forwarded unchanged so the client contract is identical.
|
||||
// Dispatch on the leftmost DNS label — accounts.<domain> -> accounts. The path is
|
||||
// forwarded unchanged so the client contract is identical to the split deployment.
|
||||
const sub = url.hostname.split('.')[0]
|
||||
if (sub in services) return { name: sub as ServiceName, request }
|
||||
|
||||
// Local dev (no service subdomain): the first path segment selects the service and
|
||||
// is stripped before forwarding — /match/player/login -> match app sees /player/login.
|
||||
// Apex (and local dev): the first path segment selects the service and is stripped
|
||||
// before forwarding — /match/player/login -> match app sees /player/login. This is
|
||||
// what the discovery document advertises; see ENDPOINT_STYLE.
|
||||
const [, first, ...rest] = url.pathname.split('/')
|
||||
if (first !== undefined && first in services) {
|
||||
url.pathname = `/${rest.join('/')}`
|
||||
@@ -103,11 +120,20 @@ export default {
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
// `ns` is the one mounted app whose answer depends on this worker's own shape: the
|
||||
// addresses it hands out have to be paths on this host. Passed as a var — the same
|
||||
// way a deploy would — so the app itself stays free of any knowledge of mono.
|
||||
if (resolved.name === 'ns') return ns.fetch(resolved.request, { ...env, ENDPOINT_STYLE }, ctx)
|
||||
|
||||
return services[resolved.name].fetch(resolved.request, env, ctx)
|
||||
},
|
||||
|
||||
// Only `match` runs a cron in the split deployment; this worker owns its presence sweep.
|
||||
scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> | void {
|
||||
scheduled(
|
||||
controller: ScheduledController,
|
||||
env: Env,
|
||||
ctx: ExecutionContext
|
||||
): Promise<void> | void {
|
||||
return matchScheduled(controller, env, ctx)
|
||||
},
|
||||
} satisfies ExportedHandler<Env>
|
||||
|
||||
@@ -9,6 +9,9 @@ declare module 'cloudflare:test' {
|
||||
|
||||
const ORIGIN = 'https://example.com'
|
||||
|
||||
// Must match the DOMAIN var default in apps/mono/wrangler.jsonc.
|
||||
const TEST_DOMAIN = 'rec.example.com'
|
||||
|
||||
// The facade's job is routing, not business logic, so one request that reaches a
|
||||
// mounted app through the path prefix is enough to prove the wiring. `api` serves a
|
||||
// static game-config with no auth/DB, so it's a clean target. The api worker namespaces
|
||||
@@ -24,8 +27,22 @@ describe('mono routing', () => {
|
||||
test('root path (no service, no prefix) serves the ns discovery document', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/`)
|
||||
expect(res.status).toBe(200)
|
||||
// The ns worker serves the service-discovery document.
|
||||
expect(await res.json()).toHaveProperty('Auth')
|
||||
// The ns worker serves the service-discovery document. This worker is one host, so
|
||||
// every service in it is a path on the base domain (the DOMAIN var default in
|
||||
// wrangler.jsonc) — no per-service subdomains anywhere in the document.
|
||||
const doc = (await res.json()) as Record<string, string>
|
||||
expect(doc).toMatchObject({
|
||||
Auth: `https://${TEST_DOMAIN}/auth`,
|
||||
Rooms: `https://${TEST_DOMAIN}/rooms`,
|
||||
Matchmaking: `https://${TEST_DOMAIN}/match`,
|
||||
})
|
||||
expect(Object.values(doc).every((url) => url.startsWith(`https://${TEST_DOMAIN}/`))).toBe(true)
|
||||
})
|
||||
|
||||
test('the ns service prefix serves that same document', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/ns/`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toMatchObject({ Rooms: `https://${TEST_DOMAIN}/rooms` })
|
||||
})
|
||||
|
||||
test('unknown service prefix returns the facade 404', async () => {
|
||||
|
||||
@@ -2,6 +2,13 @@ import { cloudflareTest } from '@cloudflare/vitest-pool-workers'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
// The facade bundles all 13 worker apps (see mono.app.ts), so the first request in
|
||||
// a run pays a cold start for the lot of it — ~3.4s even on an idle machine. The
|
||||
// 5s default leaves no headroom for that, and the whole-monorepo run puts 21
|
||||
// projects on the CPU at once, which pushed these tests into flaky timeouts.
|
||||
testTimeout: 30_000,
|
||||
},
|
||||
plugins: [
|
||||
cloudflareTest({
|
||||
wrangler: { configPath: `${__dirname}/wrangler.jsonc` },
|
||||
|
||||
@@ -76,6 +76,11 @@
|
||||
"vars": {
|
||||
"NAME": "mono", // logging tag; split workers derive this per-app
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
"SENTRY_RELEASE": "unknown" // overridden during deployment
|
||||
"SENTRY_RELEASE": "unknown", // overridden during deployment
|
||||
// Base domain the discovery document is built from; replaced with RECFLARE_DOMAIN by
|
||||
// both `just dev` and `just deploy-mono`. It must name the host that actually reaches
|
||||
// this worker, which serves every service it mounts from a path on that ONE host — so
|
||||
// deployed, it belongs on the APEX of that domain (see src/context.ts).
|
||||
"DOMAIN": "rec.example.com"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
/**
|
||||
* The `Msg` bodies the client expects behind each {@link NotificationType}.
|
||||
*
|
||||
* Recovered from the client's Utf8Json generated formatters (see `recnet-patcher`,
|
||||
* `il2cpp-tools/dtoshape.py`), not from a spec. Three properties of that decoder decide how
|
||||
* much these matter:
|
||||
*
|
||||
* - **Every key is accepted in three casings** — Original, camelCase and all-lowercase. So
|
||||
* `RoomId`, `roomId` and `roomid` are the same field. The spelling used below is the
|
||||
* client's own canonical one.
|
||||
* - **Unknown members are dropped in silence.** A typo'd key behaves exactly like an omitted
|
||||
* one, which is why a wrong payload shows up as a blank UI rather than an error.
|
||||
* - **A missing nested object is worse than a missing scalar.** Several handlers dereference
|
||||
* one level down with no null guard, so omitting a nested field surfaces as a bare
|
||||
* `NullReferenceException` in the client. Send a stub rather than nothing.
|
||||
*
|
||||
* Field *names* below are verified. Where a payload's type mapping could not be pinned down
|
||||
* as confidently as its key list, the interface says so on the member.
|
||||
*/
|
||||
|
||||
/** How a balance came to change. Log/telemetry only on the purchase frame — see below. */
|
||||
export enum BalanceAddType {
|
||||
Invalid = 0,
|
||||
DirectBalanceWithMultiplier = 1,
|
||||
FromGiftBox = 2,
|
||||
NUXChallenge = 10,
|
||||
AllNUXChallenges = 11,
|
||||
DailyChallenge = 100,
|
||||
AllDailyChallenges = 101,
|
||||
FinishActivity = 200,
|
||||
RecRoyaleMatchFinished = 250,
|
||||
ChecklistCredit = 303,
|
||||
WonGame = 1000,
|
||||
LostGame = 1001,
|
||||
WonGameRateLimited = 1002,
|
||||
WonGamePartial = 1003,
|
||||
LevelUp = 1100,
|
||||
Registered = 1200,
|
||||
CreatorReward = 1300,
|
||||
CommercePurchase = 1400,
|
||||
CommercePurchaseRevoked = 1401,
|
||||
ManualRefund = 2000,
|
||||
ManualThanks = 2010,
|
||||
ManualApology = 2020,
|
||||
}
|
||||
|
||||
/**
|
||||
* Which store a balance belongs to. Note the **wire key is `Platform`, not `BalanceType`** —
|
||||
* the client's property is called `BalanceType` but carries a `[DataMember]` rename, so
|
||||
* `balanceType` on the wire is dropped and the balance silently reads as `SteamPurchased`.
|
||||
*
|
||||
* Balances are held per `(CurrencyType, Platform)` pair, so this also selects which bucket a
|
||||
* balance frame updates. `RecNetPurchased` is the one to use for a self-hosted store.
|
||||
*/
|
||||
export enum BalancePlatform {
|
||||
NonPurchasedNotUsableInP2P = -2,
|
||||
NonPurchasedDefault = -1,
|
||||
SteamPurchased = 0,
|
||||
OculusPurchased = 1,
|
||||
PlayStationPurchased = 2,
|
||||
MicrosoftPurchased = 3,
|
||||
RecNetPurchased = 4,
|
||||
IOSPurchased = 5,
|
||||
GooglePlayPurchased = 6,
|
||||
PicoPurchased = 8,
|
||||
PlayStationNonPurchasedP2P = 100,
|
||||
NonPlayStationNonPurchasedP2P = 101,
|
||||
NonPurchasedEarnedByP2P = 1000,
|
||||
}
|
||||
|
||||
export enum CurrencyType {
|
||||
Invalid = 0,
|
||||
LaserTagTickets = 1,
|
||||
RecCenterTokens = 2,
|
||||
LostSkullsGold = 100,
|
||||
DraculaSilver = 101,
|
||||
RecRoyaleSeason1 = 200,
|
||||
RoomCurrency = 300,
|
||||
ProgressionEvent = 400,
|
||||
}
|
||||
|
||||
/** Why a player was kicked/banned/warned. Shared by ModerationKick and ModerationUnkick. */
|
||||
export enum KickReportCategory {
|
||||
Moderator = -1,
|
||||
Unknown = 0,
|
||||
DeprecatedMicrophoneAbuse = 1,
|
||||
Harassment = 2,
|
||||
Cheating = 3,
|
||||
DeprecatedImmatureBehavior = 4,
|
||||
AFK = 5,
|
||||
Misc = 6,
|
||||
Underage = 7,
|
||||
VoteKick = 10,
|
||||
MisleadingPurchases = 11,
|
||||
CoCUnderage = 100,
|
||||
CoCSexual = 101,
|
||||
CoCDiscrimination = 102,
|
||||
CoCTrolling = 103,
|
||||
CoCNameOrProfile = 104,
|
||||
InappropriateClothing = 200,
|
||||
IssuingInaccurateReports = 1000,
|
||||
}
|
||||
|
||||
export enum LogoutReason {
|
||||
Unknown = 0,
|
||||
UserInitiated = 1,
|
||||
SessionTakeover = 2,
|
||||
ForciblyLoggedOut = 3,
|
||||
Banned = 4,
|
||||
}
|
||||
|
||||
/** The error the client renders when a `GoTo` fails. */
|
||||
export enum GoToFailureError {
|
||||
UnknownError = -1,
|
||||
Success = 0,
|
||||
NoSuchGame = 1,
|
||||
PlayerNotOnline = 2,
|
||||
InsufficientSpace = 3,
|
||||
EventNotStarted = 4,
|
||||
EventAlreadyFinished = 5,
|
||||
BlockedFromRoom = 7,
|
||||
JuniorNotAllowed = 11,
|
||||
Banned = 12,
|
||||
AlreadyInBestInstance = 13,
|
||||
InsufficientRelationship = 14,
|
||||
UpdateRequired = 16,
|
||||
AlreadyInTargetInstance = 17,
|
||||
UGCNotAllowed = 19,
|
||||
NoSuchRoom = 20,
|
||||
RoomIsNotActive = 22,
|
||||
RoomBlockedByCreator = 23,
|
||||
RoomIsPrivate = 25,
|
||||
RoomInstanceIsPrivate = 26,
|
||||
DeviceClassNotSupported = 30,
|
||||
DeviceClassNotSupportedByRoomOwner = 31,
|
||||
MovementModeNotSupportedByRoomOwner = 32,
|
||||
EventIsPrivate = 35,
|
||||
EventIsFull = 36,
|
||||
RoomInviteExpired = 40,
|
||||
NoAvailableRegion = 45,
|
||||
}
|
||||
|
||||
// ---- Payloads ------------------------------------------------------------------
|
||||
|
||||
/** `StorefrontBalancePurchase` (62). */
|
||||
export interface PurchaseBalanceModificationPayload {
|
||||
BalanceAddType: BalanceAddType
|
||||
/**
|
||||
* The change, **for display only** — the client does not apply it. Its handler logs a
|
||||
* line and then stores {@link Balance} outright, so a correct `Delta` with a stale
|
||||
* `Balance` leaves the player's balance wrong.
|
||||
*/
|
||||
Delta: number
|
||||
/** The post-transaction total. Absolute, and the only field that changes client state. */
|
||||
Balance: number
|
||||
Platform: BalancePlatform
|
||||
CurrencyType: CurrencyType
|
||||
}
|
||||
|
||||
/** `StorefrontBalanceUpdate` (61) — a bare set of one bucket to an absolute value. */
|
||||
export interface BalanceResponsePayload {
|
||||
Balance: number
|
||||
CurrencyType: CurrencyType
|
||||
Platform: BalancePlatform
|
||||
}
|
||||
|
||||
/** One element of the `StorefrontBalanceAdd` (60) batch. */
|
||||
export interface RewardBalanceModificationPayload {
|
||||
BalanceAddType: BalanceAddType
|
||||
BaseAward: number
|
||||
BonusAward: number
|
||||
RateLimit: number
|
||||
CurrentCount: number
|
||||
Total: number
|
||||
Platform: BalancePlatform
|
||||
BalanceInGiftBox: boolean
|
||||
}
|
||||
|
||||
/** `ConsumableMappingAdded` (70) / `ConsumableMappingRemoved` (71). */
|
||||
export interface ConsumableMappingPayload {
|
||||
Id: number
|
||||
ConsumableItemDesc: string
|
||||
Count: number
|
||||
InitialCount: number
|
||||
/** ISO-8601. */
|
||||
CreatedAt: string
|
||||
ActiveDurationMinutes: number | null
|
||||
IsActive: boolean
|
||||
IsTransferable: boolean
|
||||
}
|
||||
|
||||
/** `ModerationKick` (22) and `ModerationUnkick`. Room bans go out as this with `IsBan`. */
|
||||
export interface ModerationKickPayload {
|
||||
ReportCategory: KickReportCategory
|
||||
/** Seconds. */
|
||||
Duration: number
|
||||
GameSessionId: number
|
||||
IsHostKick: boolean
|
||||
Message: string
|
||||
PlayerIdReporter: number | null
|
||||
IsBan: boolean
|
||||
IsVoiceModAutoban: boolean
|
||||
IsWarning: boolean
|
||||
VoteKickReason: string
|
||||
/** ISO-8601. */
|
||||
TimeoutStartedAt: string | null
|
||||
}
|
||||
|
||||
/** `ModerationKickAttemptFailed` (23) — a vote-kick that didn't carry. */
|
||||
export interface ModerationKickFailedPayload {
|
||||
ReportCategory: KickReportCategory
|
||||
YesVotes: number
|
||||
NoVotes: number
|
||||
PlayerIdReported: number
|
||||
}
|
||||
|
||||
/** `ServerMaintenance` (25). */
|
||||
export interface ServerMaintenancePayload {
|
||||
StartsInMinutes: number
|
||||
}
|
||||
|
||||
/** `Logout` (6). */
|
||||
export interface LogoutPayload {
|
||||
Reason: LogoutReason
|
||||
}
|
||||
|
||||
/** `MessageDeleted` (3) — the id of the message to drop. */
|
||||
export interface MessageDeletedPayload {
|
||||
Id: number
|
||||
}
|
||||
|
||||
/** `MessageReceived` (2). */
|
||||
export interface MessageReceivedPayload {
|
||||
Id: number
|
||||
FromPlayerId: number
|
||||
/** ISO-8601. */
|
||||
SentTime: string
|
||||
Type: number
|
||||
Data: string
|
||||
RoomId: number | null
|
||||
PlayerEventId: number | null
|
||||
}
|
||||
|
||||
/** `RelationshipChanged` (1). */
|
||||
export interface RelationshipChangedPayload {
|
||||
/** Note the spelling — capital `ID`, unlike every other id key on the wire. */
|
||||
PlayerID: number
|
||||
RelationshipType: number
|
||||
Muted: boolean
|
||||
Ignored: boolean
|
||||
Favorited: boolean
|
||||
}
|
||||
|
||||
/** `PlayerEventDeleted` (82) / `PlayerEventResponseDeleted` (84). */
|
||||
export interface PlayerEventIdPayload {
|
||||
PlayerEventId: number
|
||||
}
|
||||
|
||||
/** `PlayerEventResponseChanged` (83). */
|
||||
export interface PlayerEventResponsePayload {
|
||||
PlayerEvent: Record<string, unknown>
|
||||
PlayerEventResponse: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** `PlayerEventCreated` (80) / `PlayerEventUpdated` (81). */
|
||||
export interface PlayerEventPayload {
|
||||
Tags: unknown[]
|
||||
PlayerEventId: number
|
||||
CreatorPlayerId: number
|
||||
RoomId: number
|
||||
SubRoomId: number | null
|
||||
ClubId: number | null
|
||||
Name: string
|
||||
Description: string
|
||||
ImageName: string
|
||||
/** ISO-8601. */
|
||||
StartTime: string
|
||||
/** ISO-8601. */
|
||||
EndTime: string
|
||||
AttendeeCount: number
|
||||
Accessibility: number
|
||||
IsMultiInstance: boolean
|
||||
SupportMultiInstanceRoomChat: boolean
|
||||
DefaultBroadcastPermissions: number
|
||||
CanRequestBroadcastPermissions: number
|
||||
BroadcastingRoomInstanceId: number | null
|
||||
}
|
||||
|
||||
/** `PlayerProgressionLevelUpdate`. `XP` is progress into the level, not a lifetime total. */
|
||||
export interface PlayerProgressionLevelPayload {
|
||||
PlayerId: number
|
||||
Level: number
|
||||
XP: number
|
||||
}
|
||||
|
||||
/** `ProgressionEventsRecordUpdate`. */
|
||||
export interface ProgressionEventRecordPayload {
|
||||
AccountId: number
|
||||
Xp: number
|
||||
GameMinutesToday: number
|
||||
RewardsCollected: number
|
||||
BonusRewardsCollected: number
|
||||
/** ISO-8601. */
|
||||
XpBoostLastPurchasedAt: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* `SubscriptionUpdateProfile` (`"AccountUpdate"`) — the public projection of an account.
|
||||
* The `Obscured*` CodeStage wrappers on the client side serialise as their plain underlying
|
||||
* value, so nothing special is needed on the wire.
|
||||
*/
|
||||
export interface AccountUpdatePayload {
|
||||
AccountId: number
|
||||
UserName: string
|
||||
DisplayName: string
|
||||
DisplayEmoji: string
|
||||
ProfileImage: string
|
||||
BannerImage: string
|
||||
TreatAsJunior: boolean
|
||||
HasBirthday: boolean
|
||||
PersonalPronouns: number
|
||||
IdentityFlags: number
|
||||
/** Lowercase-camel on the client's canonical spelling, unlike its neighbours. */
|
||||
createdAt: string
|
||||
IsJunior: boolean | null
|
||||
}
|
||||
|
||||
/**
|
||||
* `SubscriptionUpdateSelfProfile` (`"SelfAccountUpdate"`) — the owner-only projection: the
|
||||
* six private fields **first**, then every field of {@link AccountUpdatePayload}. That order
|
||||
* is not cosmetic; the client's decoder emits a derived DTO's own members before its base's.
|
||||
*/
|
||||
export interface SelfAccountUpdatePayload extends AccountUpdatePayload {
|
||||
Email: string
|
||||
Phone: string
|
||||
/** ISO-8601. Its absence is what caused the under-13 junior crash. */
|
||||
Birthday: string | null
|
||||
JuniorState: number
|
||||
ParentAccountId: number | null
|
||||
AvailableUsernameChanges: number
|
||||
}
|
||||
|
||||
/** `ChatMessageReceived` and `PlayerLeftChat` — same shape. */
|
||||
export interface ChatMessagePayload {
|
||||
ChatMessageId: number
|
||||
ChatThreadId: number
|
||||
SenderPlayerId: number
|
||||
/** ISO-8601. */
|
||||
TimeSent: string
|
||||
Contents: string
|
||||
ModerationState: number
|
||||
}
|
||||
|
||||
/** `ClubMembershipUpdate`. */
|
||||
export interface ClubMembershipPayload {
|
||||
ClubId: number
|
||||
MembershipType: number
|
||||
}
|
||||
|
||||
/** `CreatorClubSubscriptionUpdate`. */
|
||||
export interface CreatorClubSubscriptionPayload {
|
||||
CreatorAccountId: number
|
||||
ClubId: number
|
||||
MembershipType: number
|
||||
}
|
||||
|
||||
/** `RoomCurrencyCreated` / `RoomCurrencyModified`. */
|
||||
export interface RoomCurrencyPayload {
|
||||
CurrencyId: string
|
||||
RoomId: number | null
|
||||
Name: string
|
||||
Description: string
|
||||
CurrencyType: CurrencyType
|
||||
Limit: number
|
||||
ImageName: string
|
||||
/** ISO-8601. */
|
||||
CreatedAt: string
|
||||
/** ISO-8601. */
|
||||
ModifiedAt: string
|
||||
}
|
||||
|
||||
/** `RoomCurrencyDeleted`. */
|
||||
export interface RoomCurrencyDeletedPayload {
|
||||
CurrencyId: string
|
||||
}
|
||||
|
||||
/** `LocalRoomKeyCreated` (120). */
|
||||
export interface LocalRoomKeyPayload {
|
||||
RoomKeyId: number
|
||||
ReplicationId: string
|
||||
RoomId: number
|
||||
Name: string
|
||||
Description: string
|
||||
Price: number
|
||||
PurchaseCurrencyId: string | null
|
||||
/** ISO-8601. */
|
||||
CreatedAt: string
|
||||
ImageName: string
|
||||
}
|
||||
|
||||
/** `LocalRoomKeyDeleted` (121). */
|
||||
export interface LocalRoomKeyDeletedPayload {
|
||||
RoomKeyId: number
|
||||
}
|
||||
|
||||
/** `AnnouncementUpdate`. */
|
||||
export interface AnnouncementPayload {
|
||||
AnnouncementId: number
|
||||
AnnouncementType: number
|
||||
Title: string
|
||||
Body: string
|
||||
ImageName: string
|
||||
LinkType: number
|
||||
LinkName: string
|
||||
LinkButtonLabel: string
|
||||
LinkUri: string
|
||||
Platform: number
|
||||
/** ISO-8601. */
|
||||
CreatedAt: string
|
||||
}
|
||||
|
||||
/** `AnnouncementDelete`. */
|
||||
export interface AnnouncementDeletePayload {
|
||||
AnnouncementId: number
|
||||
}
|
||||
|
||||
/** `CommunityBoardAnnouncementUpdate` (96) — the board's single current announcement. */
|
||||
export interface CommunityBoardAnnouncementPayload {
|
||||
Message: string
|
||||
MoreInfoUrl: string
|
||||
}
|
||||
|
||||
/** `ReputationUpdate`. */
|
||||
export interface ReputationPayload {
|
||||
AccountId: number
|
||||
IsCheerful: boolean
|
||||
SelectedCheer: number | null
|
||||
CheerCredit: number
|
||||
CheerGeneral: number
|
||||
CheerHelpful: number
|
||||
CheerCreative: number
|
||||
CheerGreatHost: number
|
||||
CheerSportsman: number
|
||||
}
|
||||
|
||||
/** `PhotonAccessToken`. */
|
||||
export interface PhotonAccessTokenPayload {
|
||||
RoomInstanceId: number
|
||||
PhotonAccessToken: string
|
||||
Permissions: unknown[]
|
||||
}
|
||||
|
||||
/** `KeepsakeInstanceAdded` / `KeepsakeInstanceRemoved`. */
|
||||
export interface KeepsakeInstancePayload {
|
||||
KeepsakeInstanceId: string
|
||||
KeepsakeCategoryConfigId: number
|
||||
PlacedByAccountId: number
|
||||
RoomId: number
|
||||
SubRoomId: number | null
|
||||
}
|
||||
|
||||
/** `PlayerCustomAvatarItemModerated`. */
|
||||
export interface CustomAvatarItemPayload {
|
||||
CustomAvatarItemId: string
|
||||
CreatorAccountId: number
|
||||
Name: string
|
||||
Description: string
|
||||
Price: number
|
||||
Accessibility: number
|
||||
IsFeatured: boolean
|
||||
BaseAvatarItemId: number | null
|
||||
BaseAvatarItemColor: string
|
||||
DesignFilename: string
|
||||
ThumbnailImageFilename: string
|
||||
/** ISO-8601. */
|
||||
CreatedAt: string
|
||||
/** ISO-8601. */
|
||||
ModifiedAt: string
|
||||
}
|
||||
|
||||
/** `GoToFailure`. */
|
||||
export interface GoToFailurePayload {
|
||||
Error: GoToFailureError
|
||||
}
|
||||
|
||||
/** `AppVersionUpdate` — the live replacement for the dead `ModerationUpdateRequired` (21). */
|
||||
export interface AppVersionUpdatePayload {
|
||||
ActivePlatforms: unknown
|
||||
}
|
||||
|
||||
/** `IncentivizedReferralUpdate`. */
|
||||
export interface IncentivizedReferralPayload {
|
||||
InviteeAccountId: number
|
||||
/** ISO-8601. */
|
||||
CreatedAt: string
|
||||
/** ISO-8601. */
|
||||
VerifiedAt: string | null
|
||||
}
|
||||
|
||||
/** `InfluencerSupportedUpdate`. */
|
||||
export interface InfluencerSupportedPayload {
|
||||
SupportedInfluencerId: number | null
|
||||
}
|
||||
|
||||
/** `StringAutoLocalizationJob`. */
|
||||
export interface StringAutoLocalizationJobPayload {
|
||||
Scope: string
|
||||
Status: number
|
||||
}
|
||||
|
||||
/** `SubscriptionUpdateGameSession` (`"RoomInstanceUpdate"`). */
|
||||
export interface RoomInstanceUpdatePayload {
|
||||
RoomInstanceId: number
|
||||
RoomId: number
|
||||
SubRoomId: number
|
||||
Location: string
|
||||
EventId: number
|
||||
ClubId: number
|
||||
RoomCode: string
|
||||
/** Canonical spelling is lower-camel here, unlike its neighbours. */
|
||||
photonRegionId: string
|
||||
PhotonRoomId: string
|
||||
Name: string
|
||||
MaxCapacity: number
|
||||
IsFull: boolean
|
||||
IsPrivate: boolean
|
||||
IsInProgress: boolean
|
||||
EncryptVoiceChat: boolean
|
||||
RoomInstanceType: number
|
||||
MatchmakingPolicy: number
|
||||
}
|
||||
|
||||
/**
|
||||
* `GiftPackageReceived` (30), `GiftPackageReceivedImmediate` (31) and
|
||||
* `GiftPackageRewardSelectionReceived` (32) all carry this. Note `BalanceType` here is NOT
|
||||
* the renamed-to-`Platform` field seen on the balance payloads — it is its own member and
|
||||
* keeps its name; `Platform` is a separate key on the same object.
|
||||
*/
|
||||
export interface GiftPackagePayload {
|
||||
Id: number | null
|
||||
FromPlayerId: number | null
|
||||
ConsumableItemDesc: string
|
||||
AvatarItemType: number | null
|
||||
AvatarItemDesc: string
|
||||
EquipmentPrefabName: string
|
||||
EquipmentModificationGuid: string
|
||||
CurrencyType: CurrencyType
|
||||
Currency: number
|
||||
Xp: number
|
||||
GiftContext: number
|
||||
GiftRarity: number
|
||||
Message: string
|
||||
Platform: number
|
||||
PlatformsToSpawnOn: unknown
|
||||
BalanceType: BalancePlatform | null
|
||||
}
|
||||
|
||||
/** `gift.manualconsumed`. */
|
||||
export interface GiftManualConsumedPayload {
|
||||
GiftPackageId: number
|
||||
}
|
||||
|
||||
/** `RewardSelectionReceived` — distinct from the gift-package frames above. */
|
||||
export interface RewardSelectionPayload {
|
||||
RewardSelectionId: number
|
||||
RewardType: number
|
||||
Message: string
|
||||
GiftContext: number
|
||||
GiftDrop1: unknown
|
||||
GiftDrop2: unknown
|
||||
GiftDrop3: unknown
|
||||
Subscriber_GiftDrop3: unknown
|
||||
/** ISO-8601. */
|
||||
CreatedAt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Channels whose handler takes no argument at all — the client refetches rather than reading
|
||||
* the frame. Send `{}`; anything else is ignored.
|
||||
*/
|
||||
export type NoPayload = Record<string, never>
|
||||
@@ -1,35 +1,104 @@
|
||||
/**
|
||||
* The client's `NotificationType` enum — the integer `Id` carried on a hub
|
||||
* notification frame (`{ Id, Msg }`, see {@link NotificationsHub}). The reference
|
||||
* server sends these as the notification type so the client's dispatcher can route
|
||||
* each frame (e.g. remove a consumed item from inventory on ConsumableMappingRemoved).
|
||||
* The client's `NotificationType` enum — the `Id` carried on a hub notification frame
|
||||
* (`{ Id, Msg }`, see {@link NotificationsHub}). The reference server sends these as the
|
||||
* notification type so the client's dispatcher can route each frame (e.g. remove a consumed
|
||||
* item from inventory on ConsumableMappingRemoved).
|
||||
*
|
||||
* Mostly integers, but some members are STRINGS, and that is not an inconsistency to tidy
|
||||
* up: the reference's hub sends a wire name for those frames (`"AccountUpdate"`,
|
||||
* `"RoomUpdate"`, …) even where its own Go enum has a number for them, and the frame's `Id`
|
||||
* is stringified as-is — so a member's value is whatever that frame is actually addressed
|
||||
* by. Where the two disagree, the wire wins; the number is noted in the member's comment.
|
||||
*
|
||||
* Lives in the `notify` worker (the hub owner); other workers import it to send a
|
||||
* typed notification instead of a magic number. No runtime dependencies, so it's safe
|
||||
* to import as a value from another worker's bundle.
|
||||
*
|
||||
* ## How this was verified
|
||||
*
|
||||
* Every member below was checked against the client's own subscription table, recovered by
|
||||
* static analysis of `GameAssembly.dll` (see `recnet-patcher`, `il2cpp-tools/regall.py`).
|
||||
* The client registers each channel by key — a decimal string for numeric ids, a name for
|
||||
* the rest — and **a key with no subscriber is dropped in silence**: the frame parses, no
|
||||
* handler runs, nothing is logged. So "the client ignores this" is indistinguishable from
|
||||
* "the server never sent it" at runtime, which is why the status is recorded here instead.
|
||||
*
|
||||
* Members marked `@deadOnClient` have no subscriber in the build this was taken from
|
||||
* (20230414-era, `GameAssembly.dll` 156,069,888 bytes). They are kept because they name a
|
||||
* real client enum member and may come back in another build — but sending one today is a
|
||||
* no-op. Everything not so marked was confirmed to have a live handler.
|
||||
*/
|
||||
export enum NotificationType {
|
||||
RelationshipChanged = 1,
|
||||
MessageReceived = 2,
|
||||
MessageDeleted = 3,
|
||||
/**
|
||||
* @deadOnClient No subscriber, and unlike its neighbours it has no wire-name twin either —
|
||||
* the string `PresenceHeartbeat…` does not appear anywhere in the client's metadata. The
|
||||
* heartbeat response has nowhere to land in this build.
|
||||
*/
|
||||
PresenceHeartbeatResponse = 4,
|
||||
/** Also reachable as the wire name `"PlayerPrivileges.Refresh"` — same handler. */
|
||||
RefreshLogin = 5,
|
||||
Logout = 6,
|
||||
SubscriptionUpdateProfile = "AccountUpdate",
|
||||
SubscriptionUpdatePresence = "PresenceUpdate",
|
||||
SubscriptionUpdateGameSession = "RoomInstanceUpdate",
|
||||
SubscriptionUpdateRoom = 15,
|
||||
SubscriptionUpdateProfile = 'AccountUpdate',
|
||||
/**
|
||||
* The owner-only twin of {@link SubscriptionUpdateProfile}: the same account, rendered
|
||||
* with the private fields (email, birthday, remaining username changes). The reference
|
||||
* sends both on connect and after a profile mutation — everyone gets the public frame,
|
||||
* the owner additionally gets this one. Named after its twin rather than after a client
|
||||
* enum member, since the client's enum doesn't list it; the WIRE name is what matters.
|
||||
*
|
||||
* Confirmed live: the client subscribes `"SelfAccountUpdate"` and the payload is the
|
||||
* public account DTO plus six owner-only fields. See `SelfAccountUpdatePayload`.
|
||||
*/
|
||||
SubscriptionUpdateSelfProfile = 'SelfAccountUpdate',
|
||||
SubscriptionUpdatePresence = 'PresenceUpdate',
|
||||
SubscriptionUpdateGameSession = 'RoomInstanceUpdate',
|
||||
/**
|
||||
* A room the player is subscribed to changed. STRING-valued like its neighbours even
|
||||
* though the reference's own enum numbers it `15`: its hub sends the wire name
|
||||
* (`NotifFrame("RoomUpdate", room)`) and never the number, and the payload builder
|
||||
* stringifies whatever it is given, so `15` would go out as the unrelated `"15"`.
|
||||
*/
|
||||
SubscriptionUpdateRoom = 'RoomUpdate',
|
||||
/** @deadOnClient No subscriber under `16` and no wire-name twin. */
|
||||
SubscriptionUpdateRoomPlaylist = 16,
|
||||
ModerationQuitGame = 20,
|
||||
/**
|
||||
* @deadOnClient Nothing listens on `21`. {@link AppVersionUpdate} is the live channel
|
||||
* that does this job — same handler class, addressed by name.
|
||||
*/
|
||||
ModerationUpdateRequired = 21,
|
||||
ModerationKick = 22,
|
||||
ModerationKickAttemptFailed = 23,
|
||||
ModerationRoomBan = 24,
|
||||
/**
|
||||
* @deadOnClient Not present in this build at all: no subscriber on `24`, and the string
|
||||
* `"ModerationRoomBan"` does not exist in the client's metadata, so neither spelling can
|
||||
* be dispatched. To ban from a room, use {@link ModerationKick} with `IsBan: true`.
|
||||
*/
|
||||
ModerationRoomBan = 'ModerationRoomBan',
|
||||
ServerMaintenance = 25,
|
||||
GiftPackageReceived = 30,
|
||||
GiftPackageReceivedImmediate = 31,
|
||||
/**
|
||||
* Carries a gift package like its two neighbours. Distinct from
|
||||
* {@link RewardSelectionReceived}, which is a different channel with a different payload.
|
||||
*/
|
||||
GiftPackageRewardSelectionReceived = 32,
|
||||
/**
|
||||
* A player's level/XP changed — `{ PlayerId, Level, XP }`, where XP is the progress into
|
||||
* the current level, not a lifetime total. STRING-valued: the reference's hub sends the
|
||||
* wire name and its enum has no number for this one at all. It pushes the frame both when
|
||||
* progression changes and when the player reads it back, which is how a client that just
|
||||
* connected gets its bar right.
|
||||
*
|
||||
* Confirmed live, and the three-field payload confirmed against the client's formatter.
|
||||
*/
|
||||
PlayerProgressionLevelUpdate = 'PlayerProgressionLevelUpdate',
|
||||
/** @deadOnClient No subscriber under `40` and no wire-name twin. */
|
||||
ProfileJuniorStatusUpdate = 40,
|
||||
/** Takes no payload — the client refetches. */
|
||||
RelationshipsInvalid = 50,
|
||||
StorefrontBalanceAdd = 60,
|
||||
StorefrontBalanceUpdate = 61,
|
||||
@@ -41,12 +110,67 @@ export enum NotificationType {
|
||||
PlayerEventDeleted = 82,
|
||||
PlayerEventResponseChanged = 83,
|
||||
PlayerEventResponseDeleted = 84,
|
||||
/** @deadOnClient No subscriber under `85` and no wire-name twin. */
|
||||
PlayerEventStateChanged = 85,
|
||||
ChatMessageReceived = "ChatMessageReceived",
|
||||
CommunityBoardUpdate = 95,
|
||||
ChatMessageReceived = 'ChatMessageReceived',
|
||||
/**
|
||||
* STRING-valued, and this one is easy to get wrong: the client's enum *does* have a
|
||||
* member numbered `95`, but nothing subscribes to `"95"` — the live subscription is on
|
||||
* the name. Sending the number is a silent no-op.
|
||||
*/
|
||||
CommunityBoardUpdate = 'CommunityBoardUpdate',
|
||||
/**
|
||||
* Numeric, unlike its `CommunityBoard` sibling above — `96` genuinely has a subscriber.
|
||||
* Do not "unify" these two; they were checked separately. Note the *announcement list*
|
||||
* has its own name-addressed channels ({@link AnnouncementUpdate} /
|
||||
* {@link AnnouncementDelete}); this one is the board's single current announcement.
|
||||
*/
|
||||
CommunityBoardAnnouncementUpdate = 96,
|
||||
/** Takes no payload — the client refetches. */
|
||||
InventionModerationStateChanged = 100,
|
||||
/** Takes no payload — the client refetches. */
|
||||
FreeGiftButtonItemsAdded = 110,
|
||||
LocalRoomKeyCreated = 120,
|
||||
LocalRoomKeyDeleted = 121,
|
||||
|
||||
// ---- Name-addressed channels with no client enum member ------------------
|
||||
// These have no number at all: the client subscribes them purely by name. Grouped
|
||||
// separately so the numeric block above stays a faithful mirror of the client enum.
|
||||
|
||||
/** Client version gate. The live replacement for {@link ModerationUpdateRequired}. */
|
||||
AppVersionUpdate = 'AppVersionUpdate',
|
||||
AnnouncementUpdate = 'AnnouncementUpdate',
|
||||
AnnouncementDelete = 'AnnouncementDelete',
|
||||
ClubMembershipUpdate = 'ClubMembershipUpdate',
|
||||
CreatorClubSubscriptionUpdate = 'CreatorClubSubscriptionUpdate',
|
||||
CommerceSubscriptionUpdate = 'CommerceSubscriptionUpdate',
|
||||
/** Takes no payload — the client refetches `api/config/v2`. */
|
||||
GameConfigRefresh = 'GameConfig.Refresh',
|
||||
/** Takes no payload — the client refetches. */
|
||||
PlayerSettingsRefresh = 'PlayerSettings.Refresh',
|
||||
/** Matchmaking told the client its `GoTo` failed; payload is a single error code. */
|
||||
GoToFailure = 'GoToFailure',
|
||||
IncentivizedReferralUpdate = 'IncentivizedReferralUpdate',
|
||||
InfluencerSupportedUpdate = 'InfluencerSupportedUpdate',
|
||||
KeepsakeInstanceAdded = 'KeepsakeInstanceAdded',
|
||||
KeepsakeInstanceRemoved = 'KeepsakeInstanceRemoved',
|
||||
/** The un-kick; same payload type as {@link ModerationKick}. */
|
||||
ModerationUnkick = 'ModerationUnkick',
|
||||
/** Hands the client a Photon token for a room instance. */
|
||||
PhotonAccessToken = 'PhotonAccessToken',
|
||||
PlayerCustomAvatarItemModerated = 'PlayerCustomAvatarItemModerated',
|
||||
/** Same payload type as {@link ChatMessageReceived}. */
|
||||
PlayerLeftChat = 'PlayerLeftChat',
|
||||
ProgressionEventsRecordUpdate = 'ProgressionEventsRecordUpdate',
|
||||
ReputationUpdate = 'ReputationUpdate',
|
||||
/** Distinct from {@link GiftPackageRewardSelectionReceived} — different payload. */
|
||||
RewardSelectionReceived = 'RewardSelectionReceived',
|
||||
RoomCommentDeleted = 'RoomCommentDeleted',
|
||||
RoomCurrencyCreated = 'RoomCurrencyCreated',
|
||||
RoomCurrencyModified = 'RoomCurrencyModified',
|
||||
RoomCurrencyDeleted = 'RoomCurrencyDeleted',
|
||||
StringAutoLocalizationJob = 'StringAutoLocalizationJob',
|
||||
WebsiteInventionPurchase = 'WebsiteInventionPurchase',
|
||||
GiftManualConsumed = 'gift.manualconsumed',
|
||||
PushToDevice = 'rrs.pushtodevice',
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { logger, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId, validateAndGetRoles } from '@repo/jwt'
|
||||
|
||||
import { NotificationsHub, OWNER_HEADER } from './notifications-hub'
|
||||
@@ -80,6 +80,14 @@ const app = new Hono<App>()
|
||||
})(c, next)
|
||||
)
|
||||
|
||||
// The website (`www`) is a browser origin calling these endpoints directly, the way
|
||||
// rec.net's own site called the game's API — so the responses need CORS headers or
|
||||
// the browser discards them. `origin: '*'` is deliberate and safe HERE because these
|
||||
// endpoints authenticate with a bearer token in the `Authorization` header, never a
|
||||
// cookie: a hostile page can't read another origin's stored token, so there is no
|
||||
// ambient credential for `*` to expose. Do not add cookie auth without narrowing it.
|
||||
.use('*', withDefaultCors())
|
||||
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
|
||||
@@ -581,3 +581,44 @@ describe('clearing pending notifications', () => {
|
||||
expect((await clear('all=true', await bearer('3', ['gameClient']))).status).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
// The website's admin controls (maintenance countdown, coach broadcast) are a browser
|
||||
// calling `/internal/*` directly rather than through a `www` proxy, so these need CORS.
|
||||
describe('CORS', () => {
|
||||
// The catch: `/internal/*` is behind `requireAdmin`, and a browser preflight carries
|
||||
// NO Authorization header — it can't, that's the header it's asking permission to
|
||||
// send. So the CORS middleware has to answer it before the admin gate sees it,
|
||||
// otherwise every admin action fails the preflight with a 401 and never gets sent.
|
||||
test('answers the preflight on an admin endpoint without a token', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
new Request(`${ORIGIN}/internal/broadcast`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
origin: 'https://www.example.com',
|
||||
'access-control-request-method': 'POST',
|
||||
'access-control-request-headers': 'authorization, content-type',
|
||||
},
|
||||
}),
|
||||
env
|
||||
)
|
||||
expect(res.status).toBe(204)
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe('*')
|
||||
const allowed = res.headers.get('access-control-allow-headers')?.toLowerCase() ?? ''
|
||||
expect(allowed).toContain('authorization')
|
||||
expect(allowed).toContain('content-type')
|
||||
})
|
||||
|
||||
// The gate itself is untouched: the preflight passing is not the request passing.
|
||||
test('still rejects the actual call without an admin token', async () => {
|
||||
const res = await exports.default.fetch(
|
||||
new Request(`${ORIGIN}/internal/broadcast`, {
|
||||
method: 'POST',
|
||||
headers: { origin: 'https://www.example.com', 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ notificationType: 25, data: {} }),
|
||||
}),
|
||||
env
|
||||
)
|
||||
expect(res.status).toBe(401)
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe('*')
|
||||
})
|
||||
})
|
||||
|
||||
+11
-3
@@ -7,11 +7,19 @@ discover every service host (Accounts, API, Auth, Econ, Matchmaking,
|
||||
Notifications, …).
|
||||
|
||||
Each host is built at runtime from the `DOMAIN` var (the base domain) plus the
|
||||
service → subdomain map in `src/endpoints.ts`. `DOMAIN` is injected at deploy
|
||||
time from `RECFLARE_DOMAIN` (see `run-wrangler-deploy`) and defaults to
|
||||
`rec.example.com` in `wrangler.jsonc` for local dev.
|
||||
service → subdomain map in `src/endpoints.ts`. `DOMAIN` is injected from
|
||||
`RECFLARE_DOMAIN` by both `run-wrangler-dev` and `run-wrangler-deploy`, and
|
||||
defaults to `rec.example.com` in `wrangler.jsonc` when that isn't set.
|
||||
|
||||
## Updating endpoints
|
||||
|
||||
- To change the base domain, set `RECFLARE_DOMAIN` (in `.env`) and redeploy.
|
||||
- To add or rename a service host, edit the map in `src/endpoints.ts`.
|
||||
|
||||
## ENDPOINT_STYLE
|
||||
|
||||
With `ENDPOINT_STYLE=path`, every service is advertised as `https://<domain>/<slug>`
|
||||
instead of `https://<slug>.<domain>`. That's for the combined `mono` worker alone —
|
||||
it's a single Worker that routes on the first path segment, so one host serves the
|
||||
lot. Unset (the split deployment, where each service is its own Worker on its own
|
||||
host) gives the subdomain document.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { HonoApp } from '@repo/hono-helpers'
|
||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
||||
import type { EndpointStyle } from './endpoints'
|
||||
|
||||
export type Env = SharedHonoEnv & {
|
||||
/**
|
||||
@@ -8,6 +9,13 @@ export type Env = SharedHonoEnv & {
|
||||
* for local dev and tests.
|
||||
*/
|
||||
DOMAIN: string
|
||||
/**
|
||||
* `path` to serve every service from a path on `DOMAIN` (`https://<domain>/rooms`)
|
||||
* instead of from its own subdomain. Set only by the combined `mono` worker, which is
|
||||
* one Worker routing on that first path segment; anything else (the split deployment)
|
||||
* leaves it unset and gets the per-service hosts.
|
||||
*/
|
||||
ENDPOINT_STYLE?: EndpointStyle
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -44,9 +44,25 @@ const SERVICE_SUBDOMAINS = {
|
||||
WWW: 'www',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Where the services live, relative to the base domain:
|
||||
*
|
||||
* `subdomain` — one host each, `https://rooms.<domain>`. The split deployment, and the
|
||||
* default, since that's what every worker in `apps/` is deployed as.
|
||||
* `path` — one host, first path segment names the service: `https://<domain>/rooms`.
|
||||
* Only the combined `mono` worker, which is a single Worker routing on that segment.
|
||||
*/
|
||||
export type EndpointStyle = 'subdomain' | 'path'
|
||||
|
||||
/** Builds the endpoints document for `domain`, e.g. `rec.example.com`. */
|
||||
export function buildEndpoints(domain: string): Record<string, string> {
|
||||
export function buildEndpoints(
|
||||
domain: string,
|
||||
style: EndpointStyle = 'subdomain'
|
||||
): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(SERVICE_SUBDOMAINS).map(([label, sub]) => [label, `https://${sub}.${domain}`])
|
||||
Object.entries(SERVICE_SUBDOMAINS).map(([label, sub]) => [
|
||||
label,
|
||||
style === 'path' ? `https://${domain}/${sub}` : `https://${sub}.${domain}`,
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@ const app = new Hono<App>()
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
// Endpoints document, derived from the deploy-time base domain.
|
||||
.get('/', (c) => c.json(buildEndpoints(c.env.DOMAIN)))
|
||||
// Endpoints document, derived from the deploy-time base domain. ENDPOINT_STYLE is set
|
||||
// only by the combined `mono` worker, to advertise the services on paths of that one
|
||||
// domain rather than on a host each; unset (the split deployment) means subdomains.
|
||||
.get('/', (c) => c.json(buildEndpoints(c.env.DOMAIN, c.env.ENDPOINT_STYLE)))
|
||||
|
||||
export default app
|
||||
|
||||
@@ -18,6 +18,20 @@ describe('ns endpoints', () => {
|
||||
expect(body).toEqual(buildEndpoints(TEST_DOMAIN))
|
||||
})
|
||||
|
||||
test('the path style puts every service on the base domain', async () => {
|
||||
const doc = buildEndpoints(TEST_DOMAIN, 'path')
|
||||
expect(doc.Rooms).toBe(`https://${TEST_DOMAIN}/rooms`)
|
||||
expect(doc.Matchmaking).toBe(`https://${TEST_DOMAIN}/match`)
|
||||
expect(doc.Images).toBe(`https://${TEST_DOMAIN}/img`)
|
||||
expect(Object.values(doc).every((url) => url.startsWith(`https://${TEST_DOMAIN}/`))).toBe(true)
|
||||
})
|
||||
|
||||
test('the default style gives every service its own host', async () => {
|
||||
const doc = buildEndpoints(TEST_DOMAIN)
|
||||
expect(doc.Rooms).toBe(`https://rooms.${TEST_DOMAIN}`)
|
||||
expect(doc.Images).toBe(`https://img.${TEST_DOMAIN}`)
|
||||
})
|
||||
|
||||
test('unknown path returns 404', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/nope`)
|
||||
expect(res.status).toBe(404)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Per-room player bans. `POST /rooms/{roomId}/bans` is how a room's owner (or a
|
||||
-- staff account) bans a player from a room; one row per (room, player), so
|
||||
-- re-banning someone already banned updates their row rather than appending a
|
||||
-- second one.
|
||||
--
|
||||
-- `ban_mask` is the client's `banMask` form field, stored verbatim. Its meaning is
|
||||
-- not known yet — the client sends 0 — so nothing interprets it; it's kept so the
|
||||
-- value isn't lost once we work out what it selects.
|
||||
--
|
||||
-- Columnar rather than a JSON blob, and deliberately NOT part of the room's `data`
|
||||
-- blob: that blob is served to the client verbatim as the room, and a room's ban
|
||||
-- list is not something every reader of a room should receive.
|
||||
--
|
||||
-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS room_ban (
|
||||
room_id INTEGER NOT NULL,
|
||||
banned_player_id INTEGER NOT NULL,
|
||||
ban_mask INTEGER NOT NULL DEFAULT 0,
|
||||
banned_by_account_id INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (room_id, banned_player_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_room_ban_player ON room_ban (banned_player_id);
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Lifetime visit counter on `room`. The `match` worker bumps it once per successful
|
||||
-- matchmake into the room (see recordRoomVisit, called from match's enterRoom), which
|
||||
-- is the only way a player ever lands in a room, and every room read serves it as the
|
||||
-- room's `Stats.VisitCount`.
|
||||
--
|
||||
-- A real column rather than a field in the `data` blob: a visit has to be one atomic
|
||||
-- `visits = visits + 1` UPDATE. Writing it into the blob would mean reading the whole
|
||||
-- room, editing the JSON and writing it back, so two players entering at once would
|
||||
-- lose one of the visits — and would race every other writer of the room besides.
|
||||
--
|
||||
-- Unlike CheerCount/FavoriteCount it can't be derived on read either: a visit leaves
|
||||
-- no per-player row to count (`interaction.last_visited_at` is only stamped by the
|
||||
-- cheer/favorite toggles). Existing rooms start from 0 — the count begins now.
|
||||
--
|
||||
-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync.
|
||||
|
||||
ALTER TABLE room ADD COLUMN visits INTEGER NOT NULL DEFAULT 0;
|
||||
@@ -89,9 +89,15 @@ export const roomIdParam = idParam('roomId', 'Room id')
|
||||
/** The `:subRoomId` path parameter. */
|
||||
export const subRoomIdParam = idParam('subRoomId', 'Subroom id (globally unique, not per-room)')
|
||||
|
||||
/** The `:saveId` path parameter — a `subroom_save` id (globally unique, not per-subroom). */
|
||||
export const saveIdParam = idParam('saveId', 'The save’s id, as `…/saves` lists it')
|
||||
|
||||
/** The `:playerId` path parameter (an account id). */
|
||||
export const playerIdParam = idParam('playerId', 'The account whose list to read')
|
||||
|
||||
/** The `:playerId` path parameter on the unban route. */
|
||||
export const bannedPlayerIdParam = idParam('playerId', 'The banned account to unban')
|
||||
|
||||
/** An optional string query parameter. */
|
||||
export function stringQuery(name: string, description: string): OpenAPIV3_1.ParameterObject {
|
||||
return { name, in: 'query', required: false, description, schema: { type: 'string' } }
|
||||
@@ -134,8 +140,10 @@ export const RoomTagDto = z.object({
|
||||
|
||||
/**
|
||||
* A room's engagement counters. `CheerCount`/`FavoriteCount` are aggregated from the
|
||||
* per-player `interaction` rows on every read; nothing records visits yet, so
|
||||
* `VisitorCount`/`VisitCount` stay at 0.
|
||||
* per-player `interaction` rows on every read. `VisitCount` is the room's lifetime
|
||||
* visits — the `room.visits` column, bumped by the `match` worker on every successful
|
||||
* matchmake into the room. Nothing records distinct visitors, so `VisitorCount` stays
|
||||
* at 0.
|
||||
*/
|
||||
export const RoomStatsDto = z.object({
|
||||
CheerCount: z.int(),
|
||||
@@ -156,6 +164,11 @@ export const LoadScreenDto = z.object({
|
||||
* from the PascalCase `CurrentSave` embedded in a room (no persistence/OM/UGC versions,
|
||||
* no moderation state, no asset arrays; but `unityAsset`/`unityAssetHash`/`dataBlobHash`
|
||||
* that `CurrentSave` doesn't show). The two are deliberately not unified.
|
||||
*
|
||||
* Also what `GET …/subrooms/{subRoomId}/saves/{saveId}` answers — one save fetched by id
|
||||
* is the same thing the save that created it returned, so both go through
|
||||
* `toSaveResponse`. Note the `…/saves` LIST is the third shape here: it serves the raw
|
||||
* PascalCase rows ({@link SubRoomDataSaveDto}), not this.
|
||||
*/
|
||||
export const SubRoomDataSaveResponseDto = z.object({
|
||||
subRoomDataSaveId: z.int(),
|
||||
@@ -235,6 +248,7 @@ export const SubRoomDto = z.object({
|
||||
RoomDataBlob: z.string().optional().describe('Uploaded room-data key; absent until first save'),
|
||||
DataSavedAt: z.string().optional().describe('ISO timestamp of the last save'),
|
||||
PersistenceVersion: z.int().optional(),
|
||||
InventionUsage: z.string().optional().describe('Recorded by a room save; absent until then'),
|
||||
})
|
||||
|
||||
/** A room's localization settings — carried through verbatim; nothing localizes yet. */
|
||||
@@ -307,7 +321,10 @@ export const RoomDto = z.object({
|
||||
PromoExternalContent: z.array(z.unknown()),
|
||||
LoadScreens: z.array(LoadScreenDto),
|
||||
RestrictedCircuitsAllowListNames: z.array(z.string()),
|
||||
InventionUsage: z.string().optional().describe('Recorded by a room save; absent until then'),
|
||||
InventionUsage: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Legacy: room saves used to write this here; it now lives on the SUBROOM'),
|
||||
})
|
||||
|
||||
/** A paged room list (`PagedResultsDTO<RoomDTO>`) — search, hot, similar. */
|
||||
@@ -441,6 +458,42 @@ export const RoleRequest = z.object({
|
||||
role: z.string().describe('The role tier: 10 Host, 20 Moderator, 30 CoOwner, 255 Creator'),
|
||||
})
|
||||
|
||||
/** `POST /rooms/{roomId}/bans` — the player to ban from the room. */
|
||||
export const BanRequest = z.object({
|
||||
id: z.string().describe('Account id of the player to ban'),
|
||||
banMask: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Stored verbatim; meaning unknown — the client sends `0`. Defaults to 0'),
|
||||
})
|
||||
|
||||
/** A stored room ban — what `POST /rooms/{roomId}/bans` answers in `value`. */
|
||||
export const RoomBanDto = z.object({
|
||||
RoomId: z.int(),
|
||||
BannedPlayerId: z.int(),
|
||||
BanMask: z.int(),
|
||||
BannedByAccountId: z.int().describe('Who issued the ban'),
|
||||
CreatedAt: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* One entry of `GET /rooms/{roomId}/bans` — the client's ban-list shape. camelCase and
|
||||
* a different field set from the {@link RoomBanDto} the write answers: no room id (the
|
||||
* path already says which room) and no ban mask.
|
||||
*/
|
||||
export const RoomBanEntryDto = z.object({
|
||||
accountId: z.int().describe('The banned player'),
|
||||
bannedByAccountId: z.int().describe('Who issued the ban'),
|
||||
banStartTime: z.string().describe('ISO 8601 UTC, when the ban was issued'),
|
||||
})
|
||||
|
||||
/** The envelope the ban write answers — same shape as the room writes, `value` is the ban. */
|
||||
export const RoomBanEnvelope = z.object({
|
||||
success: z.boolean(),
|
||||
error: z.string().describe('Empty on success'),
|
||||
value: RoomBanDto.nullable().describe('Null on a rejection'),
|
||||
})
|
||||
|
||||
/** `PUT /rooms/{roomId}/warning`. */
|
||||
export const WarningRequest = z.object({
|
||||
warningMask: z.string().describe('Content-warning bit flags, as an integer'),
|
||||
@@ -466,7 +519,7 @@ export const RestrictionsRequest = z.object({
|
||||
supportsJuniors: z.string().optional().describe('`True` / `False`'),
|
||||
})
|
||||
|
||||
/** `PUT /rooms/{roomId}/loadscreen` — appends one screen to the list. */
|
||||
/** `PUT /rooms/{roomId}/loadscreen` — the posted screen replaces the whole list. */
|
||||
export const LoadScreenRequest = z.object({
|
||||
imageName: z.string().describe('A key from the storage upload'),
|
||||
title: z.string().optional(),
|
||||
@@ -559,9 +612,12 @@ export const SaveSubRoomDataRequest = z.object({
|
||||
.object({ Filename: z.string() })
|
||||
.optional()
|
||||
.describe('The uploaded room-level data blob — becomes `RoomDataBlob`'),
|
||||
Description: z.string().optional().describe('The save comment; also written to the ROOM'),
|
||||
PersistenceVersion: z.int().optional(),
|
||||
InventionUsage: z.string().optional().describe('Written to the room'),
|
||||
Description: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The save comment — a description of THIS revision, not the room’s description'),
|
||||
PersistenceVersion: z.int().optional().describe('Recorded on the save and the subroom'),
|
||||
InventionUsage: z.string().optional().describe('Recorded on the subroom'),
|
||||
UnityAssetId: z.string().nullable().optional().describe('Recorded on the save when set'),
|
||||
AutoPublish: z
|
||||
.boolean()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user