mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -07:00
Compare commits
63 Commits
0.0.5
...
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 |
@@ -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.
|
||||
|
||||
@@ -99,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:
|
||||
|
||||
|
||||
@@ -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')]
|
||||
|
||||
@@ -11,9 +11,18 @@ import {
|
||||
searchAccounts,
|
||||
updateAccount,
|
||||
} from '@repo/domain'
|
||||
import { logger, withCleanSpec, withDefaultCors, 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,
|
||||
@@ -69,18 +78,17 @@ function unauthorized(c: Context<App>) {
|
||||
const DEFAULT_USERNAME_CHANGES = 1
|
||||
|
||||
/**
|
||||
* Username-change result envelope: `{ success, error, value }`. On success `value` is
|
||||
* the updated account; on a refusal `error` carries the message and `value` is an empty
|
||||
* string.
|
||||
* 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.
|
||||
*
|
||||
* A refusal is a 400. The body shape is unchanged — anything reading `error` still
|
||||
* works — but it used to come back at HTTP 200, which meant a caller keying off the
|
||||
* status read every refusal as a success. That envelope-at-200 was the reference's
|
||||
* (`RecNet`) convention and is kept by `POST /account/create`; here it was traded for a
|
||||
* status a client can actually branch on.
|
||||
* 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 }, error === '' ? 200 : 400)
|
||||
return c.json({ success: error === '', error, value })
|
||||
}
|
||||
|
||||
/** Read a single string field from a form-urlencoded / multipart body. */
|
||||
@@ -111,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',
|
||||
@@ -139,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,
|
||||
@@ -461,8 +477,7 @@ const app = new Hono<App>()
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(UsernameResult, 'The updated account, in the result envelope'),
|
||||
400: json(UsernameResult, 'Refused — `error` carries the reason, `value` is ""'),
|
||||
200: json(UsernameResult, 'Result envelope (success or a validation error)'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -77,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'),
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -220,9 +223,8 @@ describe('auth-gated endpoints', () => {
|
||||
...form({ username: 'Coach' }),
|
||||
headers: { ...(await bearer('893')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
})
|
||||
// A refusal is a 400 carrying the same { success, error, value } envelope. It used
|
||||
// to be HTTP 200, which read as a success to anything branching on the status.
|
||||
expect(res.status).toBe(400)
|
||||
// Business errors are HTTP 200 with the { success, error, value } envelope.
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { success: boolean; error: string; value: string }
|
||||
expect(body.success).toBe(false)
|
||||
expect(body.error).toMatch(/already taken/i)
|
||||
@@ -261,7 +263,7 @@ describe('auth-gated endpoints', () => {
|
||||
...form({ username: 'coachy' }),
|
||||
headers,
|
||||
})
|
||||
expect(blocked.status).toBe(400)
|
||||
expect(blocked.status).toBe(200)
|
||||
const blockedBody = (await blocked.json()) as { success: boolean; error: string }
|
||||
expect(blockedBody.success).toBe(false)
|
||||
expect(blockedBody.error).toMatch(/no username changes/i)
|
||||
@@ -509,8 +511,9 @@ describe('name, email and bio validation', () => {
|
||||
headers,
|
||||
})
|
||||
// Refused by the SCHEMA (see openapi.ts `UsernameRequest`) before the handler
|
||||
// runs — but still in this route's envelope, because the hook puts it there.
|
||||
expect(res.status, username).toBe(400)
|
||||
// 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/)
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
+244
-14
@@ -49,6 +49,13 @@ export const SCHEMA_DDL: string[] = [
|
||||
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)`,
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -73,14 +80,62 @@ export function isEventResponseType(value: number): boolean {
|
||||
return EVENT_RESPONSE_VALUES.includes(value)
|
||||
}
|
||||
|
||||
/** One player's answer to one event. */
|
||||
/**
|
||||
* 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.
|
||||
@@ -170,6 +225,27 @@ export interface PlayerEventNotification {
|
||||
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)
|
||||
@@ -181,10 +257,16 @@ function toTickPrecision(iso: string): string {
|
||||
* 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): PlayerEventNotification {
|
||||
export function toEventNotification(
|
||||
event: PlayerEvent,
|
||||
tags: EventTag[] = []
|
||||
): PlayerEventNotification {
|
||||
return {
|
||||
tags: [],
|
||||
tags,
|
||||
playerEventId: event.PlayerEventId,
|
||||
creatorPlayerId: event.CreatorPlayerId,
|
||||
roomId: event.RoomId,
|
||||
@@ -214,6 +296,39 @@ 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 —
|
||||
@@ -221,6 +336,8 @@ function eventTime(ms: number): string {
|
||||
* 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
|
||||
@@ -281,6 +398,33 @@ export function eventInputRejection(input: EventInput): string | null {
|
||||
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
|
||||
@@ -316,6 +460,7 @@ export function parseEventBody(body: unknown): EventInput {
|
||||
}
|
||||
|
||||
return {
|
||||
tags: parseEventTags(obj.Tags ?? obj.tags),
|
||||
imageName: nullableString('ImageName'),
|
||||
roomId: asInt(obj.RoomId),
|
||||
subRoomId: nullableInt('SubRoomId'),
|
||||
@@ -387,6 +532,9 @@ export async function createEvent(
|
||||
)
|
||||
.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
|
||||
}
|
||||
|
||||
@@ -422,6 +570,52 @@ export async function setEventResponse(
|
||||
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
|
||||
@@ -438,18 +632,26 @@ export async function getEventResponse(
|
||||
playerId: number
|
||||
): Promise<EventAttendeeRow | null> {
|
||||
return db
|
||||
.prepare('SELECT * FROM event_attendee WHERE event_id = ?1 AND player_id = ?2')
|
||||
.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. Backs a future guest list. */
|
||||
/**
|
||||
* 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 * FROM event_attendee WHERE event_id = ?1 ORDER BY responded_at, player_id')
|
||||
.prepare(
|
||||
`SELECT rowid AS id, * FROM event_attendee
|
||||
WHERE event_id = ?1 ORDER BY responded_at, player_id`
|
||||
)
|
||||
.bind(eventId)
|
||||
.all<EventAttendeeRow>()
|
||||
return results
|
||||
@@ -499,6 +701,9 @@ export async function updateEvent(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -581,9 +786,19 @@ function bySoonest(a: PlayerEvent, b: PlayerEvent): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Event search — the browse query on the player-events screen. `query` is matched
|
||||
* case-insensitively against the name and description, term by term; an empty query
|
||||
* browses everything upcoming. Paginated via skip/take, soonest first.
|
||||
* 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
|
||||
@@ -595,16 +810,31 @@ export async function searchEvents(
|
||||
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 — the filter stays in SQL.
|
||||
// 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('SELECT data FROM event WHERE end_time >= ?1')
|
||||
.bind(eventTime(Date.now()))
|
||||
.prepare(sql)
|
||||
.bind(eventTime(Date.now()), ...wanted)
|
||||
.all<EventRow>()
|
||||
let events = results.map((r) => JSON.parse(r.data) as PlayerEvent)
|
||||
|
||||
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||
for (const term of terms) {
|
||||
for (const term of textTerms) {
|
||||
events = events.filter(
|
||||
(e) => e.Name.toLowerCase().includes(term) || e.Description.toLowerCase().includes(term)
|
||||
)
|
||||
|
||||
@@ -1,421 +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,
|
||||
}
|
||||
}
|
||||
|
||||
/** How many recent images the slideshow feed returns when the caller doesn't say. */
|
||||
export const SLIDESHOW_LIMIT = 10
|
||||
|
||||
/**
|
||||
* The most a caller can ask the slideshow feed for. The endpoint is public and
|
||||
* unauthenticated, so the cap is what keeps an arbitrary `take` from turning into a
|
||||
* scan of the whole image table plus the two batched joins behind it.
|
||||
*/
|
||||
export const SLIDESHOW_MAX_LIMIT = 100
|
||||
|
||||
/** 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)
|
||||
}
|
||||
@@ -301,6 +301,48 @@ export async function getMyInventions(db: D1Database, playerId: number): Promise
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
|
||||
+89
-6
@@ -184,6 +184,21 @@ export const SendMessageRequest = z.object({
|
||||
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() })
|
||||
|
||||
@@ -420,6 +435,15 @@ 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
|
||||
@@ -445,6 +469,32 @@ export const PlayerEventDto = z.object({
|
||||
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'),
|
||||
@@ -469,12 +519,51 @@ export const PlayerEventRequest = PlayerEventDto.partial().extend({
|
||||
.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: z.array(PlayerEventDto).describe('Events the caller created, soonest first'),
|
||||
@@ -490,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 ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,427 +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))
|
||||
}
|
||||
|
||||
/**
|
||||
* The ids of everyone a player is actually friends with — `Friend` rows only, from
|
||||
* either side of the pair (the row records one direction, the friendship is mutual).
|
||||
* Pending requests and `None` rows are excluded, unlike
|
||||
* {@link getRelationshipsForPlayer}, which reports the whole graph.
|
||||
*/
|
||||
export async function getFriendIds(db: D1Database, playerId: number): Promise<number[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT CASE WHEN requester_id = ?1 THEN target_id ELSE requester_id END AS id
|
||||
FROM relationship
|
||||
WHERE relationship_type = ?2 AND (requester_id = ?1 OR target_id = ?1)`
|
||||
)
|
||||
.bind(playerId, RelationshipType.Friend)
|
||||
.all<{ id: number }>()
|
||||
return results.map((r) => r.id)
|
||||
}
|
||||
|
||||
/** How many mutual friends the mutual-friends lookup will return at most. */
|
||||
export const MUTUAL_FRIENDS_LIMIT = 100
|
||||
|
||||
/**
|
||||
* The ids two players are both friends with — the intersection of their friend lists,
|
||||
* ascending and capped at {@link MUTUAL_FRIENDS_LIMIT}. Each player's friends are a
|
||||
* small set, so the intersection is done in memory rather than as a SQL INTERSECT.
|
||||
*/
|
||||
export async function getMutualFriendIds(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
otherId: number
|
||||
): Promise<number[]> {
|
||||
const [mine, theirs] = await Promise.all([
|
||||
getFriendIds(db, playerId),
|
||||
getFriendIds(db, otherId),
|
||||
])
|
||||
const ours = new Set(theirs)
|
||||
return mine
|
||||
.filter((id) => ours.has(id))
|
||||
.sort((a, b) => a - b)
|
||||
.slice(0, MUTUAL_FRIENDS_LIMIT)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
+110
-12
@@ -3,19 +3,33 @@
|
||||
*
|
||||
* 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: nothing updates or dedupes a
|
||||
* report, so the table is a log of exactly what players submitted.
|
||||
* 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,
|
||||
* applied under its own `migrations_table` so it doesn't clash with the other
|
||||
* workers' migrations that share the database).
|
||||
* 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).
|
||||
*
|
||||
* Nothing acts on the rows yet — `/api/PlayerReporting/v1/moderationBlockDetails`
|
||||
* still answers "not blocked" unconditionally; this is the record that a future
|
||||
* moderation flow would read.
|
||||
* 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, sans seed rows). */
|
||||
/**
|
||||
* 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,
|
||||
@@ -27,10 +41,15 @@ export const SCHEMA_DDL: string[] = [
|
||||
height_reported REAL,
|
||||
room_id INTEGER,
|
||||
room_instance_type TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
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). */
|
||||
@@ -47,6 +66,16 @@ export interface ReportRow {
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,6 +93,8 @@ export interface NewReport {
|
||||
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). */
|
||||
@@ -72,8 +103,9 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
|
||||
.prepare(
|
||||
`INSERT INTO report (
|
||||
reporter_player_id, reported_player_id, report_category, details,
|
||||
height_reporter, height_reported, room_id, room_instance_type, created_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||
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(
|
||||
@@ -85,7 +117,8 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
|
||||
input.heightReported ?? null,
|
||||
input.roomId ?? null,
|
||||
input.roomInstanceType ?? null,
|
||||
new Date().toISOString()
|
||||
new Date().toISOString(),
|
||||
input.eventId ?? null
|
||||
)
|
||||
.first<ReportRow>()
|
||||
// RETURNING always yields the inserted row; the non-null assert keeps the caller
|
||||
@@ -101,3 +134,68 @@ export async function getReportsAgainst(db: D1Database, playerId: number): Promi
|
||||
.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>()
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
getInventionVersion,
|
||||
getMyInventions,
|
||||
getTopInventions,
|
||||
ownsAllInventions,
|
||||
parsePermissionLevel,
|
||||
publishInvention,
|
||||
searchInventions,
|
||||
@@ -82,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
|
||||
@@ -276,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)
|
||||
@@ -293,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(
|
||||
@@ -377,24 +422,26 @@ 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. A supplied name/description must satisfy the same rules `v6/save` ' +
|
||||
'enforces. 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'),
|
||||
@@ -402,7 +449,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
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'),
|
||||
|
||||
+281
-16
@@ -8,17 +8,23 @@ import { logger } from '@repo/hono-helpers'
|
||||
import { NotificationType } from '../../../notify/src/notification-types'
|
||||
import {
|
||||
createEvent,
|
||||
eventInputRejection,
|
||||
getEventAttendees,
|
||||
getEventById,
|
||||
getEventResponse,
|
||||
getEventsByClubs,
|
||||
getEventsByCreator,
|
||||
getEventsByIds,
|
||||
getEventTags,
|
||||
getLiveEvents,
|
||||
inviteToEvent,
|
||||
isEventResponseType,
|
||||
eventInputRejection,
|
||||
parseEventBody,
|
||||
searchEvents,
|
||||
setEventResponse,
|
||||
toEventListing,
|
||||
toEventNotification,
|
||||
toEventResponse,
|
||||
toEventResult,
|
||||
updateEvent,
|
||||
} from '../events-db'
|
||||
@@ -30,20 +36,28 @@ import {
|
||||
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 { PlayerEvent } from '../events-db'
|
||||
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'
|
||||
@@ -56,12 +70,16 @@ const HUB_INSTANCE = 'global'
|
||||
* 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): Promise<void> {
|
||||
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) }
|
||||
{ ...toEventNotification(event, tags) }
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push PlayerEventCreated notification', {
|
||||
@@ -71,6 +89,49 @@ async function notifyEventCreated(c: Context<App>, event: PlayerEvent): Promise<
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -83,6 +144,33 @@ async function notifyEventCreated(c: Context<App>, event: PlayerEvent): Promise<
|
||||
* 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({
|
||||
@@ -221,13 +309,23 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
tags: ['Events'],
|
||||
summary: 'Search player events',
|
||||
description:
|
||||
'The browse query on the player-events screen. `query` is matched ' +
|
||||
'case-insensitively against the event name and description, term by term; an empty ' +
|
||||
'query browses everything upcoming. 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.',
|
||||
'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 text; every term must match the name or description'),
|
||||
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') },
|
||||
@@ -305,6 +403,137 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// 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(
|
||||
@@ -343,7 +572,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
// 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)
|
||||
await notifyEventCreated(c, event, input.tags ?? [])
|
||||
return c.json(toEventResult(event))
|
||||
}
|
||||
)
|
||||
@@ -390,6 +619,29 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// 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(
|
||||
@@ -399,15 +651,28 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
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.',
|
||||
parameters: [idParam('eventId', 'Event id')],
|
||||
'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(PlayerEventDto, 'The event'),
|
||||
200: json(PlayerEventDetailsDto, 'The event, with `tags` when details were asked for'),
|
||||
404: { description: 'No such event (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const event = await getEventById(c.env.DB, Number.parseInt(c.req.param('eventId'), 10))
|
||||
return event === null ? c.body(null, 404) : c.json(event)
|
||||
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) })
|
||||
}
|
||||
)
|
||||
|
||||
@@ -11,16 +11,16 @@ import {
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
JsonObject,
|
||||
KeepsakeCategories,
|
||||
KeepsakeConfig,
|
||||
SanitizeRequest,
|
||||
stringParam,
|
||||
SubscriptionResponse,
|
||||
} 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.
|
||||
@@ -98,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 ---------------------------------------
|
||||
@@ -150,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,
|
||||
@@ -16,7 +15,9 @@ import {
|
||||
SLIDESHOW_LIMIT,
|
||||
SLIDESHOW_MAX_LIMIT,
|
||||
toImagesPlayer,
|
||||
} from '../images-db'
|
||||
} from '@repo/domain'
|
||||
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
CheeredEntry,
|
||||
|
||||
@@ -59,18 +59,22 @@ const asFloat = (v: string | undefined): number | null => {
|
||||
|
||||
// ---- 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.',
|
||||
@@ -122,9 +126,10 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
tags: ['Moderation'],
|
||||
summary: 'Submit a player report',
|
||||
description:
|
||||
'Records a player report in the `report` table — an append-only log; nothing ' +
|
||||
'dedupes or acts on the rows yet, and `moderationBlockDetails` still answers ' +
|
||||
'“not blocked” unconditionally.\n\n' +
|
||||
'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 ' +
|
||||
|
||||
@@ -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',
|
||||
|
||||
+121
-23
@@ -1,7 +1,17 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { getAccountsByIds } from '@repo/domain'
|
||||
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
|
||||
@@ -16,34 +26,59 @@ import {
|
||||
intQuery,
|
||||
json,
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
MutualFriendDto,
|
||||
RelationshipDto,
|
||||
SendMessageRequest,
|
||||
SendMultipleMessagesRequest,
|
||||
SuccessErrorEnvelope,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from '../openapi'
|
||||
import {
|
||||
acceptFriendRequest,
|
||||
addFriend,
|
||||
getMutualFriendIds,
|
||||
getRelationshipsForPlayer,
|
||||
MUTUAL_FRIENDS_LIMIT,
|
||||
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'
|
||||
|
||||
/**
|
||||
* 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
|
||||
* logged and swallowed — the DB write has already committed, so a hub hiccup must not fail
|
||||
@@ -303,24 +338,87 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
||||
// 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 message = {
|
||||
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)
|
||||
}
|
||||
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
toPlayerId,
|
||||
NotificationType.MessageReceived,
|
||||
message
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push MessageReceived notification', {
|
||||
toPlayerId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +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,
|
||||
@@ -13,21 +23,28 @@ import {
|
||||
|
||||
import '../../api.app'
|
||||
|
||||
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 { createImage, getImageByName, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
|
||||
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
|
||||
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db'
|
||||
import { getReportsAgainst, SCHEMA_DDL as REPORTS_SCHEMA_DDL } from '../../reports-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 { PlayerEvent, PlayerEventResult } from '../../events-db'
|
||||
import type { SavedImage } from '../../images-db'
|
||||
import type { InventionSaveResult, SavedInvention } from '../../inventions-db'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
@@ -85,19 +102,23 @@ 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()
|
||||
@@ -291,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',
|
||||
@@ -353,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 () => {
|
||||
@@ -836,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> => {
|
||||
@@ -1047,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',
|
||||
@@ -1331,6 +1519,84 @@ describe('player reports', () => {
|
||||
// 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', () => {
|
||||
@@ -2329,6 +2595,63 @@ describe('messages', () => {
|
||||
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', () => {
|
||||
@@ -2423,9 +2746,18 @@ describe('mutual friends', () => {
|
||||
|
||||
describe('player events', () => {
|
||||
const HOUR = 60 * 60 * 1000
|
||||
/** Seconds precision, no milliseconds — the form the client sends and reads back. */
|
||||
/**
|
||||
* 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(Date.now() + offsetMs).toISOString().replace(/\.\d{3}Z$/, 'Z')
|
||||
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}`, {
|
||||
@@ -2684,6 +3016,23 @@ describe('player events', () => {
|
||||
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}`
|
||||
@@ -2726,6 +3075,95 @@ describe('player events', () => {
|
||||
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)
|
||||
@@ -2819,6 +3257,53 @@ describe('player events', () => {
|
||||
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' })
|
||||
|
||||
@@ -2843,6 +3328,165 @@ describe('player events', () => {
|
||||
).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,
|
||||
@@ -2918,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}`)
|
||||
@@ -2955,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',
|
||||
@@ -2972,6 +3618,7 @@ 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}',
|
||||
@@ -2980,6 +3627,7 @@ describe('openapi', () => {
|
||||
'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',
|
||||
@@ -3000,7 +3648,6 @@ 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',
|
||||
@@ -3009,11 +3656,15 @@ describe('openapi', () => {
|
||||
'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}',
|
||||
@@ -3065,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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
+133
-9
@@ -24,6 +24,9 @@ import {
|
||||
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,
|
||||
@@ -58,6 +61,23 @@ 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
|
||||
@@ -175,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
|
||||
@@ -545,7 +582,25 @@ const app = new Hono<App>()
|
||||
'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,
|
||||
@@ -557,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(
|
||||
@@ -700,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
|
||||
@@ -869,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
|
||||
@@ -898,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 */
|
||||
|
||||
@@ -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, '/')
|
||||
@@ -473,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)
|
||||
@@ -490,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')
|
||||
})
|
||||
|
||||
@@ -512,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)
|
||||
@@ -1104,9 +1153,7 @@ describe('CORS', () => {
|
||||
)
|
||||
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'
|
||||
)
|
||||
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
|
||||
@@ -1148,3 +1195,210 @@ describe('CORS', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// A banned account is refused a token at all — the outer wall of a ban, since with no
|
||||
// token every other worker is shut to it. The ban is a `report` row with `banned` set
|
||||
// (the api worker owns that table); matchmaking enforces the same ban on tokens issued
|
||||
// before it was handed down.
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+47
-35
@@ -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())
|
||||
|
||||
@@ -247,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.
|
||||
|
||||
+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,14 +2,6 @@ import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
glyphLength,
|
||||
MAX_CLUB_DESCRIPTION_LENGTH,
|
||||
MAX_CLUB_NAME_LENGTH,
|
||||
} from '@repo/domain'
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import {
|
||||
clearHomeClub,
|
||||
ClubJoinability,
|
||||
@@ -27,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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
|
||||
}
|
||||
+1070
-122
@@ -3,22 +3,28 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
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'
|
||||
|
||||
// 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 (owned by the `notify` worker). Imported
|
||||
// as a value — the enum has no runtime dependencies.
|
||||
// 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'
|
||||
@@ -36,6 +42,11 @@ import {
|
||||
isSpendable,
|
||||
spendCurrency,
|
||||
} from './balance-db'
|
||||
import {
|
||||
claimChallengeGift,
|
||||
getCompletedChallengeIds,
|
||||
recordChallengeProgress,
|
||||
} from './challenge-db'
|
||||
import {
|
||||
consumeConsumable,
|
||||
countConsumable,
|
||||
@@ -60,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'
|
||||
@@ -85,7 +105,7 @@ 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 (avatar items, equipment, bought inventions), consumables, saved outfits,
|
||||
* avatars and gift boxes are D1-backed;
|
||||
* 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, …).
|
||||
*
|
||||
@@ -100,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
|
||||
@@ -200,36 +240,63 @@ 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 reflects immediately, without
|
||||
* waiting for a `GET /balance` re-fetch.
|
||||
* THE BALANCE-FRAME RULE, which both balance bugs came from getting wrong.
|
||||
*
|
||||
* `Balance` is the CHANGE — negative for a debit, positive for a payout — not the
|
||||
* resulting total. The client ADDS what it receives to the balance it is already showing,
|
||||
* so sending the total made a 10,000-token player who earned 250 read 20,250: their own
|
||||
* balance plus the new total. That also makes this frame non-idempotent, so push exactly
|
||||
* once per change and never re-send it as a "refresh".
|
||||
* 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:
|
||||
*
|
||||
* `BalanceType` is -2 (account-wide, all platforms). Best-effort: a hub failure is logged
|
||||
* and swallowed, since the balance change has already committed.
|
||||
* 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>,
|
||||
accountId: number,
|
||||
currencyType: number,
|
||||
change: 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: change,
|
||||
CurrencyType: currencyType,
|
||||
BalanceType: ALL_PLATFORMS,
|
||||
}
|
||||
payload
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push StorefrontBalanceUpdate notification', {
|
||||
@@ -239,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
|
||||
@@ -272,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
|
||||
@@ -358,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,
|
||||
@@ -370,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
|
||||
@@ -500,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).
|
||||
@@ -1019,8 +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 frame carrying the',
|
||||
'same change, which the client ADDS to the balance it is showing.',
|
||||
'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'),
|
||||
@@ -1096,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 debit over the socket so the buyer's client updates the shown total
|
||||
// immediately — the buyer (`id`) is who was charged, in the currency they spent. The
|
||||
// frame carries the CHANGE, so a purchase is negative. Best-effort; the HTTP response
|
||||
// carries the same change either way.
|
||||
await pushBalanceUpdate(c, id, currencyType as number, -price.Price)
|
||||
// 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 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: [
|
||||
{
|
||||
@@ -1160,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,
|
||||
},
|
||||
],
|
||||
@@ -1214,9 +2006,9 @@ const app = new Hono<App>({ strict: false })
|
||||
'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 StorefrontBalanceUpdate push carrying',
|
||||
'their CHANGE (the buyer’s negative, the creator’s positive), which the client adds to',
|
||||
'the balance it is showing — unlike this response body, which replaces it.',
|
||||
'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,
|
||||
@@ -1304,27 +2096,34 @@ const app = new Hono<App>({ strict: false })
|
||||
// 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)
|
||||
await creditCurrency(
|
||||
const creatorBalance = await creditCurrency(
|
||||
c.env.DB,
|
||||
invention.CreatorPlayerId,
|
||||
CurrencyType.RecCenterTokens,
|
||||
price,
|
||||
startingTokens
|
||||
)
|
||||
// The creator is a different, probably-online player: push the payout so a sale
|
||||
// lands on their shown balance without a re-fetch. Positive, because the frame
|
||||
// carries the change. Best-effort, as everywhere.
|
||||
await pushBalanceUpdate(c, invention.CreatorPlayerId, CurrencyType.RecCenterTokens, price)
|
||||
// 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 socket frame below is the other way round: the HTTP
|
||||
// body REPLACES the shown balance, the push ADDS to it.
|
||||
// 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 change to push for it.
|
||||
// A free invention moved nothing, so there is no purchase to report.
|
||||
if (price > 0) {
|
||||
await pushBalanceUpdate(c, id, CurrencyType.RecCenterTokens, -price)
|
||||
await pushBalancePurchase(c, id, CurrencyType.RecCenterTokens, -price, balance)
|
||||
}
|
||||
return c.json({
|
||||
BalanceUpdateResponse: {
|
||||
@@ -1348,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,
|
||||
})
|
||||
}
|
||||
)
|
||||
@@ -1402,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 "[]".
|
||||
@@ -1420,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
|
||||
|
||||
@@ -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'),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -195,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
|
||||
}
|
||||
@@ -6,13 +6,21 @@ import '../../econ.app'
|
||||
|
||||
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,
|
||||
@@ -21,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'
|
||||
|
||||
@@ -34,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 () => {
|
||||
@@ -42,6 +55,10 @@ 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()
|
||||
@@ -145,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',
|
||||
@@ -342,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)
|
||||
@@ -729,13 +784,25 @@ describe('econ endpoints', () => {
|
||||
expect(gift.AvatarItemDesc).not.toBe('')
|
||||
expect(gift.Id).toBeGreaterThan(0)
|
||||
|
||||
// The socket frame carries the same change the response does — the client adds it to
|
||||
// the balance it is showing, so the resulting total here would double-count the 9550.
|
||||
// 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: STOREFRONT_BALANCE_UPDATE,
|
||||
payload: { Balance: -450, CurrencyType: 2, BalanceType: -2 },
|
||||
notificationType: NotificationType.StorefrontBalancePurchase,
|
||||
payload: {
|
||||
// 1400 = CommercePurchase; -2 = NonPurchasedNotUsableInP2P, the only bucket we use.
|
||||
BalanceAddType: 1400,
|
||||
Delta: -450,
|
||||
Balance: 9550,
|
||||
Platform: -2,
|
||||
CurrencyType: 2,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
@@ -1010,19 +1077,16 @@ describe('econ endpoints', () => {
|
||||
* test sees what was actually pushed.
|
||||
*/
|
||||
const drainFrames = async (): Promise<
|
||||
Array<{ accountId: number; notificationType: number; payload: Record<string, number> }>
|
||||
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, number> }>
|
||||
Array<{ accountId: number; notificationType: number; payload: Record<string, unknown> }>
|
||||
>
|
||||
}
|
||||
).drainFrames()
|
||||
|
||||
/** `NotificationType.StorefrontBalanceUpdate` in the notify worker's enum. */
|
||||
const STOREFRONT_BALANCE_UPDATE = 61
|
||||
|
||||
// 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(
|
||||
@@ -1089,19 +1153,31 @@ describe('econ endpoints', () => {
|
||||
).toBe(DEFAULT_STARTING_TOKENS + 250)
|
||||
expect(await getOwnedInventionIds(env.DB, 51)).toEqual([9])
|
||||
|
||||
// Both sides get a socket frame carrying their CHANGE, not their new total: the client
|
||||
// ADDS what it receives to the balance it is showing, so a total would have the creator
|
||||
// reading their own balance plus the payout. Equal and opposite, like the ledger.
|
||||
// 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: STOREFRONT_BALANCE_UPDATE,
|
||||
payload: { Balance: 250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 },
|
||||
notificationType: NotificationType.StorefrontBalanceUpdate,
|
||||
payload: {
|
||||
Balance: DEFAULT_STARTING_TOKENS + 250,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
Platform: -2,
|
||||
},
|
||||
},
|
||||
{
|
||||
accountId: 51,
|
||||
notificationType: STOREFRONT_BALANCE_UPDATE,
|
||||
payload: { Balance: -250, CurrencyType: CurrencyType.RecCenterTokens, BalanceType: -2 },
|
||||
notificationType: NotificationType.StorefrontBalancePurchase,
|
||||
payload: {
|
||||
BalanceAddType: 1400,
|
||||
Delta: -250,
|
||||
Balance: DEFAULT_STARTING_TOKENS - 250,
|
||||
Platform: -2,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -1311,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 () => {
|
||||
@@ -1349,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 () => {
|
||||
@@ -1425,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": ""
|
||||
}
|
||||
|
||||
+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;
|
||||
|
||||
@@ -19,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
|
||||
}
|
||||
+146
-29
@@ -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'
|
||||
|
||||
@@ -152,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) {
|
||||
@@ -172,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 })
|
||||
@@ -184,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 })
|
||||
@@ -256,8 +327,9 @@ app.get(
|
||||
'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 RSA-SHA1 `Content-Signature`',
|
||||
'header the client verifies against `KEY:RSA:p1.rec.net`.',
|
||||
'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',
|
||||
@@ -301,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: [
|
||||
{
|
||||
@@ -343,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'] },
|
||||
},
|
||||
@@ -358,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' },
|
||||
},
|
||||
@@ -369,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'),
|
||||
@@ -381,7 +479,7 @@ 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
|
||||
@@ -389,16 +487,22 @@ app.get(
|
||||
// one. Skip the precondition when a transform is requested.
|
||||
const ifNoneMatch = transform ? undefined : c.req.header('if-none-match')?.replace(/"/g, '')
|
||||
const { bucket, objectKey } = resolveObject(c.env, key)
|
||||
const object = await bucket.get(
|
||||
objectKey,
|
||||
ifNoneMatch ? { onlyIf: { etagDoesNotMatch: ifNoneMatch } } : undefined
|
||||
)
|
||||
// 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()
|
||||
@@ -409,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'
|
||||
|
||||
@@ -38,6 +38,18 @@ const R2_KEY = 'user-photo.jpg'
|
||||
// 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' },
|
||||
@@ -128,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)
|
||||
@@ -188,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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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",
|
||||
@@ -54,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 */
|
||||
|
||||
+429
-25
@@ -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,
|
||||
@@ -24,7 +26,9 @@ import {
|
||||
getRoomInstanceSummariesByRoom,
|
||||
isClubMember,
|
||||
isPlayerBannedFromRoom,
|
||||
MatchmakingErrorCode,
|
||||
MessageType,
|
||||
recordRoomVisit,
|
||||
refreshInstanceFullness,
|
||||
RoomInstanceType,
|
||||
setPresence,
|
||||
@@ -35,11 +39,20 @@ import {
|
||||
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,
|
||||
@@ -126,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>
|
||||
|
||||
@@ -233,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 —
|
||||
@@ -258,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
|
||||
@@ -274,16 +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
|
||||
|
||||
/**
|
||||
* MatchmakingErrorCode for "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.
|
||||
* "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 = 55
|
||||
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'
|
||||
@@ -494,27 +637,94 @@ async function inviteParty(
|
||||
* there (NoSuchRoom) from one the caller is banned from — those answer different codes.
|
||||
*/
|
||||
type ResolvedInstance =
|
||||
| { instance: RoomInstance; errorCode: 0 }
|
||||
| { instance: null; errorCode: number }
|
||||
| { 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. 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
|
||||
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 { instance: null, errorCode: NO_SUCH_ROOM }
|
||||
if (!requested) return { instance: null, errorCode: NO_SUCH_ROOM }
|
||||
|
||||
const { room, subRoomId } = await substituteRoom(c, requested, requestedSubRoomId)
|
||||
|
||||
const f = instanceFieldsFromRoom(room, subRoomId)
|
||||
|
||||
@@ -565,7 +775,7 @@ async function resolveRoomInstance(
|
||||
instance.photonRoomId,
|
||||
f.subRoomId
|
||||
),
|
||||
errorCode: 0,
|
||||
errorCode: MatchmakingErrorCode.Success,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -609,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())
|
||||
|
||||
@@ -878,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).
|
||||
@@ -943,6 +1254,88 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -1201,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,
|
||||
},
|
||||
}),
|
||||
@@ -1475,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`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1513,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' }],
|
||||
|
||||
@@ -150,6 +150,23 @@ export const MatchmakeResponse = z.object({
|
||||
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 })
|
||||
@@ -1032,8 +1497,11 @@ describe('auth-gated endpoints', () => {
|
||||
|
||||
// No token → 401.
|
||||
expect(
|
||||
(await exports.default.fetch(`${ORIGIN}/matchmake/instance/${instanceId}`, { method: 'POST' }))
|
||||
.status
|
||||
(
|
||||
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,
|
||||
@@ -1412,8 +1880,9 @@ describe('auth-gated endpoints', () => {
|
||||
roomInstance: null,
|
||||
})
|
||||
} finally {
|
||||
await env.DB.prepare('DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9800')
|
||||
.run()
|
||||
await env.DB.prepare(
|
||||
'DELETE FROM room_ban WHERE room_id = 2 AND banned_player_id = 9800'
|
||||
).run()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1549,12 +2018,14 @@ 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}',
|
||||
@@ -1566,6 +2037,7 @@ describe('auth-gated endpoints', () => {
|
||||
'POST /player/notifydisconnect',
|
||||
'POST /roominstance/{id}/markprivate',
|
||||
'POST /roominstance/{id}/reportjoinresult',
|
||||
'PUT /player/avoidjuniors',
|
||||
'PUT /player/gameserverregionpings',
|
||||
'PUT /player/photonregionpings',
|
||||
'PUT /player/statusvisibility',
|
||||
@@ -1579,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 = "ModerationRoomBan",
|
||||
/**
|
||||
* @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',
|
||||
}
|
||||
|
||||
+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,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,6 +89,9 @@ 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')
|
||||
|
||||
@@ -137,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(),
|
||||
@@ -159,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(),
|
||||
@@ -238,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. */
|
||||
@@ -310,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. */
|
||||
@@ -598,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()
|
||||
|
||||
+152
-44
@@ -29,14 +29,14 @@ import {
|
||||
getRoomsByIds,
|
||||
getSimilarRooms,
|
||||
getSubRoomPermissions,
|
||||
getSubRoomSaveById,
|
||||
getSubRoomSaves,
|
||||
getVisitedRooms,
|
||||
MAX_ROOM_NAME_LENGTH,
|
||||
modifySubRoom,
|
||||
nameRejection,
|
||||
publishSubRoomSave,
|
||||
removeCheer,
|
||||
removeFavorite,
|
||||
roomNameRejection,
|
||||
saveSubRoomData,
|
||||
searchRooms,
|
||||
setRoomDescription,
|
||||
@@ -50,12 +50,19 @@ import {
|
||||
unbanPlayerFromRoom,
|
||||
updateRoomFields,
|
||||
} 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 { 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.
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
|
||||
import {
|
||||
AccessibilityRequest,
|
||||
AUTHED,
|
||||
@@ -85,18 +92,20 @@ import {
|
||||
PublishSaveRequest,
|
||||
RestrictionsRequest,
|
||||
RoleRequest,
|
||||
RoomBanEnvelope,
|
||||
RoomBanEntryDto,
|
||||
RoomBanEnvelope,
|
||||
RoomDto,
|
||||
RoomEnvelope,
|
||||
roomIdParam,
|
||||
RoomLookup,
|
||||
RoomResultEnvelope,
|
||||
RoomSaveEnvelope,
|
||||
saveIdParam,
|
||||
SaveSubRoomDataRequest,
|
||||
ServiceStatus,
|
||||
stringQuery,
|
||||
SubRoomAccessibilityRequest,
|
||||
SubRoomDataSaveResponseDto,
|
||||
subRoomIdParam,
|
||||
SubRoomPermissionsRequest,
|
||||
SubRoomSavesPage,
|
||||
@@ -157,6 +166,7 @@ const MAKER_PEN_ACCOUNT_IDS = new Set([1, 2, 3])
|
||||
*/
|
||||
interface PresenceView {
|
||||
roomInstanceId?: number
|
||||
roomId?: number
|
||||
subRoomId?: number
|
||||
}
|
||||
|
||||
@@ -241,6 +251,31 @@ async function handlePhotonAccessToken(c: Context<App>) {
|
||||
return c.json(photonAccessToken(accountId, instance?.roomInstanceId ?? null, overrides))
|
||||
}
|
||||
|
||||
/**
|
||||
* May this caller read the room's saves? The room's creator always may. So may anyone
|
||||
* whose live presence puts them IN the room: they are already loading its scene, and the
|
||||
* client resolves which version to load — the published one or the creator's latest — from
|
||||
* the save list, so refusing everyone but the creator leaves a visitor unable to load what
|
||||
* the instance is actually running.
|
||||
*
|
||||
* Presence is the shared `presence` table the `match` heartbeat maintains, so this grant
|
||||
* lasts only as long as the player is actually there (rows carry an absolute expiry and
|
||||
* expired ones don't read back). Co-owners get nothing extra from being co-owners — a
|
||||
* co-owner standing in the room passes because of where they are, not what they hold.
|
||||
*
|
||||
* The presence read only happens for a non-creator, so the owner's own path stays one query.
|
||||
*/
|
||||
async function canReadSaves(
|
||||
c: Context<App>,
|
||||
room: Record<string, unknown>,
|
||||
roomId: number,
|
||||
accountId: number
|
||||
): Promise<boolean> {
|
||||
if (room.CreatorAccountId === accountId) return true
|
||||
const instance = (await getPresence<PresenceView>(c.env.DB, accountId))?.roomInstance
|
||||
return instance?.roomId === roomId
|
||||
}
|
||||
|
||||
/** The Bearer token's account id (`sub`), or null when there's no valid token. */
|
||||
async function authedAccountId(c: Context<App>): Promise<number | null> {
|
||||
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
@@ -367,7 +402,7 @@ async function pushRoomUpdate(
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
playerId,
|
||||
'RoomUpdate',
|
||||
NotificationType.SubscriptionUpdateRoom,
|
||||
room
|
||||
)
|
||||
} catch (err) {
|
||||
@@ -518,6 +553,15 @@ 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 — its "My rooms" list is this worker's
|
||||
// `GET /rooms/ownedby/me` — 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())
|
||||
|
||||
@@ -597,9 +641,11 @@ const app = new Hono<App>()
|
||||
|
||||
// "Hot" rooms feed — public, non-dorm rooms ordered by live player count (their
|
||||
// instances' presence), then stored engagement, optionally filtered to a single
|
||||
// `tag` (e.g. `rro`). `tag=new` is a pseudo-tag no room carries: it serves the
|
||||
// player-made (non-RRO) rooms newest-first. Paginated via skip/take (take defaults
|
||||
// to 100). Returns `{ Results, TotalResults }` like search.
|
||||
// `tag` (e.g. `rro`). `tag=new` and `tag=community` are pseudo-tags no room
|
||||
// carries: `new` serves the player-made (non-RRO) rooms newest-first, `community`
|
||||
// keeps the normal ordering but drops the rooms the Coach account created.
|
||||
// Paginated via skip/take (take defaults to 100). Returns
|
||||
// `{ Results, TotalResults }` like search.
|
||||
.get(
|
||||
'/rooms/hot',
|
||||
describeRoute({
|
||||
@@ -609,11 +655,16 @@ const app = new Hono<App>()
|
||||
'Public, non-dorm rooms ordered by how many players are in them right now — live',
|
||||
'presence summed across each room’s instances — falling back to stored engagement',
|
||||
'for rooms nobody is in. Optionally narrowed to a single `tag` (the browse screen’s',
|
||||
'filter chips post one, e.g. `rro`). The `new` chip is a pseudo-tag — no room carries',
|
||||
'a `new` tag — and instead serves the player-made (non-RRO) rooms, newest first.',
|
||||
'filter chips post one, e.g. `rro`). The `new` and `community` chips are pseudo-tags —',
|
||||
'no room carries either. `new` instead serves the player-made (non-RRO) rooms, newest',
|
||||
'first; `community` keeps the ordering above but serves only rooms the Coach account',
|
||||
'(the system account owning the seeded first-party rooms) did not create.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
stringQuery('tag', 'Restrict to rooms carrying this tag (or `new`, a pseudo-tag)'),
|
||||
stringQuery(
|
||||
'tag',
|
||||
'Restrict to rooms carrying this tag (or `new`/`community`, pseudo-tags)'
|
||||
),
|
||||
...pageParams(100),
|
||||
],
|
||||
responses: { 200: json(PagedRooms, 'The feed page') },
|
||||
@@ -743,7 +794,10 @@ const app = new Hono<App>()
|
||||
|
||||
// Rooms created/owned by the caller. Auth-gated — no token is a 401, never
|
||||
// account 1. `ownedby/me` drops the dorm (it's not a room the player made);
|
||||
// the `createdby` variants return everything the account created.
|
||||
// the `createdby` variants return everything the account created. None of them
|
||||
// filter on Accessibility: these are the owner's own "My Rooms" lists, so a room
|
||||
// they haven't published yet (a fresh clone is Private) has to show up here.
|
||||
// Only the public `ownedby/:accountId` profile list is accessibility-filtered.
|
||||
.get(
|
||||
'/roomserver/rooms/createdby/me',
|
||||
describeRoute({
|
||||
@@ -767,7 +821,9 @@ const app = new Hono<App>()
|
||||
description: [
|
||||
'The caller’s own rooms with the dorm filtered out: a dorm is auto-provisioned, not a',
|
||||
'room the player made, so it doesn’t belong in the “rooms you own” list. Use',
|
||||
'`createdby/me` for everything the account created.',
|
||||
'`createdby/me` for everything the account created. Accessibility is deliberately NOT',
|
||||
'filtered — this is the owner’s own list, so unpublished (Private) rooms appear, unlike',
|
||||
'the public `ownedby/{accountId}` profile list.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
@@ -1048,8 +1104,9 @@ const app = new Hono<App>()
|
||||
'Copies a room’s content (scene, subrooms, settings) into a new room owned by the',
|
||||
'caller. Cloning is the only way to make a room, so the per-account room cap is',
|
||||
'enforced here — it counts the rooms the account created, minus their auto-provisioned',
|
||||
'dorm (`MAX_ROOMS_PER_ACCOUNT`; 0 lifts the cap). The clone starts with no tags and',
|
||||
'`IsRRO` cleared.',
|
||||
'dorm (`MAX_ROOMS_PER_ACCOUNT`; 0 lifts the cap). The clone starts with no tags,',
|
||||
'`IsRRO` cleared, and PRIVATE accessibility — a new room is unpublished until its',
|
||||
'owner sets its accessibility, so it never lands in the public feeds on creation.',
|
||||
'',
|
||||
'Rejections — a blank or taken name, the cap, a source that disallows cloning — are',
|
||||
'HTTP 200 with `success: false` and the message the client shows.',
|
||||
@@ -1074,7 +1131,7 @@ const app = new Hono<App>()
|
||||
|
||||
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your room.')
|
||||
// Shape before availability, so a rejected name costs no D1 read.
|
||||
const badName = nameRejection(name, 'room name', MAX_ROOM_NAME_LENGTH)
|
||||
const badName = roomNameRejection(name, 'room name')
|
||||
if (badName !== null) return roomEnvelope(c, null, badName)
|
||||
if (await getRoomByName(c.env.DB, name)) {
|
||||
return roomEnvelope(c, null, 'A room with that name already exists!')
|
||||
@@ -1202,7 +1259,7 @@ const app = new Hono<App>()
|
||||
}
|
||||
// Same ErrorId as the empty case — the client keys off it to mark the field, and
|
||||
// both are the name being unusable. The sentence is what tells them which.
|
||||
const badName = nameRejection(name, 'room name', MAX_ROOM_NAME_LENGTH)
|
||||
const badName = roomNameRejection(name, 'room name')
|
||||
if (badName !== null) {
|
||||
return roomResult(c, { Success: false, ErrorId: 'Rooms.InvalidName', Error: badName })
|
||||
}
|
||||
@@ -1222,9 +1279,9 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// Toggle a tag on a room. Auth-gated (401) and owner-only. Body is the `tag`
|
||||
// form field. There's no delete/patch endpoint, so this call toggles: it adds
|
||||
// the tag (Type 0) if absent and removes it if present. The "main" tags
|
||||
// Toggle a tag on a room. Auth-gated (401) and owner/co-owner-only (403). Body is
|
||||
// the `tag` form field. There's no delete/patch endpoint, so this call toggles: it
|
||||
// adds the tag (Type 0) if absent and removes it if present. The "main" tags
|
||||
// (#pvp/#quest/#game/#hangout/#art) are radio buttons — setting one clears the
|
||||
// others. Returns the `{ success, error, value }` envelope with the updated
|
||||
// room as `value`; business failures are 200 with success:false.
|
||||
@@ -1234,11 +1291,11 @@ const app = new Hono<App>()
|
||||
tags: ['Room settings'],
|
||||
summary: 'Toggle a tag on a room',
|
||||
description: [
|
||||
'Owner-only. There is no delete/patch counterpart, so this call TOGGLES: it adds the',
|
||||
'tag (Type 0) when absent and removes it when present. The “main” tags',
|
||||
'(`pvp`/`quest`/`game`/`hangout`/`art`) behave as radio buttons — setting one clears',
|
||||
'the others. Answers the lowercase envelope with the updated room, which the client',
|
||||
're-renders from.',
|
||||
'Owner or co-owner only (403 otherwise). There is no delete/patch counterpart, so',
|
||||
'this call TOGGLES: it adds the tag (Type 0) when absent and removes it when',
|
||||
'present. The “main” tags (`pvp`/`quest`/`game`/`hangout`/`art`) behave as radio',
|
||||
'buttons — setting one clears the others. Answers the lowercase envelope with the',
|
||||
'updated room, which the client re-renders from.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [roomIdParam],
|
||||
@@ -1246,6 +1303,7 @@ const app = new Hono<App>()
|
||||
responses: {
|
||||
200: json(RoomEnvelope, 'The updated room, or a rejection with `success: false`'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
@@ -1255,9 +1313,9 @@ const app = new Hono<App>()
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room) return roomEnvelope(c, null, 'This room does not exist!')
|
||||
if (room.CreatorAccountId !== accountId) {
|
||||
return roomEnvelope(c, null, 'You are not the owner of this room!')
|
||||
}
|
||||
// A valid token but not the room's owner/co-owner → 403 (the auth gate above
|
||||
// already returned 401 for a missing/invalid token).
|
||||
if (!canManageRoom(room, accountId)) return c.body(null, 403)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const tag = typeof body.tag === 'string' ? body.tag.trim() : ''
|
||||
@@ -1872,8 +1930,8 @@ const app = new Hono<App>()
|
||||
|
||||
// A subroom's saved-data versions — the room-history / "restore a save" list. Every
|
||||
// save is its own `subroom_save` row (nothing is overwritten), so this is real
|
||||
// history, newest first, paged by skip/take. Auth-gated (401) and creator-only (403):
|
||||
// the list exposes unpublished saves, which only the owner is entitled to see.
|
||||
// history, newest first, paged by skip/take. Auth-gated (401), and readable by the
|
||||
// room's creator or anyone whose presence puts them in the room (see `canReadSaves`).
|
||||
.get(
|
||||
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves',
|
||||
describeRoute({
|
||||
@@ -1885,9 +1943,11 @@ const app = new Hono<App>()
|
||||
'only when the subroom has never been saved.',
|
||||
'`unityAssetTarget`/`unityAssetVersion` are accepted and ignored.',
|
||||
'',
|
||||
'Owner-only (403 otherwise) — the list includes STAGED saves that were never',
|
||||
'published, so it is not public. It is what the client reads to offer the owner',
|
||||
'“load the latest or the published version?” when they enter a private instance.',
|
||||
'The list includes STAGED saves that were never published, so it is not public:',
|
||||
'the room’s creator may read it, and so may anyone standing IN the room (their live',
|
||||
'presence says so). Anyone else is a 403. It is what the client reads to resolve',
|
||||
'“load the latest or the published version?” on entering a private instance — a',
|
||||
'visitor who cannot read it cannot load what the instance is running.',
|
||||
'',
|
||||
'`TotalResults` and `TotalCount` carry the same number: the client’s paged DTO and',
|
||||
'the reference disagree on the name, so both are emitted.',
|
||||
@@ -1918,7 +1978,7 @@ const app = new Hono<App>()
|
||||
if (!room || !findSubRoom(room, subRoomId)) {
|
||||
return c.json({ Results: [], TotalResults: 0, TotalCount: 0 })
|
||||
}
|
||||
if (room.CreatorAccountId !== accountId) return c.body(null, 403)
|
||||
if (!(await canReadSaves(c, room, roomId, accountId))) return c.body(null, 403)
|
||||
const saves = await getSubRoomSaves(c.env.DB, subRoomId)
|
||||
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '', 10)
|
||||
@@ -1930,11 +1990,58 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// One of a subroom's saves by id — the detail behind a row of the `…/saves` list.
|
||||
// Same gate as that list: a save id resolves whether or not it was ever published, so
|
||||
// this exposes the same unpublished work, to the same readers.
|
||||
.get(
|
||||
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/saves/:saveId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Subrooms'],
|
||||
summary: 'One of a subroom’s saves by id',
|
||||
description: [
|
||||
'A single save, in the SAME camelCase projection the room save that created it',
|
||||
'returned — not the PascalCase rows `…/saves` lists. Save ids are globally',
|
||||
'unique but resolved scoped to the subroom, so one subroom cannot read another’s',
|
||||
'save by guessing an id: a save that belongs elsewhere is a 404, same as an unknown',
|
||||
'one.',
|
||||
'',
|
||||
'Gated like the list it details — the room’s creator, or anyone whose presence puts',
|
||||
'them in the room. A save id resolves whether or not it was ever published, so this',
|
||||
'reads unpublished work.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [roomIdParam, subRoomIdParam, saveIdParam],
|
||||
responses: {
|
||||
200: json(SubRoomDataSaveResponseDto, 'The save'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: FORBIDDEN_RESPONSE,
|
||||
404: { description: 'No such room, subroom, or save on that subroom' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedAccountId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
const subRoomId = Number.parseInt(c.req.param('subRoomId'), 10)
|
||||
const saveId = Number.parseInt(c.req.param('saveId'), 10)
|
||||
|
||||
// Scoped through the room, like the list, so a subroom id from another room can't
|
||||
// be used to read its saves.
|
||||
const room = await getRoomById(c.env.DB, roomId)
|
||||
if (!room || !findSubRoom(room, subRoomId)) return c.notFound()
|
||||
if (!(await canReadSaves(c, room, roomId, accountId))) return c.body(null, 403)
|
||||
|
||||
const save = await getSubRoomSaveById(c.env.DB, subRoomId, saveId)
|
||||
return save ? c.json(toSaveResponse(save)) : c.notFound()
|
||||
}
|
||||
)
|
||||
|
||||
// Save a subroom's data (room save). Auth-gated (401 with empty body). Editable
|
||||
// by the room creator or a Creator/CoOwner role holder. Points the subroom at
|
||||
// the uploaded data blobs and records the room-level save fields, notifies the
|
||||
// owner, and returns the updated ROOM in the lowercase `{ success, error, value }`
|
||||
// envelope the reference's SetRoomData uses.
|
||||
// the uploaded data blobs and records the revision's fields against that SUBROOM,
|
||||
// notifies the owner, and returns the updated ROOM in the lowercase
|
||||
// `{ success, error, value }` envelope the reference's SetRoomData uses.
|
||||
.post(
|
||||
'/rooms/:roomId{[0-9]+}/subrooms/:subRoomId{[0-9]+}/data',
|
||||
describeRoute({
|
||||
@@ -1942,10 +2049,11 @@ const app = new Hono<App>()
|
||||
summary: 'Save a subroom’s data (room save)',
|
||||
description: [
|
||||
'Records a save against the subroom from the blobs the client has already uploaded',
|
||||
'through the `storage` worker; the room-level fields it carries (`Description`,',
|
||||
'`PersistenceVersion`, `InventionUsage`) are written to the room. Editable by the',
|
||||
'room’s creator or a co-owner (403 otherwise); a missing token is an EMPTY-body 401,',
|
||||
'unlike the other room writes.',
|
||||
'through the `storage` worker. Everything the body carries describes THAT revision',
|
||||
'and lands on the save and its subroom — `Description` is the save comment, NOT the',
|
||||
'room’s description (only `PUT /rooms/{roomId}/description` sets that). Nothing here',
|
||||
'writes to the room. Editable by the room’s creator or a co-owner (403 otherwise); a',
|
||||
'missing token is an EMPTY-body 401, unlike the other room writes.',
|
||||
'',
|
||||
'`AutoPublish: true` makes the save live immediately. Otherwise it is STAGED: it',
|
||||
'lands on `StagedSubRoomDataSaveId` with the live `CurrentSave` untouched, so',
|
||||
@@ -2086,7 +2194,7 @@ const app = new Hono<App>()
|
||||
Error: 'You must enter a name for your room!',
|
||||
})
|
||||
}
|
||||
const badName = nameRejection(name, 'subroom name', MAX_ROOM_NAME_LENGTH)
|
||||
const badName = roomNameRejection(name, 'subroom name')
|
||||
if (badName !== null) {
|
||||
return roomResult(c, { Success: false, ErrorId: 'Rooms.InvalidName', Error: badName })
|
||||
}
|
||||
@@ -2398,7 +2506,7 @@ const app = new Hono<App>()
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||
if (name === '') return roomEnvelope(c, null, 'You must enter a name for your subroom!')
|
||||
const badName = nameRejection(name, 'subroom name', MAX_ROOM_NAME_LENGTH)
|
||||
const badName = roomNameRejection(name, 'subroom name')
|
||||
if (badName !== null) return roomEnvelope(c, null, badName)
|
||||
|
||||
const result = await createSubRoom(c.env.DB, roomId, accountId, name)
|
||||
|
||||
@@ -50,6 +50,33 @@ async function bearer(sub: string, roles?: string[]): Promise<Record<string, str
|
||||
return { Authorization: `Bearer ${signingInput}.${b64url(sig)}` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Put a player in a room, the way the `match` heartbeat would — the save routes read this
|
||||
* to decide whether a non-creator may see the room's history. `expired` writes a row that
|
||||
* has already lapsed, which reads back as no presence at all.
|
||||
*/
|
||||
async function putInRoom(
|
||||
accountId: number,
|
||||
roomId: number,
|
||||
{ expired = false }: { expired?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId,
|
||||
roomInstance: { roomInstanceId: 1000000 + roomId, roomId, subRoomId: roomId },
|
||||
expiresAt: expired ? now - 1 : now + 900,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
}
|
||||
|
||||
/** Take a player back out of whatever room they were in. */
|
||||
async function clearPresence(accountId: number): Promise<void> {
|
||||
await env.DB.prepare('DELETE FROM presence WHERE account_id = ?1').bind(accountId).run()
|
||||
}
|
||||
|
||||
// Apply the schema + seed the imported rooms into the test D1 (mirrors the migrations).
|
||||
beforeAll(async () => {
|
||||
// Seed the shared JWT signing key into the local Secrets Store so .get() resolves.
|
||||
@@ -154,6 +181,71 @@ describe('rooms endpoints', () => {
|
||||
expect(other).toEqual([])
|
||||
})
|
||||
|
||||
// The website's "My rooms" list is a browser calling this worker from another origin,
|
||||
// so a response without CORS headers is one the browser throws away — and the page
|
||||
// can't tell that apart from the server being down. Pinned on the preflight too: the
|
||||
// SPA sends `Authorization`, which makes even the GET a preflighted request.
|
||||
it('answers CORS so the website can read a room list from the browser', async () => {
|
||||
const preflight = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
origin: 'https://www.example.com',
|
||||
'access-control-request-method': 'GET',
|
||||
'access-control-request-headers': 'authorization',
|
||||
},
|
||||
})
|
||||
expect(preflight.status).toBe(204)
|
||||
expect(preflight.headers.get('access-control-allow-origin')).toBe('*')
|
||||
expect(preflight.headers.get('access-control-allow-headers')?.toLowerCase()).toContain(
|
||||
'authorization'
|
||||
)
|
||||
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/ownedby/me`, {
|
||||
headers: { ...(await bearer('1')), origin: 'https://www.example.com' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe('*')
|
||||
})
|
||||
|
||||
it('GET /rooms/ownedby|createdby/me lists the caller’s UNPUBLISHED rooms too', async () => {
|
||||
// "My Rooms" is the owner's own list, not a catalog: it must show a room that
|
||||
// isn't public yet, or a freshly created room (which starts Private — see
|
||||
// cloneRoom) would be invisible to the person who just made it. Only the
|
||||
// PUBLIC-facing `ownedby/:accountId` profile list filters on accessibility.
|
||||
const headers = {
|
||||
...(await bearer('804')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}
|
||||
await SELF.fetch(`${ORIGIN}/rooms/24/clone`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: new URLSearchParams({ name: 'MyUnpublishedRoom' }).toString(),
|
||||
})
|
||||
|
||||
const listOf = async (path: string) =>
|
||||
(await (await SELF.fetch(`${ORIGIN}${path}`, { headers })).json()) as Array<{
|
||||
Name: string
|
||||
Accessibility: number
|
||||
}>
|
||||
|
||||
for (const path of [
|
||||
'/rooms/ownedby/me',
|
||||
'/rooms/createdby/me',
|
||||
'/roomserver/rooms/createdby/me',
|
||||
]) {
|
||||
const mine = await listOf(path)
|
||||
const room = mine.find((r) => r.Name === 'MyUnpublishedRoom')
|
||||
expect(room, `${path} must list the caller's unpublished room`).toBeDefined()
|
||||
expect(room!.Accessibility).toBe(0)
|
||||
}
|
||||
|
||||
// The same room is absent from the account's PUBLIC profile list.
|
||||
const publicList = (await (await SELF.fetch(`${ORIGIN}/rooms/ownedby/804`)).json()) as Array<{
|
||||
Name: string
|
||||
}>
|
||||
expect(publicList.some((r) => r.Name === 'MyUnpublishedRoom')).toBe(false)
|
||||
})
|
||||
|
||||
it('GET /rooms/ownedby/:id returns an account public rooms (no auth)', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/ownedby/1`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -485,6 +577,56 @@ describe('rooms endpoints', () => {
|
||||
await env.DB.prepare(`DELETE FROM subroom WHERE room_id IN (${ids})`).run()
|
||||
})
|
||||
|
||||
it('GET /rooms/hot?tag=community serves rooms the Coach account did not create', async () => {
|
||||
type Feed = { Results: Array<{ Name: string }>; TotalResults: number }
|
||||
const feed = async (): Promise<Feed> =>
|
||||
(await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=community&skip=0&take=100`)
|
||||
).json()) as Feed
|
||||
const names = async (): Promise<string[]> => (await feed()).Results.map((r) => r.Name)
|
||||
|
||||
// No room carries a `community` tag, and every seeded room belongs to Coach
|
||||
// (account 1) — so the feed is empty until another account makes something.
|
||||
expect(await feed()).toEqual({ Results: [], TotalResults: 0 })
|
||||
|
||||
const seeded: number[] = []
|
||||
const seed = async (room: Record<string, unknown>) => {
|
||||
seeded.push(Number(room.RoomId))
|
||||
await seedRoomWithSubRooms(env.DB, {
|
||||
Accessibility: 1,
|
||||
IsDorm: false,
|
||||
CreatorAccountId: 2,
|
||||
...room,
|
||||
})
|
||||
}
|
||||
|
||||
await seed({ RoomId: 9101, Name: 'CommunityOne' })
|
||||
await seed({ RoomId: 9102, Name: 'CommunityTwo' })
|
||||
// Coach's own rooms stay out, and so do non-public rooms as everywhere else.
|
||||
await seed({ RoomId: 9103, Name: 'CoachRoom', CreatorAccountId: 1 })
|
||||
await seed({ RoomId: 9104, Name: 'UnlistedCommunityRoom', Accessibility: 2 })
|
||||
|
||||
// Nobody is in any of them and their stats are all zero, so the feed's normal
|
||||
// ordering falls through to RoomId.
|
||||
expect(await names()).toEqual(['CommunityOne', 'CommunityTwo'])
|
||||
|
||||
// Creator, not RRO-ness, is what `community` filters on — unlike `new`, a
|
||||
// player-made room flagged as an RRO still belongs here.
|
||||
await seed({ RoomId: 9105, Name: 'PlayerMadeRRO', IsRRO: true })
|
||||
expect(await names()).toEqual(['CommunityOne', 'CommunityTwo', 'PlayerMadeRRO'])
|
||||
|
||||
// Paging comes off the same order.
|
||||
const page = (await (
|
||||
await SELF.fetch(`${ORIGIN}/rooms/hot?tag=community&skip=1&take=1`)
|
||||
).json()) as Feed
|
||||
expect(page).toMatchObject({ Results: [{ Name: 'CommunityTwo' }], TotalResults: 3 })
|
||||
|
||||
// Leave the shared feeds as they were for the tests that follow.
|
||||
const ids = seeded.join(',')
|
||||
await env.DB.prepare(`DELETE FROM room WHERE room_id IN (${ids})`).run()
|
||||
await env.DB.prepare(`DELETE FROM subroom WHERE room_id IN (${ids})`).run()
|
||||
})
|
||||
|
||||
it('GET /rooms/base returns a bare array of base/template rooms (incl. non-public)', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/base`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -603,6 +745,7 @@ describe('rooms endpoints', () => {
|
||||
CreatorAccountId: number
|
||||
Tags?: Array<{ Tag: string }>
|
||||
IsRRO: boolean
|
||||
Accessibility: number
|
||||
Roles: Array<{ AccountId: number; Role: number; InvitedRole: number }>
|
||||
} | null
|
||||
}
|
||||
@@ -620,6 +763,8 @@ describe('rooms endpoints', () => {
|
||||
expect(ok.value!.Tags).toEqual([])
|
||||
// IsRRO is cleared so the client doesn't render a virtual "RRO" tag on the clone.
|
||||
expect(ok.value!.IsRRO).toBe(false)
|
||||
// A new room is unpublished: Private (0), never the source's visibility.
|
||||
expect(ok.value!.Accessibility).toBe(0)
|
||||
// Ownership is reset to the cloner: sole owner (Role 255), and none of the
|
||||
// source base room's roles (accounts 1/2) carry over.
|
||||
expect(ok.value!.Roles).toEqual([
|
||||
@@ -638,6 +783,39 @@ describe('rooms endpoints', () => {
|
||||
expect(dup.error).toMatch(/already exists/i)
|
||||
})
|
||||
|
||||
it('POST /rooms/:id/clone of a PUBLIC source stays out of the public feeds', async () => {
|
||||
// Park (RoomId 25) is the one seeded base room that is itself public
|
||||
// (Accessibility 1). Cloning used to inherit that, so a room appeared in
|
||||
// hot/search/recommendations the instant it was created — before its owner had
|
||||
// published anything.
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/25/clone`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer('802')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({ name: 'ParkCloneUnpublished' }).toString(),
|
||||
})
|
||||
const { value } = (await res.json()) as { value: { RoomId: number; Accessibility: number } }
|
||||
expect(value.Accessibility).toBe(0)
|
||||
|
||||
const namesIn = async (path: string) => {
|
||||
const body = (await (await SELF.fetch(`${ORIGIN}${path}`)).json()) as
|
||||
{ Results: Array<{ Name: string }> } | Array<{ Name: string }>
|
||||
return (Array.isArray(body) ? body : body.Results).map((r) => r.Name)
|
||||
}
|
||||
expect(await namesIn('/rooms/hot?take=200')).not.toContain('ParkCloneUnpublished')
|
||||
expect(await namesIn('/rooms/hot?tag=new&take=200')).not.toContain('ParkCloneUnpublished')
|
||||
expect(await namesIn('/rooms/recommendations?take=200')).not.toContain('ParkCloneUnpublished')
|
||||
expect(await namesIn('/rooms/search?query=parkcloneunpublished')).not.toContain(
|
||||
'ParkCloneUnpublished'
|
||||
)
|
||||
|
||||
// Publishing it (owner sets Accessibility to Public) puts it in the feed.
|
||||
await putForm('/rooms/' + value.RoomId + '/accessibility', { accessibility: '1' }, '802')
|
||||
expect(await namesIn('/rooms/hot?take=200')).toContain('ParkCloneUnpublished')
|
||||
})
|
||||
|
||||
it('POST /rooms/:id/clone requires auth (401, no account-1 fallback)', async () => {
|
||||
// No Authorization header → hard 401, and nothing is created.
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/24/clone`, {
|
||||
@@ -1376,6 +1554,15 @@ describe('rooms endpoints', () => {
|
||||
})
|
||||
expect(await envOf(await authed(2, 9999, '1'))).toMatchObject({ success: false })
|
||||
|
||||
// The room's own fields are read first: a save is a revision of a SUBROOM and must
|
||||
// leave them alone. `Description` in the body is the save comment, not the room's
|
||||
// description — that is `PUT /rooms/:id/description`'s to set.
|
||||
const before = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
|
||||
Description: string
|
||||
PersistenceVersion: number
|
||||
}
|
||||
expect(before.Description).not.toBe('mydescription here')
|
||||
|
||||
// Owner saves → 200. `value` carries BOTH the updated room and the new save, and
|
||||
// `error` is null (not ''). This fixture sends `AutoPublish: true`, so it goes live.
|
||||
const ok = await authed(2, 2, '1')
|
||||
@@ -1390,7 +1577,7 @@ describe('rooms endpoints', () => {
|
||||
}
|
||||
expect(saved.success).toBe(true)
|
||||
expect(saved.error).toBeNull()
|
||||
expect(saved.value.room).toMatchObject({ RoomId: 2, Description: 'mydescription here' })
|
||||
expect(saved.value.room).toMatchObject({ RoomId: 2, Description: before.Description })
|
||||
|
||||
// The save is a camelCase projection, NOT the PascalCase CurrentSave shape.
|
||||
expect(saved.value.subRoomDataSave).toEqual({
|
||||
@@ -1430,6 +1617,7 @@ describe('rooms endpoints', () => {
|
||||
SubRoomDataSaveId: number
|
||||
SavedByAccountId: number
|
||||
PersistenceVersion: number
|
||||
Description: string
|
||||
UnitySubAssets: unknown[]
|
||||
Tags: unknown[]
|
||||
}
|
||||
@@ -1446,13 +1634,18 @@ describe('rooms endpoints', () => {
|
||||
expect(sub.CurrentSave.SubRoomDataSaveId).toBeGreaterThan(0)
|
||||
expect(sub.StagedSubRoomDataSaveId).toBeNull()
|
||||
|
||||
// Room-level fields land on the room too.
|
||||
// The save comment and the scene fields land on the SUBROOM's revision, and the
|
||||
// room's own fields are untouched — a save must never rewrite the room.
|
||||
expect(sub.CurrentSave.Description).toBe('mydescription here')
|
||||
expect(sub).toMatchObject({ PersistenceVersion: 41, InventionUsage: 'CAE=' })
|
||||
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as {
|
||||
Description: string
|
||||
PersistenceVersion: number
|
||||
InventionUsage?: string
|
||||
}
|
||||
expect(room.Description).toBe('mydescription here')
|
||||
expect(room.PersistenceVersion).toBe(41)
|
||||
expect(room.Description).toBe(before.Description)
|
||||
expect(room.PersistenceVersion).toBe(before.PersistenceVersion)
|
||||
expect(room.InventionUsage).toBeUndefined()
|
||||
|
||||
// A CoOwner (account 2 holds Role 30 in the seeded rooms) may also save — 200
|
||||
// with the room envelope. The creator stays account 1 (not clobbered).
|
||||
@@ -1701,7 +1894,7 @@ describe('rooms endpoints', () => {
|
||||
expect(clone.CurrentSave!.SubRoomId).toBe(clone.SubRoomId)
|
||||
})
|
||||
|
||||
it('PUT /rooms/:id/tags is auth-gated, owner-only, and toggles (add/remove)', async () => {
|
||||
it('PUT /rooms/:id/tags is auth-gated, owner/co-owner-only, and toggles (add/remove)', async () => {
|
||||
// The lowercase `{ success, error, value }` envelope this endpoint returns.
|
||||
type TagResult = {
|
||||
success: boolean
|
||||
@@ -1713,11 +1906,8 @@ describe('rooms endpoints', () => {
|
||||
|
||||
// No token → 401.
|
||||
expect((await putForm('/rooms/2/tags', { tag: 'quest' })).status).toBe(401)
|
||||
// Not the owner → failure envelope.
|
||||
expect(await envOf(await putForm('/rooms/2/tags', { tag: 'quest' }, '999'))).toMatchObject({
|
||||
success: false,
|
||||
error: 'You are not the owner of this room!',
|
||||
})
|
||||
// A valid token but no role on the room → 403.
|
||||
expect((await putForm('/rooms/2/tags', { tag: 'quest' }, '999')).status).toBe(403)
|
||||
// Unknown room → failure envelope.
|
||||
expect(await envOf(await putForm('/rooms/99999/tags', { tag: 'quest' }, '1'))).toMatchObject({
|
||||
success: false,
|
||||
@@ -1753,6 +1943,11 @@ describe('rooms endpoints', () => {
|
||||
const off = await envOf(await putForm('/rooms/2/tags', { tag: 'quest' }, '1'))
|
||||
expect(tagsIn(off)).not.toContain('quest')
|
||||
expect(tagsIn(off)).toContain('campfire')
|
||||
|
||||
// The co-owner (account 2, Role 30) may edit tags too.
|
||||
const byCoOwner = await envOf(await putForm('/rooms/2/tags', { tag: 'spooky' }, '2'))
|
||||
expect(byCoOwner).toMatchObject({ success: true, error: '' })
|
||||
expect(tagsIn(byCoOwner)).toContain('spooky')
|
||||
})
|
||||
|
||||
it('PUT /rooms/:id/name is auth-gated, owner-only, unique, and persists', async () => {
|
||||
@@ -1946,7 +2141,8 @@ describe('rooms endpoints', () => {
|
||||
expect(await searched()).toMatchObject({ CheerCount: 2, FavoriteCount: 1 })
|
||||
expect(await direct()).toMatchObject({ CheerCount: 2, FavoriteCount: 1 })
|
||||
|
||||
// Clearing a cheer decrements it. Nothing records visits, so those stay 0.
|
||||
// Clearing a cheer decrements it. Visits are counted by the `match` worker on
|
||||
// matchmake and nobody has entered this room, so those stay 0.
|
||||
await interact('562', 'cheer', 'DELETE')
|
||||
expect(await direct()).toEqual({
|
||||
CheerCount: 1,
|
||||
@@ -1954,6 +2150,35 @@ describe('rooms endpoints', () => {
|
||||
VisitorCount: 0,
|
||||
VisitCount: 0,
|
||||
})
|
||||
|
||||
// VisitCount is the `room.visits` column (what match bumps on each matchmake),
|
||||
// served on every read of the room — here and in the search results — and it
|
||||
// survives the cheer/favorite aggregation rather than being zeroed by it.
|
||||
await env.DB.prepare('UPDATE room SET visits = 7 WHERE room_id = 15').run()
|
||||
expect(await direct()).toEqual({
|
||||
CheerCount: 1,
|
||||
FavoriteCount: 1,
|
||||
VisitorCount: 0,
|
||||
VisitCount: 7,
|
||||
})
|
||||
expect(await searched()).toMatchObject({ VisitCount: 7 })
|
||||
|
||||
// A write to the room doesn't bake the count into the blob (nor reset it).
|
||||
// Account 1 created room 15, so the description write is allowed.
|
||||
const wrote = await SELF.fetch(`${ORIGIN}/rooms/15/description`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...(await bearer('1')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams({ description: 'counted' }).toString(),
|
||||
})
|
||||
expect(wrote.status).toBe(200)
|
||||
const blob = await env.DB.prepare('SELECT data FROM room WHERE room_id = 15').first<{
|
||||
data: string
|
||||
}>()
|
||||
expect((JSON.parse(blob!.data) as { Stats: Stats }).Stats.VisitCount).toBe(0)
|
||||
expect(await direct()).toMatchObject({ VisitCount: 7 })
|
||||
})
|
||||
|
||||
it('DELETE /rooms/:id/interactionby/me/cheer clears the cheer (auth-gated, idempotent)', async () => {
|
||||
@@ -2564,18 +2789,98 @@ describe('rooms endpoints', () => {
|
||||
})
|
||||
expect(await empty.json()).toEqual({ Results: [], TotalResults: 0, TotalCount: 0 })
|
||||
|
||||
// The list exposes unpublished saves, so it is owner-only: no token → 401, and a
|
||||
// valid token that isn't the room's creator → 403.
|
||||
// The list exposes unpublished saves, so it isn't public: no token → 401, and a
|
||||
// valid token from someone who is neither the creator nor in the room → 403. Account
|
||||
// 2 is a co-owner (Role 30 on the seeded rooms) and is refused too — holding a role
|
||||
// grants nothing here; being in the room does (see below).
|
||||
expect((await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`)).status).toBe(401)
|
||||
expect(
|
||||
(await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('999') }))
|
||||
.status
|
||||
).toBe(403)
|
||||
// Even a co-owner (account 2 holds Role 30 on the seeded rooms) is refused.
|
||||
expect(
|
||||
(await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('2') }))
|
||||
.status
|
||||
).toBe(403)
|
||||
|
||||
// …but a player standing IN the room reads it: the client resolves which version to
|
||||
// load from this list, so a visitor who can't read it can't load the instance.
|
||||
await putInRoom(999, 2)
|
||||
expect(
|
||||
(await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('999') }))
|
||||
.status
|
||||
).toBe(200)
|
||||
// Presence in a DIFFERENT room is not presence in this one.
|
||||
await putInRoom(999, 5)
|
||||
expect(
|
||||
(await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('999') }))
|
||||
.status
|
||||
).toBe(403)
|
||||
// And the grant lasts only as long as the presence does — an expired row reads as
|
||||
// absent, so the visitor is refused again the moment they leave.
|
||||
await putInRoom(999, 2, { expired: true })
|
||||
expect(
|
||||
(await SELF.fetch(`${ORIGIN}/rooms/2/subrooms/2/saves`, { headers: await bearer('999') }))
|
||||
.status
|
||||
).toBe(403)
|
||||
await clearPresence(999)
|
||||
})
|
||||
|
||||
it('GET /rooms/:id/subrooms/:sid/saves/:saveId is the detail behind a history row', async () => {
|
||||
const get = async (path: string, sub?: string) =>
|
||||
SELF.fetch(`${ORIGIN}${path}`, sub === undefined ? {} : { headers: await bearer(sub) })
|
||||
|
||||
// Pick a real save off the history the previous test paged.
|
||||
const list = (await (await get('/rooms/2/subrooms/2/saves', '1')).json()) as {
|
||||
Results: Array<{ SubRoomDataSaveId: number; DataBlob: string; Description: string }>
|
||||
}
|
||||
const row = list.Results[0]!
|
||||
|
||||
const res = await get(`/rooms/2/subrooms/2/saves/${row.SubRoomDataSaveId}`, '1')
|
||||
expect(res.status).toBe(200)
|
||||
// The camelCase projection the room save returns — NOT the PascalCase row the list
|
||||
// serves. Same field set, exactly: no persistence/OM/UGC versions, no asset arrays.
|
||||
expect(await res.json()).toEqual({
|
||||
subRoomDataSaveId: row.SubRoomDataSaveId,
|
||||
subRoomId: 2,
|
||||
unityAssetId: null,
|
||||
unityAsset: null,
|
||||
unityAssetHash: null,
|
||||
dataBlob: row.DataBlob,
|
||||
dataBlobHash: null,
|
||||
savedByAccountId: expect.any(Number),
|
||||
savedOnPlatform: 0,
|
||||
savedOnDeviceClass: 0,
|
||||
description: row.Description,
|
||||
createdAt: expect.any(String),
|
||||
})
|
||||
|
||||
// Unknown save, and a save that exists but belongs to ANOTHER subroom (ids are
|
||||
// global, so an unscoped lookup would happily resolve this one) — both 404.
|
||||
expect((await get('/rooms/2/subrooms/2/saves/99999', '1')).status).toBe(404)
|
||||
const foreign = (
|
||||
(await subRoomOf(5, 5)) as unknown as { CurrentSave: { SubRoomDataSaveId: number } }
|
||||
).CurrentSave.SubRoomDataSaveId
|
||||
expect((await get(`/rooms/2/subrooms/2/saves/${foreign}`, '1')).status).toBe(404)
|
||||
// …and it does resolve on its own subroom, so the 404 above is the scoping, not a
|
||||
// missing row.
|
||||
expect((await get(`/rooms/5/subrooms/5/saves/${foreign}`, '1')).status).toBe(200)
|
||||
|
||||
// Unknown room or subroom is a 404 too (the LIST answers an empty page instead).
|
||||
expect((await get('/rooms/99999/subrooms/2/saves/1', '1')).status).toBe(404)
|
||||
expect((await get('/rooms/2/subrooms/99999/saves/1', '1')).status).toBe(404)
|
||||
|
||||
// Same gate as the list it details: 401 unauthed, 403 for someone who is neither the
|
||||
// creator nor in the room (a co-owner included) — it reads unpublished saves.
|
||||
const detail = `/rooms/2/subrooms/2/saves/${row.SubRoomDataSaveId}`
|
||||
expect((await get(detail)).status).toBe(401)
|
||||
expect((await get(detail, '999')).status).toBe(403)
|
||||
expect((await get(detail, '2')).status).toBe(403)
|
||||
// A player standing in the room reads it, for as long as they're there.
|
||||
await putInRoom(999, 2)
|
||||
expect((await get(detail, '999')).status).toBe(200)
|
||||
await clearPresence(999)
|
||||
expect((await get(detail, '999')).status).toBe(403)
|
||||
})
|
||||
|
||||
it('GET /openapi.json documents every route', async () => {
|
||||
@@ -2625,6 +2930,7 @@ describe('rooms endpoints', () => {
|
||||
'GET /rooms/{roomId}/playerdata/me',
|
||||
'GET /rooms/{roomId}/similar',
|
||||
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves',
|
||||
'GET /rooms/{roomId}/subrooms/{subRoomId}/saves/{saveId}',
|
||||
'GET /roomserver/rooms/createdby/me',
|
||||
'POST /rooms/{roomId}/bans',
|
||||
'POST /rooms/{roomId}/clone',
|
||||
@@ -2657,9 +2963,10 @@ describe('rooms endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Room and subroom names are held to the same rule as usernames — letters and digits,
|
||||
// at most 32 (see `nameRejection` in @repo/domain). All four routes that take a
|
||||
// player-supplied name enforce it, and each keeps its OWN refusal shape: the create
|
||||
// Room and subroom names take letters, digits and underscores, at most 32 (see
|
||||
// `roomNameRejection` in @repo/domain — usernames are held to the narrower rule, with no
|
||||
// underscore). All four routes that take a player-supplied name enforce it, and each
|
||||
// keeps its OWN refusal shape: the create
|
||||
// paths answer the lowercase `{ success, error, value }` envelope, the two settings
|
||||
// routes answer `{ Success, ErrorId, Error }` with the same `Rooms.InvalidName` id they
|
||||
// already used for an empty name. The client keys off those, so the rule had to fit the
|
||||
@@ -2668,7 +2975,7 @@ describe('rooms endpoints', () => {
|
||||
// Names the SERVER generates are exempt on purpose — a dorm is `@<username>'s Dorm`,
|
||||
// which this rule would reject. That's why the check lives in the handlers.
|
||||
describe('room name validation', () => {
|
||||
const bad = ['My Room', 'under_score', 'punct!', 'a'.repeat(33)]
|
||||
const bad = ['My Room', 'punct!', 'a'.repeat(33)]
|
||||
|
||||
const post = async (path: string, fields: Record<string, string>, sub: string) =>
|
||||
SELF.fetch(`${ORIGIN}${path}`, {
|
||||
@@ -2689,7 +2996,7 @@ describe('room name validation', () => {
|
||||
const res = await post('/rooms/2/clone', { name }, '1')
|
||||
const body = (await res.json()) as { success: boolean; error: string; value: unknown }
|
||||
expect(body.success, name).toBe(false)
|
||||
expect(body.error).toMatch(/letters and numbers|at most 32 characters/)
|
||||
expect(body.error).toMatch(/letters, numbers and underscores|at most 32 characters/)
|
||||
expect(body.value).toBeNull()
|
||||
}
|
||||
})
|
||||
@@ -2700,12 +3007,12 @@ describe('room name validation', () => {
|
||||
const body = (await res.json()) as { Success: boolean; ErrorId: string; Error: string }
|
||||
expect(body.Success, name).toBe(false)
|
||||
expect(body.ErrorId).toBe('Rooms.InvalidName')
|
||||
expect(body.Error).toMatch(/letters and numbers|at most 32 characters/)
|
||||
expect(body.Error).toMatch(/letters, numbers and underscores|at most 32 characters/)
|
||||
}
|
||||
|
||||
// Unchanged: the refusals above never reached the write.
|
||||
const room = (await (await SELF.fetch(`${ORIGIN}/rooms/2`)).json()) as { Name: string }
|
||||
expect(room.Name).not.toMatch(/[^A-Za-z0-9]/)
|
||||
expect(room.Name).not.toMatch(/[^A-Za-z0-9_]/)
|
||||
})
|
||||
|
||||
it('refuses a bad name when creating or modifying a subroom', async () => {
|
||||
@@ -2713,7 +3020,7 @@ describe('room name validation', () => {
|
||||
const created = await post('/rooms/2/subrooms', { name }, '1')
|
||||
const env1 = (await created.json()) as { success: boolean; error: string }
|
||||
expect(env1.success, name).toBe(false)
|
||||
expect(env1.error).toMatch(/letters and numbers|at most 32 characters/)
|
||||
expect(env1.error).toMatch(/letters, numbers and underscores|at most 32 characters/)
|
||||
|
||||
const modified = await put(
|
||||
'/rooms/2/subrooms/2/modify',
|
||||
@@ -2726,11 +3033,15 @@ describe('room name validation', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts a 32-character alphanumeric name', async () => {
|
||||
const name = 'a'.repeat(32)
|
||||
it('accepts a 32-character name, and an underscore where a space is refused', async () => {
|
||||
for (const name of ['a'.repeat(32), 'Laser_Tag']) {
|
||||
const res = await post('/rooms/2/subrooms', { name }, '1')
|
||||
const body = (await res.json()) as { success: boolean; value: { SubRooms: Array<{ Name: string }> } }
|
||||
expect(body.success).toBe(true)
|
||||
const body = (await res.json()) as {
|
||||
success: boolean
|
||||
value: { SubRooms: Array<{ Name: string }> }
|
||||
}
|
||||
expect(body.success, name).toBe(true)
|
||||
expect(body.value.SubRooms.some((s) => s.Name === name)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,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, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import {
|
||||
@@ -79,6 +79,11 @@ const app = new Hono<App>()
|
||||
release: c.env.SENTRY_RELEASE,
|
||||
})(c, next)
|
||||
)
|
||||
// The game posts here with no Origin at all, but the website does too — it uploads a
|
||||
// subroom's scene blob straight from the browser, the same way it calls `rooms` and
|
||||
// `accounts` directly. An `Authorization` header makes that a preflighted request, so
|
||||
// without this the OPTIONS gets a 404 and the upload never leaves the page.
|
||||
.use('*', withDefaultCors())
|
||||
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
@@ -160,6 +160,23 @@ it('POST /upload 400s when there is neither a file nor a name', async () => {
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('answers the CORS preflight the website’s upload needs', async () => {
|
||||
// The room management page uploads a subroom's scene blob straight from the browser.
|
||||
// The bearer token makes that a preflighted request, so a missing OPTIONS handler
|
||||
// stops the upload before any of the tests above are even reached.
|
||||
const res = await SELF.fetch(`${ORIGIN}/upload`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: 'https://www.example.com',
|
||||
'Access-Control-Request-Method': 'POST',
|
||||
'Access-Control-Request-Headers': 'authorization',
|
||||
},
|
||||
})
|
||||
expect(res.status).toBe(204)
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe('*')
|
||||
expect(res.headers.get('access-control-allow-headers')?.toLowerCase()).toContain('authorization')
|
||||
})
|
||||
|
||||
it('GET /openapi.json documents every route', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"test": "run-vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/domain": "workspace:*",
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@scalar/api-reference": "1.63.0",
|
||||
"hono": "4.12.27",
|
||||
|
||||
@@ -32,6 +32,15 @@ const AUTH_MESSAGES: Record<string, string> = {
|
||||
'no linked account for this platform identity':
|
||||
'No account is linked to this platform sign-in yet. Sign in with your password once to link it.',
|
||||
'refresh_token is invalid or expired': 'Your session has expired. Please sign in again.',
|
||||
// Deliberately says nothing about when it lifts: auth sends one fixed description for
|
||||
// every ban (see its BANNED_DESCRIPTION), permanent or timed, so there is no expiry
|
||||
// here to quote.
|
||||
'this account is banned': 'This account is banned and cannot be signed in to.',
|
||||
// Not this account, but one it shares a device or network with. Phrased for BOTH the
|
||||
// person evading a ban and the housemate of one — the IP arm cannot tell them apart —
|
||||
// and for both forms, since signup and sign-in send the same description.
|
||||
'this device or network is blocked':
|
||||
'This device or network is blocked. If you think that is a mistake, contact the server operator.',
|
||||
}
|
||||
|
||||
/** Fallbacks when nothing above matched, so a player never reads an OAuth code. */
|
||||
|
||||
+793
-2
@@ -1,5 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Accessibility } from '@repo/domain/src/enums'
|
||||
import { GAME_VERSION } from '@repo/domain/src/presence-db'
|
||||
|
||||
import { NotificationType } from '../../../notify/src/notification-types'
|
||||
import { authFailure, authUnreachable } from '../auth-messages'
|
||||
import {
|
||||
@@ -30,6 +33,9 @@ interface Hosts {
|
||||
api: string
|
||||
img: string
|
||||
notify: string
|
||||
rooms: string
|
||||
cdn: string
|
||||
storage: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,6 +62,91 @@ interface SelfAccount {
|
||||
availableUsernameChanges?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One subroom, as `rooms` re-attaches them to every room read. A room is a container;
|
||||
* the subrooms are the actual places players load into, each with its own accessibility
|
||||
* and its own save history.
|
||||
*/
|
||||
interface SubRoom {
|
||||
SubRoomId: number
|
||||
Name: string
|
||||
/** Set INDEPENDENTLY of the room's — a public room can hold a private subroom. */
|
||||
Accessibility: number
|
||||
IsSandbox: boolean
|
||||
MaxPlayers: number
|
||||
/** The subroom's scene-data key, served back by `cdn` under `room/`. */
|
||||
DataBlob?: string
|
||||
/**
|
||||
* A save posted without `AutoPublish` waits here. Cleared when that save is
|
||||
* published, so a non-null value means "edited since players last saw a change".
|
||||
*/
|
||||
StagedSubRoomDataSaveId: number | null
|
||||
/**
|
||||
* What players actually load. Null until the first publish — and a subroom without
|
||||
* one silently loads nothing, which is worth surfacing to an owner who can't tell
|
||||
* that apart from a broken room.
|
||||
*/
|
||||
CurrentSave: {
|
||||
SubRoomDataSaveId: number
|
||||
CreatedAt: string
|
||||
Description: string
|
||||
/**
|
||||
* The scene-data key for the published save — what the client downloads to load
|
||||
* the place, and the file worth keeping a copy of. `subRoomDataBlob()` resolves
|
||||
* this one first, ahead of the subroom's own.
|
||||
*/
|
||||
DataBlob: string
|
||||
} | null
|
||||
}
|
||||
|
||||
/**
|
||||
* One room from `rooms` (`GET /rooms/ownedby/me`), narrowed to what these pages draw.
|
||||
* The worker serves the stored room blob verbatim — dozens of fields the game needs and
|
||||
* the website doesn't — so only the ones read here are declared.
|
||||
*/
|
||||
interface OwnedRoom {
|
||||
RoomId: number
|
||||
Name: string
|
||||
Description: string
|
||||
/** A key on the `img` worker; a room with no image of its own gets the fallback. */
|
||||
ImageName: string
|
||||
/** The `Accessibility` ordinal, NOT the enum name — see ACCESSIBILITY_LABEL. */
|
||||
Accessibility: number
|
||||
CreatedAt: string
|
||||
MaxPlayers: number
|
||||
/** False blocks `POST /rooms/{id}/clone` — nobody can take a copy of the room. */
|
||||
CloningAllowed: boolean
|
||||
SupportsScreens: boolean
|
||||
SupportsWalkVR: boolean
|
||||
SupportsTeleportVR: boolean
|
||||
SupportsQuest2: boolean
|
||||
SupportsMobile: boolean
|
||||
SupportsJuniors: boolean
|
||||
/** `Type` 0 is a tag the owner set, 2 one the server derived. */
|
||||
Tags: Array<{ Tag: string; Type: number }>
|
||||
SubRooms: SubRoom[]
|
||||
/** Always present: the worker folds the live counters in on every read. */
|
||||
Stats: {
|
||||
CheerCount: number
|
||||
FavoriteCount: number
|
||||
VisitorCount: number
|
||||
VisitCount: number
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What an `Accessibility` ordinal is called on screen — rooms and subrooms both carry
|
||||
* one. The two dev values are reachable (the game sets them), so they're named rather
|
||||
* than left to fall through to the unknown case in `accessibilityLabel`.
|
||||
*/
|
||||
const ACCESSIBILITY_LABEL: Record<number, string> = {
|
||||
[Accessibility.Private]: 'Private',
|
||||
[Accessibility.Public]: 'Public',
|
||||
[Accessibility.Unlisted]: 'Unlisted',
|
||||
[Accessibility.Dev_only]: 'Dev only',
|
||||
[Accessibility.Dev_Unlisted]: 'Dev unlisted',
|
||||
}
|
||||
|
||||
/**
|
||||
* RecNet (4) is the web platform, stamped as the token's `platform` claim on sign-in.
|
||||
* NOT passed on signup: create_account treats an asserted platform as one to verify
|
||||
@@ -148,6 +239,12 @@ interface CallOptions {
|
||||
form?: Record<string, string>
|
||||
/** A JSON body — what notify's internal endpoints take instead. */
|
||||
json?: unknown
|
||||
/**
|
||||
* A multipart body — what `storage`'s `/upload` takes, since it carries a file. Passed
|
||||
* to `fetch` as-is: the browser writes the `content-type` itself, because only it
|
||||
* knows the boundary it generated.
|
||||
*/
|
||||
multipart?: FormData
|
||||
/** Send the session token. */
|
||||
authed?: boolean
|
||||
/**
|
||||
@@ -162,13 +259,16 @@ interface CallOptions {
|
||||
async function call<T = Record<string, unknown>>(url: string, opts: CallOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {}
|
||||
if (opts.authed && token) headers.authorization = `Bearer ${token}`
|
||||
let body: string | undefined
|
||||
let body: string | FormData | undefined
|
||||
if (opts.form) {
|
||||
headers['content-type'] = 'application/x-www-form-urlencoded'
|
||||
body = new URLSearchParams(opts.form).toString()
|
||||
} else if (opts.json !== undefined) {
|
||||
headers['content-type'] = 'application/json'
|
||||
body = JSON.stringify(opts.json)
|
||||
} else if (opts.multipart) {
|
||||
// Deliberately no content-type: setting one would omit the boundary.
|
||||
body = opts.multipart
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
@@ -197,6 +297,115 @@ async function call<T = Record<string, unknown>>(url: string, opts: CallOptions
|
||||
const fetchMe = (): Promise<SelfAccount> =>
|
||||
call<SelfAccount>(`${where().accounts}/account/me`, { authed: true })
|
||||
|
||||
/**
|
||||
* The caller's own rooms, from the `rooms` worker — the same list the game's "My Rooms"
|
||||
* loads. `ownedby/me` rather than `createdby/me`: the dorm is auto-provisioned, not a
|
||||
* room the player made, and it's the one room they can't do anything with from here.
|
||||
*
|
||||
* The worker deliberately does NOT filter on accessibility for this list, so a room that
|
||||
* has never been published shows up — which is the point, since that's the one its owner
|
||||
* is most likely to be looking for.
|
||||
*
|
||||
* Sorted newest-first here rather than upstream: the query has no ORDER BY (D1 hands
|
||||
* back insertion order, which is not a promise), and the room someone just made is the
|
||||
* one they came to see.
|
||||
*/
|
||||
async function fetchMyRooms(): Promise<OwnedRoom[]> {
|
||||
const rooms = await call<OwnedRoom[]>(`${where().rooms}/rooms/ownedby/me`, { authed: true })
|
||||
// A bare array is the contract; anything else is treated as "no rooms" rather than
|
||||
// thrown, since `.sort` on a non-array would surface as an unreadable TypeError.
|
||||
if (!Array.isArray(rooms)) return []
|
||||
// ISO-8601 timestamps, so lexical order IS chronological order.
|
||||
return [...rooms].sort((a, b) => (a.CreatedAt < b.CreatedAt ? 1 : -1))
|
||||
}
|
||||
|
||||
/**
|
||||
* The `UploadFileType` a room's scene data is posted under. `storage` maps this to the
|
||||
* `room/` subfolder of the CDN bucket — the one prefix `cdn`'s `GET /room/:dataBlob`
|
||||
* reads back, and so the only one a `DataBlob` key can point into.
|
||||
*/
|
||||
const FILE_TYPE_ROOM_SAVE = '1'
|
||||
|
||||
/**
|
||||
* The game build this server targets, as `YYYY-MM-DD` — read from the same `GAME_VERSION`
|
||||
* the auth token and presence carry rather than written out again here, so upgrading the
|
||||
* client moves this line with it instead of leaving a stale date on the upload form.
|
||||
*
|
||||
* It's shown because a scene blob is only loadable by the build that wrote it (or older
|
||||
* ones that understand it): a save taken out of a room built on a later version can fail
|
||||
* outright, and nothing between here and the game says why.
|
||||
*/
|
||||
const CLIENT_BUILD_DATE = `${GAME_VERSION.slice(0, 4)}-${GAME_VERSION.slice(4, 6)}-${GAME_VERSION.slice(6, 8)}`
|
||||
|
||||
/**
|
||||
* Upload a scene blob to `storage` and return the key it was stored under — the
|
||||
* `<date>/<uuid>` name every `DataBlob` field holds.
|
||||
*
|
||||
* This is the same two-step the game does: the bytes go to `storage` first, and only its
|
||||
* generated name is handed to `rooms`. Nothing about the file is inspected here — a room
|
||||
* blob is an opaque Unity payload, and the server doesn't parse it either, so the only
|
||||
* honest validation available is whether the game can load it afterwards.
|
||||
*/
|
||||
async function uploadRoomBlob(file: File): Promise<string> {
|
||||
const form = new FormData()
|
||||
form.set('FileType', FILE_TYPE_ROOM_SAVE)
|
||||
form.set('File', file)
|
||||
const { filename } = await call<{ filename?: string }>(`${where().storage}/upload`, {
|
||||
method: 'POST',
|
||||
multipart: form,
|
||||
authed: true,
|
||||
})
|
||||
if (!filename) throw new Error('The storage worker accepted the file but returned no name.')
|
||||
return filename
|
||||
}
|
||||
|
||||
/**
|
||||
* The blob's SHA-256, base64 — the encoding this API's hash fields use (an invention's
|
||||
* `BlobHash` comes back the same way). `rooms` only echoes it back on the save, but a
|
||||
* save whose hash doesn't describe its blob is worse than one carrying none.
|
||||
*/
|
||||
async function blobHash(file: File): Promise<string> {
|
||||
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', await file.arrayBuffer()))
|
||||
let binary = ''
|
||||
for (const byte of digest) binary += String.fromCharCode(byte)
|
||||
return btoa(binary)
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a room save against one subroom, pointing it at an already-uploaded blob.
|
||||
*
|
||||
* `AutoPublish` decides whether players see it now or whether it waits on the room's
|
||||
* publish step, exactly as it does for the game — the site doesn't get its own rule.
|
||||
* The envelope answers HTTP 200 either way and puts the refusal in `error`, so success
|
||||
* has to be read from the body rather than the status. `value.room` is the updated room,
|
||||
* which the page re-renders from rather than re-fetching the whole list.
|
||||
*/
|
||||
async function saveSubRoomBlob(
|
||||
roomId: number,
|
||||
subRoomId: number,
|
||||
input: { filename: string; hash: string; description: string; autoPublish: boolean }
|
||||
): Promise<OwnedRoom> {
|
||||
const res = await call<{
|
||||
success?: boolean
|
||||
error?: string | null
|
||||
value?: { room?: OwnedRoom } | null
|
||||
}>(`${where().rooms}/rooms/${roomId}/subrooms/${subRoomId}/data`, {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
json: {
|
||||
SubRoomData: { Filename: input.filename, Hash: input.hash },
|
||||
Description: input.description,
|
||||
AutoPublish: input.autoPublish,
|
||||
},
|
||||
})
|
||||
if (res.success !== true) {
|
||||
throw new Error(res.error || 'The rooms worker refused the save.')
|
||||
}
|
||||
const room = res.value?.room
|
||||
if (!room) throw new Error('The save was recorded but the room came back empty.')
|
||||
return room
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign in with auth's password grant, posted directly the way the game posts it. The
|
||||
* account is resolved by `username` (case-insensitive) — web players sign in with their
|
||||
@@ -353,6 +562,16 @@ function Link({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The room id in `/rooms/<id>`, or null for any other path. Numeric rather than the
|
||||
* room's name: a name is renameable (`PUT /rooms/{id}/name`), so a link someone
|
||||
* bookmarked would rot the moment they renamed the room.
|
||||
*/
|
||||
function roomIdFromPath(path: string): number | null {
|
||||
const match = /^\/rooms\/(\d+)$/.exec(path)
|
||||
return match ? Number.parseInt(match[1], 10) : null
|
||||
}
|
||||
|
||||
export function App() {
|
||||
// undefined = still checking the session; null = signed out.
|
||||
const [account, setAccount] = useState<SelfAccount | null | undefined>(undefined)
|
||||
@@ -360,6 +579,7 @@ export function App() {
|
||||
// so a slow (or failed) config fetch can't flash a form the server would refuse.
|
||||
const [config, setConfig] = useState<SiteConfig | undefined>(undefined)
|
||||
const { path, navigate } = useRouter()
|
||||
const roomId = roomIdFromPath(path)
|
||||
|
||||
useEffect(() => {
|
||||
// Config first, and everything else after it: it carries the hostnames every other
|
||||
@@ -408,6 +628,8 @@ export function App() {
|
||||
/>
|
||||
) : path === '/account' ? (
|
||||
<AccountPage account={account} navigate={navigate} onChange={setAccount} />
|
||||
) : roomId !== null ? (
|
||||
<RoomPage account={account} roomId={roomId} navigate={navigate} />
|
||||
) : (
|
||||
<HomePage account={account} config={config} navigate={navigate} />
|
||||
)}
|
||||
@@ -830,11 +1052,474 @@ function AccountPage({
|
||||
return (
|
||||
<main className="shell wide">
|
||||
<h1>My account</h1>
|
||||
<Dashboard account={account} onChange={onChange} />
|
||||
<Dashboard account={account} navigate={navigate} onChange={onChange} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One room's own page — what it is, how it's set up, and the subrooms inside it.
|
||||
*
|
||||
* The room is found in the caller's OWN list rather than read from the public
|
||||
* `GET /rooms?id=`, which is unfiltered by design (the game looks any room up that way).
|
||||
* Going through `ownedby/me` is what makes this the owner's page: a room that isn't
|
||||
* yours simply isn't in the list, so there's no second ownership rule here to drift out
|
||||
* of step with the one the mutating endpoints enforce.
|
||||
*/
|
||||
function RoomPage({
|
||||
account,
|
||||
roomId,
|
||||
navigate,
|
||||
}: {
|
||||
account: SelfAccount | null | undefined
|
||||
roomId: number
|
||||
navigate: Navigate
|
||||
}) {
|
||||
const [rooms, setRooms] = useState<OwnedRoom[] | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const accountId = account?.accountId
|
||||
|
||||
useEffect(() => {
|
||||
if (account === null) navigate('/login')
|
||||
}, [account, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
// Waits for the session: the list is auth-gated, and `account === undefined` only
|
||||
// means the stored token hasn't been checked yet.
|
||||
if (accountId === undefined) return
|
||||
void fetchMyRooms()
|
||||
.then(setRooms)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||
}, [accountId])
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<main className="shell">
|
||||
<p className="muted">{account === undefined ? 'Loading…' : 'Redirecting…'}</p>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const room = rooms?.find((r) => r.RoomId === roomId)
|
||||
|
||||
return (
|
||||
<main className="shell wide">
|
||||
<p className="backlink">
|
||||
<Link to="/account" navigate={navigate}>
|
||||
← My rooms
|
||||
</Link>
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="error">{error}</p>
|
||||
) : rooms === null ? (
|
||||
<p className="muted">Loading…</p>
|
||||
) : room === undefined ? (
|
||||
// Covers both "no such room" and "someone else's" — deliberately the same
|
||||
// sentence, since telling a stranger which of the two it is answers a question
|
||||
// they have no business asking.
|
||||
<p className="muted">That isn't one of your rooms.</p>
|
||||
) : (
|
||||
<RoomDetail
|
||||
room={room}
|
||||
imgHost={where().img}
|
||||
cdnHost={where().cdn}
|
||||
// A save answers with the whole updated room, so swapping it into the list
|
||||
// is enough — no re-fetch, and the other rooms keep their place.
|
||||
onRoomChange={(updated) =>
|
||||
setRooms((current) =>
|
||||
(current ?? []).map((r) => (r.RoomId === updated.RoomId ? updated : r))
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
/** The platforms a room says it supports, named the way the game names them. */
|
||||
function platformList(room: OwnedRoom): string[] {
|
||||
const on: string[] = []
|
||||
if (room.SupportsScreens) on.push('Screens')
|
||||
if (room.SupportsWalkVR) on.push('VR (walk)')
|
||||
if (room.SupportsTeleportVR) on.push('VR (teleport)')
|
||||
if (room.SupportsQuest2) on.push('Quest 2')
|
||||
if (room.SupportsMobile) on.push('Mobile')
|
||||
if (room.SupportsJuniors) on.push('Juniors')
|
||||
return on
|
||||
}
|
||||
|
||||
/**
|
||||
* A room's settings and its subrooms. Its own fields are read-only — rooms are edited in
|
||||
* game — with one exception: a subroom's scene data can be replaced from here, which is
|
||||
* the one thing the game gives an owner no way to do (it can only save what it just
|
||||
* built, never restore a file they kept).
|
||||
*/
|
||||
function RoomDetail({
|
||||
room,
|
||||
imgHost,
|
||||
cdnHost,
|
||||
onRoomChange,
|
||||
}: {
|
||||
room: OwnedRoom
|
||||
imgHost: string
|
||||
cdnHost: string
|
||||
onRoomChange: (room: OwnedRoom) => void
|
||||
}) {
|
||||
const created = new Date(room.CreatedAt)
|
||||
const platforms = platformList(room)
|
||||
const subRooms = room.SubRooms ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="card room-hero">
|
||||
{/* 512 rather than the list's 256: this one is displayed large. Both are sizes
|
||||
the img worker allows, so each is a cached variant. */}
|
||||
<img className="room-hero-img" src={`${imgHost}/${room.ImageName}?width=512`} alt="" />
|
||||
<div className="room-hero-body">
|
||||
<div className="room-head">
|
||||
<h1 className="room-hero-name">^{room.Name}</h1>
|
||||
<VisibilityBadge accessibility={room.Accessibility} />
|
||||
</div>
|
||||
{room.Description ? (
|
||||
<p className="muted room-hero-desc">{room.Description}</p>
|
||||
) : (
|
||||
<p className="muted room-hero-desc">No description set.</p>
|
||||
)}
|
||||
<p className="room-stats">
|
||||
{room.Stats.VisitCount.toLocaleString()} visit
|
||||
{room.Stats.VisitCount === 1 ? '' : 's'} · {room.Stats.FavoriteCount.toLocaleString()}{' '}
|
||||
favourite
|
||||
{room.Stats.FavoriteCount === 1 ? '' : 's'} · {room.Stats.CheerCount.toLocaleString()}{' '}
|
||||
cheer{room.Stats.CheerCount === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2>Settings</h2>
|
||||
<dl className="facts">
|
||||
<dt>Room id</dt>
|
||||
<dd>{room.RoomId}</dd>
|
||||
<dt>Visibility</dt>
|
||||
<dd>{accessibilityLabel(room.Accessibility)}</dd>
|
||||
<dt>Max players</dt>
|
||||
<dd>{room.MaxPlayers}</dd>
|
||||
<dt>Cloning</dt>
|
||||
<dd>
|
||||
{room.CloningAllowed ? 'Anyone may clone this room' : 'Nobody may clone this room'}
|
||||
</dd>
|
||||
<dt>Plays on</dt>
|
||||
<dd>
|
||||
{platforms.length > 0 ? platforms.join(', ') : 'Nothing — no platform is enabled'}
|
||||
</dd>
|
||||
<dt>Tags</dt>
|
||||
<dd>{room.Tags?.length ? room.Tags.map((t) => t.Tag).join(', ') : 'None'}</dd>
|
||||
<dt>Created</dt>
|
||||
<dd>{Number.isNaN(created.getTime()) ? room.CreatedAt : created.toLocaleDateString()}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2>Subrooms</h2>
|
||||
<p className="muted">
|
||||
The places inside the room players actually load into. Each keeps its own accessibility
|
||||
and its own saves, so a public room can still hold a subroom nobody else can reach.
|
||||
</p>
|
||||
{subRooms.length === 0 ? (
|
||||
<p className="muted">This room has no subrooms.</p>
|
||||
) : (
|
||||
<ul className="subrooms">
|
||||
{subRooms.map((sub) => (
|
||||
<SubRoomRow
|
||||
key={sub.SubRoomId}
|
||||
sub={sub}
|
||||
roomId={room.RoomId}
|
||||
roomName={room.Name}
|
||||
cdnHost={cdnHost}
|
||||
onRoomChange={onRoomChange}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** One subroom: what it is, and — the part an owner can't see anywhere else — its save. */
|
||||
function SubRoomRow({
|
||||
sub,
|
||||
roomId,
|
||||
roomName,
|
||||
cdnHost,
|
||||
onRoomChange,
|
||||
}: {
|
||||
sub: SubRoom
|
||||
roomId: number
|
||||
roomName: string
|
||||
cdnHost: string
|
||||
onRoomChange: (room: OwnedRoom) => void
|
||||
}) {
|
||||
const save = sub.CurrentSave ?? null
|
||||
const saved = save ? new Date(save.CreatedAt) : null
|
||||
// Cleared when that save is published (see publishSubRoomSave), so a value here always
|
||||
// means work the owner saved but players still can't see.
|
||||
const staged = sub.StagedSubRoomDataSaveId !== null && sub.StagedSubRoomDataSaveId !== undefined
|
||||
const name = sub.Name || `Subroom ${sub.SubRoomId}`
|
||||
|
||||
return (
|
||||
<li className="subroom">
|
||||
<div className="room-head">
|
||||
<span className="subroom-name">{name}</span>
|
||||
<VisibilityBadge accessibility={sub.Accessibility} />
|
||||
{sub.IsSandbox && <span className="badge">Sandbox</span>}
|
||||
</div>
|
||||
<p className="subroom-meta">
|
||||
#{sub.SubRoomId} · up to {sub.MaxPlayers} players
|
||||
</p>
|
||||
<p className="subroom-save">
|
||||
{save === null ? (
|
||||
// A subroom with no published save loads an empty scene without erroring, which
|
||||
// from the inside looks exactly like a broken room. Say so plainly.
|
||||
<span className="warn">Never published — players load an empty scene.</span>
|
||||
) : (
|
||||
<>
|
||||
Published save #{save.SubRoomDataSaveId}
|
||||
{saved && !Number.isNaN(saved.getTime()) && `, saved ${saved.toLocaleString()}`}
|
||||
{save.Description && ` — “${save.Description}”`}
|
||||
</>
|
||||
)}
|
||||
{staged && (
|
||||
<span className="warn"> · a newer save is staged, waiting to be published.</span>
|
||||
)}
|
||||
</p>
|
||||
{/* The published save's blob first: that's the copy of the room worth keeping,
|
||||
and the one the client resolves ahead of the subroom's own key. */}
|
||||
{save?.DataBlob && (
|
||||
<BlobDownload
|
||||
label="Save DataBlob"
|
||||
blobKey={save.DataBlob}
|
||||
filename={safeFilename(roomName, name, `save-${save.SubRoomDataSaveId}`)}
|
||||
cdnHost={cdnHost}
|
||||
/>
|
||||
)}
|
||||
{sub.DataBlob && (
|
||||
<BlobDownload
|
||||
label="Subroom DataBlob"
|
||||
blobKey={sub.DataBlob}
|
||||
filename={safeFilename(roomName, name, 'datablob')}
|
||||
cdnHost={cdnHost}
|
||||
/>
|
||||
)}
|
||||
<BlobUpload roomId={roomId} subRoomId={sub.SubRoomId} onRoomChange={onRoomChange} />
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace one subroom's scene data with a file from disk.
|
||||
*
|
||||
* The two steps are the game's own: the bytes go to `storage` under the RoomSave type,
|
||||
* and the key it hands back is posted to the subroom's `…/data` route as
|
||||
* `SubRoomData.Filename`. So this is a room save like any other — it lands in the
|
||||
* subroom's history beside the ones the game wrote, and both endpoints are already gated
|
||||
* on the room's creator (or a co-owner), which is why there is no ownership check here:
|
||||
* the page only lists rooms that came back from `ownedby/me` in the first place.
|
||||
*
|
||||
* Publishing is offered rather than assumed. A save normally only STAGES — players keep
|
||||
* loading the last published version until the owner publishes — and quietly making an
|
||||
* uploaded file live would be a bigger step than the game's own save takes. Left on by
|
||||
* default all the same: someone uploading a blob here is restoring a room, and a restore
|
||||
* nobody can see isn't one.
|
||||
*/
|
||||
function BlobUpload({
|
||||
roomId,
|
||||
subRoomId,
|
||||
onRoomChange,
|
||||
}: {
|
||||
roomId: number
|
||||
subRoomId: number
|
||||
onRoomChange: (room: OwnedRoom) => void
|
||||
}) {
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [description, setDescription] = useState('')
|
||||
const [publish, setPublish] = useState(true)
|
||||
// The file input is uncontrolled — React can't set its value — so clearing the picked
|
||||
// file after a save takes a handle on the element itself.
|
||||
const input = useRef<HTMLInputElement>(null)
|
||||
const { pending, error, done, run } = useAction()
|
||||
|
||||
return (
|
||||
<form
|
||||
className="blob-upload"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (!file) return
|
||||
void run(async () => {
|
||||
const [filename, hash] = await Promise.all([uploadRoomBlob(file), blobHash(file)])
|
||||
onRoomChange(
|
||||
await saveSubRoomBlob(roomId, subRoomId, {
|
||||
filename,
|
||||
hash,
|
||||
description: description.trim(),
|
||||
autoPublish: publish,
|
||||
})
|
||||
)
|
||||
setFile(null)
|
||||
setDescription('')
|
||||
if (input.current) input.current.value = ''
|
||||
return publish
|
||||
? 'Uploaded and published — players load this scene now.'
|
||||
: 'Uploaded and staged. Publish it in game to make it live.'
|
||||
})
|
||||
}}
|
||||
>
|
||||
{/* Said out loud, on the control itself: this is the newest thing on the site and
|
||||
the only one that overwrites what players load. Someone about to hand us a file
|
||||
they can't get back should read that before the file picker, not after. */}
|
||||
<p className="blob-upload-head">
|
||||
<span className="blob-upload-title">Replace scene data</span>
|
||||
<span className="badge beta">Beta</span>
|
||||
</p>
|
||||
<p className="muted blob-upload-caveat">
|
||||
New and lightly tested. Nothing here checks the file — the server stores whatever it
|
||||
is and the game finds out on load. This server runs the {CLIENT_BUILD_DATE} build, so
|
||||
scene data from a room built on anything newer may not load at all. Download the save
|
||||
above and keep it before replacing it.
|
||||
</p>
|
||||
<label className="blob-upload-file">
|
||||
Scene data file
|
||||
<input
|
||||
ref={input}
|
||||
type="file"
|
||||
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="blob-upload-note">
|
||||
Save comment<span className="optional">optional</span>
|
||||
<input
|
||||
type="text"
|
||||
value={description}
|
||||
placeholder="Uploaded from the website"
|
||||
maxLength={200}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="check">
|
||||
<input type="checkbox" checked={publish} onChange={(e) => setPublish(e.target.checked)} />
|
||||
Publish it straight away
|
||||
</label>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{done && <p className="ok">{done}</p>}
|
||||
<button type="submit" disabled={pending || file === null}>
|
||||
{pending ? 'Uploading…' : 'Upload scene data'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A download filename built from player-supplied names, with everything that isn't a
|
||||
* word character, dot or dash flattened to a dash — a subroom can be called anything,
|
||||
* and that string is about to become a path on someone's disk.
|
||||
*/
|
||||
const safeFilename = (...parts: string[]): string =>
|
||||
`${parts.join('-').replace(/[^\w.-]+/g, '-')}.bin`
|
||||
|
||||
/**
|
||||
* One scene-data blob: the key, and a link that downloads it from `cdn`.
|
||||
*
|
||||
* `href` is the real CDN URL, so open-in-new-tab and right-click → Save As work like any
|
||||
* other link. The click is intercepted only to give the file a NAME: blobs are stored
|
||||
* under a date-foldered UUID, so three downloads otherwise land as three
|
||||
* indistinguishable extensionless files. The `download` attribute can't do that on its
|
||||
* own — browsers ignore it cross-origin, and `cdn` is always a different origin from the
|
||||
* website — hence fetching the bytes and saving them through an object URL.
|
||||
*/
|
||||
function BlobDownload({
|
||||
label,
|
||||
blobKey,
|
||||
filename,
|
||||
cdnHost,
|
||||
}: {
|
||||
label: string
|
||||
blobKey: string
|
||||
filename: string
|
||||
cdnHost: string
|
||||
}) {
|
||||
// Room build data is served under `room/` — the same prefix the storage worker
|
||||
// uploads it to, and the one the game downloads it from.
|
||||
const url = `${cdnHost}/room/${blobKey}`
|
||||
const [pending, setPending] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const download = async () => {
|
||||
setPending(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
// The blob key is stored on the subroom, so a miss here means the object is gone
|
||||
// from the bucket — worth saying, rather than saving a file of the 404 body.
|
||||
if (!res.ok) throw new Error(`the CDN answered ${res.status}`)
|
||||
const href = URL.createObjectURL(await res.blob())
|
||||
const link = document.createElement('a')
|
||||
link.href = href
|
||||
link.download = filename
|
||||
link.click()
|
||||
// The click is dispatched synchronously but the save reads the URL after this
|
||||
// frame, so the revoke waits a tick rather than pulling it out from under.
|
||||
setTimeout(() => URL.revokeObjectURL(href), 0)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setPending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="blob">
|
||||
<span className="blob-label">{label}</span>
|
||||
<a
|
||||
className="blob-key"
|
||||
href={url}
|
||||
download={filename}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
void download()
|
||||
}}
|
||||
>
|
||||
{blobKey}
|
||||
</a>
|
||||
{/* Only rendered when it has something to say — an empty span would still take a
|
||||
gap from the flex row, leaving the key trailed by a stray space. */}
|
||||
{pending ? (
|
||||
<span className="blob-note">Downloading…</span>
|
||||
) : error ? (
|
||||
<span className="blob-note error">Couldn’t download — {error}.</span>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** How a room or subroom's `Accessibility` reads on screen. */
|
||||
const accessibilityLabel = (accessibility: number): string =>
|
||||
// Unknown ordinals shouldn't happen, but this label is the only thing telling an owner
|
||||
// whether a room is visible — so show the raw value rather than nothing at all.
|
||||
ACCESSIBILITY_LABEL[accessibility] ?? `Accessibility ${accessibility}`
|
||||
|
||||
/**
|
||||
* The visibility pill. Public gets the same green "healthy" reading as the server
|
||||
* status; every other value stays neutral, since Private is a choice, not a fault.
|
||||
*/
|
||||
function VisibilityBadge({ accessibility }: { accessibility: number }) {
|
||||
return (
|
||||
<span className={`badge ${accessibility === Accessibility.Public ? 'live' : ''}`}>
|
||||
{accessibilityLabel(accessibility)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Small hook wrapping a submit handler with pending/error/success state. */
|
||||
function useAction() {
|
||||
const [pending, setPending] = useState(false)
|
||||
@@ -1091,14 +1776,19 @@ function LoginForm({ onAuthed }: { onAuthed: (a: SelfAccount) => void }) {
|
||||
|
||||
function Dashboard({
|
||||
account,
|
||||
navigate,
|
||||
onChange,
|
||||
}: {
|
||||
account: SelfAccount
|
||||
navigate: Navigate
|
||||
onChange: (a: SelfAccount) => void
|
||||
}) {
|
||||
// The dashboard sections, shown one at a time via the left tab rail. Admin-only
|
||||
// sections are appended when the session carries an admin role.
|
||||
const sections = [
|
||||
// First, so a player who just signed in lands on what they made rather than on a
|
||||
// settings form they opened the page to avoid.
|
||||
{ id: 'rooms', label: 'My rooms', render: () => <MyRooms navigate={navigate} /> },
|
||||
{
|
||||
id: 'username',
|
||||
label: 'Username',
|
||||
@@ -1147,6 +1837,107 @@ function Dashboard({
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The rooms the signed-in player owns.
|
||||
*
|
||||
* Read-only on purpose: rooms are made and edited in game, and there is nothing here a
|
||||
* player could change that the game doesn't already own. What the web is better at is
|
||||
* the overview — everything you've made in one place, including the rooms you never
|
||||
* published, which are invisible everywhere else.
|
||||
*/
|
||||
function MyRooms({ navigate }: { navigate: Navigate }) {
|
||||
const [rooms, setRooms] = useState<OwnedRoom[] | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
void fetchMyRooms()
|
||||
.then(setRooms)
|
||||
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<h2>My rooms</h2>
|
||||
<p className="muted">
|
||||
Every room you've made, newest first — unpublished ones included. Your dorm isn't
|
||||
here: it was made for you rather than by you.
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="error">{error}</p>
|
||||
) : rooms === null ? (
|
||||
<p className="muted">Loading…</p>
|
||||
) : rooms.length === 0 ? (
|
||||
<p className="muted">
|
||||
You haven't made a room yet. Rooms are created in game — clone one you like, or start
|
||||
from a blank one in the Rec Center.
|
||||
</p>
|
||||
) : (
|
||||
// `where()` THROWS when the config never landed, and a throw in render takes the
|
||||
// page down (see useSlideshow). It can't here: this branch is only reached once
|
||||
// the fetch above resolved, and that fetch went through `where()` itself.
|
||||
<ul className="rooms">
|
||||
{rooms.map((room) => (
|
||||
<RoomCard key={room.RoomId} room={room} imgHost={where().img} navigate={navigate} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One room in the list: its thumbnail, what it's called in game (`^Name`), and how it's
|
||||
* doing. The whole row links to the room's own page.
|
||||
*
|
||||
* The thumbnail is asked for at 256px wide — one of the img worker's four allowed sizes,
|
||||
* so it's a cached variant rather than the full-size upload. A room with no image of its
|
||||
* own still answers 200 there (the worker serves its fallback), so there's no broken
|
||||
* frame to handle.
|
||||
*/
|
||||
function RoomCard({
|
||||
room,
|
||||
imgHost,
|
||||
navigate,
|
||||
}: {
|
||||
room: OwnedRoom
|
||||
imgHost: string
|
||||
navigate: Navigate
|
||||
}) {
|
||||
const created = new Date(room.CreatedAt)
|
||||
|
||||
return (
|
||||
<li className="room">
|
||||
{/* A real `<a href>` (see Link), not a click handler on the row: it has to be
|
||||
reachable by keyboard, and openable in a new tab like any other link. */}
|
||||
<Link to={`/rooms/${room.RoomId}`} navigate={navigate} className="room-link">
|
||||
<img
|
||||
className="room-thumb"
|
||||
src={`${imgHost}/${room.ImageName}?width=256`}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="room-body">
|
||||
<div className="room-head">
|
||||
{/* The caret is how the game writes a room name, so it reads as the thing you
|
||||
type to get there rather than as a title someone wrote. */}
|
||||
<span className="room-name">^{room.Name}</span>
|
||||
<VisibilityBadge accessibility={room.Accessibility} />
|
||||
</div>
|
||||
{room.Description && <p className="room-desc">{room.Description}</p>}
|
||||
<p className="room-stats">
|
||||
{room.Stats.VisitCount.toLocaleString()} visit
|
||||
{room.Stats.VisitCount === 1 ? '' : 's'} · {room.Stats.FavoriteCount.toLocaleString()}{' '}
|
||||
favourite
|
||||
{room.Stats.FavoriteCount === 1 ? '' : 's'} · {room.Stats.CheerCount.toLocaleString()}{' '}
|
||||
cheer{room.Stats.CheerCount === 1 ? '' : 's'}
|
||||
{!Number.isNaN(created.getTime()) && ` · made ${created.toLocaleDateString()}`}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** Admin-only: send a coach/system message to every online player. */
|
||||
function CoachMessageForm() {
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
@@ -605,6 +605,349 @@ h2 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- My rooms ----------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
* The owner's own room list. Rows rather than a grid of tiles: a room is identified by
|
||||
* its name, and the counts underneath are the reason to look — both read left-to-right,
|
||||
* which a tile would stack into a column of tiny type.
|
||||
*/
|
||||
.rooms {
|
||||
list-style: none;
|
||||
margin: 18px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.room {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* The first row sits directly under the intro copy, which already separates it. */
|
||||
.room:first-child {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* The whole row is the link to the room's page, so the target is the size of the thing
|
||||
you're aiming at rather than the name alone. Padded out to the card edge and pulled
|
||||
back by the same amount, so the hover fill covers the row instead of stopping short. */
|
||||
.room-link {
|
||||
display: grid;
|
||||
grid-template-columns: 112px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
margin: 0 -10px;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.room-link:hover {
|
||||
background: var(--surface-hi);
|
||||
}
|
||||
|
||||
.room-link:hover .room-name {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* Fixed 3:2 frame — room thumbnails are screenshots and arrive at any ratio, and a
|
||||
per-room height would leave the names in a ragged column. */
|
||||
.room-thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-hi);
|
||||
}
|
||||
|
||||
.room-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.room-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Room names have no spaces to break at, so let a long one wrap anywhere rather than
|
||||
widen the row past the panel. */
|
||||
.room-name {
|
||||
font-family: var(--display);
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.01em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Whether the room is visible to anyone else — the one piece of state an owner can't
|
||||
see anywhere but here. Public gets the same green "healthy" reading as the server
|
||||
status; every other value stays neutral, since Private isn't a fault. */
|
||||
.badge {
|
||||
flex: none;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.badge.live {
|
||||
color: var(--live);
|
||||
border-color: color-mix(in srgb, var(--live) 45%, transparent);
|
||||
}
|
||||
|
||||
/* Same pill in the accent `.warn` uses, for a control that isn't finished: a beta mark
|
||||
is "worth knowing before you act", like a staged save — not a neutral fact like Private. */
|
||||
.badge.beta {
|
||||
color: var(--accent);
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
|
||||
}
|
||||
|
||||
.room-desc {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--muted);
|
||||
/* Two lines: enough to tell rooms apart, not enough for one to own the list. */
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.room-stats {
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ---- One room's page ---------------------------------------------------- */
|
||||
|
||||
/* The way back out, above the page's own heading. */
|
||||
.backlink {
|
||||
margin: 0 0 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.backlink a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.backlink a:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Wider thumbnail than the list's, and the name beside it rather than under: this is
|
||||
the page about this one room, so the photo can carry some of the weight. */
|
||||
.room-hero {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.room-hero-img {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-hi);
|
||||
}
|
||||
|
||||
.room-hero-name {
|
||||
font-family: var(--display);
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.room-hero-desc {
|
||||
margin: 10px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Label/value pairs. A two-column grid rather than a list of "Label: value" lines, so
|
||||
the values line up and the page can be read down one column. */
|
||||
.facts {
|
||||
display: grid;
|
||||
grid-template-columns: 150px minmax(0, 1fr);
|
||||
gap: 8px 16px;
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.facts dt {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.facts dd {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subrooms {
|
||||
list-style: none;
|
||||
margin: 18px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.subroom {
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.subroom:first-child {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.subroom-name {
|
||||
font-family: var(--display);
|
||||
font-weight: 700;
|
||||
font-size: 0.975rem;
|
||||
letter-spacing: -0.01em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.subroom-meta,
|
||||
.subroom-save {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.825rem;
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Something the owner probably wants to act on — an unpublished subroom, a staged save
|
||||
nobody can see yet. Not an error: nothing is broken, it just isn't live. */
|
||||
.warn {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/*
|
||||
* A scene-data blob and the link that downloads it. The KEY is the link, rather than a
|
||||
* "Download" button beside it: the key is the thing an owner came to find (it's what
|
||||
* `CurrentSave.DataBlob` holds, and what a bug report quotes), so making it the target
|
||||
* keeps it readable and clickable without a second control competing for the row.
|
||||
*/
|
||||
.blob {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 10px;
|
||||
margin-top: 8px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.blob-label {
|
||||
flex: none;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Blob keys are `<date>/<uuid>` — long, and with only two places a line could break.
|
||||
`anywhere` lets one wrap mid-key instead of pushing the card sideways. */
|
||||
.blob-key {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
overflow-wrap: anywhere;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.blob-key:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.blob-note {
|
||||
color: var(--muted);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/*
|
||||
* Replacing a subroom's scene data. Boxed off from the download links above it: those
|
||||
* only read the room, this one overwrites what players load, and the two shouldn't read
|
||||
* as one row of blob controls.
|
||||
*/
|
||||
.blob-upload {
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.blob-upload label {
|
||||
margin-bottom: 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.blob-upload-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.blob-upload-title {
|
||||
font-family: var(--display);
|
||||
font-weight: 700;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Tighter than the shared `.muted` paragraph: it's a caveat under a heading, not body
|
||||
copy, and the file picker should still be the first thing the eye lands on. */
|
||||
.blob-upload-caveat {
|
||||
margin: 0 0 10px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* The file picker draws its own button, so the shared input chrome would frame it a
|
||||
second time. Padding stays, so the row lines up with the text field under it. */
|
||||
.blob-upload input[type='file'] {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 8px 0 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* A checkbox is not a text field: the shared `input` rule would stretch it to the card's
|
||||
full width and break it onto its own line, away from the words it labels. */
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.check input[type='checkbox'] {
|
||||
display: inline-block;
|
||||
width: auto;
|
||||
margin: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
/* Smaller than the account forms' submit — this one sits inside a subroom row, not at
|
||||
the foot of its own card. */
|
||||
.blob-upload button[type='submit'] {
|
||||
padding: 8px 14px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* ---- Forms -------------------------------------------------------------- */
|
||||
|
||||
label {
|
||||
@@ -780,6 +1123,21 @@ button[type='submit']:disabled {
|
||||
.vtabs button {
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
/* The room page's hero and its fact table both stack: 260px of photo (or 150px of
|
||||
label) beside a value leaves the value in a column too narrow to read. */
|
||||
.room-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.facts {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2px 0;
|
||||
}
|
||||
|
||||
.facts dd {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -6,6 +6,12 @@ export type Env = SharedHonoEnv & {
|
||||
DOMAIN: string
|
||||
/** Static-asset fetcher for the built React SPA (see wrangler.jsonc `assets`). */
|
||||
ASSETS: Fetcher
|
||||
/**
|
||||
* The shared `recflare` D1, bound READ-ONLY in practice: the only thing www asks it
|
||||
* is the live presence head-count behind `/server-status`. Every table it can see is
|
||||
* owned (and migrated) by another worker.
|
||||
*/
|
||||
DB: D1Database
|
||||
/**
|
||||
* Service binding to the `auth` worker — how the BFF reaches it, so the browser's real
|
||||
* IP survives the hop (see wrangler.jsonc and src/upstream.ts `postAuthForm`).
|
||||
|
||||
@@ -23,6 +23,7 @@ export const DOCUMENTED_SERVICES: ReadonlyArray<{ slug: string; title: string }>
|
||||
{ slug: 'match', title: 'match — matchmaking & presence' },
|
||||
{ slug: 'econ', title: 'econ — avatar & economy' },
|
||||
{ slug: 'clubs', title: 'clubs — clubs & clubhouses' },
|
||||
{ slug: 'commerce', title: 'commerce — store catalog & purchases' },
|
||||
{ slug: 'chat', title: 'chat — threads & messages' },
|
||||
{ slug: 'img', title: 'img — image serving & resizing' },
|
||||
{ slug: 'cdn', title: 'cdn — binary asset delivery' },
|
||||
|
||||
@@ -14,7 +14,7 @@ export const DISCORD_INVITE = 'https://discord.gg/HhmMAKhrz'
|
||||
export const DOWNLOAD_URL = 'https://github.com/djdevin/recflare-client/releases'
|
||||
|
||||
/** The stage's "Download for Quest" button: the build's listing on the Meta store. */
|
||||
export const QUEST_DOWNLOAD_URL = 'https://www.meta.com/s/22O3QO7ytn'
|
||||
export const QUEST_DOWNLOAD_URL = 'https://www.meta.com/s/6lL20Fnhz'
|
||||
|
||||
/** The public source repo, linked from the homepage and footer. */
|
||||
export const SOURCE_REPO = 'https://github.com/djdevin/recflare'
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||
import { beforeAll, expect, it } from 'vitest'
|
||||
|
||||
import { PRESENCE_SCHEMA_DDL, PRESENCE_TTL_SECONDS } from '@repo/domain/src/presence-db'
|
||||
|
||||
import { DOCUMENTED_SERVICES } from '../../docs'
|
||||
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
|
||||
import { turnstileKeys } from '../../turnstile'
|
||||
@@ -22,12 +24,15 @@ const TEST_SECRET_KEY = '1x0000000000000000000000000000000AA'
|
||||
beforeAll(async () => {
|
||||
await adminSecretsStore(env.TURNSTILE_SITE_KEY).create(TEST_SITE_KEY)
|
||||
await adminSecretsStore(env.TURNSTILE_SECRET_KEY).create(TEST_SECRET_KEY)
|
||||
// `presence` is owned (and migrated) by other workers — www only reads it — so the
|
||||
// table has to be created here for the head-count behind /server-status.
|
||||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
// Web signup is open, but only behind the Turnstile check. These pin the closed door:
|
||||
// the pass path can't be tested here (it would call Cloudflare's siteverify for real).
|
||||
//
|
||||
// The hostnames matter as much as the key: the SPA calls auth/accounts/api/notify
|
||||
// The hostnames matter as much as the key: the SPA calls auth/accounts/api/notify/rooms
|
||||
// DIRECTLY (as rec.net's site did), and this is the only place it learns where they are.
|
||||
// A build with them missing can't sign anyone in.
|
||||
it('advertises signup and where the other workers live', async () => {
|
||||
@@ -43,6 +48,9 @@ it('advertises signup and where the other workers live', async () => {
|
||||
api: 'https://api.rec.example.com',
|
||||
img: 'https://img.rec.example.com',
|
||||
notify: 'https://notify.rec.example.com',
|
||||
rooms: 'https://rooms.rec.example.com',
|
||||
cdn: 'https://cdn.rec.example.com',
|
||||
storage: 'https://storage.rec.example.com',
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -223,6 +231,35 @@ it('carries the browser IP across to auth instead of losing it to the edge', asy
|
||||
expect(seen[1]!.headers.get('cf-connecting-ip')).toBeNull()
|
||||
})
|
||||
|
||||
// The public status snapshot. Two things are pinned: it needs no auth and no origin (a
|
||||
// status page or Discord bot fetches it from anywhere), and its player count is LIVE
|
||||
// presence — a row whose TTL has run out is a player who crashed or hard-quit, and
|
||||
// counting them would leave the number permanently inflated between sweeps.
|
||||
it('serves a public head-count of the players actually online', async () => {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const write = (accountId: number, expiresAt: number) =>
|
||||
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId, roomInstance: null, expiresAt }))
|
||||
.run()
|
||||
|
||||
// Empty table: online, nobody playing.
|
||||
let res = await SELF.fetch('https://example.com/server-status')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ status: 'online', players: 0 })
|
||||
|
||||
await write(1, now + PRESENCE_TTL_SECONDS) // in a lobby — still online
|
||||
await write(2, now + PRESENCE_TTL_SECONDS)
|
||||
await write(3, now - 1) // stopped heartbeating, not yet swept
|
||||
|
||||
res = await SELF.fetch('https://example.com/server-status', {
|
||||
headers: { origin: 'https://s.example' },
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
// Readable from any origin — it's meant to be embedded elsewhere.
|
||||
expect(res.headers.get('access-control-allow-origin')).toBe('*')
|
||||
expect(await res.json()).toEqual({ status: 'online', players: 2 })
|
||||
})
|
||||
|
||||
it('serves the aggregated docs page with a source per documented service', async () => {
|
||||
const res = await SELF.fetch('https://example.com/docs')
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
@@ -15,6 +15,9 @@ export const accountsBase = (env: Env): string => `https://accounts.${env.DOMAIN
|
||||
export const notifyBase = (env: Env): string => `https://notify.${env.DOMAIN}`
|
||||
export const apiBase = (env: Env): string => `https://api.${env.DOMAIN}`
|
||||
export const imgBase = (env: Env): string => `https://img.${env.DOMAIN}`
|
||||
export const roomsBase = (env: Env): string => `https://rooms.${env.DOMAIN}`
|
||||
export const cdnBase = (env: Env): string => `https://cdn.${env.DOMAIN}`
|
||||
export const storageBase = (env: Env): string => `https://storage.${env.DOMAIN}`
|
||||
|
||||
/**
|
||||
* POST a form body to the `auth` worker, carrying the browser's real IP across.
|
||||
|
||||
+28
-1
@@ -1,7 +1,8 @@
|
||||
import { Hono } from 'hono'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { logger, withOnError } from '@repo/hono-helpers'
|
||||
import { countOnlinePlayers } from '@repo/domain/src/presence-db'
|
||||
import { logger, withDefaultCors, withOnError } from '@repo/hono-helpers'
|
||||
|
||||
import { authUnreachable } from './auth-messages'
|
||||
import { docsPage, fetchSpec } from './docs'
|
||||
@@ -11,10 +12,13 @@ import {
|
||||
accountsBase,
|
||||
apiBase,
|
||||
authBase,
|
||||
cdnBase,
|
||||
imgBase,
|
||||
notifyBase,
|
||||
postAuthForm,
|
||||
readAuthError,
|
||||
roomsBase,
|
||||
storageBase,
|
||||
} from './upstream'
|
||||
|
||||
import type { App } from './context'
|
||||
@@ -66,10 +70,33 @@ const app = new Hono<App>()
|
||||
api: apiBase(c.env),
|
||||
img: imgBase(c.env),
|
||||
notify: notifyBase(c.env),
|
||||
rooms: roomsBase(c.env),
|
||||
cdn: cdnBase(c.env),
|
||||
storage: storageBase(c.env),
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Server status ------------------------------------------------------
|
||||
|
||||
// A public, unauthenticated snapshot of the server — what a status page, a Discord
|
||||
// bot or the homepage can poll without a token. CORS is open on this one route (the
|
||||
// rest of www is same-origin) so a page hosted anywhere can read it.
|
||||
//
|
||||
// `status` is a stub: this handler only runs when the worker is up, so there is no
|
||||
// state in which it answers anything but "online". It's here so callers can key off
|
||||
// a field rather than off HTTP 200, and so a real health signal can replace the
|
||||
// constant without changing the payload's shape.
|
||||
.get('/server-status', withDefaultCors(), async (c) => {
|
||||
return c.json({
|
||||
status: 'online',
|
||||
// One presence row per account, expired rows excluded — see countOnlinePlayers.
|
||||
// Players sitting in the lobby count as online, same as anywhere else we read
|
||||
// presence.
|
||||
players: await countOnlinePlayers(c.env.DB),
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Signup -------------------------------------------------------------
|
||||
|
||||
// Create an account from the website, behind a Turnstile bot check. The check is what
|
||||
|
||||
+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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user