mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
Compare commits
154 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f3111bcf0d | |||
| 585cf68637 | |||
| dd687f02d7 | |||
| 9a3ab3e50c | |||
| c049c8f14d | |||
| ab948f1f28 | |||
| e9af56aff4 | |||
| ef65b505cc | |||
| f9beba0dae | |||
| 74bab0f552 | |||
| 3916e40c2e | |||
| cebeafeb40 | |||
| 6b8c908771 | |||
| 7ec295ef33 | |||
| c6e035e4e1 | |||
| 1415ebcc06 | |||
| e54224dbd0 | |||
| 909bcebb38 | |||
| f71d55c4d3 | |||
| 6885c2eec7 | |||
| e56cfa802e | |||
| 853050c2f0 | |||
| d8a1c4b0da | |||
| e1391b59b3 | |||
| 525e6ee24e | |||
| 19fe7ac7c3 | |||
| 7dd88a0198 | |||
| ae0f2f4cc9 | |||
| aceaf54519 | |||
| 4e3301ade8 | |||
| 032e37733d | |||
| 53844c807f | |||
| 26270e9c25 | |||
| 0900bcac25 | |||
| 8ea72fcdc5 | |||
| f7167358d8 | |||
| ec423b2eb9 | |||
| 739176a3ed | |||
| 41d0b0543a | |||
| 625cec113b | |||
| 6d9a871e2c | |||
| fd2e266e50 | |||
| bfe88a9837 | |||
| a2f4e4959e | |||
| 6167925ee0 | |||
| 10973d9685 | |||
| 602d6cea90 | |||
| 1458df1664 | |||
| b09f014b6d | |||
| de607b7632 | |||
| 0636f535f3 | |||
| 4319f2942b | |||
| e00103c6b3 | |||
| 90c808c4a3 | |||
| 7e10143454 | |||
| 56ccb23412 | |||
| d543b301e2 | |||
| fec3fc5258 | |||
| 08d06004ee | |||
| a5ae46fac4 | |||
| ef5bfece59 | |||
| e8dcaa3051 | |||
| 94816b63b1 | |||
| ef3c5100fe | |||
| 83ccb4bc12 | |||
| ab15eb9768 | |||
| a499196692 | |||
| b090dc105a | |||
| 91db9ff82b | |||
| 3324a935b4 | |||
| b32b16c1f0 | |||
| 53a46d91bc | |||
| fd5d0e6330 | |||
| 08bad7742d | |||
| 65365d8fc3 | |||
| 3037043a00 | |||
| 4d4d0b1cd2 | |||
| 37c4e4d61e | |||
| bbdc061d05 | |||
| 979e8bc595 | |||
| b2942a0a51 | |||
| 9c4c1bdd3a | |||
| 5178f18c79 | |||
| f72d29e088 | |||
| 95e752513a | |||
| 72fd75e29e | |||
| a1e89d6948 | |||
| 13b80f9748 | |||
| 5013c5d4e6 | |||
| 4597f9cb03 | |||
| 9bd3e1630f | |||
| 400f2903bd | |||
| b172260cfd | |||
| 1b9984c72c | |||
| ca9f4197c6 | |||
| 907b6efe15 | |||
| b81974476e | |||
| 8ef8f9af50 | |||
| 30038afd30 | |||
| 70fd0896c5 | |||
| 811a42f189 | |||
| 5ba0cef60d | |||
| dfe772432e | |||
| c6bda1fc6a | |||
| 01f64f78af | |||
| db8ea1d056 | |||
| 7376f2706f | |||
| f64b04544a | |||
| 533644fcf1 | |||
| 2dc374ab70 | |||
| c46341b331 | |||
| ed1a9f4714 | |||
| 4e070a59bd | |||
| 58a794d4e2 | |||
| cf0317ab5b | |||
| 85dde374f4 | |||
| 7c5baccf59 | |||
| c61fccdb79 | |||
| 3bcef61c32 | |||
| 2d7d37f382 | |||
| 95bca2458a | |||
| 2909550cd6 | |||
| c7ee2bab53 | |||
| 8995000259 | |||
| 5e2fad31df | |||
| 69dda96739 | |||
| a2e635df24 | |||
| d3a1a30e62 | |||
| ba428199c5 | |||
| 04554b0826 | |||
| fd4264d982 | |||
| 61aa125af5 | |||
| ac6f648593 | |||
| 81df863aa4 | |||
| 6bf5d218dd | |||
| e5b0836755 | |||
| 95d0cc2856 | |||
| 61bf09d18e | |||
| a3d026743a | |||
| 8d638072b4 | |||
| 40c434c296 | |||
| 3e49ebb0ec | |||
| 741604be24 | |||
| 823dd57c0c | |||
| 7774975619 | |||
| cce921e4cd | |||
| 872bdfa64e | |||
| 27ebc95109 | |||
| 71bb29b8c7 | |||
| 3299f05940 | |||
| bacd359b61 | |||
| ba8423e438 | |||
| 295ec7bc16 | |||
| 4b4db04069 |
+53
-1
@@ -62,8 +62,20 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# ...PER_IP counts accounts per signup IP — coarse, since a household, NAT or campus
|
||||
# network shares one address. This is the one to raise (or zero out) if real players
|
||||
# report being locked out.
|
||||
# ...PER_DEVICE_ID counts accounts per client device id (the game's per-install hash,
|
||||
# sent as `device_id`) — narrower than an IP and unmoved by a VPN, which is what the
|
||||
# IP cap alone can't stop. Client-chosen, so it catches the stock client, not a script.
|
||||
# Signups that post no device id (the website's) aren't counted.
|
||||
# RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID=3
|
||||
# RECFLARE_MAX_ACCOUNTS_PER_IP=3
|
||||
# RECFLARE_MAX_ACCOUNTS_PER_DEVICE_ID=3
|
||||
|
||||
# Whether accounts can be created from the WEBSITE (`auth` and `www`) — a password
|
||||
# account with no Steam/Meta identity, which nothing but the caps above can limit, and a
|
||||
# VPN or Tor exit resets the IP one. `on` or `off`; unset is OFF. Signing in to an
|
||||
# existing password account is never affected, nor is creating one by launching the game.
|
||||
# Turning it on also needs the Turnstile keypair in the Secrets Store (see www).
|
||||
# RECFLARE_PASSWORD_SIGNUP=off
|
||||
|
||||
# How far a ban reaches beyond the account it was handed to (`match` and `auth`), as a
|
||||
# comma-separated list out of `ip` and `platform` — or `off` for neither. Unset means
|
||||
@@ -105,7 +117,8 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# it came through the room list, a club's clubhouse, or a party. Substitution is a single
|
||||
# hop (2=3,3=2 swaps the two rooms), a requested subroom is dropped in favour of the
|
||||
# substitute's default one, and a target that doesn't exist leaves the original room in
|
||||
# place. Following a friend or joining a specific instance is unaffected — those join a
|
||||
# place. Only client builds newer than 20230414 are switched; the 2023 client and older
|
||||
# enter the room they asked for. Following a friend or joining a specific instance is unaffected — those join a
|
||||
# live instance, which is already in whichever room it was created in.
|
||||
# RECFLARE_ROOM_REDIRECTS=2=MyHub
|
||||
|
||||
@@ -134,6 +147,11 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# mock pool (RFC 5737 documentation addresses, which answer nothing): tachyon-1/-2 share
|
||||
# a host, as do tachyon-4/-5.
|
||||
# RECFLARE_TACHYON_HOST_PORT=198.51.100.10:7777,198.51.100.10:7778,198.51.100.11:7777,203.0.113.20:7777,203.0.113.20:7778
|
||||
#
|
||||
# The pool SANDBOX accounts (the account's `isSandbox` flag) are served from instead,
|
||||
# same format. Its ids are generated as `dev-1`, `dev-2`, …. Unset, sandbox accounts
|
||||
# use the pool above.
|
||||
# RECFLARE_TACHYON_HOST_PORT_SANDBOX=198.51.100.12:7777,198.51.100.12:7778
|
||||
|
||||
# The Photon region every session is pinned to (`match`). Unlike the app ids above this
|
||||
# does default, to `us` (us-east1) — an instance stamped with an empty region is one the
|
||||
@@ -149,6 +167,15 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# raising it later does NOT top up existing players.
|
||||
# RECFLARE_STARTING_TOKENS=10000
|
||||
|
||||
# The most RecCenterTokens one gift from the staff card on a player's profile can carry
|
||||
# (`www`). A guard against typos — a gift can't be taken back — not a limit on how much
|
||||
# staff can give in total.
|
||||
# RECFLARE_MAX_TOKEN_GIFT=10000
|
||||
|
||||
# The same guard for one XP gift from that staff card (`www`). Every level to the top costs
|
||||
# 15,090 XP in all (level 1 to 50).
|
||||
# RECFLARE_MAX_XP_GIFT=100
|
||||
|
||||
# Signup on the website is configured OUTSIDE this file: it's guarded by a Cloudflare
|
||||
# Turnstile widget, and both of that widget's keys live in the shared Secrets Store
|
||||
# (RECFLARE_SECRETS_STORE above), alongside JWT_SECRET — not as vars, not as worker secrets.
|
||||
@@ -188,3 +215,28 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# Also add https://<your domain>/claim to the app's Redirects in the Discord developer
|
||||
# portal, or the exchange is refused. Granting Plus takes effect on the player's NEXT
|
||||
# sign-in. `runx admin grant-plus` sets it directly, with no Discord involved.
|
||||
#
|
||||
# OPTIONAL: the daily role refresh (www cron) re-reads claimed members' roles through a
|
||||
# bot token, so `platform_account.role` tracks the guild instead of the day they claimed.
|
||||
# Add a bot user to the same Discord app, invite it to the server (no permissions needed),
|
||||
# and store its token in the Secrets Store:
|
||||
#
|
||||
# wrangler secrets-store secret create <store-id> --name DISCORD_BOT_TOKEN \
|
||||
# --scopes workers --remote
|
||||
#
|
||||
# The binding has to resolve for `www` to deploy, so create it with a placeholder even if
|
||||
# you don't want the sweep — it then does nothing. It never touches hasPlus.
|
||||
|
||||
# OPTIONAL: a token gift for Discord supporters (`econ` cron). Maps Discord ROLE ids to the
|
||||
# RecCenterTokens a holder is handed in a gift box EVERY TIME THE CRON FIRES, as
|
||||
# `<roleId>=<tokens>` pairs. How often is the schedule's call: `triggers.crons` in
|
||||
# apps/econ/wrangler.jsonc ships weekly (Monday 05:00 UTC); change that line for daily. ONE
|
||||
# box per member per run — a member holding several mapped roles is paid the HIGHEST amount
|
||||
# among them, not the sum, so the map reads as tiers. Same rules as ROLE_IDS above:
|
||||
# snowflakes, not names; commas, no spaces. Roles are read from the snapshot the benefits
|
||||
# claim and the daily sweep above keep, so it reaches only players who have claimed on the
|
||||
# website, and is only as fresh as that sweep (with a bot, the morning's; without one, the
|
||||
# day they claimed). The website also pays the same box ONCE, on the spot, the first time an
|
||||
# account links its Discord, so a new supporter isn't waiting for the next run. Unset — or
|
||||
# parsing to no roles — leaves the cron a logged no-op and the first-link gift off.
|
||||
# RECFLARE_DISCORD_ROLE_TOKENS=1077000000000000001=2500,1077000000000000002=10000
|
||||
|
||||
@@ -51,7 +51,7 @@ Supported client builds live in `SUPPORTED_GAME_VERSIONS`
|
||||
- `20230414` — default, official. Manifest `7859140924515540835` (2023).
|
||||
- `20250718.01` — beta, official. Manifest `1151455856673601091`; reaches this server via
|
||||
the [patch-2025](https://github.com/recflare/patch-2025) patch.
|
||||
- `20250424.01`, `20231207`, `20230616` — alpha.
|
||||
- `20260323.14`, `20250424.01`, `20231207`, `20230616` — alpha.
|
||||
|
||||
Builds are date-stamped (`YYYYMMDD[.NN]`) so they order as plain strings; several
|
||||
surfaces gate on "newer than `20230414`" (econ storefront catalog, `api` event tags,
|
||||
@@ -115,15 +115,24 @@ inconsistency here without checking the client first.
|
||||
`StagedSubRoomDataSaveId` carries no subroom context), and nothing is overwritten, so
|
||||
`…/saves` is real history and `publish_save` doubles as restore-a-save. There
|
||||
is no `GET …/subrooms/:sid/data`; only the POST (the room save) exists on that path.
|
||||
`GET …/saves/:saveId` is the detail behind a list row, under the same gate, but in the
|
||||
CAMELCASE projection the room save's response uses — not the PascalCase rows the list
|
||||
serves. Three shapes of one save; keep them straight.
|
||||
- Both save reads (`rooms`: `…/saves` and `…/saves/:saveId`) are auth-gated and readable by
|
||||
the room's CREATOR or by anyone whose live `presence` row puts them in that room — not by
|
||||
co-owners as such (a co-owner passes only by standing there). They list unpublished
|
||||
staged saves, so they aren't public; but a visitor resolves which version an instance is
|
||||
running from this list, so creator-only locks them out of loading the room. The grant
|
||||
expires with the presence row.
|
||||
`GET …/saves/:saveId` is the detail behind a list row, under the same gate, and a
|
||||
PASCALCASE row like the list serves — NOT the camelCase projection the room save's
|
||||
response uses (served that, the client showed a save with every value missing). It
|
||||
differs from the list row only in how it names bundles: each `UnitySubAssets` entry is
|
||||
`{ UnityAssetId, UnityAsset, UnityAssetHash }`, the main bundle is lifted to top-level
|
||||
`UnityAsset`/`UnityAssetHash`, and there is no `Tags`. Three shapes of one save; keep
|
||||
them straight.
|
||||
- The save reads (`rooms`: `…/saves`, `…/saves/no_unity_assets` and `…/saves/:saveId`) are
|
||||
auth-gated and readable by the room's creator, a CO-OWNER, anyone whose live `presence`
|
||||
row puts them in that room, or STAFF (a `developer`/`moderator` token). They list
|
||||
unpublished staged saves, so they aren't public; but a co-owner opens the save history from
|
||||
the room's settings without being in the room (creator-only showed them the buttons and a
|
||||
list that 403'd), and a visitor resolves which version an instance is running from this
|
||||
list, so managers-only locks them out of loading the room. The presence grant expires with
|
||||
the presence row. The client reads `…/saves/no_unity_assets` BEFORE it matchmakes a private
|
||||
instance, when the caller has no presence in the room yet, so presence alone cannot let a
|
||||
non-owner create one: a developer creating a private instance of a room they hold no role
|
||||
on was 403'd there until staff were let through.
|
||||
- A room save writes ONLY to the subroom and its save row — never to the room. Everything
|
||||
the body carries describes that one revision: `Description` is the save comment shown in
|
||||
`…/saves`, and `PersistenceVersion`/`InventionUsage` describe the scene just saved (the
|
||||
@@ -187,15 +196,110 @@ inconsistency here without checking the client first.
|
||||
with structure to it goes in ESCAPED — `"Data": "{\"PlayerId\":\"205\"}"`, never a nested
|
||||
object. An object there does not degrade: the client's decoder rejects it outright
|
||||
(`expected:'String Begin Token', actual:'{'`) and loses the whole notification, not just
|
||||
the field. Bites the vote-to-kick message (`api`: `POST /api/PlayerReporting/v3/voteToKick`,
|
||||
whose `Data` carries `{ PlayerId, Response, GameSessionId }` — `PlayerId` a string inside
|
||||
it, as the reference relays it) and, the same way, a chat message's `Contents`.
|
||||
the field. Bites a chat message's `Contents` the same way.
|
||||
- The vote-to-kick frame (`api`: `POST /api/PlayerReporting/v3/voteToKick`) carries the player
|
||||
being VOTED ON as the Message's `FromPlayerId` — not the caller, whom nothing on the frame
|
||||
names — because the client raises its prompt about whoever that field names. Sending the
|
||||
voter asks the room to kick the player who called the vote. Its `Data` is the posted
|
||||
`Reason` as PLAIN TEXT (`Inactive in games (AFK)`), which is the text the prompt shows: it
|
||||
once carried an escaped `{ PlayerId, Response, GameSessionId }`, and players saw that JSON
|
||||
printed where the reason belongs. `Data` is still a string on the wire — that part of the
|
||||
rule above holds; a reason simply needs no escaping to satisfy it.
|
||||
- Leaderboard `Rank` (`leaderboard`: `GetRanks`, `GetNearbyScores`, `GetPlayerRank`) is
|
||||
0-BASED — the client adds one before it draws, so a `Rank` of 1 shows in game as second
|
||||
place and the top of a board must be 0. Its own slice says the same: it asks for the first
|
||||
ten rows as `RankStart` 0, `RankEnd` 9, both inclusive, so reading them as 1-based also
|
||||
serves nine rows starting at the runner-up. The unranked sentinel stays a big number
|
||||
(99999) precisely because 0 is now a real rank, first place.
|
||||
- A custom avatar item (`api`: `custom_avatar_item`, every `/api/customAvatarItems/*` read) is ONE
|
||||
record for two kinds of thing. A player-made shirt has `BaseAvatarItemId`/`BaseAvatarItemColor`
|
||||
and a design PNG (`DesignFilename`, `ThumbnailImageFilename`) and an empty `CurrentSaves`; a
|
||||
FIRST-PARTY item (imported from the official export, Coach-authored, its own `OutfitType`) has
|
||||
those four NULL and is rendered from `CurrentSaves` — one built Unity assetbundle per
|
||||
`BodyType`, which the client picks by the wearer's body. The row is the record as JSON, so an
|
||||
import is the export verbatim except that `CreatorAccountId` is forced to 1, the Coach — which
|
||||
is what files it as stock content and whom a sale pays — and each save's `ThumbnailFileName`
|
||||
is put under `avatar/`, where this server serves it from (`just cai-load` loads it;
|
||||
`apps/api/scripts/import-custom-avatar-items.ts` writes a migration; re-importing an id
|
||||
replaces it). The export's `Price` is mostly 0, and a purchase charges the record's `Price`,
|
||||
so first-party prices are written into the export from the storefront dump
|
||||
(`apps/econ/static/db/Watch_EnumValue_3.json`, joined on
|
||||
`AvatarItemInfo.DownloadableAvatarItemId`) by `apps/api/scripts/price-custom-avatar-items.ts`
|
||||
before loading. Serve both through one shape: the shirt carries
|
||||
empty `CurrentSaves`/`Tags` and null `CustomBadgeMetadata`, never a missing key. The saves
|
||||
name their assetbundles and thumbnails by bare filename; storing the record does nothing
|
||||
about serving those files.
|
||||
- An assetbundle is built PER UNITY TARGET, and the client names the one it wants as
|
||||
`unityAssetTarget` on every custom-avatar-item read (0 PC/Windows, 2 Android/Oculus — the
|
||||
rest unobserved; a query param on the GETs, a FORM field on `POST …/v1/bulk`). The row stores
|
||||
the bare PC names; a caller asking for 2 is served `UnityAsset`/`UnityAsset2` under `quest/`
|
||||
(`toQuestCustomAvatarItem`), fetched from the cdn as `/avatar/quest/<name>.assetbundle`.
|
||||
Every read a Quest renders from has to make the switch — `api` search and bulk, AND `econ`
|
||||
`GET /econ/customAvatarItems/v1/owned`, which the client reads FIRST and renders worn items
|
||||
from: with only `api` switched the Quest kept downloading PC bundles. The load BLANKS each
|
||||
save's `UnityAssetHash`/`UnityAsset2Hash` (`""`, null stays null): the export's are the PC
|
||||
builds' hashes, one stored save serves both targets, and the client refuses a download that
|
||||
fails the hash it was given but skips the check on an empty one.
|
||||
- Buying a custom avatar item goes through the ordinary bag (`econ`: `POST /api/items/bulkpurchase`)
|
||||
as a line whose `ItemPurchaseMethodId` is `{ Type: 1, Guid: <CustomAvatarItemId> }`, beside
|
||||
the `Type: 0, NumberId` catalog lines. It is a SALE between players: the price leaves the buyer
|
||||
in the bag's one debit and is credited to the item's `CreatorAccountId` (pushed to them as a
|
||||
`StorefrontBalanceUpdate` carrying their resulting total), ownership is a row in
|
||||
`inventory_custom`, and the entry's `Data.CustomAvatarItem` is the whole item record. The item
|
||||
still comes in a gift box, like any purchase: its `GiftPackage` names it by
|
||||
`CustomAvatarItemId` with `AvatarItemDesc`/`EquipmentPrefabName`/etc. empty — without a box
|
||||
the purchase shows the player nothing. `BypassGiftPackages` skips it as it does any line.
|
||||
A custom line may carry a `Gift` block: ownership and the box go to `ToPlayerId` (announced
|
||||
with `GiftPackageReceivedImmediate`), the buyer still pays and the creator is still paid, and
|
||||
the owned/creator refusals are checked against the RECEIVER — so a creator may gift their own. `GET /econ/customAvatarItems/v1/owned` is that table
|
||||
joined to the item record PLUS everything the caller created (drafts included): a creator
|
||||
owns their own through `CreatorAccountId`, and the bag refuses to sell it to them.
|
||||
- A chat report (`api`: `POST /api/chatreport/createChatReport`) is form-encoded and sends
|
||||
`ReportCategory` as an enum NAME (`Discriminatory`), where every other report sends the
|
||||
number. It names no player: the reported one is the message's sender, read from `message`.
|
||||
The name is mapped onto the numeric `KickReportCategory` the `report` table stores
|
||||
(`chatReportCategory`); only `Discriminatory` has been observed, so an unmapped name is
|
||||
filed as 0 with the name kept in the details — extend the alias table as more are seen.
|
||||
The reply is the `{ success, error: "" }` envelope the other reports use, which is an
|
||||
ASSUMPTION here: what the client does with this response has not been observed.
|
||||
- A friend request is SHOWN to the target as a Message of type 4 `FriendInvite` (`api`:
|
||||
`/api/relationships/v2/sendfriendrequest`), and an acceptance to the requester as type 40
|
||||
`FriendRequestAccepted` (`acceptfriendrequest`, and the crossing-request auto-accept),
|
||||
each stored and pushed as `MessageReceived`. The `RelationshipChanged` frames only
|
||||
refresh relationship state: sent alone, the request "worked" server-side and the target
|
||||
saw nothing. Same pattern as the cheer.
|
||||
- A gift box's `AvatarItemType` (`econ`: the stored box in `GET /api/avatar/v2/gifts`, `buyItem`'s
|
||||
`BalanceUpdates[].Data`, `bulkpurchase`'s `GiftPackage`, and the `GiftPackageReceived*` frame;
|
||||
`www`'s staff boxes; `api`'s `gifts/generate`) must be NULL for anything that is not an avatar
|
||||
item — a consumable, a skin, a box, tokens, XP — and a NUMBER (the type, or 0) when the box holds
|
||||
one, whether a baked item by `AvatarItemDesc` or a custom item by `CustomAvatarItemId`. The
|
||||
client routes the box on that field before it reads what else the box names, so a 0 sends it
|
||||
looking for avatar item type 0 and it fails with "can't find avatar item". The 2025 store lists
|
||||
every non-avatar drop with `AvatarItemType: null` for this reason; `boxAvatarItemType` applies
|
||||
it on every surface, so don't coalesce it with `?? 0` anywhere a box is built. This holds for
|
||||
every worker that mints a box, not only `econ`.
|
||||
- A ban's `Duration` (`api`: `/api/PlayerReporting/v1/moderationBlockDetails`, and the
|
||||
`ModerationKick` frame `www`'s staff ban sends) is the seconds LEFT as of the response — the
|
||||
client counts it down from receipt. It is NOT the ban's full span paired with
|
||||
`TimeoutStartedAt` (start + duration): served that way, the screen read correctly only in
|
||||
the kick frame, sent at the instant the span and the remainder coincide, and every later
|
||||
sign-in showed the whole ban still to run. `TimeoutStartedAt` stays null for a ban; where
|
||||
other implementations set it at all, it names the start of a voice-chat timeout. Other
|
||||
implementations with timed bans keep their set-time private and serve the remainder on
|
||||
every read; `banned_at` here is the staff panel's audit trail, not the client's.
|
||||
- An outfit is served to OTHER players (`api`: `POST /outfits/bulk`) with the selections the
|
||||
wearer does not own stripped (`restrictOutfitToOwned` in `@repo/domain`); the owner's
|
||||
`GET /outfits/me` stays verbatim. Nothing on the save path checks ownership. A legacy
|
||||
selection entry (`SelectionsV1`/`OutfitSelections`, `;`-delimited) is
|
||||
`<AvatarItemDesc>,<extra>,<bodyPart>` with the descriptor's empty trailing fields dropped,
|
||||
which is why `5cd08cfb-…,,1` and `40528de7-…,51ef8d39-…,0b2395e1-…,,0` are both one entry;
|
||||
a v2 selection (`SelectionsV2`/`OutfitSelectionsV2`, JSON-in-a-string) names the item as
|
||||
`PrefabGuid` and a custom shirt as `UgcOutfitData.CustomAvatarItemId`. Ownership is decided
|
||||
on the BASE ASSET (first descriptor field, which is `PrefabGuid`) — not the colour — because
|
||||
the v2 forms carry no colour and the forms must agree; the free default catalog and the
|
||||
custom-clothing base items count as owned. `CustomizationSettings` has only been observed
|
||||
as its header (`AvatarVersion`, `AvatarBodyType`); any v2-shaped selections inside it are
|
||||
stripped wherever they sit. A document is re-encoded only when something was removed.
|
||||
- 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
|
||||
|
||||
@@ -51,6 +51,88 @@ bun runx admin grant-developer --account 1 --revoke
|
||||
bun runx admin grant-moderator --username alice --remote
|
||||
```
|
||||
|
||||
### `grant-studio` — grant or revoke Rec Room Studio upload access
|
||||
|
||||
Sets the account's `hasStudio` flag, which `auth` stamps into the token as the
|
||||
`betastudio` role — the claim RecFlare Studio reads as permission to upload. Staff
|
||||
normally manage this from the website (`/settings/recroomstudio`); this is the
|
||||
operator's way in. Takes effect on the player's next Studio sign-in or refresh.
|
||||
|
||||
```sh
|
||||
bun runx admin grant-studio --username alice
|
||||
bun runx admin grant-studio --account 42 --revoke
|
||||
```
|
||||
|
||||
### `reload-plus` — credit every Plus subscriber's tokens
|
||||
|
||||
Also exposed directly as `just reload-plus`.
|
||||
|
||||
```sh
|
||||
just reload-plus 1000 --dry-run # list subscribers and their balances, change nothing
|
||||
just reload-plus 1000 --remote # +1000 RecCenterTokens to each subscriber, production
|
||||
```
|
||||
|
||||
Adds `<amount>` RecCenterTokens (currency type 2) to the `balance` row of every account
|
||||
whose `hasPlus` flag is set — the flag `grant-plus` and the website's Discord claim write.
|
||||
Subscribers are found through the indexed `has_plus` column on `account` (auth migration
|
||||
0009), so the reload is one statement however many accounts there are. Nothing schedules
|
||||
it: run it when the subscription's tokens are due, and note that running it twice credits
|
||||
twice.
|
||||
|
||||
A subscriber who has never loaded the game has no balance row yet. They get one holding
|
||||
their signup grant **plus** the reload, so the grant is not lost — the grant amount is
|
||||
`RECFLARE_STARTING_TOKENS` from the environment or `.env`, falling back to econ's default
|
||||
of 10000. Prints every credited account with its resulting balance.
|
||||
|
||||
This command targets the `balance` table, so it runs under `apps/econ`'s wrangler config;
|
||||
with `--local` that is econ's dev D1 state, which needs both `balance` and a migrated
|
||||
`account` table in it.
|
||||
|
||||
### `cai-load` — load the first-party custom avatar items
|
||||
|
||||
```sh
|
||||
bun runx admin cai-load # apps/econ/static/db/2025-1-cai.json → local
|
||||
bun runx admin cai-load --remote
|
||||
bun runx admin cai-load --file path/to/export.json --dry-run
|
||||
just cai-load --remote # the same, as a recipe
|
||||
```
|
||||
|
||||
Loads an export of `CustomAvatarItem` records (a JSON array, a `{ Results }` page, or one
|
||||
record) into the `custom_avatar_item` table, each stored as the row's JSON with its
|
||||
`CreatorAccountId` forced to 1, the Coach account, whatever the export said: that is what
|
||||
files it as stock content (the storefront tab searches by creator) and what a purchase
|
||||
pays. Each save's `ThumbnailFileName` is put under `avatar/`, where this server serves
|
||||
those images from (a name already prefixed is left alone). It MERGES: an id already in the table has its row replaced, and nothing is deleted, so the
|
||||
players' own shirts, which share the table, are untouched. Re-running is safe. The load is
|
||||
verified by counting rows and probing ids from across the export; it fails loudly rather
|
||||
than report a partial load. `--dry-run` reads and validates the export and prints the
|
||||
statement count without writing.
|
||||
|
||||
The saves in an export name their assetbundles by bare filename; the load stores the
|
||||
names only.
|
||||
|
||||
A record's `Price` is what a purchase charges, and the official export mostly says 0. The
|
||||
prices come from a storefront dump instead, `apps/econ/static/db/Watch_EnumValue_3.json`
|
||||
(a `{ StoreItems }` page with price and rarity, joined on
|
||||
`GiftDrop.AvatarItemInfo.DownloadableAvatarItemId` = `CustomAvatarItemId`). Before loading
|
||||
a fresh export, write those prices into it:
|
||||
|
||||
```sh
|
||||
bun apps/api/scripts/price-custom-avatar-items.ts \
|
||||
apps/econ/static/db/2025-1-cai.json apps/econ/static/db/Watch_EnumValue_3.json
|
||||
just cai-load --remote
|
||||
```
|
||||
|
||||
The script rewrites the export in place with the base token price (`CurrencyType` 2),
|
||||
ignoring any sale. It warns about every price it changes that wasn't 0 before. The
|
||||
storefront only prices items already in the export; nothing is added from it. An item the
|
||||
storefront doesn't list keeps its old price, unless that price is 0, which would make it
|
||||
free to buy. Those items (the Cryptid Creek event rewards and the Evergrown Hip Pack) get the
|
||||
catalog's rarity-50 price, `PRICE_BY_RARITY[50]` in `apps/econ/src/catalog-load.ts`. The
|
||||
script names every item the storefront doesn't list. The
|
||||
committed `2025-1-cai.json` is already priced. Rarity isn't carried over, since the record
|
||||
has no field for it.
|
||||
|
||||
### `lookup` — print an account
|
||||
|
||||
```sh
|
||||
@@ -65,7 +147,7 @@ the account has a password, the developer role, and the moderator role.
|
||||
|
||||
### Selecting an account
|
||||
|
||||
Every command targets exactly one account, by **either**:
|
||||
Every command except `reload-plus` and `cai-load` targets exactly one account, by **either**:
|
||||
|
||||
- `--account <id>` — numeric account id
|
||||
- `--username <name>` — username (case-insensitive)
|
||||
|
||||
+11
-4
@@ -10,7 +10,7 @@ These are the instructions for deploying the RecFlare infrastructure to Cloudfla
|
||||
the real thing — services scale,
|
||||
fail, and deploy independently — instead of collapsing everything into a single
|
||||
giant server.
|
||||
- **It's free/cheap to run a lot of service.**
|
||||
- **It's free/cheap to run a lot of services.**
|
||||
Cloudflare Workers' free tier is keyed
|
||||
to usage, not to the number of Workers — so whether you deploy 1 service or all
|
||||
36, the baseline cost is the same. You only start paying once usage crosses the
|
||||
@@ -43,13 +43,16 @@ services but would require small code changes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**You must have all these requirements or RecFlare deployment will fail!**
|
||||
**You must have all these requirements or RecFlare will not work!**
|
||||
|
||||
- node 24 (https://nodejs.org)
|
||||
- pnpm (install with `npm install -g pnpm`)
|
||||
- bun (https://bun.sh)
|
||||
- jq/awk/sed (on Windows try `winget jq` etc.)
|
||||
- A Cloudflare account with a zone (domain) you control, for deploying.
|
||||
- A Cloudflare account with a zone (domain) you control, for deploying
|
||||
- **For 2023:** A Photon Cloud Realtime and Voice account OR a custom Photon server (Luxon, OpenPhoton)
|
||||
- **For 2025:** A Photon Cloud Realtime account AND a Tachyon server, which is a custom fork of Dissonance voice that Rec Room developed. See https://discord.com/channels/1524485206550118460/1544055485336915979
|
||||
If you don't provide a Tachyon server address during deployment, your server will still work but without voice.
|
||||
|
||||
Cloudflare's free plan is good enough for testing (100k worker requests/day) but the
|
||||
Rec Room client is very chatty. Frequent testing may exhaust that quota. The $5/month
|
||||
@@ -59,6 +62,8 @@ See https://developers.cloudflare.com/workers/platform/pricing/#workers
|
||||
|
||||
## Getting Started
|
||||
|
||||
**WSL** is preferred for deploying if you are on Windows. Only Linux has been fully tested.
|
||||
|
||||
**Install dependencies:**
|
||||
|
||||
We use [Just](https://github.com/casey/just) for convenience. This will install all dependencies across the microservices.
|
||||
@@ -67,7 +72,7 @@ We use [Just](https://github.com/casey/just) for convenience. This will install
|
||||
just install
|
||||
```
|
||||
|
||||
You do not have to use `just` but you will have to run things manually with `pnpm`/`bun`.
|
||||
You do not have to use `just` but then you will have to run things manually with `pnpm`/`bun`.
|
||||
|
||||
**Configure your custom domain:**
|
||||
|
||||
@@ -79,6 +84,8 @@ cp .env.example .env
|
||||
|
||||
Edit `.env` and set `RECFLARE_DOMAIN` to your domain (or declare it with `export RECFLARE_DOMAIN=rec.example.com`)
|
||||
|
||||
Go through the rest of the file and set the variables as needed.
|
||||
|
||||
(Optional) - per-service subdomain overrides come from `RECFLARE_SUBDOMAINS`, a JSON
|
||||
object keyed by each service's default subdomain (see `SERVICES.md`), e.g.
|
||||
`'{"playersettings":"settings"}'`. A single entry both decides which host `just deploy`
|
||||
|
||||
@@ -71,7 +71,7 @@ preview:
|
||||
deploy *args:
|
||||
bun turbo deploy "$@"
|
||||
|
||||
# Apply D1 migrations (rooms + auth own them). Defaults to --remote; pass `-- --local`
|
||||
# Apply D1 migrations (every worker with a `migrate` script). Defaults to --remote; pass `-- --local`
|
||||
# for the dev db. Scope with -F, e.g. `just migrate -F rooms`.
|
||||
[group('2. local dev')]
|
||||
[positional-arguments]
|
||||
@@ -109,10 +109,28 @@ update *args:
|
||||
runx *args:
|
||||
bun runx "$@"
|
||||
|
||||
# Admin account tools (set-password, clear-password, grant-developer, lookup).
|
||||
# Admin account tools (set-password, clear-password, grant-developer, grant-plus, lookup).
|
||||
# Run `just admin --help` for usage and examples. See CLI.md.
|
||||
[group('4. utility')]
|
||||
[positional-arguments]
|
||||
[no-cd]
|
||||
admin *args:
|
||||
bun runx admin "$@"
|
||||
|
||||
# Credit every Rec Room Plus subscriber's RecCenterTokens balance by <amount>. Defaults to
|
||||
# --local; pass --remote for production, --dry-run to only list who'd be credited. See CLI.md.
|
||||
[group('4. utility')]
|
||||
[positional-arguments]
|
||||
[no-cd]
|
||||
reload-plus *args:
|
||||
bun runx admin reload-plus "$@"
|
||||
|
||||
# Load the first-party custom avatar items (apps/econ/static/db/2025-1-cai.json by default)
|
||||
# into the custom_avatar_item table. Merges: existing ids are replaced, nothing is deleted.
|
||||
# Defaults to --local; pass --remote for production, --dry-run to only validate. Prices come
|
||||
# from the storefront dump via apps/api/scripts/price-custom-avatar-items.ts. See CLI.md.
|
||||
[group('4. utility')]
|
||||
[positional-arguments]
|
||||
[no-cd]
|
||||
cai-load *args:
|
||||
bun runx admin cai-load "$@"
|
||||
|
||||
@@ -44,6 +44,7 @@ These game builds are supported:
|
||||
| ------------- | --------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `20230414` | `7859140924515540835` | **Default**, official — the 2023 build the rest of the stack targets |
|
||||
| `20250718.01` | `1151455856673601091` | **Beta**, official — use the [patch-2025](https://github.com/recflare/patch-2025) patch |
|
||||
| `20260323.14` | | Alpha |
|
||||
| `20250424.01` | | Alpha |
|
||||
| `20231207` | | Alpha |
|
||||
| `20230616` | | Alpha |
|
||||
|
||||
+38
-38
@@ -20,41 +20,41 @@ A small `ns` worker itself serves this discovery document at the
|
||||
apex/`ns` host and isn't listed within it. Each implemented worker has its own
|
||||
`README.md` under `apps/<name>/` documenting its routes.
|
||||
|
||||
| Service | Subdomain | Worker | Notes |
|
||||
| --------------------- | ----------------------- | ----------------------- | --------------------------------------------------------------------- |
|
||||
| Accounts | `accounts` | `accounts` | Player accounts & profile reads/writes (D1) |
|
||||
| AI | `ai` | `ai` | Game AI access check (always refuses — no model runs here) |
|
||||
| API | `api` | `api` | Core Game API — config, social, avatar, rooms, image uploads (D1, R2) |
|
||||
| Auth | `auth` | `auth` | OAuth token issuance (`/connect/token`); (D1) |
|
||||
| BugReporting | `bugreporting` | — | Not yet implemented |
|
||||
| Cards | `cards` | `cards` | Stub — deploys and answers, no card endpoints yet |
|
||||
| CDN | `cdn` | `cdn` | Binary CDN — room data (R2) |
|
||||
| Chat | `chat` | `chat` | Player chat service (not in room) |
|
||||
| Clubs | `clubs` | `clubs` | Clubs, not yet implemented |
|
||||
| CMS | `cms` | — | Not yet implemented |
|
||||
| Commerce | `commerce` | `commerce` | Store / purchase endpoints |
|
||||
| Data | `data` | — | Not yet implemented |
|
||||
| DataCollection | `datacollection` | `datacollection` | Client telemetry / analytics sink |
|
||||
| Discovery | `discovery` | `discovery` | Discovery page layouts (static assets) |
|
||||
| Econ | `econ` | `econ` | Economy & avatar endpoints (separate from `api`) |
|
||||
| GameLogs | `gamelogs` | — | Not yet implemented |
|
||||
| Geo | `geo` | — | Not yet implemented |
|
||||
| Images | `img` | `img` | Image storage & signed delivery (R2) |
|
||||
| Leaderboard | `leaderboard` | `leaderboard` | Per-room stat leaderboards, one board per stat channel (D1) |
|
||||
| Link | `link` | `link` | Stub — deploys and answers, no link endpoints yet |
|
||||
| Lists | `lists` | `lists` | Curated & algorithmic discovery lists (canned — nothing ranks yet) |
|
||||
| Matchmaking | `match` | `match` | Matchmaking & per-player presence (D1, KV) |
|
||||
| Moderation | `moderation` | `moderation` | Stub — the reporting routes still live in `api`; see note below |
|
||||
| Notifications | `notify` | `notify` | Real-time notifications over SignalR/WebSockets (Durable Object) |
|
||||
| PlatformNotifications | `platformnotifications` | `platformnotifications` | Stub — deploys and answers, no notification endpoints yet |
|
||||
| PlayerSettings | `playersettings` | `playersettings` | Per-player settings (KV) |
|
||||
| RoomComments | `roomcomments` | `roomcomments` | Notes pinned in a room’s scene — read & create (D1) |
|
||||
| RoomieIntegrations | `roomieintegrations` | — | Not yet implemented |
|
||||
| Rooms | `rooms` | `rooms` | Room storage & queries; seeds the Dorm & Orientation rooms (D1) |
|
||||
| Storage | `storage` | — | Room uploader |
|
||||
| Strings | `strings` | — | Not yet implemented |
|
||||
| StringsCDN | `strings-cdn` | — | Not yet implemented |
|
||||
| Studio | `studio` | — | Not yet implemented |
|
||||
| Thorn | `thorn` | — | Not yet implemented |
|
||||
| Videos | `videos` | — | Not yet implemented |
|
||||
| WWW | `www` | — | Website/Panel |
|
||||
| Service | Subdomain | Worker | Notes |
|
||||
| --------------------- | ----------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Accounts | `accounts` | `accounts` | Player accounts & profile reads/writes (D1) |
|
||||
| AI | `ai` | `ai` | Game AI access check (always refuses — no model runs here) |
|
||||
| API | `api` | `api` | Core Game API — config, social, avatar, rooms, image uploads (D1, R2) |
|
||||
| Auth | `auth` | `auth` | OAuth tokens, plus Studio device login (`POST /connect/deviceauthorization`, device-code grant on `/connect/token`); (D1) |
|
||||
| BugReporting | `bugreporting` | — | Not yet implemented |
|
||||
| Cards | `cards` | `cards` | Stub — deploys and answers, no card endpoints yet |
|
||||
| CDN | `cdn` | `cdn` | Binary CDN — room data, and Studio room bundles at `/unityasset/{filename}` (R2, D1 lookup). The editor's `studiocdn` host is this same app on a combined deploy |
|
||||
| Chat | `chat` | `chat` | Player chat service (not in room) |
|
||||
| Clubs | `clubs` | `clubs` | Clubs, not yet implemented |
|
||||
| CMS | `cms` | — | Not yet implemented |
|
||||
| Commerce | `commerce` | `commerce` | Store / purchase endpoints |
|
||||
| Data | `data` | — | Not yet implemented |
|
||||
| DataCollection | `datacollection` | `datacollection` | Client telemetry / analytics sink |
|
||||
| Discovery | `discovery` | `discovery` | Discovery page layouts (static assets) |
|
||||
| Econ | `econ` | `econ` | Economy & avatar endpoints (separate from `api`) |
|
||||
| GameLogs | `gamelogs` | — | Not yet implemented |
|
||||
| Geo | `geo` | — | Not yet implemented |
|
||||
| Images | `img` | `img` | Image storage & signed delivery (R2) |
|
||||
| Leaderboard | `leaderboard` | `leaderboard` | Per-room stat leaderboards, one board per stat channel (D1) |
|
||||
| Link | `link` | `link` | Stub — deploys and answers, no link endpoints yet |
|
||||
| Lists | `lists` | `lists` | Curated & algorithmic discovery lists (canned — nothing ranks yet) |
|
||||
| Matchmaking | `match` | `match` | Matchmaking & per-player presence (D1, KV) |
|
||||
| Moderation | `moderation` | `moderation` | Stub — the reporting routes still live in `api`; see note below |
|
||||
| Notifications | `notify` | `notify` | Real-time notifications over SignalR/WebSockets (Durable Object) |
|
||||
| PlatformNotifications | `platformnotifications` | `platformnotifications` | Stub — deploys and answers, no notification endpoints yet |
|
||||
| PlayerSettings | `playersettings` | `playersettings` | Per-player settings (KV) |
|
||||
| RoomComments | `roomcomments` | `roomcomments` | Notes pinned in a room’s scene — read & create (D1) |
|
||||
| RoomieIntegrations | `roomieintegrations` | — | Not yet implemented |
|
||||
| Rooms | `rooms` | `rooms` | Room storage & queries; seeds the Dorm & Orientation rooms (D1) |
|
||||
| Storage | `storage` | — | Room uploader |
|
||||
| Strings | `strings` | — | Not yet implemented |
|
||||
| StringsCDN | `strings-cdn` | — | Not yet implemented |
|
||||
| Studio | `studio` | `studio` | `GET /cloud-builds/for-room` lists that room's cloud builds. `POST /cloud-builds/from-editor` stores a locally built Windows and Android asset-bundle pair as a finished cloud build on the latest save, and sets `BecameRRStudioRoomAt` the first time. The game loads those bundles from **Rooms** (`CurrentSave.UnitySubAssets`, `GET …/unityasset`) and **CDN** `/unityasset/{filename}`. `GET /collaboration/owners-in-room` lists co-owners whose presence is in that room and subroom. Device login stays on **Auth**; `GET /account/me` stays on **Accounts**; the approval page is **WWW** `/device` |
|
||||
| Thorn | `thorn` | — | Not yet implemented |
|
||||
| Videos | `videos` | — | Not yet implemented |
|
||||
| WWW | `www` | `www` | Website, including the Studio sign-in page at `/device` |
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
createAccount,
|
||||
DEFAULT_USERNAME_CHANGES,
|
||||
defaultAccount,
|
||||
getAccount,
|
||||
getAccountByUsername,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
BannerImageRequest,
|
||||
BioRequest,
|
||||
BioResponse,
|
||||
BulkIdsRequest,
|
||||
CreateAccountRequest,
|
||||
CreateAccountResult,
|
||||
DisplayNameRequest,
|
||||
@@ -51,6 +53,7 @@ import {
|
||||
import { resolveWhitelistedEmoji, WHITELISTED_EMOJIS } from './whitelisted-emojis'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { DescribeRouteOptions } from 'hono-openapi'
|
||||
import type { Account } from '@repo/domain'
|
||||
import type { App } from './context'
|
||||
|
||||
@@ -78,9 +81,6 @@ function unauthorized(c: Context<App>) {
|
||||
return c.body(null, 401)
|
||||
}
|
||||
|
||||
/** Username changes a fresh account starts with (until one has been consumed). */
|
||||
const DEFAULT_USERNAME_CHANGES = 3
|
||||
|
||||
/**
|
||||
* Username-change result envelope: `{ success, error, value }`, always HTTP 200.
|
||||
* On success `value` is the updated account; on error `error` carries the message
|
||||
@@ -102,6 +102,69 @@ async function formField(c: Context<App>, name: string): Promise<string> {
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a REPEATED string field from a form-urlencoded / multipart body (`id=1&id=2`).
|
||||
* `parseBody` keeps only the last value for a duplicated key unless it's told to collect
|
||||
* them all, which is the whole point here.
|
||||
*/
|
||||
async function formFields(c: Context<App>, name: string): Promise<string[]> {
|
||||
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
|
||||
const value = body[name]
|
||||
const values = Array.isArray(value) ? value : [value]
|
||||
return values.filter((v): v is string => typeof v === 'string')
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAPI spec for the two `/account/bulk` registrations, which differ only in where the
|
||||
* ids ride.
|
||||
*/
|
||||
function bulkRouteSpec(source: 'query' | 'body'): DescribeRouteOptions {
|
||||
return {
|
||||
tags: ['Lookup'],
|
||||
summary: 'Look up many accounts by id',
|
||||
description: [
|
||||
'Accepts repeated `id` values (query params and/or a form-urlencoded body) and',
|
||||
'comma-separated lists. Every requested id appears in the response — ids with no',
|
||||
'stored row get a synthesized default.',
|
||||
].join(' '),
|
||||
...(source === 'query'
|
||||
? {
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Repeatable; each value may be a comma-separated list of ids',
|
||||
schema: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
],
|
||||
}
|
||||
: { requestBody: form(BulkIdsRequest, 'Repeated `id` fields') }),
|
||||
responses: { 200: json(AccountDto.array(), 'One public account per requested id') },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up many accounts at once. Ids come from repeated `id` query params AND, on the
|
||||
* POST form, repeated `id` body fields; either may carry a comma-separated list. Deduped
|
||||
* (first occurrence wins) so a repeated id can't double a row or waste a bound parameter.
|
||||
*/
|
||||
async function bulkAccounts(c: Context<App>) {
|
||||
const raw = [...(c.req.queries('id') ?? []), ...(await formFields(c, 'id'))]
|
||||
const ids = [
|
||||
...new Set(
|
||||
raw
|
||||
.flatMap((v) => v.split(','))
|
||||
.map((v) => Number.parseInt(v.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n))
|
||||
),
|
||||
]
|
||||
// Resolve stored accounts, synthesizing a default for any id not in the DB
|
||||
// so every requested id is present in the response.
|
||||
const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.accountId, a]))
|
||||
return c.json(ids.map((id) => toAccountDto(stored.get(id) ?? defaultAccount(id))))
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a stored account into the public account DTO — the client's camelCase
|
||||
* shape, excluding private fields like `email` (surfaced only by /account/me).
|
||||
@@ -286,40 +349,12 @@ const app = new Hono<App>()
|
||||
|
||||
// ---- Bulk / single lookup ------------------------------------------------
|
||||
// Register the static `bulk` path before the `/account/:id` param route.
|
||||
.get(
|
||||
'/account/bulk',
|
||||
describeRoute({
|
||||
tags: ['Lookup'],
|
||||
summary: 'Look up many accounts by id',
|
||||
description: [
|
||||
'Accepts repeated `id` query params and/or comma-separated lists. Every requested',
|
||||
'id appears in the response — ids with no stored row get a synthesized default.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Repeatable; each value may be a comma-separated list of ids',
|
||||
schema: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(AccountDto.array(), 'One public account per requested id') },
|
||||
}),
|
||||
async (c) => {
|
||||
// Reads repeated `id` query params; also accept a comma-separated list.
|
||||
const ids =
|
||||
c.req
|
||||
.queries('id')
|
||||
?.flatMap((v) => v.split(','))
|
||||
.map((s) => Number.parseInt(s.trim(), 10))
|
||||
.filter((n) => !Number.isNaN(n)) ?? []
|
||||
// Resolve stored accounts, synthesizing a default for any id not in the DB
|
||||
// so every requested id is present in the response.
|
||||
const stored = new Map((await getAccountsByIds(c.env.DB, ids)).map((a) => [a.accountId, a]))
|
||||
return c.json(ids.map((id) => toAccountDto(stored.get(id) ?? defaultAccount(id))))
|
||||
}
|
||||
)
|
||||
.get('/account/bulk', describeRoute(bulkRouteSpec('query')), bulkAccounts)
|
||||
// The 2023 client asks for its friends list as a POST with the ids in a
|
||||
// form-urlencoded body — the same `id=1&id=2&…` it would put in a query string,
|
||||
// moved off the URL because a few hundred friends overflow it. GET is the same
|
||||
// lookup and stays for callers (and docs) that prefer it.
|
||||
.post('/account/bulk', describeRoute(bulkRouteSpec('body')), bulkAccounts)
|
||||
|
||||
.get(
|
||||
'/account/:id/bio',
|
||||
|
||||
@@ -212,6 +212,16 @@ export const UsernameRequest = z.object({
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /account/bulk` form body. `id` repeats once per requested account (the client
|
||||
* sends its whole friends list this way); each value may also be a comma-separated list.
|
||||
*/
|
||||
export const BulkIdsRequest = z.object({
|
||||
id: z
|
||||
.union([z.string(), z.string().array()])
|
||||
.describe('Repeatable; each value may be a comma-separated list of ids'),
|
||||
})
|
||||
|
||||
export const EmailRequest = z.object({
|
||||
email: z
|
||||
.string()
|
||||
|
||||
@@ -97,6 +97,48 @@ describe('public endpoints', () => {
|
||||
expect(accounts[1].username).toBe('Player2')
|
||||
})
|
||||
|
||||
test('POST /account/bulk reads the ids from a form body', async () => {
|
||||
// The 2023 client asks for its friends list as a POST — the ids are in a
|
||||
// form-urlencoded body, not the query string. GET-only left it a 404.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: 'id=1&id=2&id=3',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
|
||||
expect(accounts.map((a) => a.accountId)).toEqual([1, 2, 3])
|
||||
expect(accounts[0].username).toBe('Coach')
|
||||
})
|
||||
|
||||
test('POST /account/bulk answers more ids than D1 will bind at once', async () => {
|
||||
// D1 caps a statement at 100 bound parameters; a real friends list runs past that,
|
||||
// and an unchunked `IN (…)` 500s. Requesting a stored id in the LAST chunk proves
|
||||
// the later statements ran, not just the first.
|
||||
const ids = [...Array.from({ length: 250 }, (_, i) => 1000 + i), 1]
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: ids.map((id) => `id=${id}`).join('&'),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
|
||||
expect(accounts.map((a) => a.accountId)).toEqual(ids)
|
||||
expect(accounts.at(-1)?.username).toBe('Coach')
|
||||
})
|
||||
|
||||
test('POST /account/bulk returns one entry per id, deduped', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
// Negative ids are real in the client's list and synthesize like any other.
|
||||
body: 'id=1&id=-1&id=1',
|
||||
})
|
||||
const accounts = (await res.json()) as Array<{ accountId: number; username: string }>
|
||||
expect(accounts.map((a) => a.accountId)).toEqual([1, -1])
|
||||
expect(accounts[1].username).toBe('Player-1')
|
||||
})
|
||||
|
||||
test('GET /account/search prefix-matches usernames, returns public DTOs', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/search?name=coa`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -652,6 +694,7 @@ describe('auth-gated endpoints', () => {
|
||||
'GET /accountprivacysettings/{id}',
|
||||
'GET /emojiConfig/whitelistedEmojis',
|
||||
'GET /parentalcontrol/me',
|
||||
'POST /account/bulk',
|
||||
'POST /account/create',
|
||||
'POST /account/me/email',
|
||||
'POST /account/me/phone',
|
||||
|
||||
@@ -39,10 +39,7 @@
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 1 // 100%
|
||||
}
|
||||
"enabled": false
|
||||
},
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
|
||||
@@ -16,10 +16,7 @@
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 1 // 100%
|
||||
}
|
||||
"enabled": false
|
||||
},
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
|
||||
Vendored
+8
@@ -10,3 +10,11 @@ declare namespace Cloudflare {
|
||||
mainModule: MainModule
|
||||
}
|
||||
}
|
||||
|
||||
// Vite serves a `?raw` import as the file's text. Used by the tests to read a data
|
||||
// migration (0032, the custom-item price cap) as a STRING and run its UPDATE against the
|
||||
// schema they build from SCHEMA_DDL, which the migration files never touch.
|
||||
declare module '*.sql?raw' {
|
||||
const content: string
|
||||
export default content
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
-- normal relational table rather than a JSON blob. Owned by the `api` worker;
|
||||
-- generated from src/events-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `status` is the response type: 0 Going, 1 Interested, 2 Can't go. Only Going
|
||||
-- counts toward the event's `AttendeeCount`, which is recomputed from this table on
|
||||
-- every response. A decline is recorded rather than deleted, so the client can show
|
||||
-- `status` is the response type: 0 Going, 1 Interested, 2 Can't go, 3 Pending (a bulk
|
||||
-- invite's row for a player who hasn't answered yet). Only Going counts toward the
|
||||
-- event's `AttendeeCount`, which is recomputed from this table on every response. A decline is recorded rather than deleted, so the client can show
|
||||
-- a player their own answer and changing your mind is an UPDATE (the composite
|
||||
-- primary key is what makes the upsert a replace).
|
||||
--
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- Stored notifications — the delayed half of a WebSocket push, served back by
|
||||
-- `GET /api/messages/v2/get` when a player logs in. Owned by the `api` worker; also written
|
||||
-- by `rooms` (a room-role invite) and `notify` (a coach message to one player), which push
|
||||
-- the matching `MessageReceived` frame. Generated from
|
||||
-- packages/domain/src/notifications-db.ts (NOTIFICATION_SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- Until this table existed the inbox endpoint answered an empty list unconditionally and the
|
||||
-- hub's queued frame was the whole of a notification sent to an offline player. That queue is
|
||||
-- best-effort by construction — dropped once sent rather than acked, bounded per player, and
|
||||
-- skipped whenever the hub believes it delivered (a socket the client has abandoned still
|
||||
-- counts) — so a push could be lost with nothing left to show for it.
|
||||
--
|
||||
-- `notification_id` is what ties the two halves together: it is the `Id` on the frame that
|
||||
-- delivers the notification, so the client can match what it was pushed to what it later
|
||||
-- reads from the inbox, and `POST /api/messages/v3/delete` has an id to match. Senders write
|
||||
-- the row FIRST and build the frame from it. AUTOINCREMENT rather than a bare rowid alias
|
||||
-- because the id is handed to the client and deleting a row frees its rowid — a reused id
|
||||
-- would point a client's stale entry at somebody else's notification.
|
||||
--
|
||||
-- Named `notification` and not `message`: the `chat` worker already owns a `message` table on
|
||||
-- this same shared database (its thread posts). `CREATE TABLE IF NOT EXISTS` against a name
|
||||
-- already taken does nothing and says nothing, so the first draft of this migration silently
|
||||
-- adopted chat's table and failed only on the index below, over a column it doesn't have.
|
||||
--
|
||||
-- Deliberately NOT stored, because none of it is worth reading later: the coach BROADCAST (a
|
||||
-- maintenance notice to everyone online at once — a row per player, thousands per send), the
|
||||
-- vote-to-kick prompt (about a session that has since ended), and a cheer (its durable record
|
||||
-- is the `reputation` counters; the Message is the part that plays in front of the room).
|
||||
--
|
||||
-- `data` is nullable because a Message's `Data` is: the types carrying no payload of their
|
||||
-- own send null, distinct from the empty string a DM sends. Where set it is always a STRING,
|
||||
-- whatever it means to that type (a room-role invite puts the offered role tier in it).
|
||||
CREATE TABLE IF NOT EXISTS notification (
|
||||
notification_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_player_id INTEGER NOT NULL,
|
||||
to_player_id INTEGER NOT NULL,
|
||||
type INTEGER NOT NULL,
|
||||
data TEXT,
|
||||
room_id INTEGER,
|
||||
player_event_id INTEGER,
|
||||
sent_time TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- The inbox read and the newest-wins trim are both "this player's notifications, newest
|
||||
-- first", which is the whole access pattern.
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_recipient ON notification (to_player_id, notification_id DESC);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Who handed a ban down, and when. A ban lives on the `report` row it rests on (see
|
||||
-- 0009_report_ban.sql), which recorded the decision but not the deciding: there was no way
|
||||
-- to tell which moderator set `banned`, and no way to tell WHEN they set it. Generated from
|
||||
-- src/reports-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `banned_at` is not bookkeeping. The client's block screen reads `TimeoutStartedAt` and
|
||||
-- `Duration` as a PAIR — the block runs from the start for the duration — and
|
||||
-- `moderationBlockDetails` had only the report's `created_at` to offer as that start. So a
|
||||
-- ban applied to a month-old report told the banned player their block began a month ago,
|
||||
-- and the duration (computed from there to `ban_expires`) came out a month short: a 7-day
|
||||
-- ban on a 30-day-old report reads as already served. With this column the ban's own start
|
||||
-- is recorded and `banBlockDetails` uses it, falling back to `created_at` so every row
|
||||
-- written before this migration keeps behaving exactly as it did.
|
||||
--
|
||||
-- Both are NULL on an unbanned row, and are CLEARED when a ban is lifted — the report
|
||||
-- itself survives, as it does for `banned`/`ban_expires`. So a non-null `banned_at` means
|
||||
-- "a ban is or was in force from here", which is the same thing `banned = 1` means.
|
||||
--
|
||||
-- Neither column is indexed. `banned_by_player_id` is read per-row when a moderator looks
|
||||
-- at a ban, never filtered on; the ban reads that exist go by player or by the ban flag
|
||||
-- (idx_report_banned). Add an index alongside a query that needs one.
|
||||
|
||||
ALTER TABLE report ADD COLUMN banned_by_player_id INTEGER;
|
||||
ALTER TABLE report ADD COLUMN banned_at TEXT;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- File every custom avatar item under OutfitType 105 (`RecRoom.Avatars.OutfitType.CustomShirt`).
|
||||
-- Creation left `outfit_type` at the column's default of 0 (Hat), and the store's
|
||||
-- user-generated-content tab searches `GET /api/customAvatarItems/v1/search?outfitTypes=105`
|
||||
-- ALONE, so the tab sat empty over a full catalog. The custom shirt is the one kind of custom avatar item the
|
||||
-- client can make, so every row written before this migration is one. Creation now writes
|
||||
-- 105 itself (src/custom-avatar-items-db.ts, OUTFIT_TYPE_CUSTOM_SHIRT).
|
||||
UPDATE custom_avatar_item SET outfit_type = 105 WHERE outfit_type = 0;
|
||||
@@ -0,0 +1,69 @@
|
||||
-- Custom avatar items become a JSON blob per row, the layout every other entity table on this
|
||||
-- database uses (`invention`, `event`, `room`, `account`, `club`): the client's `CustomAvatarItem`
|
||||
-- record verbatim in `data`, with the fields the queries filter and sort on exposed as generated
|
||||
-- (virtual) columns. Generated from src/custom-avatar-items-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- Why: the flat table (0015) had one NOT NULL column per field of the player-made shirt, and the
|
||||
-- FIRST-PARTY items being imported are the same record with those four fields NULL
|
||||
-- (`BaseAvatarItemId`, `BaseAvatarItemColor`, `DesignFilename`, `ThumbnailImageFilename` — a
|
||||
-- studio-built item has no base item and no design PNG) plus fields the table had no home for:
|
||||
-- `CurrentSaves` (one built Unity assetbundle per body type, which is how the client renders
|
||||
-- them), `Tags`, `CustomBadgeMetadata`, `RankedEntityId`. Storing the record whole means the
|
||||
-- import is the record as exported, and a field the source adds later is served without a
|
||||
-- migration. `CurrentSaves` stays embedded rather than in a child table: the client reads a
|
||||
-- save off its item and picks one by `BodyType`; nothing looks a save up by its bare id.
|
||||
--
|
||||
-- The generated columns keep the names the flat table had, so the indexes and every query
|
||||
-- read the same. `name_lower`/`description_lower` back the store's search, which matches the
|
||||
-- needle as a case-folded substring of either. Booleans come out of `json_extract` as 1/0
|
||||
-- (see 0008), so `is_featured = 1` keeps working.
|
||||
--
|
||||
-- The existing player-made rows are carried across by rebuilding the DTO the old `toDto`
|
||||
-- built from their columns — the same record the routes were already serving — with the
|
||||
-- fields the flat table never had filled in as it served them (empty saves and tags, null
|
||||
-- badge/ranking, `RankedEntityId` = the item id, as the official record carries it).
|
||||
|
||||
CREATE TABLE custom_avatar_item_new (
|
||||
data TEXT NOT NULL,
|
||||
custom_avatar_item_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.CustomAvatarItemId')) VIRTUAL,
|
||||
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL,
|
||||
accessibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Accessibility')) VIRTUAL,
|
||||
outfit_type INTEGER GENERATED ALWAYS AS (json_extract(data, '$.OutfitType')) VIRTUAL,
|
||||
price INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Price')) VIRTUAL,
|
||||
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL,
|
||||
created_at TEXT GENERATED ALWAYS AS (json_extract(data, '$.CreatedAt')) VIRTUAL,
|
||||
name_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Name'), ''))) VIRTUAL,
|
||||
description_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Description'), ''))) VIRTUAL
|
||||
);
|
||||
|
||||
INSERT INTO custom_avatar_item_new (data)
|
||||
SELECT json_object(
|
||||
'CustomAvatarItemId', custom_avatar_item_id,
|
||||
'CreatorAccountId', creator_account_id,
|
||||
'Name', name,
|
||||
'Description', description,
|
||||
'Price', price,
|
||||
'Accessibility', accessibility,
|
||||
'ForceCannotPublish', json(iif(force_cannot_publish, 'true', 'false')),
|
||||
'IsFeatured', json(iif(is_featured, 'true', 'false')),
|
||||
'IsRecRoomApproved', json(iif(is_rec_room_approved, 'true', 'false')),
|
||||
'BaseAvatarItemId', base_avatar_item_id,
|
||||
'BaseAvatarItemColor', base_avatar_item_color,
|
||||
'DesignFilename', design_filename,
|
||||
'ThumbnailImageFilename', thumbnail_image_filename,
|
||||
'CreatedAt', created_at,
|
||||
'ModifiedAt', modified_at,
|
||||
'PreviewOrientation', preview_orientation,
|
||||
'OutfitType', outfit_type,
|
||||
'CurrentSaves', json('[]'),
|
||||
'Tags', json('[]'),
|
||||
'CustomBadgeMetadata', NULL,
|
||||
'RankedEntityId', custom_avatar_item_id,
|
||||
'RankingContext', NULL
|
||||
) FROM custom_avatar_item;
|
||||
|
||||
DROP TABLE custom_avatar_item;
|
||||
ALTER TABLE custom_avatar_item_new RENAME TO custom_avatar_item;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Vote-to-kick ballots: one row per vote cast, appended, never updated.
|
||||
--
|
||||
-- Generated from apps/api/src/votes-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `response` is 1 for yes (kick them) and 0 for no. A player who votes twice leaves two
|
||||
-- rows: the tally counts DISTINCT voters and reads only each one's latest ballot, so
|
||||
-- re-posting a vote cannot carry one, and a voter may change their mind.
|
||||
--
|
||||
-- Rows are keyed to `game_session_id` (a `room_instance` id) and outlive the session.
|
||||
-- Nothing prunes them; instance ids are not reused, so an old session's ballots can never
|
||||
-- be counted into a new one's.
|
||||
CREATE TABLE IF NOT EXISTS room_vote (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_session_id INTEGER NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
response INTEGER NOT NULL,
|
||||
voter_id INTEGER NOT NULL,
|
||||
voted_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_room_vote_subject ON room_vote (game_session_id, player_id);
|
||||
@@ -0,0 +1,40 @@
|
||||
-- The audit log — one row per privileged action a staff account took. Owned by the `api`
|
||||
-- worker; WRITTEN by `notify`, which records every call to its admin-gated `/internal/*`
|
||||
-- endpoints here. Generated from packages/domain/src/audit-db.ts (AUDIT_LOG_SCHEMA_DDL) —
|
||||
-- keep in sync.
|
||||
--
|
||||
-- This is not the worker log. `logger.info` lines are for reading during an incident: they
|
||||
-- are sampled, they expire, and nothing can be asked of them later. The actions this table
|
||||
-- exists for leave no other trace on the database at all — a maintenance broadcast is a
|
||||
-- WebSocket frame and then nothing, and a coach message to every online player is
|
||||
-- deliberately not stored (see 0019_notification.sql) — so without a row here there is no
|
||||
-- answer at all to "who sent that, and when", a question that tends to arrive weeks later.
|
||||
--
|
||||
-- `player_id` is the ACTOR: the staff account that made the call, never whoever it was
|
||||
-- aimed at. A target, where there is one, lives inside `data` with the rest of what was
|
||||
-- asked for. The broadcasts have no target at all, so a column for one would be null on
|
||||
-- half the table and could not be the thing every row is filed under.
|
||||
--
|
||||
-- `data` is JSON as TEXT, and deliberately unstructured: each action records what it was
|
||||
-- asked to do, and those shapes have nothing in common beyond being worth keeping. It is
|
||||
-- nullable because an action whose whole content is its name has nothing to put there, and
|
||||
-- null says that where `{}` would only look like a payload that went missing. Capped at
|
||||
-- 4096 characters by the writer, since a request body's size is the caller's to choose;
|
||||
-- over that the row keeps the head of the JSON and says `truncated`.
|
||||
--
|
||||
-- `audit_log_id` is a plain rowid alias, not AUTOINCREMENT: the id never leaves the server
|
||||
-- (unlike `notification`'s, which is handed to the client) and nothing deletes from this
|
||||
-- table, so there are no freed rowids to be reused. Reads order by it rather than by
|
||||
-- `date`, which ties when two actions land in the same millisecond.
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
audit_log_id INTEGER PRIMARY KEY,
|
||||
player_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
data TEXT,
|
||||
date TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- The two ways anyone asks this table anything: everything one staffer did, and every time
|
||||
-- a given action was taken. Both newest-first, which is the order an audit trail is read in.
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_player ON audit_log (player_id, audit_log_id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log (action, audit_log_id DESC);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Reporting a CHAT MESSAGE (`POST /api/chatreport/createChatReport`) reuses the report
|
||||
-- table, as the event, invention and custom-avatar-item reports before it do: same fields,
|
||||
-- same moderation life — a moderator acting on one sets `banned` on the row exactly as they
|
||||
-- would for a player report. Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `chat_message_id` names the reported message; NULL on every other kind of report. It
|
||||
-- joins `event_id`, `invention_id` and `custom_avatar_item_id`, and all four are mutually
|
||||
-- exclusive: a row names one of those things or none of them (an ordinary player report),
|
||||
-- which is what tells the kinds apart.
|
||||
--
|
||||
-- The message id is the WHOLE reference. The client also posts the `ChatThreadId`, which is
|
||||
-- not stored: `message.chat_message_id` is unique across every thread (see the `chat`
|
||||
-- worker's 0001_message.sql), so the thread is one lookup away and a stored copy could only
|
||||
-- ever disagree with it.
|
||||
--
|
||||
-- The row's `reported_player_id` is the message's SENDER (`message.sender_player_id`), read
|
||||
-- from the message rather than sent by the client — the body names no player at all, and
|
||||
-- the column is NOT NULL. No `room_id`: a chat thread is not in a room.
|
||||
--
|
||||
-- NOT indexed, for the reason 0016 and 0017 give: it is written on every report of this kind
|
||||
-- and read by nothing. Every moderation read goes by player (`idx_report_reported`) or by
|
||||
-- the ban flag. Add an index with the query that needs it.
|
||||
|
||||
ALTER TABLE report ADD COLUMN chat_message_id INTEGER;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Marks the ballot that CALLED a vote-to-kick. Generated from src/votes-db.ts (SCHEMA_DDL) —
|
||||
-- keep in sync.
|
||||
--
|
||||
-- The client posts the call and every answer to the same endpoint with the same body, so
|
||||
-- nothing on the wire says which ballot started a vote. `init` is 1 on the first ballot cast
|
||||
-- against a player in a session while no vote on them is open (VOTE_WINDOW_MS), 0 on every
|
||||
-- answer. Two things read it:
|
||||
-- - the tally counts only ballots from the open vote's `init` row on, so an expired vote's
|
||||
-- yes ballots cannot carry the next one;
|
||||
-- - the caller throttle: a player who called a vote within VOTE_CALL_COOLDOWN_MS may not
|
||||
-- call another (`idx_room_vote_caller`). Answering is never throttled.
|
||||
--
|
||||
-- Backfill: each (session, player)'s first ballot is taken as its call. Those sessions are
|
||||
-- over, so this only keeps the caller history honest.
|
||||
|
||||
ALTER TABLE room_vote ADD COLUMN init INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE room_vote SET init = 1
|
||||
WHERE id IN (SELECT MIN(id) FROM room_vote GROUP BY game_session_id, player_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_room_vote_caller ON room_vote (voter_id, voted_at) WHERE init = 1;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Index the browse/search feed (`searchInventions`) in the order it pages: newest first.
|
||||
-- Without it every page — however small — parsed the JSON of every published invention,
|
||||
-- sorted the lot in a temp B-tree and threw all but one page away. With it the query walks
|
||||
-- this index in order and stops after OFFSET + LIMIT rows.
|
||||
--
|
||||
-- PARTIAL, over exactly the rows `VISIBLE_IN_FEEDS` admits, so it holds only what a feed can
|
||||
-- show. SQLite uses a partial index only when the query's WHERE contains each of these terms
|
||||
-- as written, so this WHERE must track `VISIBLE_IN_FEEDS` in src/inventions-db.ts term for
|
||||
-- term — change one and the index silently stops being used. The key is the same
|
||||
-- `json_extract` expression the query orders by, which is what lets it replace the sort.
|
||||
-- Generated from src/inventions-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_invention_feed_newest
|
||||
ON invention (json_extract(data, '$.CreatedAt') DESC, id DESC)
|
||||
WHERE is_published = 1
|
||||
AND hide_from_player = 0
|
||||
AND COALESCE(json_extract(data, '$.Accessibility'), 0) <> 2;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- A never-reused id sequence for player events. Owned by the `api` worker; generated
|
||||
-- from src/events-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- Event ids used to be `MAX(id) + 1` over the `event` table, which hands the NEWEST
|
||||
-- event's id straight back out once it is deleted: delete an event, create another,
|
||||
-- and the new one wears the old one's id — and inherits everything still filed under
|
||||
-- it that lives outside the event's own rows (a `PlayerEventInvitation` message's
|
||||
-- `player_event_id`, the client's own cache of the old event). An event id has to be
|
||||
-- unique for all time, like the other ids here.
|
||||
--
|
||||
-- SQLite's AUTOINCREMENT is exactly that guarantee: the counter in `sqlite_sequence`
|
||||
-- only ever goes up, whatever rows are deleted. `createEvent` draws an id by inserting
|
||||
-- a row here and reading it back; the row itself is throwaway (it is deleted once
|
||||
-- used — only the counter matters).
|
||||
--
|
||||
-- The seed row moves the counter past every event that already exists: inserting an
|
||||
-- explicit id into an AUTOINCREMENT table raises the sequence to at least that id, so
|
||||
-- the first draw after this migration is one past the current max, exactly what the
|
||||
-- old query would have produced. On an empty table it inserts nothing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_id_seq (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||
);
|
||||
-- HAVING, not WHERE: an aggregate over an empty table still yields one (NULL) row, and
|
||||
-- a NULL id would draw 1 from the counter, making the first real event 2.
|
||||
INSERT INTO event_id_seq (id)
|
||||
SELECT MAX(id) FROM event HAVING MAX(id) IS NOT NULL;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Who LIFTED a ban, and when. Owned by the `api` worker; generated from src/reports-db.ts
|
||||
-- (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- A lift clears `banned`, `ban_expires`, `banned_by_player_id` and `banned_at` (see
|
||||
-- 0020_report_ban_audit.sql), which leaves the row looking exactly like a report nobody
|
||||
-- ever acted on. On the staff panel that read as "Ban…" next to a report whose ban a
|
||||
-- colleague had deliberately lifted an hour earlier, and a second moderator re-banned the
|
||||
-- player without knowing. The `audit_log` still had the lift, but nothing reads it per row.
|
||||
--
|
||||
-- So the lift now signs the row: `unbanned_by_player_id` is the moderator who lifted the
|
||||
-- ban and `unbanned_at` when they did. Both are NULL until a ban is lifted, and are CLEARED
|
||||
-- again by a re-ban (the row describes the ban's current state, and a standing ban has not
|
||||
-- been lifted). A non-null `unbanned_at` on a `banned = 0` row therefore means "somebody
|
||||
-- decided this player should be back in the game", which is what the panel shows.
|
||||
--
|
||||
-- Rows lifted before this migration are backfilled from `audit_log` (0024), where every
|
||||
-- lift has been recorded as an `unban` row naming the report in `data.reportId` — the
|
||||
-- latest such row per report, since a report can be banned and lifted more than once.
|
||||
-- Only rows not banned NOW are touched: a re-ban after a lift has cleared the lift.
|
||||
--
|
||||
-- Neither column is indexed: read per row on the panel's tables, never filtered on.
|
||||
|
||||
ALTER TABLE report ADD COLUMN unbanned_by_player_id INTEGER;
|
||||
ALTER TABLE report ADD COLUMN unbanned_at TEXT;
|
||||
|
||||
UPDATE report
|
||||
SET unbanned_by_player_id = (
|
||||
SELECT player_id FROM audit_log
|
||||
WHERE action = 'unban' AND json_extract(data, '$.reportId') = report.id
|
||||
ORDER BY audit_log_id DESC LIMIT 1
|
||||
),
|
||||
unbanned_at = (
|
||||
SELECT date FROM audit_log
|
||||
WHERE action = 'unban' AND json_extract(data, '$.reportId') = report.id
|
||||
ORDER BY audit_log_id DESC LIMIT 1
|
||||
)
|
||||
WHERE banned = 0
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM audit_log
|
||||
WHERE action = 'unban' AND json_extract(data, '$.reportId') = report.id
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Cover the friend-list read (`getFriendIds` in @repo/domain, and the inlined copy in the
|
||||
-- friends-filtered leaderboard). Owned by the `api` worker; generated from
|
||||
-- packages/domain/src/relationships-db.ts (RELATIONSHIP_SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- The query is `relationship_type = ?2 AND (requester_id = ?1 OR target_id = ?1)`, which
|
||||
-- SQLite already splits into one probe per side of the OR. But `relationship_type` was in
|
||||
-- neither index and neither index carried the other side's id, so every row the player
|
||||
-- appears in — of ANY type — cost an index read plus a table-row read, and the non-friends
|
||||
-- were then discarded. Those rows accumulate: an unfriend keeps the row as type 0, ignoring
|
||||
-- or muting a stranger inserts a type 0 row, pending requests are rows. And the query runs
|
||||
-- on every matchmake and every disconnect (the presence push to friends), so D1 row reads
|
||||
-- were roughly 2 x (all relationships) x (matchmakes).
|
||||
--
|
||||
-- These two indexes cover each half: seek to (player, Friend), read the other id off the
|
||||
-- index, never touch the table. Reads become one per actual friend.
|
||||
--
|
||||
-- (target_id, relationship_type, requester_id) subsumes the old (target_id) index, so that
|
||||
-- one goes. The unique (requester_id, target_id) index stays — it is the pair constraint
|
||||
-- and what the single-pair lookups use.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_relationship_requester_type
|
||||
ON relationship (requester_id, relationship_type, target_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_relationship_target_type
|
||||
ON relationship (target_id, relationship_type, requester_id);
|
||||
DROP INDEX IF EXISTS idx_relationship_target;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Restore the UNIQUE pair index on `relationship`.
|
||||
--
|
||||
-- Generated from packages/domain/src/relationships-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
-- Migration 0001 declared `idx_relationship` and 0030 counts on it staying ("it is the
|
||||
-- pair constraint"), but the deployed table did not have it: an audit of sqlite_master
|
||||
-- against every worker's migrations found it and the two `room_instance` unique indexes
|
||||
-- (rooms 0022) missing, with every other declared index present. Every relationship
|
||||
-- write is select-then-insert (`upsertPair`, `setRelationshipFlag`), so this index is the
|
||||
-- only thing that stops a replayed or concurrent request from filing a pair twice —
|
||||
-- which shows up as a doubled friend entry, with `findPair` reading whichever twin the
|
||||
-- planner returns.
|
||||
--
|
||||
-- A UNIQUE index refuses to build over duplicates, so any are merged first: the oldest
|
||||
-- row of each (requester_id, target_id) group survives, carrying the MAX of every field
|
||||
-- across the group — for the flags that is an OR, and for `relationship_type` it is the
|
||||
-- furthest-along state (3 Friend > 2 Received > 1 Sent > 0 None). Then the rest go. Both
|
||||
-- statements are no-ops on a table with no duplicates.
|
||||
|
||||
UPDATE relationship SET
|
||||
relationship_type = (SELECT MAX(r.relationship_type) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
|
||||
requester_favorited = (SELECT MAX(r.requester_favorited) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
|
||||
requester_ignored = (SELECT MAX(r.requester_ignored) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
|
||||
requester_muted = (SELECT MAX(r.requester_muted) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
|
||||
target_favorited = (SELECT MAX(r.target_favorited) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
|
||||
target_ignored = (SELECT MAX(r.target_ignored) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id),
|
||||
target_muted = (SELECT MAX(r.target_muted) FROM relationship r WHERE r.requester_id = relationship.requester_id AND r.target_id = relationship.target_id)
|
||||
WHERE id IN (SELECT MIN(id) FROM relationship GROUP BY requester_id, target_id HAVING COUNT(*) > 1);
|
||||
|
||||
DELETE FROM relationship
|
||||
WHERE id NOT IN (SELECT MIN(id) FROM relationship GROUP BY requester_id, target_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_relationship ON relationship (requester_id, target_id);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Player-made custom shirts are capped at 1000 tokens (`MAX_PLAYER_ITEM_PRICE` in
|
||||
-- src/custom-avatar-items-db.ts): the create and the edit now refuse a price above it, and
|
||||
-- this lowers every existing player-made row that is already over it to exactly the cap.
|
||||
--
|
||||
-- Only PLAYER-MADE rows are touched, told apart by `BaseAvatarItemId`: a player-made shirt
|
||||
-- is built on a base item, while a first-party item (imported from the official export,
|
||||
-- Coach-authored) has it NULL and is priced from the storefront dump, where several sit
|
||||
-- well above 1000. Those keep their prices.
|
||||
--
|
||||
-- `ModifiedAt` is bumped as the edit endpoint bumps it, since the record did change.
|
||||
-- `price` is a generated column off `data`, so rewriting the JSON is the whole update.
|
||||
|
||||
UPDATE custom_avatar_item
|
||||
SET data = json_set(data,
|
||||
'$.Price', 1000,
|
||||
'$.ModifiedAt', strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||
WHERE json_extract(data, '$.BaseAvatarItemId') IS NOT NULL
|
||||
AND price > 1000;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Inventions are capped at 1000 tokens (`MAX_INVENTION_PRICE` in src/inventions-db.ts): the
|
||||
-- three writes a creator has (`v3/publish`, `v4/publish`, `v1/updateprice`) now refuse a
|
||||
-- price above it, and this lowers every existing row already over it to exactly the cap.
|
||||
-- Drafts included — a draft's price is what its next publish would list it at.
|
||||
--
|
||||
-- `ModifiedAt` is left alone, as `v1/updateprice` leaves it: a reprice is not an edit of
|
||||
-- the invention. `price` is not a generated column here, so the JSON is the whole update.
|
||||
|
||||
UPDATE invention
|
||||
SET data = json_set(data, '$.Price', 1000)
|
||||
WHERE json_extract(data, '$.Price') > 1000;
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Reporting a saved IMAGE (`POST /api/images/v1/{id}/report`) reuses the report table, as
|
||||
-- the event, invention, custom-avatar-item and chat-message reports beside it do: same
|
||||
-- fields, same moderation life. Generated from src/reports-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `image_id` names the reported image (`image.id`). NULL on every other kind of report, and
|
||||
-- mutually exclusive with `event_id`, `invention_id`, `custom_avatar_item_id` and
|
||||
-- `chat_message_id`.
|
||||
--
|
||||
-- The request has no body, so the row is three facts: the reporter (the caller), the image,
|
||||
-- and `reported_player_id` — the image's `PlayerId`, who took it, read from the image.
|
||||
-- `report_category` stays 0 and `details` NULL: the client sends neither.
|
||||
--
|
||||
-- NOT indexed, like the other kind columns: written on every report of this kind and read by
|
||||
-- nothing. Add an index with the query that needs it.
|
||||
|
||||
ALTER TABLE report ADD COLUMN image_id INTEGER;
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Turns an export of first-party custom avatar items into a D1 migration that seeds the
|
||||
* `custom_avatar_item` table with them, the way `apps/rooms/migrations/0002_import_rooms.sql`
|
||||
* seeds the rooms.
|
||||
*
|
||||
* bun apps/api/scripts/import-custom-avatar-items.ts <export.json> <NNNN>
|
||||
*
|
||||
* `export.json` is a JSON array of `CustomAvatarItem` records as the official service serves
|
||||
* them (a `{ Results: [...] }` page or a single record are accepted too). `NNNN` is the
|
||||
* migration number; the file is written to `apps/api/migrations/NNNN_import_custom_avatar_items.sql`.
|
||||
*
|
||||
* For a load that needs no migration and no deploy — the usual case — use
|
||||
* `just admin cai-load` instead, which runs the same statements straight against a database.
|
||||
* The statements come from src/custom-avatar-items-load.ts either way: each record stored as
|
||||
* the row's JSON minus `PurchaseInfo`, with `CreatorAccountId` forced to the Coach account (1)
|
||||
* and each save's `ThumbnailFileName` put under `avatar/` and its assetbundle hashes blanked,
|
||||
* an existing id REPLACED, nothing deleted.
|
||||
*
|
||||
* The export's `Price` is mostly 0; run `price-custom-avatar-items.ts` against it first to fill
|
||||
* in prices from the storefront dump.
|
||||
*
|
||||
* The saves name assetbundles by bare filename; the migration does nothing about those files,
|
||||
* which have to be served from wherever the client fetches them.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
import {
|
||||
customAvatarItemUpsertStatements,
|
||||
readCustomAvatarItemExport,
|
||||
} from '../src/custom-avatar-items-load'
|
||||
|
||||
const [input, number] = process.argv.slice(2)
|
||||
if (!input || !number || !/^\d{4}$/.test(number)) {
|
||||
console.error('usage: bun apps/api/scripts/import-custom-avatar-items.ts <export.json> <NNNN>')
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
// Tolerate the BOM a .NET export carries (`JSON.parse` rejects U+FEFF).
|
||||
const records = readCustomAvatarItemExport(
|
||||
JSON.parse(readFileSync(input, 'utf8').replace(/^/, ''))
|
||||
)
|
||||
const lines = [
|
||||
`-- Seed first-party custom avatar items (${records.length} records) from ${input.split('/').pop()}.`,
|
||||
'-- Generated by apps/api/scripts/import-custom-avatar-items.ts — do not edit by hand.',
|
||||
'-- Each record is the official `CustomAvatarItem`, filed under the Coach account (CreatorAccountId 1);',
|
||||
'-- an existing id is replaced.',
|
||||
'',
|
||||
...customAvatarItemUpsertStatements(records),
|
||||
]
|
||||
|
||||
const out = decodeURIComponent(
|
||||
new URL(`../migrations/${number}_import_custom_avatar_items.sql`, import.meta.url).pathname
|
||||
)
|
||||
writeFileSync(out, lines.join('\n') + '\n')
|
||||
console.log(`wrote ${records.length} records to ${out}`)
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Fills in `Price` on an export of first-party custom avatar items from a storefront dump,
|
||||
* rewriting the export in place so `import-custom-avatar-items.ts` / `just admin cai-load`
|
||||
* carry the price into the table.
|
||||
*
|
||||
* bun apps/api/scripts/price-custom-avatar-items.ts <export.json> <storefront.json>
|
||||
*
|
||||
* The export (e.g. `apps/econ/static/db/2025-1-cai.json`) mostly carries `Price: 0`. The
|
||||
* storefront (e.g. `Watch_EnumValue_3.json`, a `{ StoreItems: [...] }` page) prices the same
|
||||
* items, joined on `GiftDrop.AvatarItemInfo.DownloadableAvatarItemId` = `CustomAvatarItemId`.
|
||||
* The price taken is the base token price (`CurrencyType` 2), ignoring any sale. The storefront
|
||||
* only prices what the export already holds: nothing is added from it.
|
||||
*
|
||||
* An item the storefront doesn't list keeps the price it had, unless that price is 0, which
|
||||
* would make it free to buy. Those are the Cryptid Creek event items and the Evergrown Hip Pack,
|
||||
* all rarity-50 rewards that were never sold, so they get the catalog's rarity-50 price.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
|
||||
import { priceForRarity } from '../../econ/src/catalog-load'
|
||||
|
||||
const TOKENS = 2
|
||||
|
||||
/** The rarity an item missing from the storefront is priced as, when it has no price. */
|
||||
const UNLISTED_RARITY = 50
|
||||
|
||||
interface StoreItem {
|
||||
GiftDrop?: { AvatarItemInfo?: { DownloadableAvatarItemId?: string | null } | null }
|
||||
Prices?: Array<{ CurrencyType: number; Price: number }>
|
||||
}
|
||||
|
||||
const [exportPath, storefrontPath] = process.argv.slice(2)
|
||||
if (!exportPath || !storefrontPath) {
|
||||
console.error(
|
||||
'usage: bun apps/api/scripts/price-custom-avatar-items.ts <export.json> <storefront.json>'
|
||||
)
|
||||
process.exit(2)
|
||||
}
|
||||
|
||||
// Tolerate the BOM a .NET export carries (`JSON.parse` rejects U+FEFF).
|
||||
const readJson = (path: string) => JSON.parse(readFileSync(path, 'utf8').replace(/^/, ''))
|
||||
|
||||
const items: Array<{ CustomAvatarItemId: string; Name: string; Price: number }> =
|
||||
readJson(exportPath)
|
||||
const storefront: { StoreItems: StoreItem[] } = readJson(storefrontPath)
|
||||
|
||||
const prices = new Map<string, number>()
|
||||
for (const storeItem of storefront.StoreItems) {
|
||||
const id = storeItem.GiftDrop?.AvatarItemInfo?.DownloadableAvatarItemId?.toLowerCase()
|
||||
const price = storeItem.Prices?.find((p) => p.CurrencyType === TOKENS)?.Price
|
||||
if (id && price !== undefined && !prices.has(id)) {
|
||||
prices.set(id, price)
|
||||
}
|
||||
}
|
||||
|
||||
let priced = 0
|
||||
let changed = 0
|
||||
const unmatched: string[] = []
|
||||
for (const item of items) {
|
||||
let price = prices.get(item.CustomAvatarItemId.toLowerCase())
|
||||
if (price === undefined) {
|
||||
unmatched.push(item.Name)
|
||||
if (item.Price) continue
|
||||
price = priceForRarity(UNLISTED_RARITY)
|
||||
}
|
||||
priced++
|
||||
if (item.Price !== price) {
|
||||
if (item.Price) {
|
||||
console.warn(`${item.Name}: ${item.Price} -> ${price}`)
|
||||
}
|
||||
item.Price = price
|
||||
changed++
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(exportPath, JSON.stringify(items, null, 2) + '\n')
|
||||
console.log(`priced ${priced}/${items.length} items (${changed} changed) in ${exportPath}`)
|
||||
if (unmatched.length) {
|
||||
console.log(
|
||||
`not in the storefront (kept their price, or rarity ${UNLISTED_RARITY} if free): ${unmatched.join(', ')}`
|
||||
)
|
||||
}
|
||||
@@ -206,3 +206,94 @@ export async function isPlayerBlocked(
|
||||
): Promise<boolean> {
|
||||
return (await resolveBan(db, accountId, options)) !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* An account a ban on someone else would also reach, and what links the two.
|
||||
*
|
||||
* `via` is never `'account'` here: this is the answer to "who ELSE", so the account asked
|
||||
* about is excluded by the query rather than returned as a match on itself.
|
||||
*/
|
||||
export interface LinkedAccount {
|
||||
accountId: number
|
||||
username: string | null
|
||||
via: Exclude<BanVia, 'account'>
|
||||
/**
|
||||
* What matched — the shared platform id, or the shared IP address. Shown to the
|
||||
* moderator so an IP match can be judged on its face: an address that reads as a
|
||||
* carrier or campus range is a different proposition from a residential one.
|
||||
*/
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Which OTHER accounts a ban on `accountId` would also block — the preview a moderator
|
||||
* sees before handing one down (`www`: `GET /api/staff/players/:id/linked`).
|
||||
*
|
||||
* This is `resolveBan` read backwards. That function asks "does any ban reach this
|
||||
* caller", driving from the few banned reports inward, because it runs on every matchmake
|
||||
* and every token grant. This one asks "who would this ban reach", driving from one
|
||||
* account's identities outward — a question with no hot path, asked once when a moderator
|
||||
* is about to act, and the only way to see the IP arm's blast radius BEFORE it lands
|
||||
* rather than from a support ticket afterwards.
|
||||
*
|
||||
* Honours the same `arms`, so a preview on a server running `BAN_EVASION_MATCH=platform`
|
||||
* doesn't list households that would not in fact be blocked. A disabled arm contributes
|
||||
* nothing rather than being listed-but-flagged: the question is who gets blocked, and the
|
||||
* answer under that setting is that they don't.
|
||||
*
|
||||
* The username comes off the account blob, which is `auth`'s — read here for the same
|
||||
* reason the resolution query reads it: the moderator needs to know WHO, and an id alone
|
||||
* makes the preview unreadable. A missing blob yields null rather than dropping the row;
|
||||
* the block would still reach that account.
|
||||
*
|
||||
* An account matched by BOTH arms appears twice, once per arm. Deliberate: the arms are
|
||||
* separate evidence, and "shares a Steam identity AND an address" is worth seeing as two
|
||||
* lines. Platform rows sort first, being the arm that proves more.
|
||||
*/
|
||||
export async function linkedAccounts(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
arms: BanMatchArms = DEFAULT_BAN_MATCH_ARMS
|
||||
): Promise<LinkedAccount[]> {
|
||||
if (!arms.ip && !arms.platform) return []
|
||||
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`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
|
||||
),
|
||||
ids AS (
|
||||
SELECT platform, platform_id FROM platform_account WHERE account_id = ?1
|
||||
)
|
||||
SELECT p.account_id AS accountId,
|
||||
json_extract(a.data, '$.username') AS username,
|
||||
'platform' AS via,
|
||||
p.platform_id AS value
|
||||
FROM platform_account p
|
||||
JOIN ids ON ids.platform = p.platform AND ids.platform_id = p.platform_id
|
||||
LEFT JOIN account a ON a.account_id = p.account_id
|
||||
WHERE ?2 = 1 AND p.account_id <> ?1
|
||||
UNION
|
||||
SELECT a.account_id AS accountId,
|
||||
json_extract(a.data, '$.username') AS username,
|
||||
'ip' AS via,
|
||||
ips.ip AS value
|
||||
FROM account a, ips
|
||||
WHERE ?3 = 1 AND a.account_id <> ?1
|
||||
AND ips.ip IN (
|
||||
json_extract(a.data, '$.signupIp'),
|
||||
json_extract(a.data, '$.lastLoginIp')
|
||||
)
|
||||
ORDER BY via DESC, accountId`
|
||||
)
|
||||
.bind(accountId, arms.platform ? 1 : 0, arms.ip ? 1 : 0)
|
||||
.all<LinkedAccount>()
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -1,42 +1,90 @@
|
||||
/**
|
||||
* Custom avatar items — player-designed items built on a base catalog item — on the
|
||||
* shared `recflare` D1 database. One column per field of the client's `CustomAvatarItem`
|
||||
* DTO, so a row maps straight onto the response.
|
||||
* Custom avatar items on the shared `recflare` D1 database: the player-designed shirts built
|
||||
* on a base catalog item, and the first-party items imported from the official service, which
|
||||
* are the same record with a set of built Unity assetbundles (`CurrentSaves`) in place of a
|
||||
* base item and a design PNG.
|
||||
*
|
||||
* The two uploads that accompany a creation (the design blob and the thumbnail PNG) live
|
||||
* in the shared image bucket (`recflare-img`, the `IMAGES` binding) under
|
||||
* `avatar-item/<date>/<id>-thumb.png` and `<id>-design.png`; the two filename columns hold
|
||||
* those bucket keys, which the `img` worker serves back by key.
|
||||
* A row is the client's `CustomAvatarItem` record verbatim, as JSON in `data` — the layout the
|
||||
* `invention`, `event`, `room`, `account` and `club` tables use — with the fields the queries
|
||||
* filter and sort on exposed as generated (virtual) columns. So a row IS the response, an
|
||||
* import is the exported record as-is, and a field the source adds later is served without a
|
||||
* migration. The flat one-column-per-field table this replaced (0015) could not hold a
|
||||
* first-party item at all: its base-item and filename columns were NOT NULL, and it had no home
|
||||
* for the saves.
|
||||
*
|
||||
* The `api` worker owns the schema/migration (migrations/0015_custom_avatar_item.sql,
|
||||
* applied under its own `migrations_table`).
|
||||
* `CurrentSaves` stays EMBEDDED rather than in a child table: the client reads a save off its
|
||||
* item and picks one by `BodyType`, and nothing on this server looks a save up by its bare id
|
||||
* (the legacy-item lookup keys them by `AvatarItemDesc`). Room saves got their own table
|
||||
* because the client points at one by bare id; that does not apply here.
|
||||
*
|
||||
* The two uploads that accompany a player's creation (the design blob and the thumbnail PNG)
|
||||
* live in the shared image bucket (`recflare-img`, the `IMAGES` binding) under
|
||||
* `avatar-item/<date>/<id>-thumb.png` and `<id>-design.png`; `ThumbnailImageFilename` and
|
||||
* `DesignFilename` hold those bucket keys, which the `img` worker serves back by key. A
|
||||
* first-party item has both null and is rendered from its saves' assetbundles instead.
|
||||
*
|
||||
* The `api` worker owns the schema/migration (migrations/0015_custom_avatar_item.sql, rebuilt
|
||||
* as JSON by 0022_custom_avatar_item_json.sql, applied under its own `migrations_table`).
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0015_custom_avatar_item.sql). */
|
||||
import { COACH_ACCOUNT_ID } from './custom-avatar-items-load'
|
||||
|
||||
/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS custom_avatar_item (
|
||||
custom_avatar_item_id TEXT PRIMARY KEY,
|
||||
creator_account_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
price INTEGER NOT NULL DEFAULT 0,
|
||||
accessibility INTEGER NOT NULL DEFAULT 0,
|
||||
force_cannot_publish INTEGER NOT NULL DEFAULT 0,
|
||||
is_featured INTEGER NOT NULL DEFAULT 0,
|
||||
is_rec_room_approved INTEGER NOT NULL DEFAULT 0,
|
||||
base_avatar_item_id INTEGER NOT NULL,
|
||||
base_avatar_item_color TEXT NOT NULL,
|
||||
design_filename TEXT NOT NULL,
|
||||
thumbnail_image_filename TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
modified_at TEXT NOT NULL,
|
||||
preview_orientation INTEGER NOT NULL DEFAULT 0,
|
||||
outfit_type INTEGER NOT NULL DEFAULT 0
|
||||
data TEXT NOT NULL,
|
||||
custom_avatar_item_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.CustomAvatarItemId')) VIRTUAL,
|
||||
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL,
|
||||
accessibility INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Accessibility')) VIRTUAL,
|
||||
outfit_type INTEGER GENERATED ALWAYS AS (json_extract(data, '$.OutfitType')) VIRTUAL,
|
||||
price INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Price')) VIRTUAL,
|
||||
is_featured INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsFeatured')) VIRTUAL,
|
||||
created_at TEXT GENERATED ALWAYS AS (json_extract(data, '$.CreatedAt')) VIRTUAL,
|
||||
name_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Name'), ''))) VIRTUAL,
|
||||
description_lower TEXT GENERATED ALWAYS AS (lower(coalesce(json_extract(data, '$.Description'), ''))) VIRTUAL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_avatar_item_id ON custom_avatar_item (custom_avatar_item_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_creator ON custom_avatar_item (creator_account_id)`,
|
||||
]
|
||||
|
||||
/** The client's `CustomAvatarItem` record (PascalCase, as served). */
|
||||
/**
|
||||
* One built version of a first-party item, as the client's PascalCase `CustomAvatarItemSave`:
|
||||
* a Unity assetbundle (`UnityAsset`, with `UnityAsset2` a second variant) the client downloads
|
||||
* to render the item, plus the thumbnail it shows in the store. An item carries one per
|
||||
* `BodyType` it was built for; the client picks the save matching the wearer's body.
|
||||
* `AdditionalConfiguration` is the client's own JSON-in-a-string document and is served as
|
||||
* stored. Not the camelCase `CustomAvatarItemSave` the legacy-item lookup documents — same
|
||||
* save, different casing and field set; keep the two apart.
|
||||
*/
|
||||
export interface CustomAvatarItemSave {
|
||||
CustomAvatarItemSaveId: number
|
||||
CustomAvatarItemId: string
|
||||
UnityAssetId: string
|
||||
BodyType: number
|
||||
OutfitType: number
|
||||
QAState: number
|
||||
CreatedAt: string
|
||||
ModifiedAt: string
|
||||
Description: string | null
|
||||
ThumbnailFileName: string
|
||||
AdditionalConfiguration: string
|
||||
UnityAsset: string
|
||||
UnityAssetHash: string
|
||||
UnityAsset2: string | null
|
||||
UnityAsset2Hash: string | null
|
||||
}
|
||||
|
||||
/** A tag on an item (`{ TagType: 0, Value: "export" }` on the imported first-party items). */
|
||||
export interface CustomAvatarItemTag {
|
||||
TagType: number
|
||||
Value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The client's `CustomAvatarItem` record (PascalCase, as served). The four nullable fields are
|
||||
* null on a first-party item, which has no base item or design PNG: it is rendered from
|
||||
* `CurrentSaves` instead, which is empty on a player-made shirt.
|
||||
*/
|
||||
export interface CustomAvatarItem {
|
||||
CustomAvatarItemId: string
|
||||
CreatorAccountId: number
|
||||
@@ -47,19 +95,52 @@ export interface CustomAvatarItem {
|
||||
ForceCannotPublish: boolean
|
||||
IsFeatured: boolean
|
||||
IsRecRoomApproved: boolean
|
||||
BaseAvatarItemId: number
|
||||
BaseAvatarItemColor: string
|
||||
DesignFilename: string
|
||||
ThumbnailImageFilename: string
|
||||
BaseAvatarItemId: number | null
|
||||
BaseAvatarItemColor: string | null
|
||||
DesignFilename: string | null
|
||||
ThumbnailImageFilename: string | null
|
||||
CreatedAt: string
|
||||
ModifiedAt: string
|
||||
PreviewOrientation: number
|
||||
RankingContext: null
|
||||
OutfitType: number
|
||||
CurrentSaves: never[]
|
||||
CurrentSaves: CustomAvatarItemSave[]
|
||||
Tags: CustomAvatarItemTag[]
|
||||
CustomBadgeMetadata: unknown
|
||||
RankedEntityId: string
|
||||
PurchaseInfo: null
|
||||
}
|
||||
|
||||
/**
|
||||
* `RecRoom.Avatars.OutfitType.CustomShirt` — the slot of a custom shirt, the one kind of custom
|
||||
* avatar item the client can make. The enum (unobfuscated in the client) is banded by body
|
||||
* region: head None -1, Hat 0, Hair 2, Ear 3, Eye 10, Beard 20; torso Shoulder 100, Shirt 101,
|
||||
* Waist 102, Neck 103, TeamJersey 104, CustomShirt 105; arms Wrist 200, TeamWrist 203; legs
|
||||
* Legs 300, Feet 301; Roomie_Hat 500, Roomie_Waist 501, Roomie_Eye 502. It reaches the search
|
||||
* as a bare `IEnumerable<int>` — nothing on the wire ties 105 to the name.
|
||||
*
|
||||
* This is what the store's user-generated-content tab searches for, ALONE
|
||||
* (`GET /api/customAvatarItems/v1/search?outfitTypes=105&includeCoachItems=False`), so it is
|
||||
* what every created item must be filed under: the table's default of 0 (Hat) matched nothing,
|
||||
* and the tab sat empty over a full catalog. The imported first-party items carry their own
|
||||
* slot (a wing is 100, Shoulder) and are found by the storefront tab's dozen-slot query.
|
||||
*
|
||||
* Not to be confused with the search's `itemTypes`, a different 3-member enum (All -1, None 0,
|
||||
* Shirt 1) whose Shirt is 1, not 101 or 105. The client sends `itemTypes=-1` (All) alongside
|
||||
* `outfitTypes=105`; the outfit type is the filter, and the two are never cross-mapped.
|
||||
*/
|
||||
export const OUTFIT_TYPE_CUSTOM_SHIRT = 105
|
||||
|
||||
/**
|
||||
* The most tokens a PLAYER-MADE item may sell for, inclusive. Enforced on the create and
|
||||
* the edit (`POST`/`PUT /api/customAvatarItems/v1`), the only two writes a player has; the
|
||||
* client has no ceiling of its own — its `minPriceForPublicItem` is a floor. First-party
|
||||
* items are not bound by it: their prices come off the storefront dump and several sit
|
||||
* above it (migrations/0032_custom_avatar_item_price_cap.sql clamps only the player-made
|
||||
* rows, told apart by `BaseAvatarItemId`).
|
||||
*/
|
||||
export const MAX_PLAYER_ITEM_PRICE = 1000
|
||||
|
||||
/** What `POST /api/customAvatarItems/v1` needs to create an item. */
|
||||
export interface CreateCustomAvatarItemInput {
|
||||
/** The item's id. Chosen by the caller because the upload keys are derived from it. */
|
||||
@@ -73,87 +154,101 @@ export interface CreateCustomAvatarItemInput {
|
||||
accessibility: number
|
||||
designFilename: string
|
||||
thumbnailImageFilename: string
|
||||
/** The slot the item is worn in; {@link OUTFIT_TYPE_CUSTOM_SHIRT} when left out. */
|
||||
outfitType?: number
|
||||
}
|
||||
|
||||
interface Row {
|
||||
custom_avatar_item_id: string
|
||||
creator_account_id: number
|
||||
name: string
|
||||
description: string
|
||||
price: number
|
||||
accessibility: number
|
||||
force_cannot_publish: number
|
||||
is_featured: number
|
||||
is_rec_room_approved: number
|
||||
base_avatar_item_id: number
|
||||
base_avatar_item_color: string
|
||||
design_filename: string
|
||||
thumbnail_image_filename: string
|
||||
created_at: string
|
||||
modified_at: string
|
||||
preview_orientation: number
|
||||
outfit_type: number
|
||||
data: string
|
||||
}
|
||||
|
||||
/**
|
||||
* What a stored record may lack and the response must carry: an imported record predates
|
||||
* some of these, and `PurchaseInfo` is a store-side projection (`econ` prices items), never
|
||||
* stored. Everything the record does carry wins over these.
|
||||
*/
|
||||
const DTO_DEFAULTS = {
|
||||
CurrentSaves: [] as CustomAvatarItemSave[],
|
||||
Tags: [] as CustomAvatarItemTag[],
|
||||
CustomBadgeMetadata: null,
|
||||
RankingContext: null,
|
||||
PurchaseInfo: null,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* A stored row's JSON as the client's record. Exported for the `econ` worker's owned-items
|
||||
* read, which joins `inventory_custom` to this table and must serve the same shape.
|
||||
*/
|
||||
export function parseCustomAvatarItem(data: string): CustomAvatarItem {
|
||||
const stored = JSON.parse(data) as CustomAvatarItem
|
||||
return { ...DTO_DEFAULTS, ...stored, RankingContext: null, PurchaseInfo: null }
|
||||
}
|
||||
|
||||
function toDto(row: Row): CustomAvatarItem {
|
||||
return {
|
||||
CustomAvatarItemId: row.custom_avatar_item_id,
|
||||
CreatorAccountId: row.creator_account_id,
|
||||
Name: row.name,
|
||||
Description: row.description,
|
||||
Price: row.price,
|
||||
Accessibility: row.accessibility,
|
||||
ForceCannotPublish: row.force_cannot_publish === 1,
|
||||
IsFeatured: row.is_featured === 1,
|
||||
IsRecRoomApproved: row.is_rec_room_approved === 1,
|
||||
BaseAvatarItemId: row.base_avatar_item_id,
|
||||
BaseAvatarItemColor: row.base_avatar_item_color,
|
||||
DesignFilename: row.design_filename,
|
||||
ThumbnailImageFilename: row.thumbnail_image_filename,
|
||||
CreatedAt: row.created_at,
|
||||
ModifiedAt: row.modified_at,
|
||||
PreviewOrientation: row.preview_orientation,
|
||||
RankingContext: null,
|
||||
OutfitType: row.outfit_type,
|
||||
CurrentSaves: [],
|
||||
PurchaseInfo: null,
|
||||
}
|
||||
return parseCustomAvatarItem(row.data)
|
||||
}
|
||||
|
||||
/** Inserts a new custom avatar item and returns it as the client's DTO. */
|
||||
/** Inserts a new player-made custom avatar item and returns it as the client's DTO. */
|
||||
export async function createCustomAvatarItem(
|
||||
db: D1Database,
|
||||
input: CreateCustomAvatarItemInput,
|
||||
now: Date = new Date()
|
||||
): Promise<CustomAvatarItem> {
|
||||
const ts = now.toISOString()
|
||||
const record: Omit<CustomAvatarItem, 'PurchaseInfo'> = {
|
||||
CustomAvatarItemId: input.customAvatarItemId,
|
||||
CreatorAccountId: input.creatorAccountId,
|
||||
Name: input.name,
|
||||
Description: input.description,
|
||||
Price: input.price,
|
||||
Accessibility: input.accessibility,
|
||||
ForceCannotPublish: false,
|
||||
IsFeatured: false,
|
||||
IsRecRoomApproved: false,
|
||||
BaseAvatarItemId: input.baseAvatarItemId,
|
||||
BaseAvatarItemColor: input.baseAvatarItemColor,
|
||||
DesignFilename: input.designFilename,
|
||||
ThumbnailImageFilename: input.thumbnailImageFilename,
|
||||
CreatedAt: ts,
|
||||
ModifiedAt: ts,
|
||||
PreviewOrientation: 0,
|
||||
OutfitType: input.outfitType ?? OUTFIT_TYPE_CUSTOM_SHIRT,
|
||||
CurrentSaves: [],
|
||||
Tags: [],
|
||||
CustomBadgeMetadata: null,
|
||||
RankedEntityId: input.customAvatarItemId,
|
||||
RankingContext: null,
|
||||
}
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO custom_avatar_item (
|
||||
custom_avatar_item_id, creator_account_id, name, description, price, accessibility,
|
||||
base_avatar_item_id, base_avatar_item_color, design_filename, thumbnail_image_filename,
|
||||
created_at, modified_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(
|
||||
input.customAvatarItemId,
|
||||
input.creatorAccountId,
|
||||
input.name,
|
||||
input.description,
|
||||
input.price,
|
||||
input.accessibility,
|
||||
input.baseAvatarItemId,
|
||||
input.baseAvatarItemColor,
|
||||
input.designFilename,
|
||||
input.thumbnailImageFilename,
|
||||
ts
|
||||
)
|
||||
.prepare('INSERT INTO custom_avatar_item (data) VALUES (?1) RETURNING data')
|
||||
.bind(JSON.stringify(record))
|
||||
.first<Row>()
|
||||
if (!row) throw new Error('custom_avatar_item insert returned no row')
|
||||
return toDto(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores a record as exported from the official service (the first-party items), verbatim,
|
||||
* replacing any row with the same `CustomAvatarItemId` so a re-import with corrections lands.
|
||||
* This is what the generated import migration does in SQL; it is here for tests and tooling.
|
||||
*/
|
||||
export async function importCustomAvatarItem(
|
||||
db: D1Database,
|
||||
record: Omit<CustomAvatarItem, 'PurchaseInfo'> & { PurchaseInfo?: unknown }
|
||||
): Promise<CustomAvatarItem> {
|
||||
const { PurchaseInfo: _purchaseInfo, ...stored } = record
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO custom_avatar_item (data) VALUES (?1)
|
||||
ON CONFLICT(custom_avatar_item_id) DO UPDATE SET data = excluded.data
|
||||
RETURNING data`
|
||||
)
|
||||
.bind(JSON.stringify(stored))
|
||||
.first<Row>()
|
||||
if (!row) throw new Error('custom_avatar_item import returned no row')
|
||||
return toDto(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ItemType` that names a custom avatar item in a UGC-purchasable reference
|
||||
* (`POST /api/ugcPurchasables/v1/items/bulk`'s `Ids[].itemType`).
|
||||
@@ -178,6 +273,8 @@ export interface UgcPurchasableItem {
|
||||
* The store-facing projection of a custom avatar item. `RoomId` is echoed from the
|
||||
* request — the item table has no room; what the client wants it for is still unknown.
|
||||
* `PurchaseCurrencyId` is null (the client's field is nullable) until a currency exists.
|
||||
* `ImageName` is the item's own thumbnail for a player-made shirt; a first-party item has
|
||||
* none at the item level, so the first save's thumbnail stands in.
|
||||
*/
|
||||
export function toUgcPurchasable(item: CustomAvatarItem, roomId: number): UgcPurchasableItem {
|
||||
return {
|
||||
@@ -185,7 +282,7 @@ export function toUgcPurchasable(item: CustomAvatarItem, roomId: number): UgcPur
|
||||
ItemId: item.CustomAvatarItemId,
|
||||
Name: item.Name,
|
||||
Description: item.Description,
|
||||
ImageName: item.ThumbnailImageFilename,
|
||||
ImageName: item.ThumbnailImageFilename ?? item.CurrentSaves[0]?.ThumbnailFileName ?? '',
|
||||
RoomId: roomId,
|
||||
Price: item.Price,
|
||||
PurchaseCurrencyId: null,
|
||||
@@ -202,13 +299,66 @@ export async function getCustomAvatarItems(
|
||||
if (ids.length === 0) return []
|
||||
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
|
||||
const { results } = await db
|
||||
.prepare(`SELECT * FROM custom_avatar_item WHERE custom_avatar_item_id IN (${placeholders})`)
|
||||
.prepare(`SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id IN (${placeholders})`)
|
||||
.bind(...ids)
|
||||
.all<Row>()
|
||||
const byId = new Map(results.map((r) => [r.custom_avatar_item_id, toDto(r)]))
|
||||
const byId = new Map(results.map(toDto).map((item) => [item.CustomAvatarItemId, item]))
|
||||
return ids.flatMap((id) => byId.get(id) ?? [])
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the QUEST builds of the assetbundles live, relative to the PC ones. An assetbundle is
|
||||
* built per Unity target, and a save names its bundle by bare filename for the client to fetch
|
||||
* from the cdn's `/avatar/`; the Android build of the same bundle keeps the same filename one
|
||||
* folder down, so a caller asking for the Quest target is served `quest/<name>.assetbundle` and asks the cdn for
|
||||
* `/avatar/quest/<name>.assetbundle`.
|
||||
*/
|
||||
export const QUEST_ASSET_PREFIX = 'quest/'
|
||||
|
||||
/**
|
||||
* The `unityAssetTarget` a Quest asks with — the Unity build target the caller wants its
|
||||
* assetbundles built for, which the client names on every custom-avatar-item read. 0 is PC
|
||||
* (Windows), and is what the saves' bare names are; 2 has been SEEN from the Android/Oculus
|
||||
* client and is read as that. The rest of the enum has not been observed.
|
||||
*/
|
||||
export const UNITY_ASSET_TARGET_QUEST = 2
|
||||
|
||||
/**
|
||||
* Whether a request's `unityAssetTarget` asks for the Quest builds. Anything else — 0, a
|
||||
* target not seen yet, or none at all — is served the PC names, which is what was served
|
||||
* before targets were read.
|
||||
*/
|
||||
export function isQuestAssetTarget(target: string | null | undefined): boolean {
|
||||
return target?.trim() === String(UNITY_ASSET_TARGET_QUEST)
|
||||
}
|
||||
|
||||
/**
|
||||
* An assetbundle name under {@link QUEST_ASSET_PREFIX}. Only `.assetbundle` names are touched,
|
||||
* and one that already carries the prefix is left alone; null (an absent `UnityAsset2`) is
|
||||
* returned as it came.
|
||||
*/
|
||||
function questAsset<T extends string | null>(name: T): T | string {
|
||||
if (name === null || !name.endsWith('.assetbundle')) return name
|
||||
return name.startsWith(QUEST_ASSET_PREFIX) ? name : QUEST_ASSET_PREFIX + name
|
||||
}
|
||||
|
||||
/**
|
||||
* An item as a caller asking for the Quest target is served it: every save's `UnityAsset`/`UnityAsset2` pointed at
|
||||
* the Quest build (see {@link QUEST_ASSET_PREFIX}). Per-response, never stored — the row keeps
|
||||
* the bare names, which are what every other target is served. The hashes are left as
|
||||
* stored. A player-made shirt has no saves and comes back unchanged.
|
||||
*/
|
||||
export function toQuestCustomAvatarItem(item: CustomAvatarItem): CustomAvatarItem {
|
||||
return {
|
||||
...item,
|
||||
CurrentSaves: item.CurrentSaves.map((save) => ({
|
||||
...save,
|
||||
UnityAsset: questAsset(save.UnityAsset),
|
||||
UnityAsset2: questAsset(save.UnityAsset2),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The featured feed (`GET /api/customAvatarItems/v1/featured`): items flagged
|
||||
* `is_featured` that are also published — `Accessibility` 0 is the unpublished state, so
|
||||
@@ -221,7 +371,7 @@ export async function listFeaturedCustomAvatarItems(
|
||||
): Promise<CustomAvatarItem[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT * FROM custom_avatar_item WHERE is_featured = 1 AND accessibility != 0
|
||||
`SELECT data FROM custom_avatar_item WHERE is_featured = 1 AND accessibility != 0
|
||||
ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1`
|
||||
)
|
||||
.bind(limit)
|
||||
@@ -241,7 +391,7 @@ export async function listHotCustomAvatarItems(
|
||||
): Promise<CustomAvatarItem[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT * FROM custom_avatar_item WHERE accessibility != 0
|
||||
`SELECT data FROM custom_avatar_item WHERE accessibility != 0
|
||||
ORDER BY created_at DESC, custom_avatar_item_id LIMIT ?1`
|
||||
)
|
||||
.bind(limit)
|
||||
@@ -251,9 +401,10 @@ export async function listHotCustomAvatarItems(
|
||||
|
||||
/**
|
||||
* The "Coach" system account — this server's stock content is authored by it, the same id the
|
||||
* `econ` worker attributes a self-buy or an anonymous gift to.
|
||||
* `econ` worker attributes a self-buy or an anonymous gift to. Lives in the loader module so
|
||||
* the CLI can name it without Workers types; re-exported here for the worker-side callers.
|
||||
*/
|
||||
export const COACH_ACCOUNT_ID = 1
|
||||
export { COACH_ACCOUNT_ID }
|
||||
|
||||
/** What `GET /api/customAvatarItems/v1/search` narrows the catalog by. */
|
||||
export interface CustomAvatarItemSearch {
|
||||
@@ -264,8 +415,21 @@ export interface CustomAvatarItemSearch {
|
||||
* the full set of types it can render, so an absent parameter is "everything", not "nothing".
|
||||
*/
|
||||
outfitTypes?: number[]
|
||||
/** Whether items authored by the Coach — this server's stock content — are included. */
|
||||
/**
|
||||
* WHICH SIDE of the catalog to serve, not whether to add one to the other: `true` is the
|
||||
* Coach's stock content ONLY, `false` is player-made content ONLY, and undefined is both.
|
||||
* The client browses the store's two tabs with this — its storefront call says `True`
|
||||
* and its user-generated-content call says `False` — so "include" reads as a toggle
|
||||
* between them, never as a superset.
|
||||
*/
|
||||
includeCoachItems?: boolean
|
||||
/**
|
||||
* Only items this account authored — how the client lists what one player has made (a
|
||||
* profile's creations, `creatorAccountId=<id>&includeCoachItems=False&take=1000`). Combines
|
||||
* with the rest as one more AND; naming the Coach here with `includeCoachItems=false`
|
||||
* matches nothing, as the two conditions read.
|
||||
*/
|
||||
creatorAccountId?: number
|
||||
/** Lowest price to include, inclusive. */
|
||||
minPrice?: number
|
||||
/** Highest price to include, inclusive. */
|
||||
@@ -286,15 +450,23 @@ export const SEARCH_MAX_TAKE = 200
|
||||
* surface everyone shares, so an unpublished item must not appear here even to its creator (who
|
||||
* has `fromCreator` for that).
|
||||
*
|
||||
* `searchQuery` matches an item's NAME or its DESCRIPTION, case-insensitively, as a substring.
|
||||
* Both sides are lowered rather than relying on `LIKE`, which folds case for ASCII only and
|
||||
* would miss half of what players type. `%` and `_` in the needle are escaped, so searching for
|
||||
* a literal one finds it instead of matching everything.
|
||||
* `searchQuery` matches an item's NAME or its DESCRIPTION, case-insensitively, as a substring,
|
||||
* or its `CustomAvatarItemId` whole. Both sides are lowered rather than relying on `LIKE`, which
|
||||
* folds case for ASCII only and would miss half of what players type. `%` and `_` in the needle
|
||||
* are escaped, so searching for a literal one finds it instead of matching everything.
|
||||
*
|
||||
* `outfitTypes` is a WHITELIST when non-empty and no filter when empty, which is the opposite of
|
||||
* how an empty IN () clause reads in SQL: the client sends every type it can render, so treating
|
||||
* an absent parameter as "match nothing" would empty the store.
|
||||
*
|
||||
* `includeCoachItems` SPLITS the catalog by author rather than widening it: `true` is the Coach's
|
||||
* items alone, `false` everyone else's alone, absent both. The client's storefront tab sends
|
||||
* `True` and its user-generated-content tab sends `False`, and the two must not overlap — read
|
||||
* as "stock plus players'", the storefront would show every player's item too.
|
||||
*
|
||||
* `creatorAccountId` narrows to one author. It was ignored until it was noticed that a player's
|
||||
* creations list (which sends it) was serving the whole player-made catalog under every name.
|
||||
*
|
||||
* Ordered by recency because there is nothing else to order by — no purchase counts, no wear
|
||||
* counts, no ratings are recorded — which is the same stand-in the `hot` feed makes. The
|
||||
* `custom_avatar_item_id` tiebreak is what makes paging stable: without it, two items sharing a
|
||||
@@ -318,8 +490,13 @@ export async function searchCustomAvatarItems(
|
||||
// Escaped so a needle of LIKE metacharacters matches them literally rather than everything.
|
||||
const escaped = needle.toLowerCase().replace(/[\\%_]/g, (ch) => `\\${ch}`)
|
||||
const pattern = bind(`%${escaped}%`)
|
||||
// `name_lower`/`description_lower` are generated columns, lowered once at the row rather
|
||||
// than per query. An id typed in whole finds its item too, since the client searches by
|
||||
// `CustomAvatarItemId` as well as by text.
|
||||
const exact = bind(needle.toLowerCase())
|
||||
where.push(
|
||||
`(lower(name) LIKE ${pattern} ESCAPE '\\' OR lower(description) LIKE ${pattern} ESCAPE '\\')`
|
||||
`(name_lower LIKE ${pattern} ESCAPE '\\' OR description_lower LIKE ${pattern} ESCAPE '\\'` +
|
||||
` OR lower(custom_avatar_item_id) = ${exact})`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -327,9 +504,14 @@ export async function searchCustomAvatarItems(
|
||||
if (outfitTypes.length > 0) {
|
||||
where.push(`outfit_type IN (${outfitTypes.map((t) => bind(t)).join(', ')})`)
|
||||
}
|
||||
if (search.includeCoachItems === false) {
|
||||
if (search.includeCoachItems === true) {
|
||||
where.push(`creator_account_id = ${bind(COACH_ACCOUNT_ID)}`)
|
||||
} else if (search.includeCoachItems === false) {
|
||||
where.push(`creator_account_id != ${bind(COACH_ACCOUNT_ID)}`)
|
||||
}
|
||||
if (search.creatorAccountId !== undefined) {
|
||||
where.push(`creator_account_id = ${bind(search.creatorAccountId)}`)
|
||||
}
|
||||
if (search.minPrice !== undefined) where.push(`price >= ${bind(search.minPrice)}`)
|
||||
if (search.maxPrice !== undefined) where.push(`price <= ${bind(search.maxPrice)}`)
|
||||
|
||||
@@ -337,7 +519,7 @@ export async function searchCustomAvatarItems(
|
||||
const offset = bind(skip)
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT * FROM custom_avatar_item WHERE ${where.join(' AND ')}
|
||||
`SELECT data FROM custom_avatar_item WHERE ${where.join(' AND ')}
|
||||
ORDER BY created_at DESC, custom_avatar_item_id
|
||||
LIMIT ${limit} OFFSET ${offset}`
|
||||
)
|
||||
@@ -360,7 +542,7 @@ export async function listCustomAvatarItemsByCreator(
|
||||
): Promise<{ Results: CustomAvatarItem[]; TotalResults: number }> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT * FROM custom_avatar_item
|
||||
`SELECT data FROM custom_avatar_item
|
||||
WHERE creator_account_id = ?1 AND (accessibility != 0 OR ?2)
|
||||
ORDER BY created_at DESC, custom_avatar_item_id`
|
||||
)
|
||||
@@ -379,8 +561,9 @@ export interface UpdateCustomAvatarItemInput {
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a partial edit to one item, bumping `modified_at`. Fields the caller leaves
|
||||
* null keep their value (the client sends every field, nulling the untouched ones).
|
||||
* Applies a partial edit to one item in place with `json_set`, bumping `ModifiedAt`. Fields
|
||||
* the caller leaves null keep their value (the client sends every field, nulling the
|
||||
* untouched ones).
|
||||
* Returns the updated item, or null when no row has that id.
|
||||
*/
|
||||
export async function updateCustomAvatarItem(
|
||||
@@ -391,14 +574,14 @@ export async function updateCustomAvatarItem(
|
||||
): Promise<CustomAvatarItem | null> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`UPDATE custom_avatar_item SET
|
||||
name = COALESCE(?2, name),
|
||||
description = COALESCE(?3, description),
|
||||
price = COALESCE(?4, price),
|
||||
accessibility = COALESCE(?5, accessibility),
|
||||
modified_at = ?6
|
||||
`UPDATE custom_avatar_item SET data = json_set(data,
|
||||
'$.Name', coalesce(?2, json_extract(data, '$.Name')),
|
||||
'$.Description', coalesce(?3, json_extract(data, '$.Description')),
|
||||
'$.Price', coalesce(?4, json_extract(data, '$.Price')),
|
||||
'$.Accessibility', coalesce(?5, json_extract(data, '$.Accessibility')),
|
||||
'$.ModifiedAt', ?6)
|
||||
WHERE custom_avatar_item_id = ?1
|
||||
RETURNING *`
|
||||
RETURNING data`
|
||||
)
|
||||
.bind(
|
||||
id,
|
||||
@@ -418,7 +601,7 @@ export async function deleteCustomAvatarItem(
|
||||
id: string
|
||||
): Promise<CustomAvatarItem | null> {
|
||||
const row = await db
|
||||
.prepare('DELETE FROM custom_avatar_item WHERE custom_avatar_item_id = ?1 RETURNING *')
|
||||
.prepare('DELETE FROM custom_avatar_item WHERE custom_avatar_item_id = ?1 RETURNING data')
|
||||
.bind(id)
|
||||
.first<Row>()
|
||||
return row ? toDto(row) : null
|
||||
@@ -430,7 +613,7 @@ export async function getCustomAvatarItem(
|
||||
id: string
|
||||
): Promise<CustomAvatarItem | null> {
|
||||
const row = await db
|
||||
.prepare('SELECT * FROM custom_avatar_item WHERE custom_avatar_item_id = ?1')
|
||||
.prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id = ?1')
|
||||
.bind(id)
|
||||
.first<Row>()
|
||||
return row ? toDto(row) : null
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Turning an export of custom avatar items into `custom_avatar_item` rows — the loader half of
|
||||
* the table, used by `runx admin cai-load` (in @repo/tools) and by the migration generator
|
||||
* (scripts/import-custom-avatar-items.ts) rather than by the worker.
|
||||
*
|
||||
* Separate from `custom-avatar-items-db.ts` for the reason `catalog-load.ts` is separate from
|
||||
* `catalog-db.ts` in econ: that module types its queries with `D1Database`, a Workers type,
|
||||
* and the loader runs in a plain Node CLI that has no such types. Everything here is pure data
|
||||
* mapping with no imports, so both sides can use it.
|
||||
*
|
||||
* A row is the record as JSON (see 0022_custom_avatar_item_json.sql), so a load is the export
|
||||
* verbatim: no per-field mapping, and a field the source adds later rides along. Three things
|
||||
* change on the way in: `PurchaseInfo`, a store-side projection the worker fills at read time,
|
||||
* is stripped; `CreatorAccountId` is FORCED to the Coach account; and each save's
|
||||
* `ThumbnailFileName` is put under the `avatar/` prefix — see {@link customAvatarItemRowLiteral}.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The "Coach" system account — this server's stock content is authored by it, the same id the
|
||||
* `econ` worker attributes a self-buy or an anonymous gift to. Defined here rather than in
|
||||
* `custom-avatar-items-db.ts` (which re-exports it) so the loader can name it without pulling
|
||||
* in Workers types.
|
||||
*/
|
||||
export const COACH_ACCOUNT_ID = 1
|
||||
|
||||
/**
|
||||
* Where a save's thumbnail is served from. The export names it bare
|
||||
* (`f2y1ndzuvm5ke2hjmn4cwfwfl.png`); this server keeps those images under `avatar/` in the
|
||||
* image bucket, so the name is prefixed on the way in and the client asks for the right key.
|
||||
*/
|
||||
export const SAVE_THUMBNAIL_PREFIX = 'avatar/'
|
||||
|
||||
/** The least an export record must carry to be a row. Everything else is kept as-is. */
|
||||
export interface CustomAvatarItemExportRecord {
|
||||
CustomAvatarItemId: string
|
||||
PurchaseInfo?: unknown
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* The records in an export, whether it is a bare JSON array, a `{ Results: [...] }` page, or a
|
||||
* single record. Refuses a record without an id and a repeated id — a repeat in one upsert
|
||||
* statement is an error at the database, and a repeat across statements would silently keep
|
||||
* whichever came last, which is a defect in the export rather than a choice to make for it.
|
||||
*/
|
||||
export function readCustomAvatarItemExport(parsed: unknown): CustomAvatarItemExportRecord[] {
|
||||
const records: unknown[] = Array.isArray(parsed)
|
||||
? parsed
|
||||
: parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
Array.isArray((parsed as { Results?: unknown }).Results)
|
||||
? (parsed as { Results: unknown[] }).Results
|
||||
: [parsed]
|
||||
const seen = new Set<string>()
|
||||
return records.map((record, i) => {
|
||||
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
||||
throw new Error(`record ${i} is not an object`)
|
||||
}
|
||||
const id = (record as { CustomAvatarItemId?: unknown }).CustomAvatarItemId
|
||||
if (typeof id !== 'string' || id === '') {
|
||||
throw new Error(`record ${i} has no CustomAvatarItemId`)
|
||||
}
|
||||
if (seen.has(id)) throw new Error(`duplicate CustomAvatarItemId in export: ${id}`)
|
||||
seen.add(id)
|
||||
return record as CustomAvatarItemExportRecord
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* A save's `ThumbnailFileName` under {@link SAVE_THUMBNAIL_PREFIX}. Idempotent — a name that
|
||||
* already carries the prefix is left alone — so loading a file that was itself dumped from
|
||||
* this table does not stack a second `avatar/`. Anything that is not a non-empty string
|
||||
* (null, absent) is returned as it came.
|
||||
*/
|
||||
export function prefixSaveThumbnail(name: unknown): unknown {
|
||||
if (typeof name !== 'string' || name === '') return name
|
||||
return name.startsWith(SAVE_THUMBNAIL_PREFIX) ? name : SAVE_THUMBNAIL_PREFIX + name
|
||||
}
|
||||
|
||||
/**
|
||||
* A save's assetbundle hash as it is stored: BLANK. The export's `UnityAssetHash` /
|
||||
* `UnityAsset2Hash` are the hashes of the PC builds, and one stored save is served to every
|
||||
* build target — a Quest asking with `unityAssetTarget=2` is pointed at the `quest/` build of
|
||||
* the same bundle, whose bytes (and so whose hash) differ. The client checks a download against
|
||||
* a hash it is given and refuses the mismatch, so the Quest rendered nothing until these were
|
||||
* emptied by hand; given `""` it skips the check. A hash that is already null — the
|
||||
* `UnityAsset2Hash` of a save with no second bundle — stays null, so the pair keeps agreeing.
|
||||
*/
|
||||
export function blankAssetHash(hash: unknown): unknown {
|
||||
return typeof hash === 'string' ? '' : hash
|
||||
}
|
||||
|
||||
/**
|
||||
* One record as the SQL string literal its row holds: the JSON, minus `PurchaseInfo`, with
|
||||
* three things rewritten.
|
||||
*
|
||||
* `CreatorAccountId` is overridden to {@link COACH_ACCOUNT_ID} whatever the export said. An
|
||||
* imported item is first-party content by definition, and the creator id is what makes it so
|
||||
* here: the storefront tab finds stock items by `creator_account_id = 1`
|
||||
* (`includeCoachItems=True`), and a sale pays the creator. An export from another server
|
||||
* carries THAT server's creator ids — an item filed under one of those would vanish from the
|
||||
* storefront into the user-generated tab, and its price would be paid to whichever local
|
||||
* account happened to share the number.
|
||||
*
|
||||
* Each save's `ThumbnailFileName` is prefixed with {@link SAVE_THUMBNAIL_PREFIX}, which is where
|
||||
* this server serves those images from, and its `UnityAssetHash`/`UnityAsset2Hash` are blanked
|
||||
* (see {@link blankAssetHash}). The rest of the save — the assetbundle names above all — is
|
||||
* untouched.
|
||||
*/
|
||||
export function customAvatarItemRowLiteral(record: CustomAvatarItemExportRecord): string {
|
||||
const { PurchaseInfo: _purchaseInfo, ...stored } = record
|
||||
const saves = Array.isArray(stored.CurrentSaves)
|
||||
? stored.CurrentSaves.map((save: unknown) => {
|
||||
if (!save || typeof save !== 'object' || Array.isArray(save)) return save
|
||||
const fields = save as Record<string, unknown>
|
||||
return {
|
||||
...fields,
|
||||
ThumbnailFileName: prefixSaveThumbnail(fields.ThumbnailFileName),
|
||||
// Only where the export carries the key, so a save is never GIVEN a field.
|
||||
...('UnityAssetHash' in fields && {
|
||||
UnityAssetHash: blankAssetHash(fields.UnityAssetHash),
|
||||
}),
|
||||
...('UnityAsset2Hash' in fields && {
|
||||
UnityAsset2Hash: blankAssetHash(fields.UnityAsset2Hash),
|
||||
}),
|
||||
}
|
||||
})
|
||||
: stored.CurrentSaves
|
||||
const row = { ...stored, CreatorAccountId: COACH_ACCOUNT_ID, CurrentSaves: saves }
|
||||
return `'${JSON.stringify(row).replaceAll("'", "''")}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* The largest statement a load builds, in bytes of SQL. D1 caps a single statement at 100 KB,
|
||||
* and a record with three built saves runs to a few KB, so rows are grouped by size rather
|
||||
* than by count — a fixed count would fit the small records and overflow on the large ones.
|
||||
*/
|
||||
export const CUSTOM_AVATAR_ITEM_LOAD_STATEMENT_BYTES = 64 * 1024
|
||||
|
||||
/**
|
||||
* The statements that upsert `records` into `custom_avatar_item`, each a multi-row INSERT
|
||||
* within {@link CUSTOM_AVATAR_ITEM_LOAD_STATEMENT_BYTES}. An id already in the table has its
|
||||
* row REPLACED (`ON CONFLICT ... DO UPDATE`), so a re-run with a corrected export lands and a
|
||||
* re-run with the same one changes nothing. Nothing is deleted: rows the export does not
|
||||
* mention — the players' own shirts among them — are left alone.
|
||||
*/
|
||||
export function customAvatarItemUpsertStatements(
|
||||
records: CustomAvatarItemExportRecord[],
|
||||
maxBytes = CUSTOM_AVATAR_ITEM_LOAD_STATEMENT_BYTES
|
||||
): string[] {
|
||||
const head = 'INSERT INTO custom_avatar_item (data) VALUES\n'
|
||||
const tail = '\nON CONFLICT(custom_avatar_item_id) DO UPDATE SET data = excluded.data;'
|
||||
const statements: string[] = []
|
||||
let rows: string[] = []
|
||||
let bytes = 0
|
||||
const flush = (): void => {
|
||||
if (rows.length === 0) return
|
||||
statements.push(head + rows.map((r) => `\t(${r})`).join(',\n') + tail)
|
||||
rows = []
|
||||
bytes = 0
|
||||
}
|
||||
for (const record of records) {
|
||||
const literal = customAvatarItemRowLiteral(record)
|
||||
// A single oversized record still gets its own statement rather than being dropped.
|
||||
if (rows.length > 0 && bytes + literal.length > maxBytes) flush()
|
||||
rows.push(literal)
|
||||
bytes += literal.length + 4
|
||||
}
|
||||
flush()
|
||||
return statements
|
||||
}
|
||||
+193
-43
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
Accessibility,
|
||||
glyphLength,
|
||||
MAX_EVENT_DESCRIPTION_LENGTH,
|
||||
MAX_EVENT_DURATION_MS,
|
||||
@@ -25,8 +26,8 @@ import {
|
||||
} from '@repo/domain'
|
||||
|
||||
/**
|
||||
* Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql, sans any
|
||||
* seed rows).
|
||||
* Schema DDL (mirror of migrations/0006_event.sql + 0007_event_attendee.sql +
|
||||
* 0010_event_tag.sql + 0028_event_id_seq.sql, sans any seed rows).
|
||||
*/
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS event (
|
||||
@@ -58,6 +59,12 @@ export const SCHEMA_DDL: string[] = [
|
||||
PRIMARY KEY (event_id, tag)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_event_tag_tag ON event_tag (tag)`,
|
||||
// The id sequence: AUTOINCREMENT so an id is never handed out twice, whatever gets
|
||||
// deleted. See {@link nextEventId}. The migration also seeds it past the existing
|
||||
// events; on a fresh database there are none, so the seed is omitted here.
|
||||
`CREATE TABLE IF NOT EXISTS event_id_seq (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||
)`,
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -67,19 +74,29 @@ export const SCHEMA_DDL: string[] = [
|
||||
* Only `going` counts toward an event's `AttendeeCount`: interested is a maybe, and
|
||||
* declining is recorded rather than deleted so the client can show the player their own
|
||||
* answer (and so changing your mind is an update, not an insert).
|
||||
*
|
||||
* `pending` is not an answer: it's the row a bulk invite writes for the invitee, before
|
||||
* they've said anything. It's what the client shows as an open invitation — an invite
|
||||
* that landed as Going looked like the player had already accepted. Only the server
|
||||
* writes it; `respond` won't take it as a `Type`.
|
||||
*/
|
||||
export const EVENT_RESPONSE = {
|
||||
going: 0,
|
||||
interested: 1,
|
||||
cantGo: 2,
|
||||
pending: 3,
|
||||
} as const
|
||||
|
||||
/** The response types, for validating an incoming `Type`. */
|
||||
const EVENT_RESPONSE_VALUES: number[] = Object.values(EVENT_RESPONSE)
|
||||
/** The answers a player may give — every type but `pending`, for validating an incoming `Type`. */
|
||||
const EVENT_ANSWER_VALUES: number[] = [
|
||||
EVENT_RESPONSE.going,
|
||||
EVENT_RESPONSE.interested,
|
||||
EVENT_RESPONSE.cantGo,
|
||||
]
|
||||
|
||||
/** Whether a number is one of the three response types. */
|
||||
/** Whether a number is one of the three answers a player can give (never `pending`). */
|
||||
export function isEventResponseType(value: number): boolean {
|
||||
return EVENT_RESPONSE_VALUES.includes(value)
|
||||
return EVENT_ANSWER_VALUES.includes(value)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,9 +191,9 @@ interface EventRow {
|
||||
}
|
||||
|
||||
/**
|
||||
* A tag as the 2023 build's `v2` envelope carries it: the PascalCase form of the stored
|
||||
* `{ tag, type }` pair. NOT the lowercase pair the v1 read serves — three casings of one
|
||||
* tag, and the client parses each in exactly one place.
|
||||
* A tag as the `v2` envelope carries it to a build newer than 20230414: the PascalCase
|
||||
* form of the stored `{ tag, type }` pair. NOT the lowercase pair the v1 read serves —
|
||||
* three casings of one tag, and the client parses each in exactly one place.
|
||||
*/
|
||||
export interface PlayerEventEnvelopeTag {
|
||||
Tag: string
|
||||
@@ -190,9 +207,11 @@ export interface PlayerEventEnvelopeTag {
|
||||
*
|
||||
* `Tags` is the one field whose shape depends on the caller's BUILD, because Rec Room
|
||||
* changed it under the same unversioned path rather than minting a `v3`: the 2023 build
|
||||
* parses `[{ Tag, Type }]` and the 2025 build parses `["celebration"]`. Serving either
|
||||
* one to the other build leaves the event's tag chips empty — the decoder drops what it
|
||||
* can't read rather than erroring. {@link toEventResult} picks; nothing else should.
|
||||
* (20230414 and older) parses `["celebration"]` and anything newer parses
|
||||
* `[{ Tag, Type }]`. Serving either one to the other build leaves the event's tag chips
|
||||
* empty — the decoder drops what it can't read rather than erroring.
|
||||
* {@link toEventResult} picks; nothing else should. `TagModifyResult.Tags` is NOT part of
|
||||
* this: it is a `List<string>` to every build.
|
||||
*/
|
||||
export interface PlayerEventEnvelope extends PlayerEventBase {
|
||||
Tags: string[] | PlayerEventEnvelopeTag[]
|
||||
@@ -218,17 +237,18 @@ export interface PlayerEventResult {
|
||||
* tags — pass what `getEventTags` returns, so the answer reflects what was actually
|
||||
* written rather than what was asked for.
|
||||
*
|
||||
* `legacyTags` picks the shape of `PlayerEvent.Tags` for the caller's build (see
|
||||
* {@link PlayerEventEnvelope}): the 2023 pairs when set, the 2025 names when not. It
|
||||
* changes nothing else — `TagModifyResult.Tags` is a name list to both builds.
|
||||
* `pairedTags` picks the shape of `PlayerEvent.Tags` for the caller's build (see
|
||||
* {@link PlayerEventEnvelope}): the `{ Tag, Type }` pairs of a build newer than 20230414
|
||||
* when set, the 2023 build's bare names when not. It changes nothing else —
|
||||
* `TagModifyResult.Tags` is a name list to both builds.
|
||||
*/
|
||||
export function toEventResult(
|
||||
event: PlayerEvent,
|
||||
tags: EventTag[] = [],
|
||||
legacyTags = false
|
||||
pairedTags = false
|
||||
): PlayerEventResult {
|
||||
const names = tags.map((t) => t.tag)
|
||||
const carried = legacyTags ? tags.map((t) => ({ Tag: t.tag, Type: t.type })) : names
|
||||
const carried = pairedTags ? tags.map((t) => ({ Tag: t.tag, Type: t.type })) : names
|
||||
return {
|
||||
PlayerEvent: { Tags: carried, ...toEventBase(event) },
|
||||
Result: 0,
|
||||
@@ -620,14 +640,11 @@ export async function createEvent(
|
||||
creatorPlayerId: number,
|
||||
input: EventInput
|
||||
): Promise<PlayerEvent> {
|
||||
// Sequential id: one past the current max (the table starts empty).
|
||||
const row = await db
|
||||
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM event')
|
||||
.first<{ next: number }>()
|
||||
const id = await nextEventId(db)
|
||||
const now = Date.now()
|
||||
const startTime = input.startTime ?? eventTime(now)
|
||||
const event: PlayerEvent = {
|
||||
PlayerEventId: row?.next ?? 1,
|
||||
PlayerEventId: id,
|
||||
CreatorPlayerId: creatorPlayerId,
|
||||
ImageName: input.imageName ?? null,
|
||||
RoomId: input.roomId ?? 0,
|
||||
@@ -665,46 +682,51 @@ export async function createEvent(
|
||||
* per player per event, so changing your mind is an update rather than a second RSVP.
|
||||
* The event's `AttendeeCount` is recomputed from the table afterwards.
|
||||
*
|
||||
* Returns the updated event, or null when there's no such event. Anyone who can see an
|
||||
* event may respond to it, the creator included (they're already Going from create, and
|
||||
* nothing stops them declining their own event).
|
||||
* Returns the updated event and the player's response row as it now stands (the route
|
||||
* pushes that row on the `PlayerEventResponseChanged` frame), or null when there's no
|
||||
* such event. Anyone who can see an event may respond to it, the creator included
|
||||
* (they're already Going from create, and nothing stops them declining their own event).
|
||||
*/
|
||||
export async function setEventResponse(
|
||||
db: D1Database,
|
||||
eventId: number,
|
||||
playerId: number,
|
||||
status: number
|
||||
): Promise<PlayerEvent | null> {
|
||||
): Promise<{ event: PlayerEvent; response: EventAttendeeRow } | null> {
|
||||
const event = await getEventById(db, eventId)
|
||||
if (event === null) return null
|
||||
|
||||
await db
|
||||
const response = await db
|
||||
.prepare(
|
||||
`INSERT INTO event_attendee (event_id, player_id, status, responded_at)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT (event_id, player_id) DO UPDATE SET status = ?3, responded_at = ?4`
|
||||
ON CONFLICT (event_id, player_id) DO UPDATE SET status = ?3, responded_at = ?4
|
||||
RETURNING rowid AS id, *`
|
||||
)
|
||||
.bind(eventId, playerId, status, eventTime(Date.now()))
|
||||
.run()
|
||||
.first<EventAttendeeRow>()
|
||||
|
||||
const updated: PlayerEvent = { ...event, AttendeeCount: await countGoing(db, eventId) }
|
||||
await writeEvent(db, updated)
|
||||
return updated
|
||||
// An upsert with RETURNING always yields the row it inserted or updated.
|
||||
return { event: updated, response: response! }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add invited players to an event as Going — the bulk invite. Returns the updated
|
||||
* Add invited players to an event as Pending — 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.
|
||||
* gave, so being invited can't reset an answer to Pending, and re-inviting the same
|
||||
* player is a no-op. The rows land as Pending (3), not Going: the invitee hasn't
|
||||
* accepted anything yet, so they don't count toward `AttendeeCount` until they answer
|
||||
* through `respond` — 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.
|
||||
* inserts nothing and returns nothing. That's who the route sends the invitation Message
|
||||
* to — a player whose existing answer was left alone gets nothing, 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.
|
||||
@@ -728,7 +750,7 @@ export async function inviteToEvent(
|
||||
ON CONFLICT (event_id, player_id) DO NOTHING
|
||||
RETURNING rowid AS id, *`
|
||||
)
|
||||
.bind(eventId, playerId, EVENT_RESPONSE.going, at)
|
||||
.bind(eventId, playerId, EVENT_RESPONSE.pending, at)
|
||||
)
|
||||
)
|
||||
const added = inserts.flatMap((r) => r.results)
|
||||
@@ -738,6 +760,22 @@ export async function inviteToEvent(
|
||||
return { event: updated, added }
|
||||
}
|
||||
|
||||
/**
|
||||
* The players who said they're GOING to an event — who the `PlayerEventUpdated` and
|
||||
* `PlayerEventDeleted` frames go to. Interested, declined and Pending rows are left out:
|
||||
* an invitee who hasn't accepted isn't attending, so nothing about the event has changed
|
||||
* for them. The creator is included through their own Going row from create.
|
||||
*/
|
||||
export async function getGoingPlayerIds(db: D1Database, eventId: number): Promise<number[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
'SELECT player_id FROM event_attendee WHERE event_id = ?1 AND status = ?2 ORDER BY player_id'
|
||||
)
|
||||
.bind(eventId, EVENT_RESPONSE.going)
|
||||
.all<{ player_id: number }>()
|
||||
return results.map((r) => r.player_id)
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -829,12 +867,31 @@ export async function updateEvent(
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the next event id. Sequential and NEVER reused: the id comes from the
|
||||
* `event_id_seq` AUTOINCREMENT counter, which only ever goes up, rather than from
|
||||
* `MAX(id) + 1` over the events themselves — that handed the newest event's id straight
|
||||
* back out once it was deleted, so a replacement event wore the old one's id and
|
||||
* inherited whatever was still filed under it (an invitation message's `PlayerEventId`,
|
||||
* the client's cache). The drawn row is thrown away in the same batch; only the counter
|
||||
* matters, and SQLite keeps that in `sqlite_sequence` regardless of the rows.
|
||||
*/
|
||||
async function nextEventId(db: D1Database): Promise<number> {
|
||||
const [drawn] = await db.batch<{ id: number }>([
|
||||
db.prepare('INSERT INTO event_id_seq DEFAULT VALUES RETURNING id'),
|
||||
db.prepare('DELETE FROM event_id_seq'),
|
||||
])
|
||||
const id = drawn.results[0]?.id
|
||||
if (id === undefined) throw new Error('event_id_seq returned no id')
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an event and everything hanging off it — its RSVPs (`event_attendee`) and its tags
|
||||
* (`event_tag`) — in one batch, so a cancelled event can't leave rows behind that the
|
||||
* attendee counts and the `#tag` search would still find. Event ids are assigned in
|
||||
* sequence and never reused, but orphan rows would still be counted against whatever id
|
||||
* they name.
|
||||
* attendee counts and the `#tag` search would still find. Event ids are drawn from a
|
||||
* sequence that never reuses one (see {@link nextEventId}), so a deleted event's id is
|
||||
* retired with it — but orphan rows would still be counted against whatever id they name.
|
||||
*
|
||||
* Answers the event as it was, so the caller can report what it deleted; `null` when there
|
||||
* was no such event.
|
||||
@@ -897,6 +954,72 @@ export async function getEventsByCreator(
|
||||
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry of a profile's `Responses` — the event beside the player's RSVP to it. The
|
||||
* event is the 17-key BASE shape (the client's v1 PlayerEvent), not the stored record.
|
||||
*/
|
||||
export interface PlayerEventWithResponse {
|
||||
PlayerEvent: PlayerEventBase
|
||||
PlayerEventResponse: PlayerEventResponse
|
||||
}
|
||||
|
||||
/** What anyone may see of a player's events — `GET /api/playerevents/v1/all/{playerId}`. */
|
||||
export interface PlayerPublicEvents {
|
||||
Created: PlayerEventBase[]
|
||||
Responses: PlayerEventWithResponse[]
|
||||
}
|
||||
|
||||
/**
|
||||
* A player's PUBLIC events, as their profile shows them to anyone: the ones they created,
|
||||
* and the ones they are attending — answered Going; a maybe, a decline and an unanswered
|
||||
* invitation are nobody else's business — each soonest first. An event's creator holds a
|
||||
* Going row for it, so their own events appear under both keys.
|
||||
*
|
||||
* Only `Accessibility` Public events are served, and FINISHED ones are left out, as the
|
||||
* browse feed and the room shelf do: this is what the player is going to, not a history.
|
||||
*/
|
||||
export async function getPublicEventsByPlayer(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
now = Date.now()
|
||||
): Promise<PlayerPublicEvents> {
|
||||
const at = eventTime(now)
|
||||
const [created, responses] = await db.batch<EventRow & Partial<EventAttendeeRow>>([
|
||||
db
|
||||
.prepare(
|
||||
`SELECT data FROM event
|
||||
WHERE creator_player_id = ?1 AND end_time >= ?2
|
||||
AND json_extract(data, '$.Accessibility') = ?3`
|
||||
)
|
||||
.bind(playerId, at, Accessibility.Public),
|
||||
db
|
||||
.prepare(
|
||||
`SELECT e.data AS data, a.rowid AS id, a.event_id, a.player_id, a.status, a.responded_at
|
||||
FROM event_attendee a
|
||||
JOIN event e ON e.id = a.event_id
|
||||
WHERE a.player_id = ?1 AND a.status = ?2 AND e.end_time >= ?3
|
||||
AND json_extract(e.data, '$.Accessibility') = ?4`
|
||||
)
|
||||
.bind(playerId, EVENT_RESPONSE.going, at, Accessibility.Public),
|
||||
])
|
||||
return {
|
||||
Created: created.results
|
||||
.map((r) => JSON.parse(r.data) as PlayerEvent)
|
||||
.sort(bySoonest)
|
||||
.map(toEventBase),
|
||||
Responses: responses.results
|
||||
.map((r) => ({
|
||||
event: JSON.parse(r.data) as PlayerEvent,
|
||||
response: toEventResponse(r as EventAttendeeRow),
|
||||
}))
|
||||
.sort((a, b) => bySoonest(a.event, b.event))
|
||||
.map(({ event, response }) => ({
|
||||
PlayerEvent: toEventBase(event),
|
||||
PlayerEventResponse: response,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The events belonging to a set of clubs — the events shelf on a club's page, soonest
|
||||
* first. Selected on the indexed club_id column. An empty id list is an empty shelf
|
||||
@@ -956,9 +1079,35 @@ function bySoonest(a: PlayerEvent, b: PlayerEvent): number {
|
||||
return a.StartTime.localeCompare(b.StartTime) || a.PlayerEventId - b.PlayerEventId
|
||||
}
|
||||
|
||||
/**
|
||||
* Best attended first — the count of players who answered Going (`AttendeeCount`, the
|
||||
* number the client shows), which is exactly what the sort is meant to rank. Interested
|
||||
* and Can't-go don't count, as they don't toward the displayed count either. Ties fall
|
||||
* back to soonest first so the order stays stable across pages.
|
||||
*/
|
||||
function byAttendance(a: PlayerEvent, b: PlayerEvent): number {
|
||||
return b.AttendeeCount - a.AttendeeCount || bySoonest(a, b)
|
||||
}
|
||||
|
||||
/**
|
||||
* The orders the search serves, by the `sort` the client sends: `StartTime` (soonest
|
||||
* first, the default) and `Attendance` (most Going replies first).
|
||||
*/
|
||||
export type EventSort = 'StartTime' | 'Attendance'
|
||||
|
||||
/**
|
||||
* Reads the `sort` query into an {@link EventSort}, case-insensitively. Anything
|
||||
* unrecognised — or nothing at all — is the default order, soonest first, rather than
|
||||
* a 400: the browse screen has always sent this param and been served regardless.
|
||||
*/
|
||||
export function parseEventSort(raw: string | undefined): EventSort {
|
||||
return raw?.toLowerCase() === 'attendance' ? 'Attendance' : 'StartTime'
|
||||
}
|
||||
|
||||
/**
|
||||
* Event search — the browse query on the player-events screen. Term by term, an empty
|
||||
* query browsing everything upcoming; paginated via skip/take, soonest first.
|
||||
* query browsing everything upcoming; paginated via skip/take, in the order `sort`
|
||||
* names ({@link EventSort}): soonest first by default, or most Going replies first.
|
||||
*
|
||||
* A term is matched one of two ways, and the `#` decides which:
|
||||
*
|
||||
@@ -979,7 +1128,8 @@ export async function searchEvents(
|
||||
db: D1Database,
|
||||
query: string,
|
||||
skip: number,
|
||||
take: number
|
||||
take: number,
|
||||
sort: EventSort = 'StartTime'
|
||||
): Promise<PlayerEvent[]> {
|
||||
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
||||
// A `#` prefix makes a term a tag; the rest are matched against the text. A bare `#`
|
||||
@@ -1011,5 +1161,5 @@ export async function searchEvents(
|
||||
)
|
||||
}
|
||||
|
||||
return events.sort(bySoonest).slice(skip, skip + take)
|
||||
return events.sort(sort === 'Attendance' ? byAttendance : bySoonest).slice(skip, skip + take)
|
||||
}
|
||||
|
||||
+155
-25
@@ -18,14 +18,19 @@
|
||||
* into the caller's own list, and to rank the "top today" feed by what players actually
|
||||
* picked up today. See @repo/domain's inventory-invention-db.ts.
|
||||
*/
|
||||
import { getInventionAcquisitionCounts, getOwnedInventionIds } from '@repo/domain'
|
||||
import {
|
||||
getAccountByUsername,
|
||||
getInventionAcquisitionCounts,
|
||||
getOwnedInventionIds,
|
||||
} from '@repo/domain'
|
||||
|
||||
/**
|
||||
* Schema DDL (mirror of migrations/0002_invention.sql + 0003_invention_featured.sql +
|
||||
* 0008_invention_visibility.sql, sans any seed rows). `is_featured` backs the featured
|
||||
* feed's query and `is_published`/`hide_from_player` most of the "may anyone see this"
|
||||
* filter every feed shares (see `VISIBLE_IN_FEEDS`, which also excludes unlisted ones); json_extract of a JSON `true` is 1, so those columns are 1/0 — and
|
||||
* NULL when the key is missing, which fails a `= 1` or `= 0` test either way.
|
||||
* feed's query and `is_published`/`hide_from_player` most of the "is this in the store"
|
||||
* filter every feed shares (see `VISIBLE_IN_FEEDS`, which also excludes unlisted ones);
|
||||
* json_extract of a JSON `true` is 1, so those columns are 1/0 — and NULL when the key is
|
||||
* missing, which fails a `= 1` or `= 0` test either way.
|
||||
*/
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS invention (
|
||||
@@ -39,6 +44,13 @@ export const SCHEMA_DDL: string[] = [
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_invention_id ON invention (id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_invention_creator ON invention (creator_player_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_invention_featured ON invention (is_featured)`,
|
||||
// Backs `searchInventions`' newest-first paging (migrations/0027). Its WHERE must match
|
||||
// `VISIBLE_IN_FEEDS` term for term or SQLite won't use it.
|
||||
`CREATE INDEX IF NOT EXISTS idx_invention_feed_newest
|
||||
ON invention (json_extract(data, '$.CreatedAt') DESC, id DESC)
|
||||
WHERE is_published = 1
|
||||
AND hide_from_player = 0
|
||||
AND COALESCE(json_extract(data, '$.Accessibility'), 0) <> 2`,
|
||||
`CREATE TABLE IF NOT EXISTS invention_interaction (
|
||||
player_id INTEGER NOT NULL,
|
||||
invention_id INTEGER NOT NULL,
|
||||
@@ -314,11 +326,20 @@ export function toInventionV9(invention: SavedInvention): InventionV9Dto {
|
||||
Name: invention.Name,
|
||||
Description: invention.Description,
|
||||
ImageName: invention.ImageName,
|
||||
UgcVersion: invention.UgcVersion ?? 0,
|
||||
// A record saved through `v6/save` stores no `UgcVersion`, and the client's byte
|
||||
// defaults to 0 — a generation no client writes (every `v9/save` sends 1). The 2025
|
||||
// client lists nothing that declares 0: these were on the wire and invisible.
|
||||
UgcVersion: invention.UgcVersion ?? 1,
|
||||
CurrentVersionNumber: invention.CurrentVersionNumber,
|
||||
// One save, one version: the newest is the current one.
|
||||
LatestVersionNumber: invention.CurrentVersionNumber,
|
||||
Accessibility: invention.Accessibility,
|
||||
// A published invention cannot be private. Everything published through
|
||||
// `v3/publish` still stores the 0 a save mints (see INVENTION_ACCESSIBILITY), which
|
||||
// this DTO — it has no `IsPublished` — would read as Private; serve it as Public.
|
||||
Accessibility:
|
||||
invention.IsPublished && invention.Accessibility === INVENTION_ACCESSIBILITY.private
|
||||
? INVENTION_ACCESSIBILITY.public
|
||||
: invention.Accessibility,
|
||||
ForceCannotPublish: false,
|
||||
ModifiedAt: invention.ModifiedAt,
|
||||
CreatedAt: invention.CreatedAt,
|
||||
@@ -575,8 +596,19 @@ export async function getInventionsByCreator(
|
||||
* purchase, they are still on the shelf of the player who paid for them. An owned id
|
||||
* with no invention row left (deleted) simply drops out. Newest first, like the other
|
||||
* invention lists; not paginated.
|
||||
*
|
||||
* Each row is the stored record WITH the client's `RRInvention` laid over it
|
||||
* ({@link toInventionV9}). The 2025 client decodes this list into that flat DTO, and a
|
||||
* key the record lacks defaults silently: no `LatestVersionNumber` reads as a current
|
||||
* version newer than the latest, no `UgcVersion` as generation 0 whatever was saved, and
|
||||
* a published record's stored `Accessibility` 0 as Private. The stored keys that DTO has
|
||||
* no member for (`CurrentVersion`, `IsPublished`, `Tags`, …) stay on the row: its decoder
|
||||
* drops them, and the older client still reads them.
|
||||
*/
|
||||
export async function getMyInventions(db: D1Database, playerId: number): Promise<SavedInvention[]> {
|
||||
export async function getMyInventions(
|
||||
db: D1Database,
|
||||
playerId: number
|
||||
): Promise<ListedInvention[]> {
|
||||
const [created, ownedIds] = await Promise.all([
|
||||
getInventionsByCreator(db, playerId),
|
||||
getOwnedInventionIds(db, playerId),
|
||||
@@ -585,9 +617,51 @@ export async function getMyInventions(db: D1Database, playerId: number): Promise
|
||||
|
||||
const byId = new Map<number, SavedInvention>()
|
||||
for (const invention of [...created, ...bought]) byId.set(invention.InventionId, invention)
|
||||
return [...byId.values()].sort(
|
||||
(a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId
|
||||
)
|
||||
return [...byId.values()]
|
||||
.sort((a, b) => b.CreatedAt.localeCompare(a.CreatedAt) || b.InventionId - a.InventionId)
|
||||
.map(withClientInvention)
|
||||
}
|
||||
|
||||
/** A stored record with the client's flat `RRInvention` laid over it — see {@link getMyInventions}. */
|
||||
type ListedInvention = Omit<SavedInvention, keyof InventionV9Dto> & InventionV9Dto
|
||||
|
||||
function withClientInvention(invention: SavedInvention): ListedInvention {
|
||||
return { ...invention, ...toInventionV9(invention) }
|
||||
}
|
||||
|
||||
/** The most creators one `v1/fromcreators` call may name — D1 caps a statement's binds. */
|
||||
const MAX_PORTFOLIO_CREATORS = 50
|
||||
|
||||
/**
|
||||
* A creator's portfolio (`v1/fromcreators?id=…`) — the shelf on their profile, and the
|
||||
* "from creators you follow" row. Newest first, paged in SQL via skip/take.
|
||||
*
|
||||
* Only PUBLISHED inventions (minus hidden ones), whoever asks — the creator included. A
|
||||
* creator's drafts are on `v2/mine` and nowhere else; this is what they MADE and put out,
|
||||
* not what they bought or are still working on. Unlisted ones ARE here: unlisted means
|
||||
* "not in the store", and a portfolio is not the store.
|
||||
*/
|
||||
export async function getInventionsByCreators(
|
||||
db: D1Database,
|
||||
creatorPlayerIds: number[],
|
||||
skip: number,
|
||||
take: number
|
||||
): Promise<ListedInvention[]> {
|
||||
const creators = [...new Set(creatorPlayerIds)].slice(0, MAX_PORTFOLIO_CREATORS)
|
||||
const limit = Math.max(take, 0)
|
||||
if (creators.length === 0 || limit === 0) return []
|
||||
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT data FROM invention
|
||||
WHERE creator_player_id IN (${creators.map((_, i) => `?${i + 3}`).join(', ')})
|
||||
AND is_published = 1 AND hide_from_player = 0
|
||||
ORDER BY json_extract(data, '$.CreatedAt') DESC, id DESC
|
||||
LIMIT ?1 OFFSET ?2`
|
||||
)
|
||||
.bind(limit, Math.max(skip, 0), ...creators)
|
||||
.all<InventionRow>()
|
||||
return results.map((r) => withClientInvention(JSON.parse(r.data) as SavedInvention))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -657,6 +731,14 @@ export async function ownsAllInventions(
|
||||
* and a tag appears in no name or description, so those searches find nothing. Matching tags
|
||||
* needs them out of the JSON blob and into something indexable first; until then this stays a
|
||||
* text search rather than one that scans every row to look at its tags.
|
||||
*
|
||||
* A value starting with `@` IS special: `@djdevin` is a search for that player's inventions —
|
||||
* the public ones, the same set `v1/fromcreators` lists for their portfolio — resolved by
|
||||
* username, case-insensitively, through the indexed `username_lower`. Any terms after the
|
||||
* `@name` narrow that player's inventions as text, so `@djdevin sofa` works. A username nobody
|
||||
* has matches nothing rather than falling back to a text search for "@djdevin": a player who
|
||||
* typed an `@` meant a person, and a list of inventions whose descriptions mention them is not
|
||||
* what they asked for.
|
||||
*/
|
||||
export async function searchInventions(
|
||||
db: D1Database,
|
||||
@@ -673,11 +755,20 @@ export async function searchInventions(
|
||||
/** Bind a value and get its placeholder, so the numbering can't drift as terms are added. */
|
||||
const bind = (v: string | number): string => `?${binds.push(v)}`
|
||||
|
||||
for (const term of value
|
||||
const terms = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/[\s+]+/)
|
||||
.filter(Boolean)) {
|
||||
.filter(Boolean)
|
||||
|
||||
if (terms[0]?.startsWith('@')) {
|
||||
const username = terms.shift()!.slice(1)
|
||||
const creator = username ? await getAccountByUsername(db, username) : null
|
||||
if (!creator) return []
|
||||
where.push(`creator_player_id = ${bind(creator.accountId)}`)
|
||||
}
|
||||
|
||||
for (const term of terms) {
|
||||
const escaped = term.replace(/[\\%_]/g, (ch) => `\\${ch}`)
|
||||
const pattern = bind(`%${escaped}%`)
|
||||
where.push(
|
||||
@@ -851,17 +942,20 @@ export const INVENTION_PERMISSION = {
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Where a published invention may be FOUND, which `v4/publish` sets and nothing before it
|
||||
* did — every record written before that endpoint carries 0, the value a save mints.
|
||||
* Whether a PUBLISHED invention is in the store, which `v4/publish` sets and nothing before
|
||||
* it did. The publish sheet sends one of two values, and both publish:
|
||||
*
|
||||
* Only `unlisted` is recovered from the client for certain; the other two mirror the room
|
||||
* accessibility enum, which they match member-for-member, and the publish sheet sends 1 for
|
||||
* an ordinary publish.
|
||||
* - `public` (1) — published and in the store: search and the browse feeds list it.
|
||||
* - `unlisted` (2) — published but NOT in the store. It is still published, so it is on the
|
||||
* creator's portfolio (`v1/fromcreators`) and reachable by id; it just isn't for browsing.
|
||||
*
|
||||
* Note what that leaves ambiguous: a stored 0 is either "private" or "written before this
|
||||
* enum meant anything", and the two are indistinguishable without a backfill. So the browse
|
||||
* filter excludes `unlisted` by name rather than requiring `public` — the latter reads
|
||||
* every invention published through `v3/publish` as private and empties the feeds.
|
||||
* `private` (0) is what a save mints and what `v2/unpublish` puts back — the state of an
|
||||
* invention that isn't published at all. Whether anyone else can see an invention is
|
||||
* `IsPublished`; this field only decides, of the published ones, which the store shows.
|
||||
*
|
||||
* Every record published through the older `v3/publish` also still carries 0, since that
|
||||
* endpoint names no accessibility. So the store filter excludes `unlisted` by name rather
|
||||
* than requiring `public` — the latter would read all of those as private and drop them.
|
||||
*/
|
||||
export const INVENTION_ACCESSIBILITY = {
|
||||
private: 0,
|
||||
@@ -870,9 +964,12 @@ export const INVENTION_ACCESSIBILITY = {
|
||||
} as const
|
||||
|
||||
/**
|
||||
* The "anyone may come across this" test the browse feeds and search share: published, not
|
||||
* hidden, and not unlisted. An unlisted invention is still reachable BY ID — that is what
|
||||
* unlisted means — so the by-id reads deliberately don't apply it.
|
||||
* The "is this in the store" test search and the browse feeds share: published, not hidden
|
||||
* by moderation, and not unlisted. A creator's portfolio deliberately does NOT apply the
|
||||
* last term (see `getInventionsByCreators`), nor do the by-id reads.
|
||||
*
|
||||
* `idx_invention_feed_newest` (SCHEMA_DDL, migrations/0027) is a partial index over exactly
|
||||
* these terms — edit one here and edit it there, or the feed goes back to a full scan.
|
||||
*/
|
||||
const VISIBLE_IN_FEEDS = [
|
||||
'is_published = 1',
|
||||
@@ -982,9 +1079,42 @@ export async function publishInvention(
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpublish an invention (`v2/unpublish`) — back to what a save mints: not published, and
|
||||
* `Accessibility` private, so the next publish sets both afresh. Buyers keep it (`v2/mine`
|
||||
* lists a bought invention whatever its state), and `FirstPublishedAt` stays: it dates the
|
||||
* FIRST publish, which still happened. The permission and price are left for the next
|
||||
* publish to restate or keep. Returns the updated invention, or null when there's no such row.
|
||||
*/
|
||||
export async function unpublishInvention(
|
||||
db: D1Database,
|
||||
inventionId: number
|
||||
): Promise<SavedInvention | null> {
|
||||
const invention = await getInventionById(db, inventionId)
|
||||
if (invention === null) return null
|
||||
|
||||
const updated: SavedInvention = {
|
||||
...invention,
|
||||
IsPublished: false,
|
||||
Accessibility: INVENTION_ACCESSIBILITY.private,
|
||||
}
|
||||
await writeInvention(db, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
/**
|
||||
* The most tokens an invention may sell for, inclusive. Enforced on every price a player
|
||||
* can send — `v3/publish`, `v4/publish` and `v1/updateprice` — by refusing the write; the
|
||||
* client has no ceiling of its own. The same number as the custom-shirt cap
|
||||
* (`MAX_PLAYER_ITEM_PRICE`), kept separate because they are separate policies that happen
|
||||
* to agree. migrations/0033_invention_price_cap.sql lowered the rows already over it.
|
||||
*/
|
||||
export const MAX_INVENTION_PRICE = 1000
|
||||
|
||||
/**
|
||||
* Set an invention's price (`v1/updateprice`). Returns the updated invention, or
|
||||
* null when there's no such row; the caller rejects negative prices.
|
||||
* null when there's no such row; the caller rejects negative prices and ones over
|
||||
* {@link MAX_INVENTION_PRICE}.
|
||||
*/
|
||||
export async function setInventionPrice(
|
||||
db: D1Database,
|
||||
|
||||
+207
-54
@@ -234,6 +234,23 @@ export const DeleteMessagesRequest = z.object({
|
||||
MessageIds: z.array(z.int()).describe('Ids of the messages to delete'),
|
||||
})
|
||||
|
||||
/**
|
||||
* One stored message, as `GET /api/messages/v2/get` serves it — the client's Message model,
|
||||
* which is also what the `MessageReceived` frame carries (see the `notify` worker's
|
||||
* `MessageReceivedPayload`). Same shape in the inbox and on the wire, deliberately: they are
|
||||
* one message, and `Id` is the same id in both so the client can match them up.
|
||||
*/
|
||||
export const MessageDto = z.object({
|
||||
Id: z.int().describe('The message’s id — also the `Id` on the frame that delivered it'),
|
||||
FromPlayerId: z.int(),
|
||||
ToPlayerId: z.int(),
|
||||
SentTime: z.string().describe('ISO-8601 UTC'),
|
||||
Type: z.int().describe('The Message-model type (a different enum from `NotificationType`)'),
|
||||
Data: z.string().nullable().describe('Null on the types carrying no payload of their own'),
|
||||
RoomId: z.int().nullable(),
|
||||
PlayerEventId: z.int().nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/messages/v1/friendOnlineStatus` — how many of the caller's friends are
|
||||
* online, wrapped in the client's `{ success, value }` envelope.
|
||||
@@ -344,7 +361,11 @@ export const CheerPlayerResponse = z.object({
|
||||
export const CreateCustomAvatarItemMetadata = z.object({
|
||||
Name: z.string(),
|
||||
Description: z.string().optional(),
|
||||
Price: z.number().int().optional(),
|
||||
Price: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.describe('Tokens, 0–1000 (MAX_PLAYER_ITEM_PRICE); 0 when left out'),
|
||||
BaseAvatarItemId: z.number().int(),
|
||||
BaseAvatarItemColor: z.string().describe('Hex colour, e.g. `#F55C1A`'),
|
||||
Accessibility: z.number().int().optional(),
|
||||
@@ -357,7 +378,36 @@ export const CreateCustomAvatarItemRequest = z.object({
|
||||
design: z.string().describe('The design blob (binary file part)'),
|
||||
})
|
||||
|
||||
/** The client's `CustomAvatarItem` record. */
|
||||
/**
|
||||
* One built version of a first-party item, PascalCase as it rides inside `CurrentSaves`: the
|
||||
* Unity assetbundle the client downloads to render the item on one `BodyType`, its hash, and
|
||||
* the thumbnail the store shows. `AdditionalConfiguration` is the client's own JSON-in-a-string.
|
||||
* Not the camelCase `CustomAvatarItemSave` of the legacy-item lookup below — same save,
|
||||
* different casing and field set.
|
||||
*/
|
||||
export const CustomAvatarItemCurrentSave = z.object({
|
||||
CustomAvatarItemSaveId: z.int(),
|
||||
CustomAvatarItemId: z.string(),
|
||||
UnityAssetId: z.string(),
|
||||
BodyType: z.int(),
|
||||
OutfitType: z.int(),
|
||||
QAState: z.int(),
|
||||
CreatedAt: z.string(),
|
||||
ModifiedAt: z.string(),
|
||||
Description: z.string().nullable(),
|
||||
ThumbnailFileName: z.string(),
|
||||
AdditionalConfiguration: z.string().describe('JSON-in-a-string'),
|
||||
UnityAsset: z.string(),
|
||||
UnityAssetHash: z.string(),
|
||||
UnityAsset2: z.string().nullable(),
|
||||
UnityAsset2Hash: z.string().nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The client's `CustomAvatarItem` record, served straight out of the row's JSON. A player-made
|
||||
* shirt has a base item and a design PNG and no saves; a first-party item has the four
|
||||
* base/filename fields null and is rendered from `CurrentSaves` instead.
|
||||
*/
|
||||
export const CustomAvatarItemDto = z.object({
|
||||
CustomAvatarItemId: z.string(),
|
||||
CreatorAccountId: z.number().int(),
|
||||
@@ -368,16 +418,19 @@ export const CustomAvatarItemDto = z.object({
|
||||
ForceCannotPublish: z.boolean(),
|
||||
IsFeatured: z.boolean(),
|
||||
IsRecRoomApproved: z.boolean(),
|
||||
BaseAvatarItemId: z.number().int(),
|
||||
BaseAvatarItemColor: z.string(),
|
||||
DesignFilename: z.string(),
|
||||
ThumbnailImageFilename: z.string(),
|
||||
BaseAvatarItemId: z.number().int().nullable(),
|
||||
BaseAvatarItemColor: z.string().nullable(),
|
||||
DesignFilename: z.string().nullable(),
|
||||
ThumbnailImageFilename: z.string().nullable(),
|
||||
CreatedAt: z.string(),
|
||||
ModifiedAt: z.string(),
|
||||
PreviewOrientation: z.number().int(),
|
||||
RankingContext: z.null(),
|
||||
OutfitType: z.number().int(),
|
||||
CurrentSaves: z.array(z.unknown()),
|
||||
CurrentSaves: z.array(CustomAvatarItemCurrentSave),
|
||||
Tags: z.array(z.object({ TagType: z.int(), Value: z.string() })),
|
||||
CustomBadgeMetadata: z.unknown().nullable(),
|
||||
RankedEntityId: z.string(),
|
||||
PurchaseInfo: z.null(),
|
||||
})
|
||||
|
||||
@@ -385,7 +438,7 @@ export const CustomAvatarItemDto = z.object({
|
||||
export const UpdateCustomAvatarItemRequest = z.object({
|
||||
Name: z.string().nullable().optional(),
|
||||
Description: z.string().nullable().optional(),
|
||||
Price: z.number().int().nullable().optional(),
|
||||
Price: z.number().int().nullable().optional().describe('Tokens, 0–1000 (MAX_PLAYER_ITEM_PRICE)'),
|
||||
Accessibility: z.number().int().nullable().optional(),
|
||||
})
|
||||
|
||||
@@ -514,7 +567,7 @@ export const InventionV9Dto = z.object({
|
||||
Name: z.string(),
|
||||
Description: z.string(),
|
||||
ImageName: z.string(),
|
||||
UgcVersion: z.int().describe('The UGC format the blob was written in; 0 when unsent'),
|
||||
UgcVersion: z.int().describe('The UGC format the blob was written in; 1 when the save named none'),
|
||||
CurrentVersionNumber: z.int(),
|
||||
LatestVersionNumber: z.int().describe('The same as CurrentVersionNumber on a fresh save'),
|
||||
Accessibility: z.int(),
|
||||
@@ -651,6 +704,43 @@ export const UpdateInventionMetadataRequest = z.object({
|
||||
.describe('Replaces both lists wholesale, as `v1/settags` does; null leaves them alone'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/inventions/v2/update` JSON body — the newer client's counterpart to
|
||||
* `v1/update`'s query string. PascalCase, and nullable the way `v2/metadata`'s is: a null
|
||||
* field keeps what the invention already has, so this is a patch rather than a replace.
|
||||
*
|
||||
* Only `InventionId` and `Permission` are confirmed from a live capture (the permission
|
||||
* picker sends exactly those two). The rest are accepted defensively — they are the fields
|
||||
* `v1/update` took that `v2/metadata` does not — so that a build sending one gets it
|
||||
* applied instead of silently dropped.
|
||||
*/
|
||||
export const UpdateInventionRequest = z.object({
|
||||
InventionId: z.int(),
|
||||
Permission: z
|
||||
.int()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'The `GeneralPermission` other players get, as a raw ladder number: Unassigned 0, ' +
|
||||
'LimitedOneUseOnly 10, DisallowKeyLock 15, UseOnly 20, EditAndSave 40, Publish 60, ' +
|
||||
'Charge 80, Unlimited 100. Unlike `v4/publish`, null LEAVES IT ALONE rather than ' +
|
||||
'falling back to UseOnly — an edit that names no permission is not a demotion'
|
||||
),
|
||||
AllowTrial: z
|
||||
.boolean()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Whether other players may trial it; null leaves it alone'),
|
||||
Name: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('3–24 chars, letters/digits/spaces/dashes/colons; null leaves it alone'),
|
||||
Description: z.string().nullable().optional().describe('Max 512 chars; empty clears it'),
|
||||
LongDescription: z.string().nullable().optional().describe('Empty clears it'),
|
||||
ImageName: z.string().nullable().optional().describe('New thumbnail; empty clears it'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/inventions/v4/publish` JSON body — PascalCase, and nullable the way
|
||||
* `v2/metadata`'s is: a null field keeps what the invention already has.
|
||||
@@ -670,12 +760,20 @@ export const PublishInventionRequest = z.object({
|
||||
.int()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Private 0, Public 1, Unlisted 2. Unlisted stays out of browse and search'),
|
||||
.describe('Public 1 publishes it into the store; Unlisted 2 publishes it but keeps it out of search and the feeds'),
|
||||
Price: z
|
||||
.int()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Price in tokens; null leaves it as it is, and a negative one is ignored'),
|
||||
.describe(
|
||||
'Price in tokens, at most 1000 (MAX_INVENTION_PRICE); null leaves it as it is, a ' +
|
||||
'negative one is ignored, and one over the cap refuses the publish'
|
||||
),
|
||||
})
|
||||
|
||||
/** `POST /api/inventions/v2/unpublish` JSON body — the id and nothing else. */
|
||||
export const UnpublishInventionRequest = z.object({
|
||||
InventionId: z.int().describe('The invention to unpublish; the caller must have created it'),
|
||||
})
|
||||
|
||||
/** `POST /api/inventions/v2/delete` JSON body — the id and nothing else. */
|
||||
@@ -699,7 +797,7 @@ export const InventionDeleteResult = z.object({
|
||||
/** `POST /api/inventions/v1/updateprice` JSON body. */
|
||||
export const UpdatePriceRequest = z.object({
|
||||
InventionId: z.int(),
|
||||
Price: z.int().describe('Must be >= 0'),
|
||||
Price: z.int().describe('Tokens, 0–1000 (MAX_INVENTION_PRICE)'),
|
||||
})
|
||||
|
||||
/** `POST /api/inventions/v6/save` JSON body — camelCase, unlike the read shapes. */
|
||||
@@ -762,7 +860,7 @@ export const GeneratedGift = z.object({
|
||||
ConsumableItemDesc: z.string(),
|
||||
AvatarItemDesc: z.string(),
|
||||
FriendlyName: z.string(),
|
||||
AvatarItemType: z.int(),
|
||||
AvatarItemType: z.int().nullable().describe('Null — a token box holds no avatar item'),
|
||||
EquipmentPrefabName: z.string(),
|
||||
EquipmentModificationGuid: z.string(),
|
||||
CurrencyType: z.int(),
|
||||
@@ -793,6 +891,13 @@ export const BulkCustomAvatarItemsRequest = z.object({
|
||||
customAvatarItemIds: z
|
||||
.array(z.string())
|
||||
.describe('The ids to resolve; repeat the field once per id'),
|
||||
unityAssetTarget: z
|
||||
.int()
|
||||
.optional()
|
||||
.describe(
|
||||
'The Unity build target the assetbundles are wanted for: 0 PC (Windows), 2 ' +
|
||||
'Android/Oculus — which is served the `quest/` builds. Read off the query string too'
|
||||
),
|
||||
})
|
||||
|
||||
/** A paginated custom-avatar-item page, out of the `custom_avatar_item` table. */
|
||||
@@ -912,14 +1017,19 @@ export const OutfitsBulkRequest = z.object({
|
||||
/**
|
||||
* `POST /outfits/bulk` — the worn outfit of each account asked for, keyed by account id.
|
||||
*
|
||||
* The key is the id as a STRING (a JSON object key always is) and the value is the same
|
||||
* stored outfit `GET /outfits/me` serves. An account with nothing saved is ABSENT from the
|
||||
* map rather than present with a null — a map expresses "no outfit" by not carrying the key.
|
||||
* The key is the id as a STRING (a JSON object key always is) and the value is the stored
|
||||
* outfit `GET /outfits/me` serves, less any selection naming an item the wearer does not own
|
||||
* (see `restrictOutfitToOwned` in @repo/domain) — this is what other players dress them
|
||||
* from. An account with nothing saved is ABSENT from the map rather than present with a
|
||||
* null — a map expresses "no outfit" by not carrying the key.
|
||||
*/
|
||||
export const OutfitsBulkResponse = z.object({
|
||||
OutfitsByAccountId: z
|
||||
.record(z.string(), StoredOutfit)
|
||||
.describe('Keyed by account id as a string. Accounts with no saved outfit are omitted'),
|
||||
.describe(
|
||||
'Keyed by account id as a string. Accounts with no saved outfit are omitted. ' +
|
||||
'Selections naming items the wearer does not own are stripped'
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -1057,15 +1167,16 @@ export const PlayerEventBaseDto = PlayerEventDto.omit({ State: true, ImageName:
|
||||
* image.
|
||||
*
|
||||
* `Tags` has two shapes, picked from the caller's build: Rec Room reshaped it without
|
||||
* minting a new path, so a build newer than `20230414` gets the tag NAMES and every older
|
||||
* one (and any caller whose token names no build) gets the `{ Tag, Type }` pairs. Neither
|
||||
* is the lowercase `{ tag, type }` the v1 read serves.
|
||||
* minting a new path, so a build newer than `20230414` gets the `{ Tag, Type }` pairs and
|
||||
* that build, every older one, and any caller whose token names no build get the tag
|
||||
* NAMES. Neither is the lowercase `{ tag, type }` the v1 read serves. `TagModifyResult.Tags`
|
||||
* is a name list to every build.
|
||||
*/
|
||||
export const PlayerEventEnvelopeDto = PlayerEventBaseDto.extend({
|
||||
Tags: z
|
||||
.union([z.array(z.string()), z.array(z.object({ Tag: z.string(), Type: z.int() }))])
|
||||
.describe(
|
||||
'The event’s tags: names for a build newer than 20230414, `{ Tag, Type }` pairs for ' +
|
||||
'The event’s tags: `{ Tag, Type }` pairs for a build newer than 20230414, names for ' +
|
||||
'that build and older'
|
||||
),
|
||||
})
|
||||
@@ -1083,7 +1194,7 @@ export const PlayerEventResultDto = z.object({
|
||||
Result: z.int().describe('0 = success'),
|
||||
TagModifyResult: z.object({
|
||||
Result: z.int().describe('0 = success'),
|
||||
Tags: z.array(z.string()).describe('The tags the event now carries'),
|
||||
Tags: z.array(z.string()).describe('The tags the event now carries — names to every build'),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1171,7 +1282,9 @@ export const PlayerEventResponseDto = z.object({
|
||||
'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'),
|
||||
Type: z
|
||||
.int()
|
||||
.describe('0 Going, 1 Interested, 2 Can’t go, 3 Pending (invited, not yet answered)'),
|
||||
})
|
||||
|
||||
/** `POST /api/playerevents/v1/respond` JSON body — how the caller is answering. */
|
||||
@@ -1244,6 +1357,17 @@ export const PlayerEventsAll = z.object({
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /api/playerevents/v1/all/{playerId}` — a player's public events. Both keys carry
|
||||
* the 17-key base event; a `Responses` entry pairs it with the player's RSVP.
|
||||
*/
|
||||
export const PlayerPublicEvents = z.object({
|
||||
Created: z.array(PlayerEventBaseDto).describe('Public events the player created'),
|
||||
Responses: z
|
||||
.array(z.object({ PlayerEvent: PlayerEventBaseDto, PlayerEventResponse: PlayerEventResponseDto }))
|
||||
.describe('Public events the player is attending, each beside their Going RSVP'),
|
||||
})
|
||||
|
||||
/** `GET /api/playerevents/v1/club/:clubId` — the paged single-club event feed. */
|
||||
export const PlayerEventsPage = z.object({
|
||||
ContinuationToken: z.string().describe('Empty = no next page'),
|
||||
@@ -1267,8 +1391,9 @@ export const VoteToKickReason = z.object({
|
||||
* `GET|POST /api/PlayerReporting/v1/moderationBlockDetails` — the caller's block. With an
|
||||
* account-wide ban in force (a `report` row with `banned` set) it describes that ban:
|
||||
* `IsBan` true, the report's `ReportCategory`, a fixed `Message` of "Rule violation", and
|
||||
* its span as `TimeoutStartedAt` (the report's `created_at`) plus `Duration` (seconds to
|
||||
* `ban_expires`; int32 max for a permanent ban). Otherwise it is the "not blocked" answer, mirroring the reference server's stub `ReturnModerationBlockDetails()`:
|
||||
* `Duration` as the seconds LEFT as of the request (`ban_expires` minus now; int32 max for
|
||||
* a permanent ban) — the client counts it down from receipt, so it is never the full span.
|
||||
* `TimeoutStartedAt` stays null for a ban. Otherwise it is the "not blocked" answer, mirroring the reference server's stub `ReturnModerationBlockDetails()`:
|
||||
* `ReportCategory` is `Unknown` (-1) rather than 0, which is a real category, and
|
||||
* `Message` is null — the client distinguishes "no message" from a blank one, so we send
|
||||
* null where the reference sends an empty string. `IsVoiceModAutoban`/`TimeoutStartedAt`
|
||||
@@ -1288,7 +1413,7 @@ export const ModerationBlockDetails = z.object({
|
||||
Duration: z
|
||||
.int()
|
||||
.describe(
|
||||
'Length of the block in seconds from `TimeoutStartedAt`; 2147483647 (int32 max) for a permanent ban; 0 when not blocked'
|
||||
'Seconds LEFT on the block as of this request (the client counts it down from receipt); 2147483647 (int32 max) for a permanent ban; 0 when not blocked'
|
||||
),
|
||||
GameSessionId: z.int(),
|
||||
IsHostKick: z.boolean().describe('Always false — no host kick is ever recorded here'),
|
||||
@@ -1308,7 +1433,7 @@ export const ModerationBlockDetails = z.object({
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
'When the block began — the ban’s report `created_at` (ISO-8601 UTC); `Duration` runs from it. Null when not blocked'
|
||||
'Always null — the client does not pair it with `Duration` for a ban (it names the start of a voice-chat timeout, which this server never hands out)'
|
||||
),
|
||||
AssociatedAccountUsername: z.string().nullable().describe('Always null'),
|
||||
ShowCreatorCodeOfConduct: z.boolean().describe('Always false'),
|
||||
@@ -1346,6 +1471,27 @@ export const CreateReportRequest = z.object({
|
||||
.describe('Instance type name, e.g. `Public`. Stored verbatim'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/chatreport/createChatReport` form body — a report against one chat message.
|
||||
* Form-encoded, so everything is a string; only `ChatMessageId` is required. Neither the
|
||||
* reporter (the bearer token) nor the reported player (the message's sender) is in the body.
|
||||
*/
|
||||
export const CreateChatReportRequest = z.object({
|
||||
ChatMessageId: z.string().describe('Id of the message being reported, e.g. `72`'),
|
||||
ChatThreadId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The thread the message is in. Accepted and unused — the message names its own'),
|
||||
ReportCategory: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The reason picked in the report UI, sent as a NAME (`Discriminatory`) where the ' +
|
||||
'player report sends a number. Mapped onto the numeric category the table stores'
|
||||
),
|
||||
ReportDescription: z.string().optional().describe('The free-text description the reporter typed'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/playerwarnings` form body — a warning a moderator hands down. Everything
|
||||
* is a string on the wire (it's form-encoded); only `WarnedPlayerId` is required. The
|
||||
@@ -1371,12 +1517,18 @@ export const CreateWarningRequest = z.object({
|
||||
* body — it's the bearer token's subject.
|
||||
*/
|
||||
export const VoteToKickRequest = z.object({
|
||||
PlayerId: z.string().describe('Account id of the player being voted on'),
|
||||
Response: z.string().describe('The caller’s own vote, e.g. `True`'),
|
||||
PlayerId: z
|
||||
.string()
|
||||
.describe(
|
||||
'Account id of the player being voted on — relayed as the frame’s `FromPlayerId`, which is what the client’s prompt names'
|
||||
),
|
||||
Response: z.string().describe('The caller’s own vote, e.g. `True`. Not relayed'),
|
||||
Reason: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('A `voteToKickReasons` label, e.g. `Inactive in games (AFK)`'),
|
||||
.describe(
|
||||
'A `voteToKickReasons` label, e.g. `Inactive in games (AFK)` — relayed verbatim as the frame’s `Data`, which is the text the prompt shows. Empty when absent'
|
||||
),
|
||||
GameSessionId: z.string().describe('The room instance both players are standing in'),
|
||||
})
|
||||
|
||||
@@ -1391,6 +1543,18 @@ export const InstantKickRequest = z.object({
|
||||
PlayerIds: z.array(z.int()).describe('Account ids to kick out of that instance'),
|
||||
})
|
||||
|
||||
/** `POST /api/PlayerReporting/v1/roomModKick` form body — one player out of one instance. */
|
||||
export const RoomModKickRequest = z.object({
|
||||
PlayerId: z.string().describe('Account id of the player to kick'),
|
||||
GameSessionId: z
|
||||
.string()
|
||||
.describe('The room instance (game session) to eject them from — they must be standing in it'),
|
||||
Reason: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Free text, shown to the kicked player in the kick message'),
|
||||
})
|
||||
|
||||
/** `POST /api/PlayerReporting/v1/deviceId` form body — the id rotation the client reports. */
|
||||
export const DeviceIdRequest = z.object({
|
||||
oldDeviceId: z.string().optional().describe('The id the client thinks we hold'),
|
||||
@@ -1437,29 +1601,6 @@ export const SavedImageDto = z.object({
|
||||
CommentCount: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /api/images/v6` — an image's metadata by bucket key. A third projection of the same
|
||||
* row: renamed like `ImagesPlayer` (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`)
|
||||
* but carrying `ClubId`, and with no nullable fields — `RoomId`, `PlayerEventId` and
|
||||
* `ClubId` are 0 where the row holds null, `Description` is `""`. Don't unify it with the
|
||||
* other two.
|
||||
*/
|
||||
export const ImageMetadataDto = z.object({
|
||||
SavedImageId: z.int(),
|
||||
ImageName: z.string().describe('The bucket key the img worker serves it back by'),
|
||||
PlayerId: z.int(),
|
||||
RoomId: z.int().describe('0 when the photo was not taken in a room'),
|
||||
PlayerEventId: z.int().describe('0 when it belongs to no event'),
|
||||
ClubId: z.int().describe('Always 0 — nothing here associates an image with a club'),
|
||||
Description: z.string().describe('Empty string, never null'),
|
||||
Accessibility: z.int(),
|
||||
AccessibilityLocked: z.boolean(),
|
||||
SavedImageType: z.int().describe('1 = share camera, 3 = room, 4 = profile, …'),
|
||||
CreatedAt: z.string(),
|
||||
CheerCount: z.int(),
|
||||
CommentCount: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The client's `ImagesPlayer` projection — the same record with `Id` → `SavedImageId`,
|
||||
* `Type` → `SavedImageType` and no `TaggedPlayerIds`. The player photo lists and feed
|
||||
@@ -1515,9 +1656,21 @@ export const UploadImageResponse = z.object({
|
||||
ImageName: z.string().describe('The bucket key; the img worker serves the object by it'),
|
||||
})
|
||||
|
||||
/** `DELETE /api/images/v1/deletesaved` JSON body. */
|
||||
/** `POST /api/images/v1/deletesaved` JSON body. */
|
||||
export const DeleteImageRequest = z.object({ ImageName: z.string() })
|
||||
|
||||
/** `POST /api/images/v2/modifyaccessibility` JSON body. */
|
||||
export const ModifyImageAccessibilityRequest = z.object({
|
||||
ImageName: z.string(),
|
||||
Accessibility: z.int().describe('0 private, 1 public'),
|
||||
})
|
||||
|
||||
/** `POST /api/images/v1/modifydescription` JSON body. */
|
||||
export const ModifyImageDescriptionRequest = z.object({
|
||||
ImageName: z.string(),
|
||||
Description: z.string().nullable().describe('The new caption; null or empty clears it'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/images/v5/cheered/bulk` form body — the saved-image ids to report cheer state
|
||||
* for, as a REPEATED `id` field (`id=651&id=570&…`), one value per id. The client sends a
|
||||
|
||||
+388
-17
@@ -7,17 +7,28 @@
|
||||
* what a player submitted: the table is a log of exactly what was reported.
|
||||
*
|
||||
* The `api` worker owns this schema/migration (migrations/0004_report.sql,
|
||||
* 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql and
|
||||
* 0017_report_custom_avatar_item.sql, applied under its own `migrations_table` so it
|
||||
* doesn't clash with the other workers' migrations that share the database).
|
||||
* 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql,
|
||||
* 0017_report_custom_avatar_item.sql, 0020_report_ban_audit.sql,
|
||||
* 0025_report_chat_message.sql and 0034_report_image.sql, applied under its own `migrations_table` so it doesn't clash
|
||||
* with the other workers' migrations that share the database).
|
||||
*
|
||||
* The moderation-side READS here — `searchReports`, `getTopReported`, `getBansInForce` —
|
||||
* are not called by any endpoint on this worker. They back the staff panel served by
|
||||
* `www` (`/api/staff/*`), which imports them: the endpoints are a recflare addition with
|
||||
* no counterpart in the real service, and those live on `www` rather than on the workers
|
||||
* that reimplement the game's own API. The SQL stays here, with the table that owns it.
|
||||
*
|
||||
* A reported player EVENT, INVENTION or CUSTOM AVATAR ITEM lands here too, rather than in a
|
||||
* table of its own: same fields, same moderation life. Such a row carries `event_id`,
|
||||
* `invention_id` or `custom_avatar_item_id`, and its `reported_player_id` is that thing's
|
||||
* CREATOR — see `POST /api/playerevents/v1/report`, `POST /api/inventions/v1/report` and
|
||||
* `POST /api/customAvatarItems/v1/{id}/report`. The three id columns are mutually exclusive;
|
||||
* a row with none of them is an ordinary player report. They are three columns rather than
|
||||
* one polymorphic id because the keys differ in TYPE: two numbers and a guid.
|
||||
* `POST /api/customAvatarItems/v1/{id}/report`. A reported CHAT MESSAGE is the fourth kind:
|
||||
* `chat_message_id`, with the message's SENDER as the reported player — see
|
||||
* `POST /api/chatreport/createChatReport`. A reported IMAGE is the fifth: `image_id`, with
|
||||
* the player who took it as the reported player — see `POST /api/images/v1/{id}/report`.
|
||||
* The five id columns are mutually exclusive; a row
|
||||
* with none of them is an ordinary player report. They are separate columns rather than one
|
||||
* polymorphic id because the keys differ in TYPE (numbers and a guid) and in what they key.
|
||||
*
|
||||
* 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. It is
|
||||
@@ -32,10 +43,12 @@
|
||||
|
||||
/**
|
||||
* Schema DDL (mirror of migrations/0004_report.sql + 0009_report_ban.sql +
|
||||
* 0011_report_event.sql + 0016_report_invention.sql + 0017_report_custom_avatar_item.sql).
|
||||
* 0011_report_event.sql + 0016_report_invention.sql + 0017_report_custom_avatar_item.sql +
|
||||
* 0020_report_ban_audit.sql + 0025_report_chat_message.sql + 0029_report_ban_lifted.sql +
|
||||
* 0034_report_image.sql).
|
||||
*
|
||||
* None of `event_id`, `invention_id` or `custom_avatar_item_id` is indexed: each is written
|
||||
* on every report of its kind and read by nothing — no query here filters on any of them,
|
||||
* None of `event_id`, `invention_id`, `custom_avatar_item_id`, `chat_message_id` or `image_id` is
|
||||
* indexed: each is written on every report of its kind and read by nothing — no query here filters on any of them,
|
||||
* and the reads that do exist go by player or by the ban flag. 0011's partial index over
|
||||
* `event_id` was dropped in 0016 rather than mirrored. Add one back alongside the query that
|
||||
* needs it.
|
||||
@@ -56,7 +69,13 @@ export const SCHEMA_DDL: string[] = [
|
||||
ban_expires TEXT,
|
||||
event_id INTEGER,
|
||||
invention_id INTEGER,
|
||||
custom_avatar_item_id TEXT
|
||||
custom_avatar_item_id TEXT,
|
||||
banned_by_player_id INTEGER,
|
||||
banned_at TEXT,
|
||||
chat_message_id INTEGER,
|
||||
unbanned_by_player_id INTEGER,
|
||||
unbanned_at TEXT,
|
||||
image_id INTEGER
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_report_reported ON report (reported_player_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_report_reporter ON report (reporter_player_id)`,
|
||||
@@ -103,6 +122,45 @@ export interface ReportRow {
|
||||
* it does not know who made it.
|
||||
*/
|
||||
custom_avatar_item_id: string | null
|
||||
/**
|
||||
* The moderator who set `banned` on this row, or NULL when nobody has (and cleared
|
||||
* again when a ban is lifted). Read per-row by the staff panel; nothing filters on it.
|
||||
*/
|
||||
banned_by_player_id: number | null
|
||||
/**
|
||||
* ISO-8601 UTC instant the ban was HANDED DOWN — distinct from `created_at`, which is
|
||||
* when the report was filed, and the two can be months apart. The staff panel's audit
|
||||
* trail and its ordering (`getBansInForce`); the client's block screen does NOT count
|
||||
* from it (see `banBlockDetails`). Rows written before 0020_report_ban_audit.sql carry
|
||||
* NULL and fall back to `created_at` where a date is needed.
|
||||
*/
|
||||
banned_at: string | null
|
||||
/**
|
||||
* The chat message this report is against, or NULL for any other kind — mutually
|
||||
* exclusive with `event_id`, `invention_id` and `custom_avatar_item_id`. See
|
||||
* `POST /api/chatreport/createChatReport`: `reported_player_id` is the message's SENDER,
|
||||
* read from the `message` table, because the body names no player. The thread the client
|
||||
* also posts is not kept — a message id is unique across threads, so it is the whole
|
||||
* reference.
|
||||
*/
|
||||
chat_message_id: number | null
|
||||
/**
|
||||
* The moderator who LIFTED the ban that once stood on this row, or NULL when none has
|
||||
* been lifted — including while one stands: a re-ban clears it, with `unbanned_at`. A
|
||||
* lift wipes the four ban columns above, so without this pair a lifted ban is
|
||||
* indistinguishable from a report nobody acted on, and the staff panel offered to ban
|
||||
* again. Read per row by the panel; nothing filters on it.
|
||||
*/
|
||||
unbanned_by_player_id: number | null
|
||||
/** ISO-8601 UTC instant the ban was lifted; NULL with `unbanned_by_player_id`. */
|
||||
unbanned_at: string | null
|
||||
/**
|
||||
* The saved image (photo) this report is against, or NULL for any other kind — mutually
|
||||
* exclusive with the four id columns above. See `POST /api/images/v1/{id}/report`: the
|
||||
* request has no body, so `reported_player_id` is the image's `PlayerId` (who took it),
|
||||
* read from the image, and there is no category or description to store.
|
||||
*/
|
||||
image_id: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,6 +184,10 @@ export interface NewReport {
|
||||
inventionId?: number | null
|
||||
/** Set only when reporting a CUSTOM AVATAR ITEM; never set alongside the two above. */
|
||||
customAvatarItemId?: string | null
|
||||
/** Set only when reporting a CHAT MESSAGE; never set alongside the three above. */
|
||||
chatMessageId?: number | null
|
||||
/** Set only when reporting an IMAGE; never set alongside the four above. */
|
||||
imageId?: number | null
|
||||
}
|
||||
|
||||
/** Record a submitted report, returning the stored row (with its assigned id). */
|
||||
@@ -135,8 +197,8 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
|
||||
`INSERT INTO report (
|
||||
reporter_player_id, reported_player_id, report_category, details,
|
||||
height_reporter, height_reported, room_id, room_instance_type, created_at,
|
||||
event_id, invention_id, custom_avatar_item_id
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
|
||||
event_id, invention_id, custom_avatar_item_id, chat_message_id, image_id
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(
|
||||
@@ -151,7 +213,9 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
|
||||
new Date().toISOString(),
|
||||
input.eventId ?? null,
|
||||
input.inventionId ?? null,
|
||||
input.customAvatarItemId ?? null
|
||||
input.customAvatarItemId ?? null,
|
||||
input.chatMessageId ?? null,
|
||||
input.imageId ?? null
|
||||
)
|
||||
.first<ReportRow>()
|
||||
// RETURNING always yields the inserted row; the non-null assert keeps the caller
|
||||
@@ -159,7 +223,11 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
|
||||
return row!
|
||||
}
|
||||
|
||||
/** Every report filed against a player, newest first. Backs a future moderation view. */
|
||||
/**
|
||||
* Every report filed against a player, newest first — unpaged, because it is the whole
|
||||
* history a moderator reads on one account (`www`: `GET /api/staff/players/:id`), not a
|
||||
* feed. `searchReports` is the paged, filtered form for looking across accounts.
|
||||
*/
|
||||
export async function getReportsAgainst(db: D1Database, playerId: number): Promise<ReportRow[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT * FROM report WHERE reported_player_id = ?1 ORDER BY id DESC')
|
||||
@@ -217,6 +285,17 @@ export async function isPlayerBanned(
|
||||
* permanent ban. Passing `banned: false` lifts the ban and clears the expiry, leaving the
|
||||
* report itself intact.
|
||||
*
|
||||
* `bannedBy` is the acting moderator, and is recorded alongside `banned_at` — the instant
|
||||
* the ban was handed down, which is NOT the report's `created_at`; the staff panel shows
|
||||
* and sorts by it. A lift clears all four columns together: an unbanned row must
|
||||
* not keep an audit trail saying a ban runs from somewhere, and `banned = 0` with a
|
||||
* `banned_at` still set would read as a ban to anything checking the timestamp.
|
||||
*
|
||||
* A lift instead SIGNS `unbanned_by_player_id`/`unbanned_at` with the same `bannedBy` and
|
||||
* the same instant, and a re-ban clears those two — a standing ban has not been lifted.
|
||||
* That is what lets the staff panel tell "a colleague let this player back in" from
|
||||
* "nobody has acted on this report", so a second moderator doesn't undo the first.
|
||||
*
|
||||
* 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).
|
||||
@@ -224,11 +303,303 @@ export async function isPlayerBanned(
|
||||
export async function banFromReport(
|
||||
db: D1Database,
|
||||
reportId: number,
|
||||
options: { banned?: boolean; banExpires?: string | null } = {}
|
||||
options: { banned?: boolean; banExpires?: string | null; bannedBy?: number | null } = {}
|
||||
): Promise<ReportRow | null> {
|
||||
const banned = options.banned ?? true
|
||||
const now = new Date().toISOString()
|
||||
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)
|
||||
.prepare(
|
||||
`UPDATE report
|
||||
SET banned = ?2, ban_expires = ?3, banned_by_player_id = ?4, banned_at = ?5,
|
||||
unbanned_by_player_id = ?6, unbanned_at = ?7
|
||||
WHERE id = ?1
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(
|
||||
reportId,
|
||||
banned ? 1 : 0,
|
||||
banned ? (options.banExpires ?? null) : null,
|
||||
banned ? (options.bannedBy ?? null) : null,
|
||||
banned ? now : null,
|
||||
banned ? null : (options.bannedBy ?? null),
|
||||
banned ? null : now
|
||||
)
|
||||
.first<ReportRow>()
|
||||
}
|
||||
|
||||
/**
|
||||
* A page of reports matching a moderator's filters — the staff panel's search
|
||||
* (`www`: `GET /api/staff/reports`).
|
||||
*
|
||||
* Every filter is optional and ANDed; an empty filter set is the whole table, newest
|
||||
* first. `ORDER BY id DESC` rather than by `created_at`: the id is AUTOINCREMENT, so it is
|
||||
* already chronological, and it is the primary key — ordering by the timestamp column,
|
||||
* which is unindexed, would sort the whole result set. The date window is applied to
|
||||
* `created_at` (an ISO-8601 string, so a lexical comparison IS a chronological one) and
|
||||
* `to` is EXCLUSIVE, which is what makes a day-boundary window composable.
|
||||
*
|
||||
* `banned` filters on the FLAG, not on whether a ban is in force: a moderator reviewing
|
||||
* what has been actioned wants the expired ones too (`getBansInForce` is the other
|
||||
* question). The count is a second statement rather than a window function, so the caller
|
||||
* can page without re-deriving the total — it counts the same predicate, unpaged.
|
||||
*/
|
||||
export interface ReportSearch {
|
||||
reportedPlayerId?: number | null
|
||||
reporterPlayerId?: number | null
|
||||
reportCategory?: number | null
|
||||
/** true = banned rows only, false = unbanned only, null/undefined = both. */
|
||||
banned?: boolean | null
|
||||
/** ISO-8601; inclusive lower bound on `created_at`. */
|
||||
from?: string | null
|
||||
/** ISO-8601; EXCLUSIVE upper bound on `created_at`. */
|
||||
to?: string | null
|
||||
}
|
||||
|
||||
/** A page of search results, plus how many rows the filters match in total. */
|
||||
export interface ReportPage {
|
||||
reports: ReportRow[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export async function searchReports(
|
||||
db: D1Database,
|
||||
filters: ReportSearch = {},
|
||||
page: { skip?: number; take?: number } = {}
|
||||
): Promise<ReportPage> {
|
||||
// Built as a parallel list of clauses and binds so a filter is added in one place and
|
||||
// can't drift between the page query and the count query, which share both.
|
||||
const clauses: string[] = []
|
||||
const binds: Array<number | string> = []
|
||||
const where = (sql: string, value: number | string) => {
|
||||
binds.push(value)
|
||||
clauses.push(sql.replace('?', `?${binds.length}`))
|
||||
}
|
||||
|
||||
if (filters.reportedPlayerId != null) where('reported_player_id = ?', filters.reportedPlayerId)
|
||||
if (filters.reporterPlayerId != null) where('reporter_player_id = ?', filters.reporterPlayerId)
|
||||
if (filters.reportCategory != null) where('report_category = ?', filters.reportCategory)
|
||||
if (filters.banned != null) where('banned = ?', filters.banned ? 1 : 0)
|
||||
if (filters.from) where('created_at >= ?', filters.from)
|
||||
if (filters.to) where('created_at < ?', filters.to)
|
||||
|
||||
const predicate = clauses.length === 0 ? '' : ` WHERE ${clauses.join(' AND ')}`
|
||||
// Clamped rather than trusted: `take` reaches this from a query string, and an
|
||||
// unbounded one would hand a moderator's browser the entire table.
|
||||
const take = Math.min(Math.max(page.take ?? 50, 1), 200)
|
||||
const skip = Math.max(page.skip ?? 0, 0)
|
||||
|
||||
const [rows, count] = await db.batch<ReportRow | { total: number }>([
|
||||
db
|
||||
.prepare(
|
||||
`SELECT * FROM report${predicate} ORDER BY id DESC LIMIT ?${binds.length + 1} OFFSET ?${binds.length + 2}`
|
||||
)
|
||||
.bind(...binds, take, skip),
|
||||
db.prepare(`SELECT COUNT(*) AS total FROM report${predicate}`).bind(...binds),
|
||||
])
|
||||
|
||||
return {
|
||||
reports: rows.results as ReportRow[],
|
||||
total: (count.results as Array<{ total: number }>)[0]?.total ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** One report by id, or null. The detail behind a search row. */
|
||||
export async function getReportById(db: D1Database, reportId: number): Promise<ReportRow | null> {
|
||||
return db.prepare('SELECT * FROM report WHERE id = ?1').bind(reportId).first<ReportRow>()
|
||||
}
|
||||
|
||||
/** A player who has collected reports, as the staff panel's triage list shows them. */
|
||||
export interface ReportedPlayerTally {
|
||||
playerId: number
|
||||
/** Reports filed against them inside the window. */
|
||||
reports: number
|
||||
/**
|
||||
* How many DIFFERENT accounts filed those reports. The number that actually means
|
||||
* something: one player filing forty reports against someone they are feuding with
|
||||
* should not outrank four unconnected players filing one each.
|
||||
*/
|
||||
distinctReporters: number
|
||||
/** `created_at` of the most recent report in the window. */
|
||||
lastReportAt: string
|
||||
/** Whether a ban is in force against them right now — so the list can skip the done ones. */
|
||||
bannedNow: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The players with the most reports against them — the staff panel's triage list
|
||||
* (`www`: `GET /api/staff/reports/top-reported`).
|
||||
*
|
||||
* WINDOWED by default (30 days), because the unwindowed version ossifies: an account that
|
||||
* collected reports a year ago and stopped would sit at the top forever, above whoever is
|
||||
* a problem this week. Pass `sinceDays: null` for all time.
|
||||
*
|
||||
* Ranked by distinct reporters FIRST and raw count second, for the reason
|
||||
* `distinctReporters` exists at all. `minReports` keeps the long tail of single reports
|
||||
* out — almost every report is a one-off, and a list of them is not a triage list.
|
||||
*
|
||||
* `GROUP BY reported_player_id` rides `idx_report_reported`. The in-force ban is decided
|
||||
* in the same statement (a correlated EXISTS over the partial `idx_report_banned`) rather
|
||||
* than with a round trip per player.
|
||||
*/
|
||||
export async function getTopReported(
|
||||
db: D1Database,
|
||||
options: { sinceDays?: number | null; minReports?: number; take?: number; now?: Date } = {}
|
||||
): Promise<ReportedPlayerTally[]> {
|
||||
const now = options.now ?? new Date()
|
||||
const sinceDays = options.sinceDays === undefined ? 30 : options.sinceDays
|
||||
// An all-time window is expressed as a floor no timestamp can precede, so the SQL keeps
|
||||
// one shape rather than growing a second predicate.
|
||||
const since =
|
||||
sinceDays === null
|
||||
? '0000-01-01T00:00:00.000Z'
|
||||
: new Date(now.getTime() - sinceDays * 86_400_000).toISOString()
|
||||
const minReports = Math.max(options.minReports ?? 3, 1)
|
||||
const take = Math.min(Math.max(options.take ?? 50, 1), 200)
|
||||
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT
|
||||
r.reported_player_id AS playerId,
|
||||
COUNT(*) AS reports,
|
||||
COUNT(DISTINCT r.reporter_player_id) AS distinctReporters,
|
||||
MAX(r.created_at) AS lastReportAt,
|
||||
EXISTS (
|
||||
SELECT 1 FROM report b
|
||||
WHERE b.reported_player_id = r.reported_player_id AND b.banned = 1
|
||||
AND (b.ban_expires IS NULL OR b.ban_expires > ?2)
|
||||
) AS bannedNow
|
||||
FROM report r
|
||||
WHERE r.created_at >= ?1
|
||||
GROUP BY r.reported_player_id
|
||||
HAVING COUNT(*) >= ?3
|
||||
ORDER BY distinctReporters DESC, reports DESC, lastReportAt DESC
|
||||
LIMIT ?4`
|
||||
)
|
||||
.bind(since, now.toISOString(), minReports, take)
|
||||
.all<Omit<ReportedPlayerTally, 'bannedNow'> & { bannedNow: number }>()
|
||||
|
||||
return results.map((row) => ({ ...row, bannedNow: row.bannedNow === 1 }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Every ban in force right now, MOST RECENTLY HANDED DOWN FIRST — the staff panel's
|
||||
* standing-bans list (`www`: `GET /api/staff/bans`).
|
||||
*
|
||||
* "In force" is `getActiveBan`'s test applied to the whole table rather than to one
|
||||
* player: a row whose `ban_expires` has passed has served its time and is not listed,
|
||||
* though it stays as the record that it happened. A player with several in force appears
|
||||
* once per ban — each is a separate report, and which report justified which ban is the
|
||||
* point of the list.
|
||||
*
|
||||
* Ordered by WHEN THE BAN LANDED because the list's job is catching mistakes: the ban most
|
||||
* likely to be wrong is the one just handed down, and a moderator who has slipped goes
|
||||
* looking for it at the top. Ordering by severity instead (longest-lasting first, as this
|
||||
* did) buries a fresh one-day ban under every permanent ban ever issued — exactly the
|
||||
* wrong way round for the one read this list exists to serve.
|
||||
*
|
||||
* `COALESCE(banned_at, created_at)`: a ban set before 0020_report_ban_audit.sql added
|
||||
* `banned_at` has only its report's date to sort by, which is the same fallback the block
|
||||
* screen uses. Without the coalesce those rows would sort as NULL — all together at one
|
||||
* end, in no order at all.
|
||||
*/
|
||||
export async function getBansInForce(
|
||||
db: D1Database,
|
||||
now: Date = new Date(),
|
||||
take = 200
|
||||
): Promise<ReportRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT * FROM report
|
||||
WHERE banned = 1 AND (ban_expires IS NULL OR ban_expires > ?1)
|
||||
ORDER BY COALESCE(banned_at, created_at) DESC, id DESC
|
||||
LIMIT ?2`
|
||||
)
|
||||
.bind(now.toISOString(), Math.min(Math.max(take, 1), 500))
|
||||
.all<ReportRow>()
|
||||
return results
|
||||
}
|
||||
|
||||
// ---- Block details ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `Duration` on a permanent ban. The client's field is a 32-bit int of seconds LEFT, so a
|
||||
* ban with no end gets the largest value the field holds — 68 years, which other
|
||||
* implementations also send and the client's screen renders as permanent.
|
||||
*/
|
||||
export const PERMANENT_BAN_DURATION = 2_147_483_647
|
||||
|
||||
/**
|
||||
* The "not blocked" answer — the reference server's stub `ReturnModerationBlockDetails()`,
|
||||
* widened to every key the client's `ModerationBlockDetail` decoder names (16 on the wire;
|
||||
* the 2025 build's formatter reads them all). The ones past the stub's nine are the block
|
||||
* kinds and screen dressings this server never uses — a device ban, a warning, the
|
||||
* vote-kick reason, an associated account, the creator code of conduct, the top/bottom
|
||||
* message overrides — so they carry their "none" values on every answer.
|
||||
*/
|
||||
export const NOT_BLOCKED = {
|
||||
ReportCategory: -1,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
IsHostKick: false,
|
||||
Message: null,
|
||||
PlayerIdReporter: null,
|
||||
IsBan: false,
|
||||
IsVoiceModAutoban: false,
|
||||
IsDeviceBan: false,
|
||||
IsWarning: false,
|
||||
VoteKickReason: null,
|
||||
TimeoutStartedAt: null,
|
||||
AssociatedAccountUsername: null,
|
||||
ShowCreatorCodeOfConduct: false,
|
||||
TopMessageOverride: null,
|
||||
BottomMessageOverride: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* The block details for a ban in force — the `report` row a moderator set `banned` on.
|
||||
*
|
||||
* Shared by the two places a player meets an account ban: `api`'s `moderationBlockDetails`
|
||||
* (the screen at sign-in) and `www`'s live `ModerationKick` frame (the screen when the ban is
|
||||
* handed down mid-session). Built once here so both screens describe the same ban.
|
||||
*
|
||||
* `Duration` is the seconds LEFT on the ban as of `now` — the time from the request to
|
||||
* `ban_expires`, rounded up so a ban with a second to run never reads 0 — or
|
||||
* `PERMANENT_BAN_DURATION` when there is no expiry. The client counts it down from the
|
||||
* moment it receives it, with no reference to when the ban began: other implementations
|
||||
* with timed bans keep the instant they set the block PRIVATE and serve `Duration` minus
|
||||
* the elapsed time on every read.
|
||||
* This once sent the FULL span from `banned_at` instead, on the theory that the client
|
||||
* pairs it with `TimeoutStartedAt` (start + duration = end). It does not: the screen read
|
||||
* correctly only at the instant the ban was handed down — when the full span IS the
|
||||
* remainder, so the mid-session `ModerationKick` looked right — and every later sign-in
|
||||
* showed the whole ban still to run.
|
||||
*
|
||||
* `TimeoutStartedAt` stays null. Other implementations don't send it for a ban; where it is
|
||||
* set at all it names the start of a voice-chat timeout, which is what the name is about.
|
||||
* `banned_at` is the audit trail and the staff panel's, not the client's.
|
||||
*
|
||||
* `now` is a parameter so the caller can pass the same instant it resolved the ban with
|
||||
* (`getActiveBan`), and a ban that was in force at the read can't come out as 0 left.
|
||||
*
|
||||
* The category is the one the report was
|
||||
* filed under, so the client's ban screen names the reason. `Message` is a fixed "Rule
|
||||
* violation" rather than the report's `details` — those are the REPORTER's words, and the
|
||||
* banned player isn't shown them, for the same reason `PlayerIdReporter` stays null: the
|
||||
* reporter is not a host who kicked them, and naming them would tell the banned player who
|
||||
* reported them. Everything else keeps its `NOT_BLOCKED` value: the other block kinds and
|
||||
* screen dressings, none of which this server hands out.
|
||||
*/
|
||||
export function banBlockDetails(ban: ReportRow, now: Date = new Date()) {
|
||||
const duration =
|
||||
ban.ban_expires === null
|
||||
? PERMANENT_BAN_DURATION
|
||||
: Math.max(1, Math.ceil((Date.parse(ban.ban_expires) - now.getTime()) / 1000))
|
||||
return {
|
||||
...NOT_BLOCKED,
|
||||
ReportCategory: ban.report_category,
|
||||
Duration: duration,
|
||||
IsBan: true,
|
||||
Message: 'Rule violation',
|
||||
TimeoutStartedAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
* don't clash with the other workers' migrations that share the database).
|
||||
*/
|
||||
|
||||
import { bindPlaceholders, chunkForBinds } from '@repo/domain'
|
||||
|
||||
/** Schema DDL (mirror of migrations/0013_reputation.sql + 0014, folded into one CREATE). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS reputation (
|
||||
@@ -186,15 +188,22 @@ export async function getCheerCredits(
|
||||
): Promise<Map<number, number>> {
|
||||
const credits = new Map<number, number>()
|
||||
if (playerIds.length === 0) return credits
|
||||
const placeholders = playerIds.map((_, i) => `?${i + 2}`).join(', ')
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT player_id, cheers_left FROM player_cheer
|
||||
WHERE created > ?1 AND player_id IN (${placeholders})`
|
||||
// The window cutoff is `?1`, so the ids start at `?2` and each chunk is one short of
|
||||
// D1's bind cap — a bulk read over a friends list runs well past it.
|
||||
const pages = await Promise.all(
|
||||
chunkForBinds(playerIds, 1).map((chunk) =>
|
||||
db
|
||||
.prepare(
|
||||
`SELECT player_id, cheers_left FROM player_cheer
|
||||
WHERE created > ?1 AND player_id IN (${bindPlaceholders(chunk, 1)})`
|
||||
)
|
||||
.bind(windowCutoff(now), ...chunk)
|
||||
.all<{ player_id: number; cheers_left: number }>()
|
||||
)
|
||||
.bind(windowCutoff(now), ...playerIds)
|
||||
.all<{ player_id: number; cheers_left: number }>()
|
||||
for (const row of results) credits.set(row.player_id, row.cheers_left)
|
||||
)
|
||||
for (const row of pages.flatMap((page) => page.results)) {
|
||||
credits.set(row.player_id, row.cheers_left)
|
||||
}
|
||||
return credits
|
||||
}
|
||||
|
||||
@@ -219,15 +228,20 @@ export async function getReputations(
|
||||
now: Date = new Date()
|
||||
): Promise<Reputation[]> {
|
||||
if (accountIds.length === 0) return []
|
||||
const placeholders = accountIds.map((_, i) => `?${i + 1}`).join(', ')
|
||||
const [{ results }, credits] = await Promise.all([
|
||||
db
|
||||
.prepare(`SELECT * FROM reputation WHERE account_id IN (${placeholders})`)
|
||||
.bind(...accountIds)
|
||||
.all<ReputationRow>(),
|
||||
const [pages, credits] = await Promise.all([
|
||||
// Chunked: the friends list behind `/api/playerReputation/v2/bulk` runs past D1's
|
||||
// bind cap, and an unchunked statement fails the whole lookup rather than part of it.
|
||||
Promise.all(
|
||||
chunkForBinds(accountIds).map((chunk) =>
|
||||
db
|
||||
.prepare(`SELECT * FROM reputation WHERE account_id IN (${bindPlaceholders(chunk)})`)
|
||||
.bind(...chunk)
|
||||
.all<ReputationRow>()
|
||||
)
|
||||
),
|
||||
getCheerCredits(db, accountIds, now),
|
||||
])
|
||||
const stored = new Map(results.map((r) => [r.account_id, r]))
|
||||
const stored = new Map(pages.flatMap((page) => page.results).map((r) => [r.account_id, r]))
|
||||
return accountIds.map((id) => {
|
||||
const credit = credits.get(id) ?? DAILY_CHEER_CREDIT
|
||||
const row = stored.get(id)
|
||||
|
||||
+424
-69
@@ -6,23 +6,34 @@ import {
|
||||
getOutfit,
|
||||
getOutfits,
|
||||
getOutfitsByAccounts,
|
||||
getOwnedOutfitItemsByAccounts,
|
||||
inventionDescriptionRejection,
|
||||
inventionLongDescriptionRejection,
|
||||
inventionNameRejection,
|
||||
inventionTagRejection,
|
||||
MAX_BULK_OUTFIT_ACCOUNTS,
|
||||
outfitItemBase,
|
||||
restrictOutfitToOwned,
|
||||
setOutfit,
|
||||
} from '@repo/domain'
|
||||
|
||||
// The free catalog, as the econ worker bundles it: what `GET /api/avatar/v4/items` hands
|
||||
// every player before their purchases, and the base items custom clothing is built on.
|
||||
// Read here so the bulk outfit read knows which worn items need no purchase.
|
||||
import defaultAvatarItems from '../../../econ/static/default-avatar-items.json'
|
||||
import defaultBaseAvatarItems from '../../../econ/static/default-base-avatar-items.json'
|
||||
import {
|
||||
createCustomAvatarItem,
|
||||
deleteCustomAvatarItem,
|
||||
getCustomAvatarItem,
|
||||
getCustomAvatarItems,
|
||||
isQuestAssetTarget,
|
||||
listCustomAvatarItemsByCreator,
|
||||
listFeaturedCustomAvatarItems,
|
||||
listHotCustomAvatarItems,
|
||||
MAX_PLAYER_ITEM_PRICE,
|
||||
searchCustomAvatarItems,
|
||||
toQuestCustomAvatarItem,
|
||||
updateCustomAvatarItem,
|
||||
} from '../custom-avatar-items-db'
|
||||
import { authedId, unauthorized } from '../http'
|
||||
@@ -31,6 +42,7 @@ import {
|
||||
deleteInvention,
|
||||
getFeaturedInventions,
|
||||
getInventionById,
|
||||
getInventionsByCreators,
|
||||
getInventionsByIds,
|
||||
getInventionsByRoom,
|
||||
getInventionTagFilters,
|
||||
@@ -42,6 +54,7 @@ import {
|
||||
inventionDeleteResult,
|
||||
inventionSaveV9Failure,
|
||||
isInventionCheered,
|
||||
MAX_INVENTION_PRICE,
|
||||
normalizeInventionTags,
|
||||
ownsAllInventions,
|
||||
parsePermissionLevel,
|
||||
@@ -52,6 +65,7 @@ import {
|
||||
setInventionTags,
|
||||
toSaveResult,
|
||||
toSaveResultV9,
|
||||
unpublishInvention,
|
||||
updateInvention,
|
||||
} from '../inventions-db'
|
||||
import {
|
||||
@@ -79,6 +93,7 @@ import {
|
||||
InventionReportRequest,
|
||||
InventionSaveResult,
|
||||
InventionSaveV9Result,
|
||||
InventionV9Dto,
|
||||
InventionVersionDto,
|
||||
json,
|
||||
JsonArray,
|
||||
@@ -102,8 +117,10 @@ import {
|
||||
SuccessValueEnvelope,
|
||||
TagFilters,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
UnpublishInventionRequest,
|
||||
UpdateCustomAvatarItemRequest,
|
||||
UpdateInventionMetadataRequest,
|
||||
UpdateInventionRequest,
|
||||
UpdatePriceRequest,
|
||||
} from '../openapi'
|
||||
import { createReport } from '../reports-db'
|
||||
@@ -125,6 +142,19 @@ import type { InventionTag, SavedInvention } from '../inventions-db'
|
||||
*/
|
||||
const BULK_CUSTOM_AVATAR_ITEM_CAP = 100
|
||||
|
||||
/**
|
||||
* The base assets every player may wear without buying anything: the default catalog econ
|
||||
* hands everyone (`GET /api/avatar/v4/items` prepends purchases to it) and the base items
|
||||
* custom clothing is built on. Folded into each wearer's owned set when `POST /outfits/bulk`
|
||||
* strips unowned selections — see `restrictOutfitToOwned` for why ownership is decided on
|
||||
* the base asset.
|
||||
*/
|
||||
const FREE_ITEM_BASES: ReadonlySet<string> = new Set(
|
||||
[...defaultAvatarItems, ...defaultBaseAvatarItems].map((item) =>
|
||||
outfitItemBase(item.AvatarItemDesc)
|
||||
)
|
||||
)
|
||||
|
||||
/**
|
||||
* The ids `POST /api/customAvatarItems/v1/bulk` was asked to resolve. They ride as repeated
|
||||
* `customAvatarItemIds` form fields, and the same spelling is read off the query string
|
||||
@@ -149,6 +179,22 @@ async function bulkCustomAvatarItemIds(c: Context<App>): Promise<string[]> {
|
||||
.filter((v) => v !== '')
|
||||
}
|
||||
|
||||
/**
|
||||
* The `unityAssetTarget` `POST /api/customAvatarItems/v1/bulk` was asked for, read the way
|
||||
* {@link bulkCustomAvatarItemIds} reads the ids and for the same reason: off the query string
|
||||
* (where the GET reads carry it) or out of the form, whatever the casing. `parseBody` caches,
|
||||
* so reading the form a second time costs nothing.
|
||||
*/
|
||||
async function bulkUnityAssetTarget(c: Context<App>): Promise<string | undefined> {
|
||||
const queried = c.req.query('unityAssetTarget')
|
||||
if (queried !== undefined) return queried
|
||||
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
|
||||
const key = Object.keys(body).find((k) => k.toLowerCase() === 'unityassettarget')
|
||||
const posted = key === undefined ? undefined : body[key]
|
||||
const value = Array.isArray(posted) ? posted[0] : posted
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The gate every invention write runs through: the caller must be signed in, the
|
||||
* invention must exist, and it must be theirs. Yields the loaded invention, or why not —
|
||||
@@ -159,9 +205,7 @@ async function bulkCustomAvatarItemIds(c: Context<App>): Promise<string[]> {
|
||||
async function creatorsInventionResult(
|
||||
c: Context<App>,
|
||||
inventionId: number
|
||||
): Promise<
|
||||
{ invention: SavedInvention } | { rejection: string; status: 400 | 401 | 403 | 404 }
|
||||
> {
|
||||
): Promise<{ invention: SavedInvention } | { rejection: string; status: 400 | 401 | 403 | 404 }> {
|
||||
const playerId = await authedId(c)
|
||||
if (playerId === null) return { rejection: 'Unauthorized', status: 401 }
|
||||
if (Number.isNaN(inventionId)) return { rejection: 'inventionId is required', status: 400 }
|
||||
@@ -217,7 +261,10 @@ function requestedTags(request: unknown): { tags: InventionTag[]; tagResult: num
|
||||
})
|
||||
return rejected
|
||||
? { tags: [], tagResult: INVENTION_TAG_RESULT.rejected }
|
||||
: { tags: normalizeInventionTags(autoTags, customTags), tagResult: INVENTION_TAG_RESULT.success }
|
||||
: {
|
||||
tags: normalizeInventionTags(autoTags, customTags),
|
||||
tagResult: INVENTION_TAG_RESULT.success,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,8 +274,7 @@ function requestedTags(request: unknown): { tags: InventionTag[]; tagResult: num
|
||||
* two versions disagree about the shape of a reply, not about what a save is.
|
||||
*/
|
||||
type InventionSaveOutcome =
|
||||
| { rejection: string }
|
||||
| { invention: SavedInvention; tags: InventionTag[]; tagResult: number }
|
||||
{ rejection: string } | { invention: SavedInvention; tags: InventionTag[]; tagResult: number }
|
||||
|
||||
/**
|
||||
* The invention save both `v6/save` and `v9/save` run through. v9 sends everything v6 does
|
||||
@@ -353,7 +399,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
ConsumableItemDesc: '',
|
||||
AvatarItemDesc: '',
|
||||
FriendlyName: '',
|
||||
AvatarItemType: 0,
|
||||
// A token box holds no avatar item: null, or the client goes looking for type 0.
|
||||
AvatarItemType: null,
|
||||
EquipmentPrefabName: '',
|
||||
EquipmentModificationGuid: '',
|
||||
CurrencyType: 2,
|
||||
@@ -449,15 +496,21 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
'Multipart: a `metadata` JSON text field plus two file parts, `thumbnailImage` ' +
|
||||
'(PNG) and `design` (the design blob). Inserts a `custom_avatar_item` row owned ' +
|
||||
'by the caller and answers with it in the PascalCase `{ Value, Success, Error, ' +
|
||||
'error_id }` envelope.\n\n' +
|
||||
'error_id }` envelope. The item is filed under `OutfitType` 105, the custom shirt, ' +
|
||||
'which is what the store’s user-generated-content tab searches for.\n\n' +
|
||||
'The two files go to the shared image bucket (`recflare-img`) under ' +
|
||||
'`avatar-item/<date>/<id>-thumb.png` and `avatar-item/<date>/<id>-design.png`; those ' +
|
||||
'keys are the `ThumbnailImageFilename` / `DesignFilename` on the row.',
|
||||
'keys are the `ThumbnailImageFilename` / `DesignFilename` on the row.\n\n' +
|
||||
'`Price` is capped at 1000 tokens (`MAX_PLAYER_ITEM_PRICE`); one above it, or below ' +
|
||||
'0, is refused with a 400 rather than clamped.',
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateCustomAvatarItemRequest, 'The metadata and the two files'),
|
||||
responses: {
|
||||
200: json(CustomAvatarItemResponse, 'The created item'),
|
||||
400: json(CustomAvatarItemResponse, 'Missing or malformed metadata / files'),
|
||||
400: json(
|
||||
CustomAvatarItemResponse,
|
||||
'Missing or malformed metadata / files, or a `Price` outside 0–1000'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
413: json(CustomAvatarItemResponse, 'Either file exceeds the configured per-file limit'),
|
||||
},
|
||||
@@ -483,6 +536,13 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
if (typeof meta.BaseAvatarItemId !== 'number') return fail('BaseAvatarItemId is required')
|
||||
if (typeof meta.BaseAvatarItemColor !== 'string')
|
||||
return fail('BaseAvatarItemColor is required')
|
||||
if (meta.Price !== undefined && meta.Price !== null) {
|
||||
if (typeof meta.Price !== 'number' || !Number.isInteger(meta.Price))
|
||||
return fail('Price must be an integer')
|
||||
if (meta.Price < 0) return fail('Price must be >= 0')
|
||||
if (meta.Price > MAX_PLAYER_ITEM_PRICE)
|
||||
return fail(`Price must be <= ${MAX_PLAYER_ITEM_PRICE}`)
|
||||
}
|
||||
if (!(body.thumbnailImage instanceof File)) return fail('thumbnailImage is required')
|
||||
if (!(body.design instanceof File)) return fail('design is required')
|
||||
const limit = maxApiUploadBytes(c.env)
|
||||
@@ -551,13 +611,14 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
'A partial edit of `Name`, `Description`, `Price` and `Accessibility` — the client ' +
|
||||
'sends every field and nulls the ones it is not changing, so null means "leave ' +
|
||||
'alone". Only the creator may edit. `ModifiedAt` is bumped. Answers the updated ' +
|
||||
'item in the same `{ Value, Success, Error, error_id }` envelope as the create.',
|
||||
'item in the same `{ Value, Success, Error, error_id }` envelope as the create. ' +
|
||||
'`Price` is bound the same way as on the create: 0 to 1000 tokens, or a 400.',
|
||||
security: AUTHED,
|
||||
parameters: [stringParam('id', 'The `CustomAvatarItemId`')],
|
||||
requestBody: jsonBody(UpdateCustomAvatarItemRequest, 'The fields to change'),
|
||||
responses: {
|
||||
200: json(CustomAvatarItemResponse, 'The updated item'),
|
||||
400: json(CustomAvatarItemResponse, 'Malformed body'),
|
||||
400: json(CustomAvatarItemResponse, 'Malformed body, or a `Price` outside 0–1000'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(CustomAvatarItemResponse, 'Not the creator'),
|
||||
404: json(CustomAvatarItemResponse, 'No such item'),
|
||||
@@ -600,6 +661,9 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
if (patch.name !== null && patch.name?.trim() === '')
|
||||
return fail(400, 'Name must not be blank')
|
||||
if (patch.price !== null && patch.price < 0) return fail(400, 'Price must be >= 0')
|
||||
if (patch.price !== null && patch.price > MAX_PLAYER_ITEM_PRICE)
|
||||
return fail(400, `Price must be <= ${MAX_PLAYER_ITEM_PRICE}`)
|
||||
|
||||
const item = await updateCustomAvatarItem(c.env.DB, itemId, patch)
|
||||
if (!item) return fail(404, 'No such item')
|
||||
@@ -637,8 +701,13 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
|
||||
const item = await deleteCustomAvatarItem(c.env.DB, itemId)
|
||||
if (!item) return fail(404, 'No such item')
|
||||
// The row is gone; the objects follow. A missing key is a no-op for R2.
|
||||
await c.env.IMAGES.delete([item.ThumbnailImageFilename, item.DesignFilename])
|
||||
// The row is gone; the objects follow. A missing key is a no-op for R2. A first-party
|
||||
// item has neither (it is rendered from its saves' assetbundles), so there is nothing
|
||||
// to delete for one.
|
||||
const keys = [item.ThumbnailImageFilename, item.DesignFilename].filter(
|
||||
(k): k is string => k !== null
|
||||
)
|
||||
if (keys.length > 0) await c.env.IMAGES.delete(keys)
|
||||
return c.json({ Value: item, Success: true, Error: null, error_id: null })
|
||||
}
|
||||
)
|
||||
@@ -681,10 +750,21 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
'rather than no results, since the client sends every type it can render.',
|
||||
'`minPrice`/`maxPrice` bound the price, inclusive.',
|
||||
'`skip`/`take` page the results, `take` capped at 200.',
|
||||
'`includeCoachItems=false` leaves out this server’s stock content.',
|
||||
'`itemTypes`, `ordering`, `unityAssetTarget` and `unityAssetVersion` are accepted and',
|
||||
'NOT yet acted on — nothing records purchase or wear counts to rank by, no per-target',
|
||||
'asset variants are stored, and custom avatar items are the only item type there is.',
|
||||
'`includeCoachItems` picks a SIDE of the catalog: `true` serves only the Coach’s',
|
||||
'stock content (account 1), `false` only player-made items, and leaving it out serves',
|
||||
'both. The client’s storefront tab sends `True` and its user-generated-content tab',
|
||||
'`False`, so the two never overlap.',
|
||||
'`creatorAccountId` narrows to one author’s items — what a player’s creations list',
|
||||
'sends (`creatorAccountId=<id>&includeCoachItems=False`); still published items only.',
|
||||
'`outfitTypes` values are `RecRoom.Avatars.OutfitType` ordinals; the user-generated-',
|
||||
'content tab sends 105 (`CustomShirt`) alone, the storefront tab a dozen slots.',
|
||||
'`itemTypes` (a separate enum: All -1, None 0, Shirt 1 — not the outfit type),',
|
||||
'`ordering` (0 is `SearchScoreDescending`) and `unityAssetVersion` are accepted and NOT',
|
||||
'yet acted on — nothing records purchase or wear counts to rank by, no per-version',
|
||||
'asset variants are stored, and custom shirts are the only item type there is.',
|
||||
'`unityAssetTarget` IS acted on: 2 (Android/Oculus) serves each save’s',
|
||||
'`UnityAsset`/`UnityAsset2` under `quest/`, the Android build of the same assetbundle;',
|
||||
'0 (PC), any other value, and none at all get the bare filenames as stored.',
|
||||
'`includePurchaseInfos` likewise: `PurchaseInfo` is null on every item for now,',
|
||||
'whatever it says.',
|
||||
].join(' '),
|
||||
@@ -735,9 +815,26 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
name: 'includeCoachItems',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Include the Coach’s stock items (default true)',
|
||||
description:
|
||||
'true: only the Coach’s stock items; false: only player-made items; absent: both',
|
||||
schema: { type: 'boolean' },
|
||||
},
|
||||
{
|
||||
name: 'creatorAccountId',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'Only items this account made',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
{
|
||||
name: 'unityAssetTarget',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description:
|
||||
'The Unity build target the assetbundles are wanted for: 0 PC (Windows), 2 ' +
|
||||
'Android/Oculus — which is served the `quest/` builds',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(CustomAvatarItemList, 'The matching items, newest first') },
|
||||
}),
|
||||
@@ -750,9 +847,11 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
?.map((v) => Number.parseInt(v, 10))
|
||||
.filter((n) => Number.isInteger(n))
|
||||
|
||||
// Three-way: `True` is the Coach's side of the store, `False` the players', absent both.
|
||||
// The client capitalises its booleans (`includeCoachItems=True`), so this is folded
|
||||
// before comparing; anything that isn't recognisably false leaves the default alone.
|
||||
const includeCoachItems = c.req.query('includeCoachItems')?.toLowerCase() !== 'false'
|
||||
// before comparing; anything that isn't recognisably true or false is treated as absent.
|
||||
const coach = c.req.query('includeCoachItems')?.toLowerCase()
|
||||
const includeCoachItems = coach === 'true' ? true : coach === 'false' ? false : undefined
|
||||
|
||||
const int = (name: string): number | undefined => {
|
||||
const raw = c.req.query(name)
|
||||
@@ -761,16 +860,22 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
return Number.isInteger(n) ? n : undefined
|
||||
}
|
||||
|
||||
const items = await searchCustomAvatarItems(c.env.DB, {
|
||||
searchQuery: c.req.query('searchQuery'),
|
||||
outfitTypes,
|
||||
includeCoachItems,
|
||||
creatorAccountId: int('creatorAccountId'),
|
||||
minPrice: int('minPrice'),
|
||||
maxPrice: int('maxPrice'),
|
||||
skip: int('skip'),
|
||||
take: int('take'),
|
||||
})
|
||||
// The same switch the bulk lookup makes: a caller asking for the Quest target is
|
||||
// pointed at the `quest/` builds of the assetbundles the saves name.
|
||||
return c.json(
|
||||
await searchCustomAvatarItems(c.env.DB, {
|
||||
searchQuery: c.req.query('searchQuery'),
|
||||
outfitTypes,
|
||||
includeCoachItems,
|
||||
minPrice: int('minPrice'),
|
||||
maxPrice: int('maxPrice'),
|
||||
skip: int('skip'),
|
||||
take: int('take'),
|
||||
})
|
||||
isQuestAssetTarget(c.req.query('unityAssetTarget'))
|
||||
? items.map(toQuestCustomAvatarItem)
|
||||
: items
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -828,6 +933,10 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
'Ids ride as repeated `customAvatarItemIds` form fields; a comma-separated value ' +
|
||||
'and the same spelling on the query string are both accepted, since the client’s ' +
|
||||
'exact encoding here has not been pinned down.\n\n' +
|
||||
'A caller asking for the Quest build — `unityAssetTarget` 2 (Android/Oculus), on ' +
|
||||
'the query string or in the form — is served each save’s `UnityAsset`/`UnityAsset2` ' +
|
||||
'under `quest/`, the Android build of the same assetbundle. Target 0 (PC), any ' +
|
||||
'other value, and no target at all get the bare filenames as stored.\n\n' +
|
||||
'A batch of more than 100 ids answers an EMPTY array without reading the table: the ' +
|
||||
'client has been seen posting more than a screen could draw, and a miss is already ' +
|
||||
'not an error here.',
|
||||
@@ -853,8 +962,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
// Unpublished items are held back from everyone but their creator — the same rule
|
||||
// the featured/hot feeds and the creator shelf apply, so an item can't be surfaced
|
||||
// through this route that the feeds hide.
|
||||
const visible = items.filter(
|
||||
(item) => item.Accessibility !== 0 || item.CreatorAccountId === id
|
||||
)
|
||||
// A Quest can't load the PC assetbundles the saves name; its builds sit beside them
|
||||
// under `quest/`, so the names are pointed there for a caller asking for that target.
|
||||
return c.json(
|
||||
items.filter((item) => item.Accessibility !== 0 || item.CreatorAccountId === id)
|
||||
isQuestAssetTarget(await bulkUnityAssetTarget(c))
|
||||
? visible.map(toQuestCustomAvatarItem)
|
||||
: visible
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -997,6 +1113,11 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
// id, and a list would make it match up the order itself. An account with nothing saved is
|
||||
// left out of the map — see `getOutfitsByAccounts`.
|
||||
//
|
||||
// This is how OTHER players see an outfit, so it is served with the selections the wearer
|
||||
// does not own stripped: nothing on the save path checks ownership, and an edited save
|
||||
// would otherwise dress a player in items they never bought for the whole room to see.
|
||||
// The owner's own `GET /outfits/me` stays verbatim.
|
||||
//
|
||||
// `UnityAssetTarget` / `UnityAssetVersion` name the baked-asset build the client would
|
||||
// like the outfits for. Nothing here bakes assets, so both are accepted and ignored.
|
||||
.post(
|
||||
@@ -1008,8 +1129,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
'The worn outfit (slot 0) of each account in `AccountIds`, keyed by account id — the ' +
|
||||
'call the client makes to dress a room full of players in one request.\n\n' +
|
||||
'A MAP rather than a list: the client looks each player up by id. The key is the id ' +
|
||||
'as a string, and the value is the same stored outfit `GET /outfits/me` serves, ' +
|
||||
'handed back exactly as it was saved. An account with nothing saved in slot 0 is ' +
|
||||
'as a string, and the value is the stored outfit `GET /outfits/me` serves, LESS any ' +
|
||||
'selection naming an item the wearer does not own: a base asset that is neither in ' +
|
||||
'the free default catalog nor on any item they bought, or a custom item they ' +
|
||||
'neither bought nor made. The save path checks nothing, and this read is what ' +
|
||||
'everyone else dresses the player from. An outfit that loses nothing is served ' +
|
||||
'exactly as saved. An account with nothing saved in slot 0 is ' +
|
||||
'ABSENT from the map rather than carrying a null — a map says “no outfit” by not ' +
|
||||
'having the key, and inventing one for a player who has never saved would dress them ' +
|
||||
'in something they never chose.\n\n' +
|
||||
@@ -1046,8 +1171,21 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
|
||||
const outfits = await getOutfitsByAccounts(c.env.DB, accountIds, CURRENT_OUTFIT_SLOT)
|
||||
// What each wearer may wear, read for the accounts that HAVE an outfit: a selection
|
||||
// naming an item the wearer never bought is dropped before anyone else dresses them
|
||||
// in it. The free catalog counts as owned; see `restrictOutfitToOwned`.
|
||||
const owned = await getOwnedOutfitItemsByAccounts(
|
||||
c.env.DB,
|
||||
[...outfits.keys()],
|
||||
FREE_ITEM_BASES
|
||||
)
|
||||
const OutfitsByAccountId: Record<string, unknown> = {}
|
||||
for (const [accountId, outfit] of outfits) OutfitsByAccountId[String(accountId)] = outfit
|
||||
for (const [accountId, outfit] of outfits) {
|
||||
const wearable = owned.get(accountId)
|
||||
OutfitsByAccountId[String(accountId)] = wearable
|
||||
? restrictOutfitToOwned(outfit, wearable)
|
||||
: outfit
|
||||
}
|
||||
return c.json({ OutfitsByAccountId })
|
||||
}
|
||||
)
|
||||
@@ -1385,15 +1523,18 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
summary: 'Publish an invention',
|
||||
description:
|
||||
'What puts an invention into search and the feeds. Sets the permission other ' +
|
||||
'players get (defaulting to UseOnly) and its price. Another GET that writes.',
|
||||
'players get (defaulting to UseOnly) and its price. Another GET that writes. A ' +
|
||||
'price over 1000 tokens (`MAX_INVENTION_PRICE`) refuses the whole publish with a ' +
|
||||
'400 rather than being clamped or dropped.',
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
intQuery('inventionId', 'Invention id; required'),
|
||||
stringQuery('permissionLevel', 'A name like `useonly`, or the raw number'),
|
||||
intQuery('price', 'Price in tokens; negative is ignored'),
|
||||
intQuery('price', 'Price in tokens, at most 1000; negative is ignored'),
|
||||
],
|
||||
responses: {
|
||||
200: json(InventionSaveResult, 'The published invention, in the save envelope'),
|
||||
400: json(ErrorResponse, 'A price over 1000'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||||
404: { description: 'No such invention' },
|
||||
@@ -1405,6 +1546,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
|
||||
const permissionLevel = c.req.query('permissionLevel')
|
||||
const price = Number.parseInt(c.req.query('price') ?? '', 10)
|
||||
if (price > MAX_INVENTION_PRICE)
|
||||
return c.json({ error: `Price must be <= ${MAX_INVENTION_PRICE}` }, 400)
|
||||
|
||||
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
|
||||
permissionLevel:
|
||||
@@ -1424,12 +1567,12 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
summary: 'Set an invention’s price',
|
||||
description:
|
||||
'Unlike update/publish, this one POSTs a JSON body. Creator only; a negative price ' +
|
||||
'is rejected.',
|
||||
'is rejected, as is one over 1000 tokens (`MAX_INVENTION_PRICE`).',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(UpdatePriceRequest, 'The invention and its new price'),
|
||||
responses: {
|
||||
200: json(InventionSaveResult, 'The repriced invention, in the save envelope'),
|
||||
400: json(ErrorResponse, 'Unparseable body, or a price below 0'),
|
||||
400: json(ErrorResponse, 'Unparseable body, or a price below 0 or over 1000'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorResponse, 'Not the caller’s invention'),
|
||||
404: { description: 'No such invention' },
|
||||
@@ -1445,6 +1588,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
|
||||
const price = typeof body.Price === 'number' ? body.Price : Number.NaN
|
||||
if (Number.isNaN(price) || price < 0) return c.json({ error: 'Price must be >= 0' }, 400)
|
||||
if (price > MAX_INVENTION_PRICE)
|
||||
return c.json({ error: `Price must be <= ${MAX_INVENTION_PRICE}` }, 400)
|
||||
|
||||
const updated = await setInventionPrice(c.env.DB, gate.invention.InventionId, price)
|
||||
return updated === null ? c.notFound() : c.json(toSaveResult(updated))
|
||||
@@ -1604,35 +1749,35 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
)
|
||||
|
||||
// Inventions by particular creators (`?id=207&id=…`) — what the client fills a creator's
|
||||
// shelf, and the "from creators you follow" row, from.
|
||||
// portfolio, and the "from creators you follow" row, from.
|
||||
//
|
||||
// STUB: an empty list for now. It is the honest answer rather than a placeholder, since
|
||||
// the client reads it as "this creator has published nothing" and renders an empty
|
||||
// shelf, where a 404 would read as a row that failed to load. When it becomes real it is
|
||||
// a filter on the invention table's creator column, the same feed shape as `toptoday`
|
||||
// and `featured` above — `id` is repeatable, and `skip`/`take` page it.
|
||||
// Published only, for every caller — a creator's own drafts are `v2/mine`'s alone.
|
||||
.get(
|
||||
'/api/inventions/v1/fromcreators',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Inventions by particular creators (stub)',
|
||||
summary: 'Inventions by particular creators',
|
||||
description:
|
||||
'The published inventions of the accounts named by `id` (repeatable), newest first — ' +
|
||||
'a creator’s shelf, and the "from creators you follow" row. STUB: always an empty ' +
|
||||
'array for now, which the client renders as "nothing published" rather than as a ' +
|
||||
'failed load. `id`, `skip` and `take` are accepted and, for the moment, ignored.',
|
||||
parameters: [
|
||||
intQuery('id', 'Creator account id; repeatable. Accepted and ignored by the stub'),
|
||||
...pageParams(100),
|
||||
],
|
||||
responses: { 200: json(InventionDto.array(), 'Empty — nothing is served here yet') },
|
||||
'The inventions of the accounts named by `id` (repeatable), newest first — a ' +
|
||||
'creator’s portfolio, and the "from creators you follow" row. Only PUBLISHED ' +
|
||||
'inventions are listed, the caller’s own included: unpublished ones are served by ' +
|
||||
'`v2/mine` alone. No `id` is an empty array.',
|
||||
parameters: [intQuery('id', 'Creator account id; repeatable'), ...pageParams(100)],
|
||||
responses: {
|
||||
200: json(InventionDto.extend(InventionV9Dto.shape).array(), 'The creators’ inventions'),
|
||||
},
|
||||
}),
|
||||
(c) => c.json([])
|
||||
async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
return c.json(await getInventionsByCreators(c.env.DB, inventionIdQuery(c), skip, take))
|
||||
}
|
||||
)
|
||||
|
||||
// Invention search/browse: published inventions matching `value` (matched against
|
||||
// name + description; absent → browse everything published), newest first.
|
||||
// Paginated via skip/take (take defaults to 100). Returns a bare array.
|
||||
// name + description; absent → browse everything published; `@username` → that
|
||||
// player's published inventions), newest first. Paginated via skip/take (take
|
||||
// defaults to 100). Returns a bare array.
|
||||
//
|
||||
// Filtered, ordered and paged in SQL — it must not read the catalogue into memory to
|
||||
// answer one page.
|
||||
@@ -1645,10 +1790,16 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
'Published inventions matching `value`, newest first. `value` is split into terms ' +
|
||||
'and every term must match, each against the name and the description. An absent ' +
|
||||
'`value` browses everything published — that is the browse screen’s initial ' +
|
||||
'request. Tags are NOT searched: a `#tag` term from the browse screen’s filter ' +
|
||||
'chips is treated as text and matches nothing.',
|
||||
'request. A `value` starting with `@` names a player: `@djdevin` lists that ' +
|
||||
'player’s PUBLISHED inventions (the same set `v1/fromcreators` serves), resolved by ' +
|
||||
'username case-insensitively, with any further terms narrowing them as text; an ' +
|
||||
'unknown username is an empty array. Tags are NOT searched: a `#tag` term from the ' +
|
||||
'browse screen’s filter chips is treated as text and matches nothing.',
|
||||
parameters: [
|
||||
stringQuery('value', 'Search text; absent browses everything'),
|
||||
stringQuery(
|
||||
'value',
|
||||
'Search text; absent browses everything; `@username` is a player’s inventions'
|
||||
),
|
||||
...pageParams(100),
|
||||
],
|
||||
responses: { 200: json(InventionDto.array(), 'The matching inventions') },
|
||||
@@ -1677,7 +1828,10 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
'invention listed even if it has since been unpublished or hidden. Not paginated.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(InventionDto.array(), 'The caller’s inventions'),
|
||||
200: json(
|
||||
InventionDto.extend(InventionV9Dto.shape).array(),
|
||||
'The caller’s inventions — the stored record with the newer client’s flat DTO laid over it'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
@@ -1793,6 +1947,49 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
// The newer client's cheer: the same body as `v1/cheer` and the same write, at a new
|
||||
// path. It answers the `{ Value, Success, Error, error_id }` envelope the other v2+
|
||||
// invention routes use, refusals in-band — an ASSUMPTION: only the request has been
|
||||
// observed, not what the client reads off the reply.
|
||||
.post(
|
||||
'/api/inventions/v2/cheer',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Cheer or un-cheer an invention (v2)',
|
||||
description:
|
||||
'`v1/cheer` for the newer client: persists the caller’s cheer state and resyncs ' +
|
||||
'the invention’s `CheerCount`; repeating the same state is idempotent. Answers ' +
|
||||
'the `{ Value, Success, Error, error_id }` envelope with `Value` null, and a ' +
|
||||
'refusal — a bad body, an unknown invention — is `Success: false` with a message ' +
|
||||
'rather than an error status.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(InventionCheerRequest, 'The invention and new cheer state'),
|
||||
responses: {
|
||||
200: json(InventionDeleteResult, 'The envelope, `Value` null either way'),
|
||||
401: json(InventionDeleteResult, 'The same envelope, refused — not an empty body'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const playerId = await authedId(c)
|
||||
if (playerId === null) return c.json(inventionDeleteResult('Unauthorized'), 401)
|
||||
const body = await c.req
|
||||
.json<{ InventionId?: unknown; Cheer?: unknown }>()
|
||||
.catch(() => ({}) as Record<string, unknown>)
|
||||
const inventionId = body.InventionId
|
||||
if (
|
||||
typeof inventionId !== 'number' ||
|
||||
!Number.isInteger(inventionId) ||
|
||||
typeof body.Cheer !== 'boolean'
|
||||
) {
|
||||
return c.json(inventionDeleteResult('InventionId and Cheer are required'))
|
||||
}
|
||||
if ((await getInventionById(c.env.DB, inventionId)) === null) {
|
||||
return c.json(inventionDeleteResult('No such invention'))
|
||||
}
|
||||
await setInventionCheer(c.env.DB, playerId, inventionId, body.Cheer)
|
||||
return c.json(inventionDeleteResult())
|
||||
}
|
||||
)
|
||||
// Report an invention. Stored in the `report` table the player and event reports use —
|
||||
// same fields, same moderation life — with `invention_id` set. See
|
||||
// migrations/0016_report_invention.sql.
|
||||
@@ -2026,9 +2223,7 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
for (const rejection of [
|
||||
name === undefined ? null : inventionNameRejection(name),
|
||||
description === undefined ? null : inventionDescriptionRejection(description),
|
||||
longDescription === undefined
|
||||
? null
|
||||
: inventionLongDescriptionRejection(longDescription),
|
||||
longDescription === undefined ? null : inventionLongDescriptionRejection(longDescription),
|
||||
]) {
|
||||
if (rejection !== null) return c.json(inventionSaveV9Failure(rejection))
|
||||
}
|
||||
@@ -2055,6 +2250,112 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Edit an invention, as the newer client sends it: the `v1/update` write with a
|
||||
// PascalCase body instead of a query string. What the permission picker posts —
|
||||
// `{ InventionId, Permission }` — and so the endpoint that changes what other players
|
||||
// may DO with an invention, where `v2/metadata` beside it changes what it SAYS.
|
||||
//
|
||||
// Creator only, which is the whole point of the gate: `GeneralPermission` is what lets
|
||||
// another player edit, re-publish or charge for someone's build, so a write that took
|
||||
// the caller's word for the id would let anyone hand themselves rights over any
|
||||
// invention in the game.
|
||||
.post(
|
||||
'/api/inventions/v2/update',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Edit an invention (v2)',
|
||||
description:
|
||||
'The newer client’s form of `v1/update`: a PascalCase JSON body in place of the ' +
|
||||
'query string. A patch, not a replace — every field but `InventionId` is nullable ' +
|
||||
'and a null one is left as it is, since the client sends the whole shape on every ' +
|
||||
'edit.\n\n' +
|
||||
'**Creator only.** `Permission` decides what other players may do with the ' +
|
||||
'invention — up to editing, re-publishing and charging for it — so only the ' +
|
||||
'account that created it may set it.\n\n' +
|
||||
'`Permission` is the raw `GeneralPermission` ladder number (UseOnly 20, ' +
|
||||
'EditAndSave 40, Publish 60 …), stored verbatim and unmapped, exactly as ' +
|
||||
'`v1/update` stores the value its picker names. A null one LEAVES IT ALONE — ' +
|
||||
'unlike `v4/publish`, where null means UseOnly: publishing something with no ' +
|
||||
'permission named is a decision, but editing it without naming one is not.\n\n' +
|
||||
'Publishing, pricing and tags are elsewhere (`v4/publish`, `v1/updateprice`, ' +
|
||||
'`v2/metadata`), as they are for `v1/update`.\n\n' +
|
||||
'Answers the enveloped result `v9/save` answers, carrying the UPDATED invention: ' +
|
||||
'the client re-renders the detail sheet from `Value.Invention`, so a refusal — an ' +
|
||||
'unknown invention and someone else’s alike — is `Success: false` with a null ' +
|
||||
'`Value` rather than a bare error body, which that client cannot parse.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(UpdateInventionRequest, 'The fields to change'),
|
||||
responses: {
|
||||
200: json(
|
||||
InventionSaveV9Result,
|
||||
'The envelope — the updated invention under `Value`, or `Success: false` with ' +
|
||||
'`Error` when the edit was refused'
|
||||
),
|
||||
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
|
||||
|
||||
// The id rides in the body here, not the query string. The gate is what makes this
|
||||
// creator-only: it loads the invention and refuses anyone but the account that
|
||||
// created it.
|
||||
const gate = await creatorsInventionResult(
|
||||
c,
|
||||
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
|
||||
)
|
||||
// Only a missing token is answered as a transport failure. An unknown invention or
|
||||
// someone else's is a domain answer this client is meant to read — its own status
|
||||
// enum has DoesNotExist and NotCreator members — so it goes in the envelope, where
|
||||
// the message reaches a human. Same split `v2/metadata` makes.
|
||||
if ('rejection' in gate) {
|
||||
return gate.status === 401
|
||||
? c.json(inventionSaveV9Failure(gate.rejection), 401)
|
||||
: c.json(inventionSaveV9Failure(gate.rejection))
|
||||
}
|
||||
|
||||
// A number is the picker's own form; a string is accepted through the same parser
|
||||
// `v1/update`'s query param goes through, so a build sending `"EditAndSave"` or
|
||||
// `"40"` lands on the same level. Anything else (null included) leaves it alone.
|
||||
const permission =
|
||||
typeof body.Permission === 'number'
|
||||
? body.Permission
|
||||
: typeof body.Permission === 'string'
|
||||
? parsePermissionLevel(body.Permission)
|
||||
: undefined
|
||||
|
||||
// Null is "leave it"; a string, empty or not, is an edit — `v2/metadata`'s rule,
|
||||
// since these are the same client's bodies.
|
||||
const edited = (key: string): string | undefined =>
|
||||
typeof body[key] === 'string' ? body[key] : undefined
|
||||
const name = edited('Name')?.trim()
|
||||
const description = edited('Description')
|
||||
const longDescription = edited('LongDescription')
|
||||
|
||||
for (const rejection of [
|
||||
name === undefined ? null : inventionNameRejection(name),
|
||||
description === undefined ? null : inventionDescriptionRejection(description),
|
||||
longDescription === undefined ? null : inventionLongDescriptionRejection(longDescription),
|
||||
]) {
|
||||
if (rejection !== null) return c.json(inventionSaveV9Failure(rejection))
|
||||
}
|
||||
|
||||
const updated = await updateInvention(c.env.DB, gate.invention.InventionId, {
|
||||
generalPermission: permission,
|
||||
allowTrial: typeof body.AllowTrial === 'boolean' ? body.AllowTrial : undefined,
|
||||
name,
|
||||
description,
|
||||
longDescription,
|
||||
imageName: edited('ImageName'),
|
||||
})
|
||||
if (updated === null) return c.json(inventionSaveV9Failure('No such invention'))
|
||||
// The tags are reported as the ones the invention HAS, not as ones this call
|
||||
// changed — it changes none. Same reading `v2/metadata` gives a null TagsRequest.
|
||||
return c.json(toSaveResultV9(updated, updated.Tags ?? [], INVENTION_TAG_RESULT.success))
|
||||
}
|
||||
)
|
||||
|
||||
// Publish an invention, as the newer client sends it: a PascalCase body instead of a
|
||||
// query string, and an Accessibility of its own — where `v3/publish` only ever flipped
|
||||
// the published flag, this decides whether the result can be FOUND. Same enveloped
|
||||
@@ -2067,13 +2368,15 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
description:
|
||||
'What puts an invention into search and the feeds. Creator only.\n\n' +
|
||||
'`Permission` is the `GeneralPermission` other players get, as a raw ladder ' +
|
||||
'number (the publish sheet sends 20, UseOnly). `Accessibility` says where it can ' +
|
||||
'be found — 1 (Public) lists it, 2 (Unlisted) publishes it reachable by id but ' +
|
||||
'keeps it out of browse and search. A null `Price` leaves the price alone rather ' +
|
||||
'number (the publish sheet sends 20, UseOnly). `Accessibility` says whether it ' +
|
||||
'is in the store — 1 (Public) lists it in search and the feeds, 2 (Unlisted) ' +
|
||||
'publishes it onto the creator’s portfolio and reachable by id but keeps it out ' +
|
||||
'of both. A null `Price` leaves the price alone rather ' +
|
||||
'than zeroing it, so re-publishing something that was for sale doesn’t give it ' +
|
||||
'away; every field but `InventionId` is nullable and an omitted one keeps what ' +
|
||||
'the invention has.\n\n' +
|
||||
'Publishing is not undone here, and re-publishing doesn’t re-date the first ' +
|
||||
'the invention has. A price over 1000 tokens (`MAX_INVENTION_PRICE`) refuses the ' +
|
||||
'publish in-band.\n\n' +
|
||||
'Publishing is undone by `v2/unpublish`, and re-publishing doesn’t re-date the first ' +
|
||||
'publish. Refusals answer `Success: false` with a null `Value`, the way ' +
|
||||
'`v9/save` does.',
|
||||
security: AUTHED,
|
||||
@@ -2110,6 +2413,8 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
const int = (key: string): number | undefined =>
|
||||
typeof body[key] === 'number' && Number.isInteger(body[key]) ? body[key] : undefined
|
||||
const price = int('Price')
|
||||
if (price !== undefined && price > MAX_INVENTION_PRICE)
|
||||
return c.json(inventionSaveV9Failure(`Price must be <= ${MAX_INVENTION_PRICE}`))
|
||||
|
||||
const published = await publishInvention(c.env.DB, gate.invention.InventionId, {
|
||||
permissionLevel: int('Permission'),
|
||||
@@ -2122,6 +2427,56 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Unpublish an invention: `v4/publish` undone. Same body shape as `v2/delete` — the id
|
||||
// and nothing else — and creator only.
|
||||
//
|
||||
// The reply is the `v9/save` envelope carrying the UPDATED invention, as `v4/publish`
|
||||
// answers. That is an ASSUMPTION: what the client reads off this response has not been
|
||||
// observed, only the request.
|
||||
.post(
|
||||
'/api/inventions/v2/unpublish',
|
||||
describeRoute({
|
||||
tags: ['Inventions'],
|
||||
summary: 'Unpublish an invention',
|
||||
description:
|
||||
'Creator only. Takes the invention back out of search, the feeds and the ' +
|
||||
'creator’s portfolio: `IsPublished` goes false and `Accessibility` back to 0, the ' +
|
||||
'state a save mints, so the next `v4/publish` sets both afresh. Players who ' +
|
||||
'already bought it keep it, the price and permission are left as they are, and ' +
|
||||
'the first-publish date is not cleared.\n\n' +
|
||||
'Answers the enveloped result `v4/publish` answers, carrying the unpublished ' +
|
||||
'invention. Refusals are `Success: false` with a null `Value`.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(UnpublishInventionRequest, 'The invention to unpublish'),
|
||||
responses: {
|
||||
200: json(
|
||||
InventionSaveV9Result,
|
||||
'The envelope — the unpublished invention under `Value`, or `Success: false` ' +
|
||||
'with `Error` when it was refused'
|
||||
),
|
||||
401: json(InventionSaveV9Result, 'The same envelope, refused — not an empty body'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (body === null) return c.json(inventionSaveV9Failure('Invalid request body'))
|
||||
|
||||
const gate = await creatorsInventionResult(
|
||||
c,
|
||||
typeof body.InventionId === 'number' ? body.InventionId : Number.NaN
|
||||
)
|
||||
if ('rejection' in gate) {
|
||||
return gate.status === 401
|
||||
? c.json(inventionSaveV9Failure(gate.rejection), 401)
|
||||
: c.json(inventionSaveV9Failure(gate.rejection))
|
||||
}
|
||||
|
||||
const unpublished = await unpublishInvention(c.env.DB, gate.invention.InventionId)
|
||||
if (unpublished === null) return c.json(inventionSaveV9Failure('No such invention'))
|
||||
return c.json(toSaveResultV9(unpublished, unpublished.Tags ?? []))
|
||||
}
|
||||
)
|
||||
|
||||
// Delete an invention. The newer client's shape: a POST with a PascalCase body
|
||||
// carrying nothing but the id. Auth-gated, creator only — the only thing that may
|
||||
// remove an invention is the account that made it, not a co-owner and not a buyer.
|
||||
|
||||
+262
-47
@@ -1,7 +1,7 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { Accessibility, GAME_VERSION } from '@repo/domain'
|
||||
import { Accessibility, createNotification, GAME_VERSION, MessageType } from '@repo/domain'
|
||||
import { logger } from '@repo/hono-helpers'
|
||||
import { validateAndGetVersion } from '@repo/jwt'
|
||||
|
||||
@@ -21,10 +21,13 @@ import {
|
||||
getEventsByIds,
|
||||
getEventsByRoom,
|
||||
getEventTags,
|
||||
getGoingPlayerIds,
|
||||
getLiveEvents,
|
||||
getPublicEventsByPlayer,
|
||||
inviteToEvent,
|
||||
isEventResponseType,
|
||||
parseEventBody,
|
||||
parseEventSort,
|
||||
parseEventTags,
|
||||
parseEventTime,
|
||||
searchEvents,
|
||||
@@ -62,6 +65,7 @@ import {
|
||||
PlayerEventsPage,
|
||||
PlayerEventTagsRequest,
|
||||
PlayerEventTimeRequest,
|
||||
PlayerPublicEvents,
|
||||
stringQuery,
|
||||
SuccessErrorEnvelope,
|
||||
TagFilters,
|
||||
@@ -70,7 +74,11 @@ import {
|
||||
import { createReport } from '../reports-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { PlayerEventResponsePayload } from '../../../notify/src/notification-payloads'
|
||||
import type { StoredNotification } from '@repo/domain'
|
||||
import type {
|
||||
PlayerEventIdPayload,
|
||||
PlayerEventResponsePayload,
|
||||
} from '../../../notify/src/notification-payloads'
|
||||
import type { App } from '../context'
|
||||
import type { EventAttendeeRow, EventInput, EventTag, PlayerEvent } from '../events-db'
|
||||
|
||||
@@ -105,12 +113,84 @@ async function notifyEventCreated(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Push one frame to every player who is GOING to an event — the audience for a change
|
||||
* to the event itself. Only Going rows (see {@link getGoingPlayerIds}): a player who is
|
||||
* merely invited, interested or declined has nothing on screen to update. The creator
|
||||
* is among them through their own Going row, so their own edit or delete reaches their
|
||||
* client too, the way `PlayerEventCreated` does.
|
||||
*
|
||||
* 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.
|
||||
* `playerIds` is passed in rather than read here because a delete has to read them
|
||||
* BEFORE the rows go. Hub failures are logged and swallowed, and one player's failure
|
||||
* doesn't stop the rest: the change is already stored by the time this runs.
|
||||
*/
|
||||
async function notifyGoing(
|
||||
c: Context<App>,
|
||||
playerIds: number[],
|
||||
type: NotificationType,
|
||||
payload: Record<string, unknown>,
|
||||
playerEventId: number
|
||||
): Promise<void> {
|
||||
const hub = c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
|
||||
for (const playerId of playerIds) {
|
||||
try {
|
||||
await hub.notifyPlayer(playerId, type, payload)
|
||||
} catch (err) {
|
||||
logger.error('failed to push player event notification', {
|
||||
notificationType: type,
|
||||
playerEventId,
|
||||
playerId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a `PlayerEventUpdated` (81) to everyone Going to an event that was just edited —
|
||||
* the whole-event update and every single-field edit alike — carrying the event as it
|
||||
* now stands, in the same camelCase {@link toEventNotification} projection the
|
||||
* `PlayerEventCreated` frame uses: the two share a payload shape on the client
|
||||
* (`PlayerEventPayload` in notification-payloads.ts documents its fields; the decoder
|
||||
* accepts either casing).
|
||||
*/
|
||||
async function notifyEventUpdated(
|
||||
c: Context<App>,
|
||||
event: PlayerEvent,
|
||||
tags: EventTag[]
|
||||
): Promise<void> {
|
||||
const payload: Record<string, unknown> = { ...toEventNotification(event, tags) }
|
||||
await notifyGoing(
|
||||
c,
|
||||
await getGoingPlayerIds(c.env.DB, event.PlayerEventId),
|
||||
NotificationType.PlayerEventUpdated,
|
||||
payload,
|
||||
event.PlayerEventId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a `PlayerEventDeleted` (82) to everyone who was Going to an event that was just
|
||||
* deleted, so it leaves their screen. The frame is just the id — there is no event left
|
||||
* to carry. `playerIds` must have been read before the delete took the RSVP rows with it.
|
||||
*/
|
||||
async function notifyEventDeleted(
|
||||
c: Context<App>,
|
||||
playerIds: number[],
|
||||
playerEventId: number
|
||||
): Promise<void> {
|
||||
const payload = { PlayerEventId: playerEventId } satisfies PlayerEventIdPayload
|
||||
await notifyGoing(c, playerIds, NotificationType.PlayerEventDeleted, payload, playerEventId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Push a `PlayerEventResponseChanged` (83) to a player who just ANSWERED an event
|
||||
* (`POST /api/playerevents/v1/respond`) — what puts the event on their screen without a
|
||||
* refetch. This is the responder's own frame, carrying their own row.
|
||||
*
|
||||
* An INVITE does not send this. What an invitee gets is the invitation as a Message
|
||||
* (see {@link pushEventInvitation}); their Pending row is not a response, and the
|
||||
* client populates the event from this frame only once they have actually responded.
|
||||
* Sending it on invite made the event appear as if the player had already answered.
|
||||
*
|
||||
* 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
|
||||
@@ -120,30 +200,100 @@ async function notifyEventCreated(
|
||||
* 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.
|
||||
* Hub failures are logged and swallowed: the answer is already stored by the time this
|
||||
* runs.
|
||||
*/
|
||||
async function notifyResponseChanged(
|
||||
c: Context<App>,
|
||||
event: PlayerEvent,
|
||||
response: EventAttendeeRow
|
||||
): Promise<void> {
|
||||
const payload = {
|
||||
PlayerEvent: { ...toEventNotification(event) },
|
||||
PlayerEventResponse: { ...toEventResponse(response) },
|
||||
} satisfies PlayerEventResponsePayload
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
response.player_id,
|
||||
NotificationType.PlayerEventResponseChanged,
|
||||
payload
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push PlayerEventResponseChanged notification', {
|
||||
playerEventId: event.PlayerEventId,
|
||||
playerId: response.player_id,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell each player a bulk invite just added about it: the invitation as a Message of
|
||||
* type 81 `PlayerEventInvitation`, stored and pushed as `MessageReceived` — see
|
||||
* {@link pushEventInvitation}. That Message is the whole of what an invitee is sent; the
|
||||
* `PlayerEventResponseChanged` frame is theirs only once they answer.
|
||||
*
|
||||
* 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. 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>,
|
||||
inviterId: number,
|
||||
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),
|
||||
})
|
||||
}
|
||||
await pushEventInvitation(c, inviterId, row.player_id, event.PlayerEventId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store and push the invitation Message a bulk invite shows the invitee: a Message of
|
||||
* type 81 `PlayerEventInvitation` from the inviter, delivered as `MessageReceived` and
|
||||
* kept in the inbox (`GET /api/messages/v2/get`) so a player who was offline still finds
|
||||
* it. The event is named twice — on the row's own `PlayerEventId` column, and as `Data`,
|
||||
* the event id as a STRING (a Message's `Data` is always a string on the wire).
|
||||
*
|
||||
* The row is committed before the push; a hub failure is logged and swallowed, since the
|
||||
* invite itself is already stored.
|
||||
*/
|
||||
async function pushEventInvitation(
|
||||
c: Context<App>,
|
||||
fromPlayerId: number,
|
||||
toPlayerId: number,
|
||||
playerEventId: number
|
||||
): Promise<void> {
|
||||
let message: StoredNotification
|
||||
try {
|
||||
message = await createNotification(c.env.DB, {
|
||||
FromPlayerId: fromPlayerId,
|
||||
ToPlayerId: toPlayerId,
|
||||
Type: MessageType.PlayerEventInvitation,
|
||||
Data: String(playerEventId),
|
||||
PlayerEventId: playerEventId,
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('failed to store PlayerEventInvitation message', {
|
||||
playerEventId,
|
||||
toPlayerId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return
|
||||
}
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
toPlayerId,
|
||||
NotificationType.MessageReceived,
|
||||
{ ...message }
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push PlayerEventInvitation MessageReceived notification', {
|
||||
notificationId: message.Id,
|
||||
playerEventId,
|
||||
toPlayerId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,12 +301,13 @@ async function notifyInvited(
|
||||
* Wrap an event in the v2 envelope for THIS caller's build.
|
||||
*
|
||||
* Rec Room reshaped `PlayerEvent.Tags` without minting a new path, so the same endpoint
|
||||
* owes the 2023 build `[{ Tag, Type }]` and the 2025 build `["celebration"]`. The build
|
||||
* owes the 2023 build `["celebration"]` and anything newer `[{ Tag, Type }]`. The build
|
||||
* comes off the token's `rn.ver` claim — the request carries no version of its own — and
|
||||
* the split is the one `/api/gameconfigs/v1/all` already makes: anything NEWER than
|
||||
* `GAME_VERSION` (20230414) is the 2025 client; that build, anything older, and a request
|
||||
* with no readable token version all get the 2023 shape. Builds are date-stamped, so they
|
||||
* order as strings.
|
||||
* `GAME_VERSION` (20230414) gets the pairs; that build, anything older, and a request
|
||||
* with no readable token version all get the 2023 names. Builds are date-stamped, so they
|
||||
* order as strings. `TagModifyResult.Tags` is a name list to every build — only the
|
||||
* inline field moves.
|
||||
*
|
||||
* Like the other version gates here the claim is unverified — a client that lies about its
|
||||
* build only empties its own tag chips.
|
||||
@@ -164,7 +315,7 @@ async function notifyInvited(
|
||||
async function eventResult(c: Context<App>, event: PlayerEvent, tags: EventTag[]) {
|
||||
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
const isModernBuild = version !== null && version > GAME_VERSION
|
||||
return toEventResult(event, tags, !isModernBuild)
|
||||
return toEventResult(event, tags, isModernBuild)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,6 +331,7 @@ async function eventResult(c: Context<App>, event: PlayerEvent, tags: EventTag[]
|
||||
*
|
||||
* These edits are creator-only like the whole-event update, and they go through the same
|
||||
* {@link updateEvent}, so a patch touching one field leaves the rest of the event alone.
|
||||
* Like it, each pushes a `PlayerEventUpdated` to everyone Going.
|
||||
*/
|
||||
function editEventField(
|
||||
parse: (c: Context<App>, event: PlayerEvent) => Promise<EventInput | null>
|
||||
@@ -199,7 +351,9 @@ function editEventField(
|
||||
if (input === null) return c.body(null, 400)
|
||||
const updated = await updateEvent(c.env.DB, eventId, input)
|
||||
// updateEvent only returns null when the row vanished, which the read above rules out.
|
||||
return c.json(await eventResult(c, updated!, await getEventTags(c.env.DB, eventId)))
|
||||
const tags = await getEventTags(c.env.DB, eventId)
|
||||
await notifyEventUpdated(c, updated!, tags)
|
||||
return c.json(await eventResult(c, updated!, tags))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,6 +449,31 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Another player's events, as their profile shows them: PUBLIC events only, and under
|
||||
// `Responses` only the ones they are attending (Going). Public. Unlike the caller's own
|
||||
// `/all` above, both keys carry the client's 17-key base event, and a `Responses` entry
|
||||
// is a PAIR — the event beside the RSVP record.
|
||||
.get(
|
||||
'/api/playerevents/v1/all/:playerId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Events'],
|
||||
summary: 'A player’s public events',
|
||||
description:
|
||||
'The events on a player’s profile: the PUBLIC ones they created (`Created`) and ' +
|
||||
'the PUBLIC ones they are attending (`Responses`), each soonest first. Public (no ' +
|
||||
'auth); finished events are left out, and a player with none — or an unknown id — ' +
|
||||
'gets two empty lists.\n\n' +
|
||||
'Events are the client’s 17-key BASE event, as on the browse feed. A `Responses` ' +
|
||||
'entry is a pair, `{ PlayerEvent, PlayerEventResponse }`, and only Going RSVPs ' +
|
||||
'(`Type` 0) are listed — a maybe, a decline or an unanswered invitation is not ' +
|
||||
'attending. A creator is Going to their own event, so it appears under both keys.',
|
||||
parameters: [idParam('playerId', 'Player id')],
|
||||
responses: { 200: json(PlayerPublicEvents, 'The player’s public events') },
|
||||
}),
|
||||
async (c) =>
|
||||
c.json(await getPublicEventsByPlayer(c.env.DB, Number.parseInt(c.req.param('playerId'), 10)))
|
||||
)
|
||||
|
||||
// The tag filter chips on the player-events browse screen. Static: these are the
|
||||
// categories the client offers when creating an event, so the list doesn't depend on
|
||||
// what's stored. `TrendingFilters` is null even in the reference — it needs
|
||||
@@ -449,14 +628,15 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
'match and the two kinds combine, so `#workshops trigonometry` is the ' +
|
||||
'workshops-tagged events whose text also mentions trigonometry.\n\n' +
|
||||
'Events that have already finished are left out — a name match on something that ' +
|
||||
'ended last month is noise on a browse screen. Soonest first, paginated via ' +
|
||||
'ended last month is noise on a browse screen. Ordered by `sort` and paginated via ' +
|
||||
'skip/take. A bare array.',
|
||||
parameters: [
|
||||
stringQuery('query', 'Search terms; `#tag` matches a tag, anything else the text'),
|
||||
stringQuery(
|
||||
'sort',
|
||||
'Accepted and echoed by the client as `StartTime`, which is the only order ' +
|
||||
'served (soonest first); any other value sorts the same way'
|
||||
'`StartTime` (soonest first, the default) or `Attendance` (most Going replies ' +
|
||||
'first — the event’s `AttendeeCount` — ties soonest first). Case-insensitive; ' +
|
||||
'any other value is served as `StartTime`'
|
||||
),
|
||||
...pageParams(50),
|
||||
],
|
||||
@@ -465,7 +645,15 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
async (c) => {
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '', 10) || 50
|
||||
return c.json(await searchEvents(c.env.DB, c.req.query('query') ?? '', skip, take))
|
||||
return c.json(
|
||||
await searchEvents(
|
||||
c.env.DB,
|
||||
c.req.query('query') ?? '',
|
||||
skip,
|
||||
take,
|
||||
parseEventSort(c.req.query('sort'))
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -546,8 +734,13 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
'event. Answers the same `{ Result, TagModifyResult, PlayerEvent }` envelope the ' +
|
||||
'v2 writes do, carrying the event with its updated count, so the client can ' +
|
||||
're-render from the response.\n\n' +
|
||||
'A body with no usable `PlayerEventId`, or a `Type` outside 0–2, is a 400; an ' +
|
||||
'unknown event is a 404.',
|
||||
'The caller is also pushed a `PlayerEventResponseChanged` frame carrying the event ' +
|
||||
'and their new response row — this frame, not the invite, is what populates the ' +
|
||||
'event on the client. An invitee answering Pending → Going gets it here like ' +
|
||||
'anyone else.\n\n' +
|
||||
'A body with no usable `PlayerEventId`, or a `Type` outside 0–2, is a 400 — 3 ' +
|
||||
'Pending is the server’s own mark for an unanswered invite, not an answer a ' +
|
||||
'player can give; an unknown event is a 404.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(PlayerEventRespondRequest, 'The event and the answer'),
|
||||
responses: {
|
||||
@@ -570,9 +763,10 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
// Going would silently inflate the count.
|
||||
if (!Number.isInteger(eventId) || !isEventResponseType(type)) return c.body(null, 400)
|
||||
|
||||
const updated = await setEventResponse(c.env.DB, eventId, id, type)
|
||||
if (updated === null) return c.body(null, 404)
|
||||
return c.json(await eventResult(c, updated, await getEventTags(c.env.DB, eventId)))
|
||||
const result = await setEventResponse(c.env.DB, eventId, id, type)
|
||||
if (result === null) return c.body(null, 404)
|
||||
await notifyResponseChanged(c, result.event, result.response)
|
||||
return c.json(await eventResult(c, result.event, await getEventTags(c.env.DB, eventId)))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -640,20 +834,30 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
)
|
||||
|
||||
// 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.
|
||||
// the same `event_attendee` table an RSVP writes to, as Pending (3) — an open
|
||||
// invitation, not an acceptance.
|
||||
.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' +
|
||||
'Adds the invited players to the event as Pending (`Type` 3) — the same ' +
|
||||
'`event_attendee` rows an RSVP writes, so an invited player shows up in ' +
|
||||
'`…/responses` with an open invitation. Pending is not Going: the invitee does not ' +
|
||||
'count toward `AttendeeCount` until they answer through `…/respond`. (Inviting as ' +
|
||||
'Going made the invitation look already accepted on the client.)\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 ' +
|
||||
'they said, so inviting someone who declined does not reset them to Pending, and ' +
|
||||
're-inviting is a no-op. The caller is skipped (they are already on the list), as ' +
|
||||
'are duplicate ids.\n\n' +
|
||||
'Each newly invited player is sent the invitation as a Message of type 81 ' +
|
||||
'`PlayerEventInvitation` from the caller — stored in their inbox ' +
|
||||
'(`GET /api/messages/v2/get`) and pushed as `MessageReceived`, with the event id ' +
|
||||
'on `PlayerEventId` and, as a string, in `Data`. That is all an invitee gets: no ' +
|
||||
'`PlayerEventResponseChanged` is pushed on invite — that frame is what `…/respond` ' +
|
||||
'sends once they answer, and is what populates the event on their client. ' +
|
||||
'Players whose existing answer was left alone get nothing.\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 ' +
|
||||
@@ -662,7 +866,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(PlayerEventBulkInviteRequest, 'The event and who to invite'),
|
||||
responses: {
|
||||
200: json(PlayerEventResultDto, 'The event, with its updated attendee count'),
|
||||
200: json(PlayerEventResultDto, 'The event (its attendee count is unchanged by an invite)'),
|
||||
400: { description: 'Missing `PlayerEventId` or `InvitedPlayerIds` (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: { description: 'The caller is not on the event (empty body)' },
|
||||
@@ -702,7 +906,7 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
]
|
||||
const result = await inviteToEvent(c.env.DB, eventId, invited)
|
||||
// inviteToEvent only returns null when the row vanished, which the read above rules out.
|
||||
await notifyInvited(c, result!.event, result!.added)
|
||||
await notifyInvited(c, id, result!.event, result!.added)
|
||||
return c.json(await eventResult(c, result!.event, await getEventTags(c.env.DB, eventId)))
|
||||
}
|
||||
)
|
||||
@@ -781,6 +985,9 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
'Deletes an event the caller created, along with its RSVPs and its tags — an event ' +
|
||||
'whose attendee rows outlived it would still be counted, and its tags would still ' +
|
||||
'answer `#tag` searches.\n\n' +
|
||||
'Everyone who was GOING to the event (the creator included) is pushed a ' +
|
||||
'`PlayerEventDeleted` frame naming the event id, so it leaves their screen; ' +
|
||||
'interested, declined and still-Pending invitees are not.\n\n' +
|
||||
'Creator only: anyone else gets 403, and an unknown event 404. Answers the v2 ' +
|
||||
'envelope with `PlayerEvent` and `TagModifyResult` both null — the event is gone, ' +
|
||||
'so there is nothing for the client to redraw from, and it reads only `Result`. ' +
|
||||
@@ -804,7 +1011,10 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
if (existing === null) return c.body(null, 404)
|
||||
if (existing.CreatorPlayerId !== id) return c.body(null, 403)
|
||||
|
||||
// Who to tell has to be read BEFORE the delete: it takes the RSVP rows with it.
|
||||
const going = await getGoingPlayerIds(c.env.DB, eventId)
|
||||
await deleteEvent(c.env.DB, eventId)
|
||||
await notifyEventDeleted(c, going, eventId)
|
||||
// Both payload fields are null here — the delete envelope is not the one the other
|
||||
// v2 routes answer with. Nothing is left to redraw, and the client reads `Result`.
|
||||
return c.json(EVENT_DELETED_RESULT)
|
||||
@@ -857,6 +1067,9 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
'The id, the creator and the attendee count are not editable: ownership doesn’t ' +
|
||||
'transfer and RSVPs aren’t set by hand. Creator only — anyone else gets 403, and ' +
|
||||
'an unknown event is 404. Answers the same envelope as create.\n\n' +
|
||||
'Everyone who is GOING to the event (the creator included) is pushed a ' +
|
||||
'`PlayerEventUpdated` frame carrying the edited event; interested, declined and ' +
|
||||
'still-Pending invitees are not. The single-field edits push the same frame.\n\n' +
|
||||
'The 24-hour window cap applies to what the post RESOLVES to, not to what it ' +
|
||||
'carries: moving the start alone still has to leave a window that ends after it ' +
|
||||
'and runs no longer than a day against the STORED end.',
|
||||
@@ -891,7 +1104,9 @@ export const eventRoutes = new Hono<App>({ strict: false })
|
||||
if (eventInputRejection(input, existing) !== null) return c.body(null, 400)
|
||||
const updated = await updateEvent(c.env.DB, eventId, input)
|
||||
// updateEvent only returns null when the row vanished, which the read above rules out.
|
||||
return c.json(await eventResult(c, updated!, await getEventTags(c.env.DB, eventId)))
|
||||
const tags = await getEventTags(c.env.DB, eventId)
|
||||
await notifyEventUpdated(c, updated!, tags)
|
||||
return c.json(await eventResult(c, updated!, tags))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+239
-37
@@ -5,17 +5,21 @@ import {
|
||||
createImage,
|
||||
deleteImage,
|
||||
getCheeredImageIds,
|
||||
getImageById,
|
||||
getImageByName,
|
||||
getImagesByIds,
|
||||
getImagesByPlayer,
|
||||
getImagesByRoom,
|
||||
getPlayerFeed,
|
||||
getSlideshowImages,
|
||||
mergePlayerSettings,
|
||||
readPlayerSettings,
|
||||
SavedImageType,
|
||||
setImageAccessibility,
|
||||
setImageCheer,
|
||||
setImageDescription,
|
||||
SLIDESHOW_LIMIT,
|
||||
SLIDESHOW_MAX_LIMIT,
|
||||
toImageMetadata,
|
||||
toImagesPlayer,
|
||||
} from '@repo/domain'
|
||||
|
||||
@@ -29,23 +33,26 @@ import {
|
||||
ErrorResponse,
|
||||
form,
|
||||
idParam,
|
||||
ImageMetadataDto,
|
||||
ImagesPlayerDto,
|
||||
intQuery,
|
||||
json,
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
ModifyImageAccessibilityRequest,
|
||||
ModifyImageDescriptionRequest,
|
||||
pageParams,
|
||||
PhotoTaggingSettingRequest,
|
||||
PhotoTaggingSettingResponse,
|
||||
SavedImageDto,
|
||||
SlideshowResponse,
|
||||
stringQuery,
|
||||
SuccessErrorEnvelope,
|
||||
SuccessResponse,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
UploadImageRequest,
|
||||
UploadImageResponse,
|
||||
} from '../openapi'
|
||||
import { createReport } from '../reports-db'
|
||||
import { exceedsApiUploadLimit, maxApiUploadBytes } from '../upload-limit'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
@@ -73,6 +80,17 @@ const typeFolder: Record<number, string> = {
|
||||
*/
|
||||
const PHOTO_TAGGING_KEY = 'playerPhotoTaggingSetting'
|
||||
|
||||
/**
|
||||
* The owner's-view clause the player photo lists share in their docs. The lists are
|
||||
* public, but they read a Bearer token if one is sent, and the one player it can unlock
|
||||
* anything for is the player whose list it is.
|
||||
*/
|
||||
const OWN_VIEW_NOTE =
|
||||
'Public, but reads a Bearer token if one is sent: a player asking for THEIR OWN list ' +
|
||||
'gets their private photos in it too (`Accessibility` 0 — that is where the photos they ' +
|
||||
'have not shared yet live). Any other player’s token, no token, or an invalid one serves ' +
|
||||
'the public list; none of those is a 401.'
|
||||
|
||||
/**
|
||||
* The preference a player has before they have ever set one. The value is an opaque enum
|
||||
* ordinal to this server (see `PhotoTaggingSettingRequest`), and 0 is what an unset .NET
|
||||
@@ -85,10 +103,7 @@ async function getPlayerSettings(
|
||||
env: App['Bindings'],
|
||||
accountId: number
|
||||
): Promise<Record<string, string> | null> {
|
||||
return env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||
`player:${accountId}`,
|
||||
'json'
|
||||
).catch(() => null)
|
||||
return readPlayerSettings(env.RECFLARE_PLAYER_SETTINGS, accountId).catch(() => null)
|
||||
}
|
||||
|
||||
/** The caller's stored photo-tagging preference, or the default when they have none. */
|
||||
@@ -104,19 +119,17 @@ async function readPhotoTaggingSetting(env: App['Bindings'], accountId: number):
|
||||
*
|
||||
* The write MERGES, as the `playersettings` worker's own PUT does: the map holds every
|
||||
* setting the player has (OOBE state, tutorial mask, …), so storing this one on its own
|
||||
* would wipe the rest. Read-modify-write on KV isn't atomic, but the same is true there,
|
||||
* and racing writers here means one player toggling two of their own options at once.
|
||||
* would wipe the rest. Re-posting the value already stored writes nothing — the client
|
||||
* does that freely, and KV writes are the cost.
|
||||
*/
|
||||
async function writePhotoTaggingSetting(
|
||||
env: App['Bindings'],
|
||||
accountId: number,
|
||||
setting: number
|
||||
): Promise<void> {
|
||||
const stored = (await getPlayerSettings(env, accountId)) ?? {}
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(
|
||||
`player:${accountId}`,
|
||||
JSON.stringify({ ...stored, [PHOTO_TAGGING_KEY]: String(setting) })
|
||||
)
|
||||
await mergePlayerSettings(env.RECFLARE_PLAYER_SETTINGS, accountId, {
|
||||
[PHOTO_TAGGING_KEY]: String(setting),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -297,8 +310,9 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
// Delete one of the caller's saved images ({ ImageName }). Auth-gated. Looks the
|
||||
// image up by name, refuses unless the caller took it (PlayerId), then removes the
|
||||
// metadata row (and its cheers) and the object from R2. 404 for an unknown image,
|
||||
// 403 for someone else's.
|
||||
.delete(
|
||||
// 403 for someone else's. A POST, not a DELETE: the client posts the JSON body to
|
||||
// this path when a player deletes a photo from their gallery.
|
||||
.post(
|
||||
'/api/images/v1/deletesaved',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
@@ -338,6 +352,99 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Make one of the caller's photos public or private ({ ImageName, Accessibility }).
|
||||
// Auth-gated and owner-only, like deletesaved; additionally refuses an image whose
|
||||
// accessibility moderation has locked. Answers the same `{ success: true }` ack.
|
||||
.post(
|
||||
'/api/images/v2/modifyaccessibility',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Make one of the caller’s photos public or private',
|
||||
description:
|
||||
'Looks the image up by name and refuses unless the caller took it (or its ' +
|
||||
'accessibility is locked), then sets `Accessibility` on the stored record: 0 ' +
|
||||
'private, 1 public.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(ModifyImageAccessibilityRequest, 'The image and its new accessibility'),
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: json(ErrorResponse, 'No ImageName, or Accessibility not 0 or 1'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorResponse, 'Not the caller’s image, or its accessibility is locked'),
|
||||
404: { description: 'No image by that name' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as {
|
||||
ImageName?: unknown
|
||||
Accessibility?: unknown
|
||||
} | null
|
||||
const imageName = typeof body?.ImageName === 'string' ? body.ImageName : ''
|
||||
if (imageName === '') return c.json({ error: 'ImageName is required' }, 400)
|
||||
const accessibility = body?.Accessibility
|
||||
if (accessibility !== 0 && accessibility !== 1) {
|
||||
return c.json({ error: 'Accessibility must be 0 or 1' }, 400)
|
||||
}
|
||||
|
||||
const image = await getImageByName(c.env.DB, imageName)
|
||||
if (!image) return c.notFound()
|
||||
if (image.PlayerId !== id) return c.json({ error: 'Not your image' }, 403)
|
||||
if (image.AccessibilityLocked) return c.json({ error: 'Accessibility is locked' }, 403)
|
||||
|
||||
await setImageAccessibility(c.env.DB, image, accessibility)
|
||||
return c.json({ success: true })
|
||||
}
|
||||
)
|
||||
|
||||
// Set the caption on one of the caller's photos ({ ImageName, Description }).
|
||||
// Auth-gated and owner-only, like deletesaved. An empty or null Description clears
|
||||
// the caption (stored as null, the same as an upload without one).
|
||||
.post(
|
||||
'/api/images/v1/modifydescription',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Set the caption on one of the caller’s photos',
|
||||
description:
|
||||
'Looks the image up by name and refuses unless the caller took it, then sets ' +
|
||||
'`Description` on the stored record. Null or empty clears it.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(ModifyImageDescriptionRequest, 'The image and its new caption'),
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: json(ErrorResponse, 'No ImageName, or Description not a string'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(ErrorResponse, 'Not the caller’s image'),
|
||||
404: { description: 'No image by that name' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.json().catch(() => null)) as {
|
||||
ImageName?: unknown
|
||||
Description?: unknown
|
||||
} | null
|
||||
const imageName = typeof body?.ImageName === 'string' ? body.ImageName : ''
|
||||
if (imageName === '') return c.json({ error: 'ImageName is required' }, 400)
|
||||
const raw = body?.Description ?? null
|
||||
if (raw !== null && typeof raw !== 'string') {
|
||||
return c.json({ error: 'Description must be a string' }, 400)
|
||||
}
|
||||
const description = raw === null || raw.trim() === '' ? null : raw
|
||||
|
||||
const image = await getImageByName(c.env.DB, imageName)
|
||||
if (!image) return c.notFound()
|
||||
if (image.PlayerId !== id) return c.json({ error: 'Not your image' }, 403)
|
||||
|
||||
await setImageDescription(c.env.DB, image, description)
|
||||
return c.json({ success: true })
|
||||
}
|
||||
)
|
||||
|
||||
// A room's photo feed — the public images taken in that room. `sort` orders the
|
||||
// feed (1 = most cheered, else newest) and `filter` narrows by SavedImageType
|
||||
// (0 = all). Paginated via skip/take (take defaults to 100). Returns a bare array.
|
||||
@@ -373,15 +480,22 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
// A player's photos — the public images that player has taken, newest first.
|
||||
// Paginated via skip/take (take defaults to 100). Returns a bare array of the
|
||||
// client's ImagesPlayer projection (SavedImageId/SavedImageType, not Id/Type).
|
||||
//
|
||||
// Public, but a token is READ if one is sent: a player asking for their own list gets
|
||||
// their private photos in it too (the profile they open on themselves is where those
|
||||
// live), while anyone else's token — or none — serves the public list. A bad token is
|
||||
// not a 401 here; it just reads as nobody.
|
||||
.get(
|
||||
'/api/images/v4/player/:playerId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'A player’s photos',
|
||||
description:
|
||||
'The public images that player has taken, newest first. Serves the client’s ' +
|
||||
'The public camera photos (`SavedImageType` 1) that player has taken, newest first — ' +
|
||||
'not the outfit, room, invention or profile thumbnails filed under them. Serves the client’s ' +
|
||||
'`ImagesPlayer` projection (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`) ' +
|
||||
'— the raw `SavedImage` renders blank thumbnails here.',
|
||||
'— the raw `SavedImage` renders blank thumbnails here.\n\n' +
|
||||
OWN_VIEW_NOTE,
|
||||
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
|
||||
responses: { 200: json(ImagesPlayerDto.array(), 'The player’s photos') },
|
||||
}),
|
||||
@@ -389,7 +503,8 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
const images = await getImagesByPlayer(c.env.DB, playerId, 0, skip, take)
|
||||
const viewer = await authedId(c)
|
||||
const images = await getImagesByPlayer(c.env.DB, playerId, 0, skip, take, viewer)
|
||||
return c.json(images.map(toImagesPlayer))
|
||||
}
|
||||
)
|
||||
@@ -401,7 +516,9 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'A player’s photos, sortable',
|
||||
description: 'v4 plus a `sort` option. Same `ImagesPlayer` projection — see the note on v4.',
|
||||
description:
|
||||
'v4 plus a `sort` option. Same `ImagesPlayer` projection, and the same owner’s view — ' +
|
||||
'see the notes on v4.',
|
||||
parameters: [
|
||||
idParam('playerId', 'Account id'),
|
||||
intQuery('sort', '1 = most cheered; anything else = newest first'),
|
||||
@@ -414,7 +531,8 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
const sort = Number.parseInt(c.req.query('sort') ?? '0', 10) || 0
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
const images = await getImagesByPlayer(c.env.DB, playerId, sort, skip, take)
|
||||
const viewer = await authedId(c)
|
||||
const images = await getImagesByPlayer(c.env.DB, playerId, sort, skip, take, viewer)
|
||||
return c.json(images.map(toImagesPlayer))
|
||||
}
|
||||
)
|
||||
@@ -428,8 +546,11 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
tags: ['Images'],
|
||||
summary: 'A player’s photo feed',
|
||||
description:
|
||||
'The public images they took PLUS the ones they are tagged in, newest first — the ' +
|
||||
'photo tab on a profile. Same `ImagesPlayer` projection as the player photo lists.',
|
||||
'The public camera photos they took PLUS the ones they are tagged in, newest first — the ' +
|
||||
'photo tab on a profile. Same `ImagesPlayer` projection as the player photo lists.\n\n' +
|
||||
OWN_VIEW_NOTE +
|
||||
' A photo someone else took and tagged them in stays public-only either way: its ' +
|
||||
'privacy is the taker’s.',
|
||||
parameters: [idParam('playerId', 'Account id'), ...pageParams(100)],
|
||||
responses: { 200: json(ImagesPlayerDto.array(), 'The player’s feed') },
|
||||
}),
|
||||
@@ -437,15 +558,17 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
const playerId = Number.parseInt(c.req.param('playerId'), 10)
|
||||
const skip = Number.parseInt(c.req.query('skip') ?? '0', 10) || 0
|
||||
const take = Number.parseInt(c.req.query('take') ?? '100', 10) || 100
|
||||
const images = await getPlayerFeed(c.env.DB, playerId, skip, take)
|
||||
const viewer = await authedId(c)
|
||||
const images = await getPlayerFeed(c.env.DB, playerId, skip, take, viewer)
|
||||
return c.json(images.map(toImagesPlayer))
|
||||
}
|
||||
)
|
||||
|
||||
// Global slideshow feed — the most recent publicly-listable ShareCamera photos
|
||||
// (Accessibility 0 or 1, Type 1) across all rooms, newest first, each joined to its
|
||||
// creator's username and room name. Public (no auth): it only surfaces already-public
|
||||
// images and backs the anonymous homepage slideshow. Returns `{ Images, ValidTill }`,
|
||||
// Global slideshow feed — the most recent public ShareCamera photos (Accessibility 1,
|
||||
// Type 1) across all rooms, newest first, each joined to its creator's username and
|
||||
// room name. Public (no auth): it only surfaces already-public images and backs the
|
||||
// anonymous homepage slideshow. Private photos (Accessibility 0) never appear — the
|
||||
// feed is anonymous, so there is no viewer it could be unlocked for. Returns `{ Images, ValidTill }`,
|
||||
// where ValidTill is a short (2-minute) cache hint the client refreshes against.
|
||||
// Serves 10 by default and never more than SLIDESHOW_MAX_LIMIT (100): it's public and
|
||||
// unauthenticated, so an unclamped `take` would let anyone ask for the whole image
|
||||
@@ -520,23 +643,24 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Image metadata by filename. Returns the stored SavedImage record, or 404 when
|
||||
// there's no metadata row for that name.
|
||||
// Image metadata by filename, or 404 when there's no metadata row for that name.
|
||||
//
|
||||
// Serves the RAW 13-key `SavedImage`, the same type the client decodes `v6/:id` into.
|
||||
// This once answered a projection of its own (`SavedImageId`/`SavedImageType`, a
|
||||
// `ClubId`, no `TaggedPlayerIds`, nulls as 0/""): the client's DTO has `Id` and `Type`
|
||||
// and no `ClubId`, so it was silently dropping most of that.
|
||||
.get(
|
||||
'/api/images/v6',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'Image metadata by filename',
|
||||
description:
|
||||
'An image’s metadata for a bucket key. 404s when the object exists but has no ' +
|
||||
'metadata row.\n\n' +
|
||||
'Its own projection: renamed like the player lists (`SavedImageId`/`SavedImageType`, ' +
|
||||
'no `TaggedPlayerIds`) but carrying `ClubId`, and with nothing nullable — `RoomId`, ' +
|
||||
'`PlayerEventId` and `ClubId` read 0 where the row holds null, `Description` reads ' +
|
||||
'`""`. Three shapes of one row; keep them straight.',
|
||||
'An image’s stored `SavedImage` record for a bucket key — the raw record, as ' +
|
||||
'`v6/{id}` and `v5/bulk` serve, with `Description`, `RoomId` and `PlayerEventId` ' +
|
||||
'null where the row holds null. 404s when the object exists but has no metadata row.',
|
||||
parameters: [stringQuery('name', 'The image name (bucket key); required')],
|
||||
responses: {
|
||||
200: json(ImageMetadataDto, 'The image’s metadata'),
|
||||
200: json(SavedImageDto, 'The image record'),
|
||||
400: json(ErrorResponse, 'No name given'),
|
||||
404: { description: 'No metadata for that name' },
|
||||
},
|
||||
@@ -545,7 +669,85 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
const name = c.req.query('name') ?? ''
|
||||
if (name === '') return c.json({ error: 'name is required' }, 400)
|
||||
const image = await getImageByName(c.env.DB, name)
|
||||
return image ? c.json(toImageMetadata(image)) : c.notFound()
|
||||
return image ? c.json(image) : c.notFound()
|
||||
}
|
||||
)
|
||||
|
||||
// One image by id — the client opening a single photo it holds only the id of.
|
||||
//
|
||||
// Serves the RAW 13-key `SavedImage` (`Id`/`Type`, `TaggedPlayerIds`, nulls left null),
|
||||
// which is the type the client decodes this route into — the same record `v6?name=`
|
||||
// serves, and `v5/bulk` a list of. NOT the `ImagesPlayer` projection of the player lists.
|
||||
//
|
||||
// Public, or the caller's own: ids are sequential, so anyone else's private photo is a
|
||||
// 404 here, the same as an id that doesn't exist.
|
||||
.get(
|
||||
'/api/images/v6/:id{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Images'],
|
||||
summary: 'One image by id',
|
||||
description:
|
||||
'The stored `SavedImage` record for an image id — the raw record, as `v5/bulk` ' +
|
||||
'serves, with `Description`, `RoomId` and `PlayerEventId` null where the row holds ' +
|
||||
'null. A private image is served only to the player who took it; to anyone else ' +
|
||||
'it is a 404, like an unknown id.',
|
||||
parameters: [idParam('id', 'Image id')],
|
||||
responses: {
|
||||
200: json(SavedImageDto, 'The image record'),
|
||||
404: { description: 'No such image, or not the caller’s to see' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const image = await getImageById(c.env.DB, Number.parseInt(c.req.param('id'), 10))
|
||||
if (image === null) return c.notFound()
|
||||
if (image.Accessibility !== 1 && image.PlayerId !== (await authedId(c))) return c.notFound()
|
||||
return c.json(image)
|
||||
}
|
||||
)
|
||||
|
||||
// Report a saved image. Stored in the `report` table the player, event, invention, item
|
||||
// and chat reports use — same moderation life — with `image_id` set. See
|
||||
// migrations/0034_report_image.sql.
|
||||
//
|
||||
// The request has NO body: the path names the image and the token names the reporter,
|
||||
// and that is the whole report. The reported player is whoever took the image, read from
|
||||
// the image. The `{ success, error: "" }` reply is the envelope the other reports use,
|
||||
// which is an ASSUMPTION here: what the client does with this response has not been
|
||||
// observed.
|
||||
.post(
|
||||
'/api/images/v1/:id{[0-9]+}/report',
|
||||
describeRoute({
|
||||
tags: ['Images', 'Moderation'],
|
||||
summary: 'Report a photo',
|
||||
description:
|
||||
'Files a report against a saved image, as a row in the same `report` table the ' +
|
||||
'player, event, invention, custom-avatar-item and chat reports go to. There is no ' +
|
||||
'request body: the row is the reporter (the caller), the image (`image_id`), and ' +
|
||||
'`reported_player_id` — the image’s `PlayerId`, who took it. No category or ' +
|
||||
'description is stored, since none is sent. Nothing dedupes the rows, and an ' +
|
||||
'unknown image is refused rather than filed against nobody.',
|
||||
security: AUTHED,
|
||||
parameters: [idParam('id', 'Image id')],
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: json(SuccessErrorEnvelope, 'No such image'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const reporterId = await authedId(c)
|
||||
if (reporterId === null) return unauthorized(c)
|
||||
|
||||
const imageId = Number.parseInt(c.req.param('id'), 10)
|
||||
const image = await getImageById(c.env.DB, imageId)
|
||||
if (image === null) return c.json({ success: false, error: 'No such image' }, 404)
|
||||
|
||||
await createReport(c.env.DB, {
|
||||
reporterPlayerId: reporterId,
|
||||
reportedPlayerId: image.PlayerId,
|
||||
imageId,
|
||||
})
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+429
-114
@@ -2,17 +2,23 @@ import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import {
|
||||
canManageRoom,
|
||||
canModerateRoom,
|
||||
deletePresence,
|
||||
countPlayersInInstance,
|
||||
getPlayerIdsInInstance,
|
||||
getPresence,
|
||||
getPresences,
|
||||
getRoomById,
|
||||
getStoredRoomInstance,
|
||||
MessageType,
|
||||
refreshInstanceFullness,
|
||||
movePlayerToDorm,
|
||||
} from '@repo/domain'
|
||||
import { logger } from '@repo/hono-helpers'
|
||||
|
||||
// The message being reported, and whether the reporter can see it — `chat` owns both tables
|
||||
// on the shared database, so its reads are used rather than restated here.
|
||||
import { getMessage } from '../../../chat/src/message-db'
|
||||
import { isThreadMember } from '../../../chat/src/thread-db'
|
||||
// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a
|
||||
// value — the enum has no runtime dependencies.
|
||||
import { KickReportCategory } from '../../../notify/src/notification-payloads'
|
||||
@@ -21,6 +27,7 @@ import { authedId, authedRoles, unauthorized } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
BareBoolean,
|
||||
CreateChatReportRequest,
|
||||
CreateReportRequest,
|
||||
CreateWarningRequest,
|
||||
DeviceIdRequest,
|
||||
@@ -30,18 +37,25 @@ import {
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
ModerationBlockDetails,
|
||||
RoomModKickRequest,
|
||||
SuccessErrorEnvelope,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
VoteToKickReason,
|
||||
VoteToKickRequest,
|
||||
} from '../openapi'
|
||||
import { createReport, getActiveBan } from '../reports-db'
|
||||
import { banBlockDetails, createReport, getActiveBan, NOT_BLOCKED } from '../reports-db'
|
||||
import {
|
||||
countKickVotes,
|
||||
getOpenVote,
|
||||
isKickMajority,
|
||||
isOnVoteCooldown,
|
||||
recordRoomVote,
|
||||
} from '../votes-db'
|
||||
import { createWarning } from '../warnings-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { ModerationKickPayload } from '../../../notify/src/notification-payloads'
|
||||
import type { App } from '../context'
|
||||
import type { ReportRow } from '../reports-db'
|
||||
|
||||
/**
|
||||
* Roles allowed to hand down a warning — the operator-granted elevated roles the auth
|
||||
@@ -65,6 +79,15 @@ function formField(
|
||||
return c.req.query(name) || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a posted `Response` is a YES. The client sends `True`/`False`; `1`/`0` are accepted
|
||||
* beside them because the same field is an int elsewhere in this controller. Anything else —
|
||||
* absent included — is a no, which is the safe way round: an unreadable answer must not count
|
||||
* toward removing somebody.
|
||||
*/
|
||||
const isYesVote = (value: string | undefined): boolean =>
|
||||
value !== undefined && ['true', '1', 'yes'].includes(value.trim().toLowerCase())
|
||||
|
||||
/** Parse a field as an integer, or null when absent / not a number. */
|
||||
const asInt = (v: string | undefined): number | null => {
|
||||
if (v === undefined) return null
|
||||
@@ -79,6 +102,39 @@ const asFloat = (v: string | undefined): number | null => {
|
||||
return Number.isNaN(n) ? null : n
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat-report category names that are not a `KickReportCategory` member's own name. Only
|
||||
* `Discriminatory` has been seen on the wire; add the others as the client is seen to send
|
||||
* them, rather than guessing at an enum nobody here has a copy of.
|
||||
*/
|
||||
const CHAT_REPORT_CATEGORY_ALIASES: Record<string, KickReportCategory> = {
|
||||
discriminatory: KickReportCategory.CoCDiscrimination,
|
||||
}
|
||||
|
||||
/**
|
||||
* The numeric category a chat report is filed under. The chat report sends its category as
|
||||
* an enum NAME (`ReportCategory=Discriminatory`) where every other report sends a number,
|
||||
* and `report.report_category` is numeric — it is what the staff panel labels rows from and
|
||||
* what a ban's block screen names as the reason — so the name is mapped onto the same
|
||||
* `KickReportCategory` codes the other reports carry. A number is taken as it comes.
|
||||
*
|
||||
* Null for a name that maps to nothing, which the caller files as `Unknown` with the name
|
||||
* kept in the details: losing which box the reporter ticked would be worse than an odd-looking
|
||||
* description.
|
||||
*/
|
||||
function chatReportCategory(raw: string | undefined): number | null {
|
||||
if (raw === undefined) return KickReportCategory.Unknown
|
||||
const name = raw.trim()
|
||||
if (/^-?\d+$/.test(name)) return Number.parseInt(name, 10)
|
||||
const key = name.toLowerCase()
|
||||
const alias = CHAT_REPORT_CATEGORY_ALIASES[key]
|
||||
if (alias !== undefined) return alias
|
||||
for (const [member, value] of Object.entries(KickReportCategory)) {
|
||||
if (typeof value === 'number' && member.toLowerCase() === key) return value
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* The vote-to-kick reasons, in the order the client renders them. `ReportCategory` is the
|
||||
* category the report a carried vote files: 102 hate, 101 sexual content, 103 griefing,
|
||||
@@ -131,14 +187,17 @@ async function pushInstantKick(
|
||||
playerIds: number[],
|
||||
gameSessionId: number,
|
||||
roomName: string,
|
||||
moderatorId: number
|
||||
moderatorId: number,
|
||||
{ reason = '', isHostKick = true }: { reason?: string; isHostKick?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const frame: ModerationKickPayload = {
|
||||
ReportCategory: KickReportCategory.Moderator,
|
||||
Duration: 0,
|
||||
GameSessionId: gameSessionId,
|
||||
IsHostKick: true,
|
||||
Message: `You have been kicked from ${roomName}.`,
|
||||
IsHostKick: isHostKick,
|
||||
Message: reason
|
||||
? `You have been kicked from ${roomName}. Reason: ${reason}`
|
||||
: `You have been kicked from ${roomName}.`,
|
||||
PlayerIdReporter: moderatorId,
|
||||
IsBan: false,
|
||||
IsVoiceModAutoban: false,
|
||||
@@ -162,27 +221,23 @@ async function pushInstantKick(
|
||||
}
|
||||
|
||||
/**
|
||||
* What a vote-to-kick Message's `Data` says, BEFORE it is serialized. It reaches the
|
||||
* client as an escaped JSON string, never as a nested object — a Message's `Data` is a
|
||||
* string on the wire like every other Message's, and the client's decoder rejects an
|
||||
* object outright: `expected:'String Begin Token', actual:'{'`, which aborts the whole
|
||||
* notification rather than dropping the field. Serialize it with {@link voteToKickData}.
|
||||
* What a vote-to-kick Message carries, and it is not what this once sent.
|
||||
*
|
||||
* `PlayerId` is the account id as a STRING — the reference passes the posted form field
|
||||
* straight through, and this mirrors it verbatim.
|
||||
* `FromPlayerId` is the player being VOTED ON, not the one who called the vote. The client
|
||||
* raises its prompt about whoever that names, so sending the caller put the voter's own name
|
||||
* on the prompt — "kick 153?" when 153 was the one asking to kick 187. Nothing on the frame
|
||||
* identifies the caller, and nothing needs to: the prompt is about the accused.
|
||||
*
|
||||
* `Response` is the empty string even though the caller posted their own vote: the frame
|
||||
* is the PROMPT put to everyone else, so it carries no answer yet. The caller's `Response`
|
||||
* is theirs alone and is not relayed.
|
||||
* `Data` is the REASON, as plain text (`Inactive in games (AFK)`) — the label the client
|
||||
* offered in `voteToKickReasons` and posted back. It used to be an escaped JSON object of
|
||||
* `{ PlayerId, Response, GameSessionId }`, which reached players as that JSON printed on the
|
||||
* prompt where the reason belongs.
|
||||
*
|
||||
* It is still a STRING on the wire, which is the part that has not changed: a Message's `Data`
|
||||
* is a string like every other Message's, and an object there fails the client's decoder
|
||||
* outright (`expected:'String Begin Token', actual:'{'`), aborting the whole notification
|
||||
* rather than dropping the field. A reason is text, so it needs no escaping to satisfy that.
|
||||
*/
|
||||
interface VoteToKickData {
|
||||
PlayerId: string
|
||||
Response: string
|
||||
GameSessionId: number
|
||||
}
|
||||
|
||||
/** Serialize a {@link VoteToKickData} into the escaped JSON string `Data` carries. */
|
||||
const voteToKickData = (data: VoteToKickData): string => JSON.stringify(data)
|
||||
|
||||
/**
|
||||
* The Message a vote-to-kick frame carries — the same four fields as every other Message
|
||||
@@ -225,67 +280,60 @@ async function pushVoteToKick(c: Context<App>, message: VoteToKickMessage): Prom
|
||||
}
|
||||
|
||||
/**
|
||||
* `Duration` on a permanent ban. The client's field is a 32-bit int of seconds that PAIRS
|
||||
* with `TimeoutStartedAt` — start + duration is the end of the block — so a ban with no
|
||||
* end gets the largest value the field holds, 68 years past its start.
|
||||
*/
|
||||
const PERMANENT_BAN_DURATION = 2_147_483_647
|
||||
|
||||
/**
|
||||
* The "not blocked" answer — the reference server's stub `ReturnModerationBlockDetails()`,
|
||||
* widened to every key the client's `ModerationBlockDetail` decoder names (16 on the wire;
|
||||
* the 2025 build's formatter reads them all). The ones past the stub's nine are the block
|
||||
* kinds and screen dressings this server never uses — a device ban, a warning, the
|
||||
* vote-kick reason, an associated account, the creator code of conduct, the top/bottom
|
||||
* message overrides — so they carry their "none" values on every answer.
|
||||
*/
|
||||
const NOT_BLOCKED = {
|
||||
ReportCategory: -1,
|
||||
Duration: 0,
|
||||
GameSessionId: 0,
|
||||
IsHostKick: false,
|
||||
Message: null,
|
||||
PlayerIdReporter: null,
|
||||
IsBan: false,
|
||||
IsVoiceModAutoban: false,
|
||||
IsDeviceBan: false,
|
||||
IsWarning: false,
|
||||
VoteKickReason: null,
|
||||
TimeoutStartedAt: null,
|
||||
AssociatedAccountUsername: null,
|
||||
ShowCreatorCodeOfConduct: false,
|
||||
TopMessageOverride: null,
|
||||
BottomMessageOverride: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* The block details for a ban in force — the `report` row a moderator set `banned` on.
|
||||
* Carry out a vote-to-kick that has passed: the player is moved into their own dorm and gets a
|
||||
* `ModerationKick` frame (id 22), exactly as the staff kicks in this controller do.
|
||||
*
|
||||
* `Duration` and `TimeoutStartedAt` are a PAIR in the client: the block runs from the
|
||||
* start for the duration. The start is the report's `created_at` — nothing records when
|
||||
* the ban itself was handed down, and the report is the record the ban rests on — and the
|
||||
* duration is the seconds from there to `ban_expires`, so the two sum to the expiry; or
|
||||
* `PERMANENT_BAN_DURATION` when there is none. The category is the one the report was
|
||||
* filed under, so the client's ban screen names the reason. `Message` is a fixed "Rule
|
||||
* violation" rather than the report's `details` — those are the REPORTER's words, and the
|
||||
* banned player isn't shown them, for the same reason `PlayerIdReporter` stays null: the
|
||||
* reporter is not a host who kicked them, and naming them would tell the banned player who
|
||||
* reported them. Everything else keeps its `NOT_BLOCKED` value: the other block kinds and
|
||||
* screen dressings, none of which this server hands out.
|
||||
* It differs from those in who it says did it. `ReportCategory` is `VoteKick` (10) and
|
||||
* `IsHostKick` is false — the ROOM removed them, not its host — and `PlayerIdReporter` is the
|
||||
* player whose vote carried it rather than a moderator. `IsBan` is false: a vote-kick ends the
|
||||
* session they are in and nothing more, so they may walk straight back in, and a room that
|
||||
* wants them gone for good bans them.
|
||||
*
|
||||
* `VoteKickReason` carries the reason the vote was called on, which is the field the client
|
||||
* has for it — unlike the staff kicks, which leave it empty and put their text in `Message`.
|
||||
*
|
||||
* Best-effort, and deliberately after the ballot is recorded: the vote happened and was
|
||||
* counted whatever the hub does next.
|
||||
*/
|
||||
function banBlockDetails(ban: ReportRow) {
|
||||
const startedAt = Date.parse(ban.created_at)
|
||||
const duration =
|
||||
ban.ban_expires === null
|
||||
? PERMANENT_BAN_DURATION
|
||||
: Math.max(1, Math.ceil((Date.parse(ban.ban_expires) - startedAt) / 1000))
|
||||
return {
|
||||
...NOT_BLOCKED,
|
||||
ReportCategory: ban.report_category,
|
||||
Duration: duration,
|
||||
IsBan: true,
|
||||
Message: 'Rule violation',
|
||||
TimeoutStartedAt: ban.created_at,
|
||||
async function applyVoteKick(
|
||||
c: Context<App>,
|
||||
playerId: number,
|
||||
gameSessionId: number,
|
||||
reason: string,
|
||||
carriedByAccountId: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Into their own dorm, not out of existence — see the note in `instantKick`.
|
||||
await movePlayerToDorm(c.env.DB, playerId)
|
||||
|
||||
const instance = await getStoredRoomInstance(c.env.DB, gameSessionId)
|
||||
const room = instance && (await getRoomById(c.env.DB, instance.roomId))
|
||||
const roomName = typeof room?.Name === 'string' ? room.Name : 'this room'
|
||||
|
||||
const frame: ModerationKickPayload = {
|
||||
ReportCategory: KickReportCategory.VoteKick,
|
||||
Duration: 0,
|
||||
GameSessionId: gameSessionId,
|
||||
IsHostKick: false,
|
||||
Message: `You have been kicked from ${roomName}.`,
|
||||
PlayerIdReporter: carriedByAccountId,
|
||||
IsBan: false,
|
||||
IsVoiceModAutoban: false,
|
||||
IsWarning: false,
|
||||
VoteKickReason: reason,
|
||||
TimeoutStartedAt: null,
|
||||
}
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayersEphemeral(
|
||||
[playerId],
|
||||
NotificationType.ModerationKick,
|
||||
{ ...frame }
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to carry out a vote kick', {
|
||||
playerId,
|
||||
gameSessionId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,10 +368,11 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
'matchmake refuses on (login still issues a token, so the client can reach this ' +
|
||||
'screen) — so a caller with one in force gets ' +
|
||||
'`IsBan: true`, the `ReportCategory` the report was filed under, the fixed ' +
|
||||
'`Message` “Rule violation”, and the block’s span as the pair the client reads ' +
|
||||
'them as: `TimeoutStartedAt` is the report’s `created_at` and `Duration` the ' +
|
||||
'seconds from there to `ban_expires` (2147483647, the int32 max, for a permanent ' +
|
||||
'ban). ' +
|
||||
'`Message` “Rule violation”, and `Duration` as the seconds LEFT on the ban as of ' +
|
||||
'this request — the client counts it down from when it receives it, so it is ' +
|
||||
'`ban_expires` minus now, rounded up, never the ban’s full span (2147483647, the ' +
|
||||
'int32 max, for a permanent ban). `TimeoutStartedAt` stays null: the client does ' +
|
||||
'not pair it with `Duration` for a ban, and no reference server sends it. ' +
|
||||
'`PlayerIdReporter` stays null: it names a kicking host, and the reporter is not ' +
|
||||
'shown to the player they reported. Only the caller’s own account is consulted, ' +
|
||||
'not the ban-evasion arms.\n\n' +
|
||||
@@ -346,8 +395,11 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const ban = await getActiveBan(c.env.DB, id)
|
||||
return c.json(ban ? banBlockDetails(ban) : NOT_BLOCKED)
|
||||
// One instant for both: the ban resolved as in force at `now` is described as of
|
||||
// `now`, so it can't come out with 0 left.
|
||||
const now = new Date()
|
||||
const ban = await getActiveBan(c.env.DB, id, now)
|
||||
return c.json(ban ? banBlockDetails(ban, now) : NOT_BLOCKED)
|
||||
}
|
||||
)
|
||||
// The reasons the client offers when a player starts a vote-to-kick. Order matters —
|
||||
@@ -482,10 +534,91 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Report one chat message. Stored in the `report` table every other report uses — same
|
||||
// fields, same moderation life — with `chat_message_id` set. See
|
||||
// migrations/0025_report_chat_message.sql.
|
||||
.post(
|
||||
'/api/chatreport/createChatReport',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Report a chat message',
|
||||
description:
|
||||
'Files a report against one chat message. Stored as a row in the same `report` table ' +
|
||||
'a player report goes to (`POST /api/PlayerReporting/v3/create`), beside the event, ' +
|
||||
'invention and custom-avatar-item reports — the same submission with the same ' +
|
||||
'moderation life, and a moderator converts any of them into a ban the same way. What ' +
|
||||
'marks it as a chat report is `chat_message_id`; the row’s `reported_player_id` is ' +
|
||||
'the message’s SENDER, read from the `message` table, since the body names no ' +
|
||||
'player. `ChatThreadId` is accepted and unused: a message id is unique across ' +
|
||||
'threads, so the message names its own thread and nothing stores a second copy.\n\n' +
|
||||
'The reporter is the caller (from the bearer token) and must be a MEMBER of the ' +
|
||||
'thread the message is in — the same gate reading the message sits behind. A ' +
|
||||
'message the caller cannot see answers exactly as one that does not exist, so the ' +
|
||||
'route cannot be used to probe message ids or to file reports citing conversations ' +
|
||||
'the reporter was never in. A system notice (a join/leave line) has no sender to ' +
|
||||
'report and is refused.\n\n' +
|
||||
'`ReportCategory` arrives as an enum NAME (`Discriminatory`), unlike the numeric ' +
|
||||
'one a player report sends, and is mapped onto the numeric category the table ' +
|
||||
'stores (`Discriminatory` → 102). A number is stored as sent; a name that maps to ' +
|
||||
'nothing is filed as 0 with the name kept at the front of the details. ' +
|
||||
'`ReportDescription` is the details. Nothing dedupes the rows.\n\n' +
|
||||
'Answers the `{ success, error }` envelope the other reports use, `error` an empty ' +
|
||||
'string rather than null, on the rejected branches too.',
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateChatReportRequest, 'The report'),
|
||||
responses: {
|
||||
200: json(SuccessErrorEnvelope, '`{ success: true, error: "" }`'),
|
||||
400: json(SuccessErrorEnvelope, 'No usable `ChatMessageId`, or a system message'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: json(SuccessErrorEnvelope, 'No such message, or not one the caller can see'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const reporterId = await authedId(c)
|
||||
if (reporterId === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const chatMessageId = asInt(formField(body, c, 'ChatMessageId'))
|
||||
if (chatMessageId === null) {
|
||||
return c.json({ success: false, error: 'ChatMessageId is required' }, 400)
|
||||
}
|
||||
|
||||
// The message supplies the reported player, and its OWN thread is the one the
|
||||
// membership check runs against — not the body's `ChatThreadId`, which would let a
|
||||
// caller vouch for a message by naming a thread they happen to be in.
|
||||
const message = await getMessage(c.env.DB, chatMessageId)
|
||||
if (message === null || !(await isThreadMember(c.env.DB, message.chatThreadId, reporterId))) {
|
||||
return c.json({ success: false, error: 'No such message' }, 404)
|
||||
}
|
||||
// System notices are sent as a pseudo-player with a negative id (see `chat`'s
|
||||
// SYSTEM_SENDER_ID); a report against one would be a report against nobody.
|
||||
if (message.senderPlayerId <= 0) {
|
||||
return c.json({ success: false, error: 'That message has no sender to report' }, 400)
|
||||
}
|
||||
|
||||
const rawCategory = formField(body, c, 'ReportCategory')
|
||||
const category = chatReportCategory(rawCategory)
|
||||
const description = formField(body, c, 'ReportDescription') ?? null
|
||||
await createReport(c.env.DB, {
|
||||
reporterPlayerId: reporterId,
|
||||
reportedPlayerId: message.senderPlayerId,
|
||||
reportCategory: category ?? KickReportCategory.Unknown,
|
||||
details:
|
||||
category === null
|
||||
? [`[${rawCategory?.trim()}]`, description].filter(Boolean).join(' ')
|
||||
: description,
|
||||
chatMessageId,
|
||||
})
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// A player calling a vote to kick another. Ungated by role — anyone may start one —
|
||||
// but both players have to be standing in the session the vote is called in, which is
|
||||
// what stops a client putting a vote to a room it isn't in, about someone who isn't
|
||||
// there. Nothing tallies the votes yet: this relays the prompt and no more.
|
||||
// there. The call and every answer arrive here alike; the first ballot while no vote on
|
||||
// that player is open CALLS one (`init`), and only a call is put to the room.
|
||||
.post(
|
||||
'/api/PlayerReporting/v3/voteToKick',
|
||||
describeRoute({
|
||||
@@ -501,17 +634,37 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
'vote is called in front of them — gets a `MessageReceived` frame carrying a ' +
|
||||
'Message of type 5 (`VoteToKick`). The caller is left out: they have voted ' +
|
||||
'already, and their own `Response` is what they posted.\n\n' +
|
||||
'`Data` is an ESCAPED JSON STRING — `"{\\"PlayerId\\":\\"205\\",…}"`, not a nested ' +
|
||||
'object. A Message’s `Data` is a string on the wire, and an object there fails the ' +
|
||||
"client’s decoder outright (`expected:'String Begin Token', actual:'{'`), " +
|
||||
'aborting the notification rather than dropping the field. Inside it, `PlayerId` ' +
|
||||
'is the account id as a STRING, as the reference relays it, and `Response` is ' +
|
||||
'empty — the frame is the question, not an answer.\n\n' +
|
||||
'`FromPlayerId` on that Message is the player being VOTED ON, not the caller — the ' +
|
||||
'client raises its prompt about whoever it names, so the caller there asks the room ' +
|
||||
'to kick the wrong player. `Data` is the `Reason` as PLAIN TEXT (`Inactive in games ' +
|
||||
'(AFK)`), which is what the prompt shows; it is still a string on the wire, because ' +
|
||||
"an object there fails the client’s decoder (`expected:'String Begin Token', " +
|
||||
"actual:'{'`) and aborts the whole notification.\n\n" +
|
||||
'The frames are EPHEMERAL: a vote belongs to the moment it was called, so an ' +
|
||||
'offline player gets nothing rather than a prompt about a dead session on their ' +
|
||||
'next connect.\n\n' +
|
||||
'Nothing is stored — no tally, no report row, and `Reason` is accepted and ' +
|
||||
'unused. Answers the same lowercase `{ success, error }` envelope as the report ' +
|
||||
'Every call RECORDS a ballot in `room_vote` — the session, the player voted on, the ' +
|
||||
'voter and their answer — and then tallies. A vote CARRIES when the yes votes are ' +
|
||||
'strictly more than half the players standing in the session right now (a tie is not ' +
|
||||
'a majority, and the count is of the room as it is, since players leave mid-vote). ' +
|
||||
'The tally counts DISTINCT voters and reads only each one’s latest ballot, so ' +
|
||||
're-posting a vote cannot carry one and a voter may change their mind.\n\n' +
|
||||
'When it carries, the player is kicked exactly as a staff kick does it — moved into ' +
|
||||
'their own dorm, the instance they left freed, an ephemeral `ModerationKick` (id 22) — ' +
|
||||
'but with `ReportCategory` `VoteKick` (10), `IsHostKick` false (the ROOM removed ' +
|
||||
'them), `PlayerIdReporter` the voter whose ballot carried it, and the reason in ' +
|
||||
'`VoteKickReason`. `IsBan` is false: nothing stops them rejoining. No prompt goes ' +
|
||||
'out in that case — the room is not asked a question that is already answered.\n\n' +
|
||||
'The call and the answers arrive on this same endpoint with the same body. A ballot ' +
|
||||
'posted while no vote on `PlayerId` is open in that session CALLS one: it is stored ' +
|
||||
'with `init` 1 and is the only kind that puts the prompt to the room. A vote stays ' +
|
||||
'open for 60 seconds; every ballot inside that window answers it — recorded and ' +
|
||||
'tallied, but relayed to nobody — and only ballots from its call on are counted, so ' +
|
||||
'an expired vote’s yes ballots cannot carry the next.\n\n' +
|
||||
'Calling is THROTTLED: a player who called a vote in the last 5 minutes, in any ' +
|
||||
'session, is refused with a 429. Answering is never throttled. A `No` cannot call a ' +
|
||||
'vote — with none open it is refused with a 409.\n\n' +
|
||||
'`Response` is the caller’s own vote, recorded but never relayed. Answers the same lowercase `{ success, error }` envelope as the ' +
|
||||
'write; a hub failure for any recipient is reported honestly as a 500, since ' +
|
||||
'with nothing behind it the frame is the whole delivery.',
|
||||
security: AUTHED,
|
||||
@@ -521,6 +674,8 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
400: json(SuccessErrorEnvelope, 'No `PlayerId` or no `GameSessionId`'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(SuccessErrorEnvelope, 'Either player is not in that game session'),
|
||||
409: json(SuccessErrorEnvelope, 'A `No` with no vote open to answer'),
|
||||
429: json(SuccessErrorEnvelope, 'The caller called a vote too recently'),
|
||||
500: json(SuccessErrorEnvelope, 'The notifications hub could not be reached'),
|
||||
},
|
||||
}),
|
||||
@@ -529,8 +684,6 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
if (voterId === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
// Kept as posted for the frame — `Data.PlayerId` goes out as the string the
|
||||
// reference relays — but parsed here to check it against presence.
|
||||
const playerIdField = formField(body, c, 'PlayerId')
|
||||
const playerId = asInt(playerIdField)
|
||||
if (playerIdField === undefined || playerId === null) {
|
||||
@@ -541,6 +694,10 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
return c.json({ success: false, error: 'GameSessionId is required' }, 400)
|
||||
}
|
||||
|
||||
// The prompt's own text. Absent is an empty string rather than a refusal: a vote with
|
||||
// no reason given is still a vote, and the client picks from a fixed list anyway.
|
||||
const reason = formField(body, c, 'Reason') ?? ''
|
||||
|
||||
// One read for both players. A vote may only be called by someone standing in the
|
||||
// session, about someone standing in the same one — the session is read from live
|
||||
// presence, never from the body, so neither side can be asserted by the client.
|
||||
@@ -557,6 +714,61 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
return c.json({ success: false, error: 'That player is not in that game session!' }, 403)
|
||||
}
|
||||
|
||||
// Call or answer? Nothing in the body says, so it is whether a vote on this player is
|
||||
// already open here: with none, this ballot calls one.
|
||||
const response = isYesVote(formField(body, c, 'Response'))
|
||||
const openVote = await getOpenVote(c.env.DB, gameSessionId, playerId)
|
||||
const init = openVote === null
|
||||
if (init) {
|
||||
// A late "no" to a vote that has expired would otherwise call a new one.
|
||||
if (!response) {
|
||||
return c.json({ success: false, error: 'There is no vote in progress!' }, 409)
|
||||
}
|
||||
// Only calling is throttled — this is what stops one player putting vote after
|
||||
// vote to the room. Answering someone else's vote never is.
|
||||
if (await isOnVoteCooldown(c.env.DB, voterId)) {
|
||||
return c.json({ success: false, error: 'You are calling votes too quickly!' }, 429)
|
||||
}
|
||||
}
|
||||
|
||||
// The caller's own ballot, recorded before the room is asked: they have voted, and a
|
||||
// vote in a session holding only them and the player they called it on should carry on
|
||||
// its own rather than waiting for an answer that can never come.
|
||||
const ballot = await recordRoomVote(c.env.DB, {
|
||||
gameSessionId,
|
||||
playerId,
|
||||
voterId,
|
||||
response,
|
||||
init,
|
||||
})
|
||||
|
||||
// Counted against who is standing there RIGHT NOW: a majority is of the room as it is,
|
||||
// not of the room as it was when the vote was called, and players leave mid-vote. Only
|
||||
// THIS vote's ballots count — from its call on.
|
||||
const yesVotes = await countKickVotes(
|
||||
c.env.DB,
|
||||
gameSessionId,
|
||||
playerId,
|
||||
openVote?.id ?? ballot.id
|
||||
)
|
||||
const playerCount = await countPlayersInInstance(c.env.DB, gameSessionId)
|
||||
if (isKickMajority(yesVotes, playerCount)) {
|
||||
logger.info('vote to kick carried', {
|
||||
playerId,
|
||||
gameSessionId,
|
||||
yesVotes,
|
||||
playerCount,
|
||||
carriedBy: voterId,
|
||||
})
|
||||
await applyVoteKick(c, playerId, gameSessionId, reason, voterId)
|
||||
// The room is not asked a question that has already been answered.
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
|
||||
// An answer is recorded and no more: the room was asked when the vote was called, and
|
||||
// re-sending the prompt on every answer would raise it again for everyone.
|
||||
if (!init) return c.json({ success: true, error: '' })
|
||||
|
||||
// The room votes, so the audience is everyone standing there — the player being
|
||||
// voted on included; a vote is called in front of them. The caller is dropped:
|
||||
// their vote is the one they just posted.
|
||||
@@ -569,15 +781,13 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
const results = await Promise.all(
|
||||
audience.map((toPlayerId) =>
|
||||
pushVoteToKick(c, {
|
||||
FromPlayerId: voterId,
|
||||
// The player being VOTED ON — the client raises its prompt about this id, so
|
||||
// the caller's own id here asks the room to kick the wrong player.
|
||||
FromPlayerId: playerId,
|
||||
ToPlayerId: toPlayerId,
|
||||
Type: MessageType.VoteToKick,
|
||||
// An escaped JSON STRING, not a nested object — see VoteToKickData.
|
||||
Data: voteToKickData({
|
||||
PlayerId: playerIdField,
|
||||
Response: '',
|
||||
GameSessionId: gameSessionId,
|
||||
}),
|
||||
// The reason as plain text; see the note above the Message type.
|
||||
Data: reason,
|
||||
})
|
||||
)
|
||||
)
|
||||
@@ -611,8 +821,10 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
'instance. Anyone else named — offline, or standing in another room — is skipped ' +
|
||||
'in silence, so naming an account id cannot reach into a session the caller has ' +
|
||||
'no authority over.\n\n' +
|
||||
'Each kicked player loses their presence row (they read offline at once and the ' +
|
||||
'instance frees a slot) and gets a `ModerationKick` frame (id 22) — the frame the ' +
|
||||
'Each kicked player is moved into their own DORM — presence rewritten, not deleted, ' +
|
||||
'so their client lands where it already is instead of loading the dorm a second ' +
|
||||
'time — and the instance they left frees a slot. They also get a `ModerationKick` ' +
|
||||
'frame (id 22) — the frame the ' +
|
||||
'client acts on to leave. It is the same frame a room ban sends, but `IsBan` is ' +
|
||||
'false: this only removes them from the session they are in, and nothing stops ' +
|
||||
'them rejoining. The frame is EPHEMERAL — a kick is true of the moment it ' +
|
||||
@@ -672,14 +884,15 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
// moderator could throw the creator out of it. Nor is the caller themselves.
|
||||
if (playerId === moderatorId || canModerateRoom(room, playerId)) continue
|
||||
if (presences.get(playerId)?.roomInstance?.roomInstanceId !== gameSessionId) continue
|
||||
await deletePresence(c.env.DB, playerId)
|
||||
// Moved to their own dorm rather than having their presence deleted: the client
|
||||
// goes to the dorm either way, and with no presence to read it arrives not knowing
|
||||
// where it is and loads the dorm a second time. `movePlayerToDorm` recomputes the
|
||||
// fullness of the instance they left, so this one no longer has to.
|
||||
await movePlayerToDorm(c.env.DB, playerId)
|
||||
kicked.push(playerId)
|
||||
}
|
||||
|
||||
if (kicked.length > 0) {
|
||||
// The instance just lost players — recompute its fullness so a full room opens
|
||||
// back up, exactly as the `match` worker does when someone logs out.
|
||||
await refreshInstanceFullness(c.env.DB, gameSessionId)
|
||||
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
|
||||
await pushInstantKick(c, kicked, gameSessionId, roomName, moderatorId)
|
||||
}
|
||||
@@ -688,6 +901,108 @@ export const moderationRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// ONE player kicked out of ONE live instance, from the in-room moderation menu, with a
|
||||
// reason. The single-player sibling of `instantKick`, but form-encoded and gated more
|
||||
// narrowly: the room's owner or a co-owner, or an account holding the staff role — not
|
||||
// the room's Moderator (20) tier, which `instantKick` lets through.
|
||||
//
|
||||
// The cross-room guard is the same and it is the point: the instance names the room, the
|
||||
// room is what the caller's authority is checked against, and the target's LIVE presence
|
||||
// must put them in that very instance. Naming a player standing in some other room — even
|
||||
// another instance of this one — kicks nobody, so authority over one room can never reach
|
||||
// a session in another.
|
||||
.post(
|
||||
'/api/PlayerReporting/v1/roomModKick',
|
||||
describeRoute({
|
||||
tags: ['Moderation'],
|
||||
summary: 'Kick one player out of a room instance, with a reason',
|
||||
description:
|
||||
'Ejects `PlayerId` from the live room instance `GameSessionId` (a `roomInstanceId`). ' +
|
||||
'Form-encoded, unlike `instantKick`’s JSON; `Reason` is optional free text shown to ' +
|
||||
'the kicked player in the kick message.\n\n' +
|
||||
'Gated to the instance’s room: its creator or a co-owner, or any account whose token ' +
|
||||
'carries the `moderator` / `developer` role. A room Moderator (20) or Host is NOT ' +
|
||||
'enough here. Anyone else with a valid token gets a 403.\n\n' +
|
||||
'The player must be standing in **that** instance — their live `presence` row must ' +
|
||||
'name it. Otherwise nothing happens and the call answers `success: false`, so an ' +
|
||||
'account id cannot reach into a session in another room, or another instance of this ' +
|
||||
'one. The caller need not be in the instance themselves. The caller cannot kick ' +
|
||||
'themselves, and nobody can kick the room’s owner or a co-owner.\n\n' +
|
||||
'A kick moves the player into their own DORM — presence rewritten rather than deleted, ' +
|
||||
'so their client does not load the dorm twice — frees the slot they held, and sends ' +
|
||||
'them an EPHEMERAL `ModerationKick` ' +
|
||||
'frame (id 22) with `IsBan: false` — nothing stops them rejoining. `IsHostKick` is ' +
|
||||
'true when the caller runs the room, false for a staff account acting in a room it ' +
|
||||
'does not.\n\n' +
|
||||
'Answers the lowercase `{ success, error }` envelope; the shape is unverified against ' +
|
||||
'the real service.',
|
||||
security: AUTHED,
|
||||
requestBody: form(RoomModKickRequest, 'The session, the player and a reason'),
|
||||
responses: {
|
||||
200: json(
|
||||
SuccessErrorEnvelope,
|
||||
'`{ success: true, error: "" }`, or `success: false` when the player cannot be kicked'
|
||||
),
|
||||
400: json(SuccessErrorEnvelope, 'No `GameSessionId` or `PlayerId`'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(SuccessErrorEnvelope, 'The caller neither runs the room nor holds a staff role'),
|
||||
404: json(SuccessErrorEnvelope, 'No such game session'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const moderatorId = await authedId(c)
|
||||
if (moderatorId === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const gameSessionId = asInt(formField(body, c, 'GameSessionId'))
|
||||
if (gameSessionId === null) {
|
||||
return c.json({ success: false, error: 'GameSessionId is required' }, 400)
|
||||
}
|
||||
const playerId = asInt(formField(body, c, 'PlayerId'))
|
||||
if (playerId === null) return c.json({ success: false, error: 'PlayerId is required' }, 400)
|
||||
const reason = formField(body, c, 'Reason')?.trim() ?? ''
|
||||
|
||||
// The instance names the room, and the room carries the roles this is gated on.
|
||||
const instance = await getStoredRoomInstance(c.env.DB, gameSessionId)
|
||||
const room = instance && (await getRoomById(c.env.DB, instance.roomId))
|
||||
if (!room) return c.json({ success: false, error: 'This game session does not exist!' }, 404)
|
||||
|
||||
// The room's own owners first; the token's roles are only read when that fails.
|
||||
const runsRoom = canManageRoom(room, moderatorId)
|
||||
if (!runsRoom) {
|
||||
const roles = await authedRoles(c)
|
||||
if (!roles?.some((role) => MODERATOR_ROLES.has(role))) {
|
||||
return c.json({ success: false, error: 'Forbidden' }, 403)
|
||||
}
|
||||
}
|
||||
|
||||
if (playerId === moderatorId) {
|
||||
return c.json({ success: false, error: 'You cannot kick yourself!' })
|
||||
}
|
||||
// Otherwise a co-owner, or a staffer, could throw the room's owner out of it.
|
||||
if (canManageRoom(room, playerId)) {
|
||||
return c.json({ success: false, error: 'You cannot kick an owner of this room!' })
|
||||
}
|
||||
|
||||
// The cross-room guard: their LIVE presence must name this exact instance. Offline,
|
||||
// expired, in another room, or in another instance of this room are all "not here".
|
||||
const presence = await getPresence<{ roomInstanceId?: number }>(c.env.DB, playerId)
|
||||
if (presence?.roomInstance?.roomInstanceId !== gameSessionId) {
|
||||
return c.json({ success: false, error: 'That player is not in this game session!' })
|
||||
}
|
||||
|
||||
// Into their own dorm, not out of existence — see the note in `instantKick`.
|
||||
await movePlayerToDorm(c.env.DB, playerId)
|
||||
const roomName = typeof room.Name === 'string' ? room.Name : 'this room'
|
||||
await pushInstantKick(c, [playerId], gameSessionId, roomName, moderatorId, {
|
||||
reason,
|
||||
isHostKick: runsRoom,
|
||||
})
|
||||
|
||||
return c.json({ success: true, error: '' })
|
||||
}
|
||||
)
|
||||
|
||||
// A warning handed down by a moderator — the staff-side counterpart to a report.
|
||||
// Gated on the `moderator` role in the token, not just a valid one.
|
||||
.post(
|
||||
|
||||
@@ -16,6 +16,7 @@ import { NotificationType } from '../../../notify/src/notification-types'
|
||||
import { authedId, parseFormIds, queryIds, unauthorized } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
BareInteger,
|
||||
BulkIdsRequest,
|
||||
CheerPlayerRequest,
|
||||
CheerPlayerResponse,
|
||||
@@ -520,23 +521,25 @@ export const progressionRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// The progression events running right now — the limited-time XP events the client shows
|
||||
// The progression event running right now — the limited-time XP event the client shows
|
||||
// a banner and a progress track for.
|
||||
//
|
||||
// STUB: an empty list, which the client reads as "no event on" and skips the event UI
|
||||
// entirely. That is the honest answer (nothing here runs events) and the safe one: a
|
||||
// fabricated event would draw a track that never fills. No auth — whether an event is
|
||||
// running is the same fact for everybody, and the client asks while loading.
|
||||
// STUB: a bare `0` — the id of the active event, or 0 when there is none (this once
|
||||
// served -1 for the same meaning). The client reads it as "no event on" and skips the
|
||||
// event UI entirely. That is the honest answer (nothing here runs events) and the safe
|
||||
// one: a fabricated event would draw a track that never fills. No auth — whether an
|
||||
// event is running is the same fact for everybody, and the client asks while loading.
|
||||
.get(
|
||||
'/api/progressionEvents/active',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'Progression events currently running (stub)',
|
||||
summary: 'Progression event currently running (stub)',
|
||||
description:
|
||||
'The limited-time XP events in progress. Always an empty list — nothing on this ' +
|
||||
'server runs one — which the client reads as “no event” and skips the event UI, ' +
|
||||
'where a 404 would stall the load. No auth: it is the same answer for every player.',
|
||||
responses: { 200: json(JsonArray, 'Empty — no event is running') },
|
||||
'The limited-time XP event in progress, as a bare event id. Always `0` — the ' +
|
||||
'“no active event” value; nothing on this server runs one — which the client reads ' +
|
||||
'as “no event” and skips the event UI, where a 404 would stall the load. No auth: ' +
|
||||
'it is the same answer for every player.',
|
||||
responses: { 200: json(BareInteger, 'A bare `0` — no event is running') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
(c) => c.json(0)
|
||||
)
|
||||
|
||||
+163
-59
@@ -5,10 +5,15 @@ import {
|
||||
acceptFriendRequest,
|
||||
addFriend,
|
||||
countOnlineFriends,
|
||||
createNotification,
|
||||
deleteNotifications,
|
||||
getAccountsByIds,
|
||||
getMutualFriendIds,
|
||||
getNotificationsForPlayer,
|
||||
getRelationshipsForPlayer,
|
||||
MessageType,
|
||||
MUTUAL_FRIENDS_LIMIT,
|
||||
RelationshipType,
|
||||
removeFriend,
|
||||
sendFriendRequest,
|
||||
setRelationshipFlag,
|
||||
@@ -30,6 +35,7 @@ import {
|
||||
json,
|
||||
JsonArray,
|
||||
jsonBody,
|
||||
MessageDto,
|
||||
MutualFriendDto,
|
||||
RelationshipDto,
|
||||
SendMessageRequest,
|
||||
@@ -43,6 +49,7 @@ import type {
|
||||
RelationshipChange,
|
||||
RelationshipFlag,
|
||||
RelationshipResponse,
|
||||
StoredNotification,
|
||||
} from '@repo/domain'
|
||||
import type { App } from '../context'
|
||||
|
||||
@@ -50,31 +57,26 @@ import type { App } from '../context'
|
||||
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.
|
||||
* Push the `MessageReceived` frame for an ALREADY-STORED message, resolving false when the
|
||||
* hub could not be reached.
|
||||
*
|
||||
* Takes the stored record rather than building one, so the frame carries the row's `Id` and
|
||||
* `SentTime` — the client matches the message it is pushed to the one it later reads from
|
||||
* `GET /api/messages/v2/get` by that id, and the delete endpoint has an id to match.
|
||||
* Spread into a plain record because `notifyPlayer` takes an index-signature type, which an
|
||||
* interface doesn't 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> {
|
||||
async function pushMessage(c: Context<App>, message: StoredNotification): Promise<boolean> {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
message.ToPlayerId,
|
||||
NotificationType.MessageReceived,
|
||||
message
|
||||
{ ...message }
|
||||
)
|
||||
return true
|
||||
} catch (err) {
|
||||
logger.error('failed to push MessageReceived notification', {
|
||||
notificationId: message.Id,
|
||||
toPlayerId: message.ToPlayerId,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
@@ -122,6 +124,34 @@ async function notifyBoth(
|
||||
await notifyRelationship(c, otherId, change.other)
|
||||
}
|
||||
|
||||
/**
|
||||
* Store and push the MESSAGE that a friend-graph change shows the other player. The
|
||||
* `RelationshipChanged` frames above only refresh relationship state; what the client
|
||||
* actually surfaces — the request in their inbox with accept/decline, the "accepted your
|
||||
* request" line — is a Message of the right {@link MessageType} delivered as
|
||||
* `MessageReceived`, and stored so it's still there in `GET /api/messages/v2/get` for a
|
||||
* player who was offline. `Data` is NULL for both — the client's Message DTO carries
|
||||
* `"Data": null` on a FriendInvite (a payload-less type, unlike a DM's empty string); the
|
||||
* sender id is the whole payload.
|
||||
* Delivery failure is logged by `pushMessage`; the row is committed either way.
|
||||
*/
|
||||
async function pushFriendMessage(
|
||||
c: Context<App>,
|
||||
fromPlayerId: number,
|
||||
toPlayerId: number,
|
||||
type: MessageType.FriendInvite | MessageType.FriendRequestAccepted
|
||||
): Promise<void> {
|
||||
await pushMessage(
|
||||
c,
|
||||
await createNotification(c.env.DB, {
|
||||
FromPlayerId: fromPlayerId,
|
||||
ToPlayerId: toPlayerId,
|
||||
Type: type,
|
||||
Data: null,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a per-player relationship flag toggle (favorited/ignored/muted). The flags are
|
||||
* private to the caller's own side of the row, so only the caller is notified. The
|
||||
@@ -296,8 +326,8 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
||||
)
|
||||
|
||||
// A message from one player to another — the "invite me!" style prompts the client
|
||||
// sends. Nothing is stored: the message IS the notification, pushed to the
|
||||
// recipient's hub connection (and queued by the hub if they're offline).
|
||||
// sends. STORED as a notification, then pushed to the recipient's hub connection from
|
||||
// the stored record, so it survives a push they never hear.
|
||||
.post(
|
||||
'/api/messages/v2/send',
|
||||
describeRoute({
|
||||
@@ -309,15 +339,16 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
||||
'`coachMessageAll`), except `FromPlayerId` is the caller rather than the Coach ' +
|
||||
'account and it goes to one player. The hub queues it when the recipient is ' +
|
||||
'offline, so it arrives on their next connect.\n\n' +
|
||||
'Nothing is persisted here — there is no message store, the notification is the ' +
|
||||
'whole delivery. The sender is the caller (from the bearer token), NOT a body ' +
|
||||
'field. `Type` is a Message-model type (a different enum from `NotificationType`) ' +
|
||||
'passed through unmapped, defaulting to 0; `Data` is the payload and is commonly ' +
|
||||
'empty.\n\n' +
|
||||
'Persisted first, then pushed from the stored record, so the frame carries the ' +
|
||||
'stored id and the recipient reads the same message back from ' +
|
||||
'`GET /api/messages/v2/get` whether or not the push reached them. The sender is ' +
|
||||
'the caller (from the bearer token), NOT a body field. `Type` is a Message-model ' +
|
||||
'type (a different enum from `NotificationType`) passed through unmapped, ' +
|
||||
'defaulting to 0; `Data` is the payload and is commonly empty.\n\n' +
|
||||
'Answers the same `{ success, error }` envelope as the report / warning writes, ' +
|
||||
'`error` an empty string on success. A hub failure is reported honestly as a 500 ' +
|
||||
'with `success: false` — with no store behind it, a swallowed error would be a ' +
|
||||
'silently dropped message.',
|
||||
'`error` an empty string on success. A hub failure is still reported honestly as ' +
|
||||
'a 500 with `success: false` — the message is in their inbox, but the caller ' +
|
||||
'asked to send one and nothing was delivered.',
|
||||
security: AUTHED,
|
||||
requestBody: form(SendMessageRequest, 'The message'),
|
||||
responses: {
|
||||
@@ -338,15 +369,20 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
||||
return c.json({ success: false, error: 'ToPlayerId is required' }, 400)
|
||||
}
|
||||
|
||||
// The Message the notification carries. Mirrors the coach message's shape with
|
||||
// a real sender and recipient; `Data` stays a string, empty included (the hub
|
||||
// drops only null/undefined from the frame).
|
||||
const delivered = await pushMessage(c, {
|
||||
// Stored FIRST, then pushed from the stored record: the row is the durable copy
|
||||
// and its id is what goes on the frame. A hub failure after this point leaves
|
||||
// the message in the recipient's inbox, which is the point of the store — they
|
||||
// read it on next login instead of losing it.
|
||||
const message = await createNotification(c.env.DB, {
|
||||
FromPlayerId: fromPlayerId,
|
||||
ToPlayerId: toPlayerId,
|
||||
Type: Number.parseInt(str(body.Type) ?? '', 10) || 0,
|
||||
// `Data` stays a string, empty included — distinct from the null the types
|
||||
// carrying no payload of their own send.
|
||||
Data: str(body.Data) ?? '',
|
||||
})
|
||||
|
||||
const delivered = await pushMessage(c, message)
|
||||
if (!delivered) {
|
||||
return c.json({ success: false, error: 'Failed to deliver message' }, 500)
|
||||
}
|
||||
@@ -367,15 +403,16 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
||||
'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' +
|
||||
'sender rule: the caller’s bearer token, never a body field. Stored the same way ' +
|
||||
'too — one notification per recipient, each with its own id.\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.',
|
||||
'honestly as a 500 — the envelope has no room to say which. Each recipient gets ' +
|
||||
'a stored notification of their own, with its own id, so an undelivered one is ' +
|
||||
'still waiting in that player’s inbox.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(SendMultipleMessagesRequest, 'The message and its recipients'),
|
||||
responses: {
|
||||
@@ -410,15 +447,19 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
||||
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.
|
||||
// players get their message either way. One STORED message each, so every
|
||||
// recipient's copy has its own id to be read and deleted by.
|
||||
const results = await Promise.all(
|
||||
toPlayerIds.map((toPlayerId) =>
|
||||
pushMessage(c, {
|
||||
FromPlayerId: fromPlayerId,
|
||||
ToPlayerId: toPlayerId,
|
||||
Type: type,
|
||||
Data: data,
|
||||
})
|
||||
toPlayerIds.map(async (toPlayerId) =>
|
||||
pushMessage(
|
||||
c,
|
||||
await createNotification(c.env.DB, {
|
||||
FromPlayerId: fromPlayerId,
|
||||
ToPlayerId: toPlayerId,
|
||||
Type: type,
|
||||
Data: data,
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
if (results.includes(false)) {
|
||||
@@ -443,8 +484,10 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
||||
'/api/relationships/v2/sendfriendrequest',
|
||||
friendMutation(
|
||||
'Send a friend request',
|
||||
'Offer friendship to another player. Re-sending an outstanding request is a no-op ' +
|
||||
'and notifies nobody.'
|
||||
'Offer friendship to another player. A new request also lands in the target’s inbox ' +
|
||||
'as a `FriendInvite` message (type 4), pushed as `MessageReceived` — that message, ' +
|
||||
'not the `RelationshipChanged` frame, is what the client shows them. Re-sending an ' +
|
||||
'outstanding request is a no-op and notifies nobody.'
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
@@ -453,6 +496,17 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
const change = await sendFriendRequest(c.env.DB, id, target)
|
||||
await notifyBoth(c, id, target, change)
|
||||
// A NEW request is what the target sees as a FriendInvite message (the reference
|
||||
// sends it on exactly this outcome). A request that crosses one the target already
|
||||
// had out auto-accepts instead — so the target hears their own request was accepted,
|
||||
// as if they'd been accepted the ordinary way. A no-op re-send says nothing.
|
||||
if (change.changed) {
|
||||
if (change.self.RelationshipType === RelationshipType.FriendRequestSent) {
|
||||
await pushFriendMessage(c, id, target, MessageType.FriendInvite)
|
||||
} else if (change.self.RelationshipType === RelationshipType.Friend) {
|
||||
await pushFriendMessage(c, id, target, MessageType.FriendRequestAccepted)
|
||||
}
|
||||
}
|
||||
return c.json(change.self)
|
||||
}
|
||||
)
|
||||
@@ -463,8 +517,9 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
||||
'/api/relationships/v2/acceptfriendrequest',
|
||||
friendMutation(
|
||||
'Accept a friend request',
|
||||
'Turn a pending incoming request into a friendship. Accepting nothing pending is a ' +
|
||||
'no-op and notifies nobody.'
|
||||
'Turn a pending incoming request into a friendship. The requester is told with a ' +
|
||||
'`FriendRequestAccepted` message (type 40) pushed as `MessageReceived`. Accepting ' +
|
||||
'nothing pending is a no-op and notifies nobody.'
|
||||
),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
@@ -473,6 +528,10 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
||||
if (target === null || target === id) return c.json({ error: 'invalid player id' }, 400)
|
||||
const change = await acceptFriendRequest(c.env.DB, id, target)
|
||||
await notifyBoth(c, id, target, change)
|
||||
// The requester is told, as a FriendRequestAccepted message from the accepter.
|
||||
if (change.changed) {
|
||||
await pushFriendMessage(c, id, target, MessageType.FriendRequestAccepted)
|
||||
}
|
||||
return c.json(change.self)
|
||||
}
|
||||
)
|
||||
@@ -620,10 +679,30 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
||||
describeRoute({
|
||||
tags: ['Social'],
|
||||
summary: 'Direct messages',
|
||||
description: 'There is no message store yet, so this is always an empty list.',
|
||||
responses: { 200: json(JsonArray, 'An empty list') },
|
||||
description:
|
||||
'The caller’s inbox — everything sent to them that they haven’t deleted, newest ' +
|
||||
'first. The client reads this on login, which is what makes it the DURABLE half of ' +
|
||||
'a message: the `MessageReceived` frame the hub pushes is best-effort (dropped ' +
|
||||
'once sent rather than acked, bounded per player, and skipped whenever the hub ' +
|
||||
'believes it delivered), so a message that only ever existed as a frame could be ' +
|
||||
'lost with nothing left to show for it.\n\n' +
|
||||
'Each entry is the same Message the frame carries, `Id` included and equal — one ' +
|
||||
'message, delivered twice, which is how the client recognises the two as the ' +
|
||||
'same.\n\n' +
|
||||
'Auth-gated, unlike the empty list this used to answer: an inbox is the caller’s ' +
|
||||
'own. Capped at the newest `MAX_NOTIFICATIONS_PER_PLAYER`, which is also what the store ' +
|
||||
'keeps — the client shows an inbox, not an archive.',
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(MessageDto.array(), 'The caller’s messages, newest first'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
(c) => c.json([])
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(await getNotificationsForPlayer(c.env.DB, id))
|
||||
}
|
||||
)
|
||||
// The inbox's delete button. A POST rather than a DELETE because the ids arrive as
|
||||
// a JSON array body — the client batches a multi-select into one call, and its HTTP
|
||||
@@ -634,18 +713,43 @@ export const socialRoutes = new Hono<App>({ strict: false })
|
||||
tags: ['Social'],
|
||||
summary: 'Delete messages',
|
||||
description:
|
||||
'Drops the given messages from the caller’s inbox. Nothing happens: there is no ' +
|
||||
'message store behind `GET /api/messages/v2/get` (the `MessageReceived` ' +
|
||||
'notification is the whole delivery — see `POST /api/messages/v2/send`), so ' +
|
||||
'there are no ids to match and nothing to remove.\n\n' +
|
||||
'Accepted unconditionally and answered 200 with an empty body, which is what the ' +
|
||||
'client wants: it removes the rows locally and re-reads the empty list either ' +
|
||||
'way. Not auth-gated, for the same reason `GET /api/messages/v2/get` isn’t — ' +
|
||||
'the call reaches no state to protect.',
|
||||
'Drops the given messages from the caller’s inbox — the ids being the `Id` on ' +
|
||||
'each message `GET /api/messages/v2/get` served.\n\n' +
|
||||
'Scoped to the CALLER’s inbox, not just to the ids: an id names a message ' +
|
||||
'globally, so matching on id alone would let anyone holding one clear somebody ' +
|
||||
'else’s inbox. An id that isn’t theirs, or is already gone, matches nothing — ' +
|
||||
'not an error, since the client removes its rows locally and re-reads the list ' +
|
||||
'either way.\n\n' +
|
||||
'Auth-gated now that there is a store behind it. The ids arrive either as a bare ' +
|
||||
'JSON array or under `MessageIds`; both are read. Answered 200 with an empty ' +
|
||||
'body, which is what the client wants.',
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(DeleteMessagesRequest, 'The messages to delete'),
|
||||
responses: { 200: { description: 'Accepted (empty body)' } },
|
||||
responses: {
|
||||
200: { description: 'Accepted (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
(c) => c.body(null, 200)
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
// The client batches a multi-select into one call. It posts a bare array; the
|
||||
// documented `{ MessageIds }` object is accepted too rather than guessing which
|
||||
// of the two a given build sends.
|
||||
const body = await c.req.json<unknown>().catch(() => null)
|
||||
const raw = Array.isArray(body)
|
||||
? body
|
||||
: Array.isArray((body as { MessageIds?: unknown } | null)?.MessageIds)
|
||||
? (body as { MessageIds: unknown[] }).MessageIds
|
||||
: []
|
||||
const ids = raw
|
||||
.map((v) => (typeof v === 'number' ? v : typeof v === 'string' ? Number(v) : Number.NaN))
|
||||
.filter((v) => Number.isInteger(v))
|
||||
|
||||
await deleteNotifications(c.env.DB, id, ids)
|
||||
return c.body(null, 200)
|
||||
}
|
||||
)
|
||||
// How many of the caller's friends are online — the friends panel's header count.
|
||||
// Answered from the friend graph joined to live presence, so it agrees with the
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { adminSecretsStore, env } from 'cloudflare:test'
|
||||
import { exports } from 'cloudflare:workers'
|
||||
import { beforeAll, describe, expect, test } from 'vitest'
|
||||
import { beforeAll, beforeEach, describe, expect, test } from 'vitest'
|
||||
|
||||
import {
|
||||
addXp,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
LEVEL_REWARDS,
|
||||
MAX_LEVEL,
|
||||
MessageType,
|
||||
NOTIFICATION_SCHEMA_DDL,
|
||||
OUTFIT_SCHEMA_DDL,
|
||||
PRESENCE_SCHEMA_DDL,
|
||||
PRESENCE_TTL_SECONDS,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
ROOM_INSTANCE_SCHEMA_DDL,
|
||||
ROOM_SCHEMA_DDL,
|
||||
seedRoomWithSubRooms,
|
||||
setOutfit,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
SUPPORTED_GAME_VERSIONS,
|
||||
} from '@repo/domain'
|
||||
@@ -30,25 +32,47 @@ import {
|
||||
import '../../api.app'
|
||||
|
||||
import { PLATFORM_SCHEMA_DDL } from '../../../../auth/src/platform-db'
|
||||
import { SCHEMA_DDL as MESSAGE_SCHEMA_DDL } from '../../../../chat/src/message-db'
|
||||
import {
|
||||
createThread,
|
||||
postMessage,
|
||||
SYSTEM_SENDER_ID,
|
||||
THREAD_SCHEMA_DDL,
|
||||
} from '../../../../chat/src/thread-db'
|
||||
import {
|
||||
grantCustomAvatarItem,
|
||||
INVENTORY_CUSTOM_SCHEMA_DDL,
|
||||
} from '../../../../econ/src/inventory-custom-db'
|
||||
import { grantItem, INVENTORY_SCHEMA_DDL } from '../../../../econ/src/inventory-db'
|
||||
import priceCapSql from '../../../migrations/0032_custom_avatar_item_price_cap.sql?raw'
|
||||
import inventionPriceCapSql from '../../../migrations/0033_invention_price_cap.sql?raw'
|
||||
import { banEvasionMatch, resolveBan } from '../../bans-db'
|
||||
import {
|
||||
createCustomAvatarItem,
|
||||
SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL,
|
||||
getCustomAvatarItem,
|
||||
importCustomAvatarItem,
|
||||
MAX_PLAYER_ITEM_PRICE,
|
||||
} from '../../custom-avatar-items-db'
|
||||
import { customAvatarItemRowLiteral } from '../../custom-avatar-items-load'
|
||||
import {
|
||||
countGoing,
|
||||
SCHEMA_DDL as EVENTS_SCHEMA_DDL,
|
||||
getEventAttendees,
|
||||
getEventResponse,
|
||||
} from '../../events-db'
|
||||
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
|
||||
import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL, MAX_INVENTION_PRICE } from '../../inventions-db'
|
||||
import {
|
||||
banFromReport,
|
||||
createReport,
|
||||
getActiveBan,
|
||||
getBansInForce,
|
||||
getReportById,
|
||||
getReportsAgainst,
|
||||
getTopReported,
|
||||
isPlayerBanned,
|
||||
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
|
||||
searchReports,
|
||||
} from '../../reports-db'
|
||||
import {
|
||||
CheerCategory,
|
||||
@@ -59,6 +83,7 @@ import {
|
||||
spendCheerCredit,
|
||||
} from '../../reputation-db'
|
||||
import { charadesWordsFor } from '../../routes/gameplay'
|
||||
import { SCHEMA_DDL as VOTES_SCHEMA_DDL } from '../../votes-db'
|
||||
import { getWarningsAgainst, SCHEMA_DDL as WARNINGS_SCHEMA_DDL } from '../../warnings-db'
|
||||
|
||||
import type { SavedImage } from '@repo/domain'
|
||||
@@ -97,7 +122,8 @@ const TEST_ROOMS = [
|
||||
},
|
||||
{
|
||||
// The instant kick's room. Owned by account 42 (the default test token); 43 holds
|
||||
// Moderator (20) and 44 only Host (10) — the tier just below that gate.
|
||||
// Moderator (20) and 44 only Host (10) — the tier just below that gate. 45 is a
|
||||
// CoOwner (30), for `roomModKick`, which is gated on owners rather than moderators.
|
||||
RoomId: 4,
|
||||
Name: 'KickRoom',
|
||||
IsDorm: false,
|
||||
@@ -106,6 +132,7 @@ const TEST_ROOMS = [
|
||||
Roles: [
|
||||
{ AccountId: 43, Role: 20, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||
{ AccountId: 44, Role: 10, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||
{ AccountId: 45, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -152,6 +179,10 @@ beforeAll(async () => {
|
||||
|
||||
// Outfit table (owned by the econ worker) — /outfits/me reads and writes slot 0.
|
||||
for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Bought items and bought custom items (owned by the econ worker) — /outfits/bulk strips
|
||||
// what a wearer doesn't own.
|
||||
for (const stmt of INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of INVENTORY_CUSTOM_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()
|
||||
@@ -162,6 +193,8 @@ beforeAll(async () => {
|
||||
|
||||
// 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()
|
||||
// Vote-to-kick ballots (owned by the api worker) — one row per vote cast.
|
||||
for (const stmt of VOTES_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()
|
||||
@@ -177,6 +210,15 @@ beforeAll(async () => {
|
||||
|
||||
// Custom avatar items (owned by the api worker).
|
||||
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// The message store (owned by the api worker) — the inbox reads it and every send
|
||||
// writes to it.
|
||||
for (const stmt of NOTIFICATION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Chat messages and thread membership (owned by the chat worker) — a chat report reads
|
||||
// the reported player off the message, and gates on the reporter being in its thread.
|
||||
for (const stmt of MESSAGE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of THREAD_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
// Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the
|
||||
@@ -839,6 +881,28 @@ describe('public endpoints', () => {
|
||||
expect(body[0]).toMatchObject({ Level: 1, XP: 0 })
|
||||
})
|
||||
|
||||
test('the bulk profile reads answer more ids than D1 will bind at once', async () => {
|
||||
// Both of these are asked about a whole friends list at once, which runs past D1's
|
||||
// 100-bound-parameter cap; unchunked, the statement fails and the endpoint 500s
|
||||
// rather than returning a short list.
|
||||
const ids = Array.from({ length: 250 }, (_, i) => 6000 + i)
|
||||
const query = ids.map((id) => `id=${id}`).join('&')
|
||||
|
||||
const progression = await exports.default.fetch(
|
||||
`${ORIGIN}/api/players/v2/progression/bulk?${query}`
|
||||
)
|
||||
expect(progression.status).toBe(200)
|
||||
const levels = (await progression.json()) as Array<{ PlayerId: number }>
|
||||
expect(levels.map((p) => p.PlayerId)).toEqual(ids)
|
||||
|
||||
const reputation = await exports.default.fetch(
|
||||
`${ORIGIN}/api/playerReputation/v2/bulk?${query}`
|
||||
)
|
||||
expect(reputation.status).toBe(200)
|
||||
const reps = (await reputation.json()) as Array<{ AccountId: number }>
|
||||
expect(reps.map((r) => r.AccountId)).toEqual(ids)
|
||||
})
|
||||
|
||||
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
|
||||
@@ -885,6 +949,13 @@ describe('public endpoints', () => {
|
||||
)
|
||||
})
|
||||
|
||||
test('the config carries the room key limit', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/config/v2`)
|
||||
expect(res.status).toBe(200)
|
||||
const config = (await res.json()) as { RoomKeyConfig: { MaxKeysPerRoom: number } }
|
||||
expect(config.RoomKeyConfig).toEqual({ MaxKeysPerRoom: 10 })
|
||||
})
|
||||
|
||||
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,
|
||||
@@ -968,7 +1039,8 @@ describe('public endpoints', () => {
|
||||
const unflagged = await createCustomAvatarItem(env.DB, item('Unflagged', 1))
|
||||
// Nothing flags items yet, so flag straight in the table — the unflagged one stays.
|
||||
await env.DB.prepare(
|
||||
'UPDATE custom_avatar_item SET is_featured = 1 WHERE custom_avatar_item_id != ?1'
|
||||
`UPDATE custom_avatar_item SET data = json_set(data, '$.IsFeatured', json('true'))
|
||||
WHERE custom_avatar_item_id != ?1`
|
||||
)
|
||||
.bind(unflagged.CustomAvatarItemId)
|
||||
.run()
|
||||
@@ -1118,9 +1190,12 @@ describe('public endpoints', () => {
|
||||
new Date(Date.UTC(2026, 7, 1 + i))
|
||||
)
|
||||
made[name] = created.CustomAvatarItemId
|
||||
// Nothing sets outfit_type on creation yet, so set it straight in the table.
|
||||
// Every created item is a custom shirt (OutfitType 105); this test wants a spread of
|
||||
// slots to filter across, so set them straight in the table.
|
||||
expect(created.OutfitType).toBe(105)
|
||||
await env.DB.prepare(
|
||||
'UPDATE custom_avatar_item SET outfit_type = ?2 WHERE custom_avatar_item_id = ?1'
|
||||
`UPDATE custom_avatar_item SET data = json_set(data, '$.OutfitType', ?2)
|
||||
WHERE custom_avatar_item_id = ?1`
|
||||
)
|
||||
.bind(created.CustomAvatarItemId, outfitType)
|
||||
.run()
|
||||
@@ -1168,20 +1243,67 @@ describe('public endpoints', () => {
|
||||
expect(await search('?skip=99&take=10')).toEqual([])
|
||||
expect(await search('?take=0')).toEqual([])
|
||||
|
||||
// The client capitalises its booleans (`includeCoachItems=True`), so the comparison folds
|
||||
// case; only a recognisable "false" turns the stock content off.
|
||||
expect((await search('?includeCoachItems=True')).map((i) => i.Name)).toContain('Coach Hat')
|
||||
expect((await search('?includeCoachItems=false')).map((i) => i.Name)).not.toContain('Coach Hat')
|
||||
// `includeCoachItems` picks a SIDE of the catalog rather than widening it: `True` is the
|
||||
// Coach's stock content alone (the storefront tab), `False` the players' alone (the
|
||||
// user-generated-content tab), and the two never overlap. Absent is both. The client
|
||||
// capitalises its booleans, so the comparison folds case.
|
||||
expect((await search('?includeCoachItems=True')).map((i) => i.Name)).toEqual(['Coach Hat'])
|
||||
expect((await search('?includeCoachItems=false')).map((i) => i.Name)).toEqual([
|
||||
'Trousers A',
|
||||
'Shirt B',
|
||||
'Shirt A',
|
||||
'Hat A',
|
||||
])
|
||||
expect((await search('?includeCoachItems=False')).map((i) => i.Name)).not.toContain('Coach Hat')
|
||||
expect((await search('?includeCoachItems=maybe')).map((i) => i.Name)).toEqual(
|
||||
all.map((i) => i.Name)
|
||||
)
|
||||
|
||||
// The whole query the client actually sends, unchanged — the parameters that aren't acted
|
||||
// on yet must be accepted rather than 400 or throw.
|
||||
// `creatorAccountId` is one author's published items — a player's creations list. It
|
||||
// was once ignored, and every player's shelf showed the whole player-made catalog.
|
||||
const spec2: Array<[name: string, creator: number]> = [['Hat C', 206]]
|
||||
for (const [name, creator] of spec2) {
|
||||
await createCustomAvatarItem(
|
||||
env.DB,
|
||||
{
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: creator,
|
||||
name,
|
||||
description: '',
|
||||
price: 0,
|
||||
baseAvatarItemId: 1,
|
||||
baseAvatarItemColor: '#fff',
|
||||
accessibility: 1,
|
||||
designFilename: 'design_x.bin',
|
||||
thumbnailImageFilename: 'thumb_x.png',
|
||||
},
|
||||
new Date(Date.UTC(2026, 7, 20))
|
||||
)
|
||||
}
|
||||
expect((await search('?creatorAccountId=206')).map((i) => i.Name)).toEqual(['Hat C'])
|
||||
// The query the client sends for a player's creations: theirs alone, `Hidden` still out.
|
||||
expect(
|
||||
(
|
||||
await search(
|
||||
'?creatorAccountId=205&includePurchaseInfos=True&includeCoachItems=False&skip=0' +
|
||||
'&take=1000&unityAssetTarget=0&unityAssetVersion=3'
|
||||
)
|
||||
).map((i) => i.Name)
|
||||
).toEqual(['Trousers A', 'Shirt B', 'Shirt A', 'Hat A'])
|
||||
expect(await search('?creatorAccountId=1&includeCoachItems=False')).toEqual([])
|
||||
expect(await search('?creatorAccountId=424242')).toEqual([])
|
||||
await env.DB.prepare('DELETE FROM custom_avatar_item WHERE creator_account_id = 206').run()
|
||||
|
||||
// The whole storefront query the client actually sends, unchanged — the parameters that
|
||||
// aren't acted on yet must be accepted rather than 400 or throw, and `includeCoachItems=True`
|
||||
// narrows it to the stock content.
|
||||
const real = await search(
|
||||
'?outfitTypes=0&outfitTypes=2&outfitTypes=3&outfitTypes=10&outfitTypes=20&outfitTypes=100' +
|
||||
'&outfitTypes=101&outfitTypes=102&outfitTypes=103&outfitTypes=200&outfitTypes=300' +
|
||||
'&outfitTypes=301&includePurchaseInfos=True&includeCoachItems=True&ordering=0&skip=0' +
|
||||
'&take=100&unityAssetTarget=0&unityAssetVersion=3'
|
||||
)
|
||||
expect(real.map((i) => i.Name)).toEqual(all.map((i) => i.Name))
|
||||
expect(real.map((i) => i.Name)).toEqual(['Coach Hat'])
|
||||
// `includePurchaseInfos=True` notwithstanding: nothing prices a custom item here yet, so
|
||||
// the field is null on every item and the parameter changes nothing.
|
||||
expect(real.every((i) => i.PurchaseInfo === null)).toBe(true)
|
||||
@@ -1254,17 +1376,18 @@ describe('public endpoints', () => {
|
||||
// Combined with the text search, since the client sends both together.
|
||||
expect(await search('?searchQuery=room&maxPrice=500')).toEqual(['Cosy Beanie', 'Room Hat'])
|
||||
|
||||
// The whole query the client actually sends. `itemTypes` and the unity asset parameters are
|
||||
// accepted and not acted on; `outfitTypes=105` matches nothing, so this is empty — which is
|
||||
// the filter working, not the search failing.
|
||||
// The whole query the client's user-generated-content tab actually sends. `itemTypes=-1`
|
||||
// ("every item type") and the unity asset parameters are accepted and not acted on;
|
||||
// `outfitTypes=105` is the custom shirt, which is what every created item is filed under,
|
||||
// so the tab finds them. (It once found nothing: creation left `outfit_type` at 0.)
|
||||
expect(
|
||||
await search(
|
||||
'?searchQuery=room&itemTypes=-1&outfitTypes=105&minPrice=0&maxPrice=10000' +
|
||||
'&includePurchaseInfos=True&includeCoachItems=False&ordering=0&skip=0&take=1000' +
|
||||
'&unityAssetTarget=0&unityAssetVersion=3'
|
||||
)
|
||||
).toEqual([])
|
||||
// Same query with the outfit-type filter dropped: the rest of it does match.
|
||||
).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
|
||||
// Same query with the outfit-type filter dropped matches the same, as it must.
|
||||
expect(
|
||||
await search(
|
||||
'?searchQuery=room&itemTypes=-1&minPrice=0&maxPrice=10000&includePurchaseInfos=True' +
|
||||
@@ -1274,6 +1397,245 @@ describe('public endpoints', () => {
|
||||
).toEqual(['Ballroom Shoes', 'Cosy Beanie', 'Room Hat'])
|
||||
})
|
||||
|
||||
test('the first-party load blanks a save’s assetbundle hashes and leaves the names alone', () => {
|
||||
// The export's hashes are the PC builds'. The same stored save is served to a Quest
|
||||
// pointed at the `quest/` build, which fails the client's check against a PC hash — so
|
||||
// the load stores them blank. A null hash (no second bundle) stays null.
|
||||
const literal = customAvatarItemRowLiteral({
|
||||
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||
CreatorAccountId: 9001,
|
||||
CurrentSaves: [
|
||||
{
|
||||
BodyType: 1,
|
||||
ThumbnailFileName: 'f2y1ndzuvm5ke2hjmn4cwfwfl.png',
|
||||
UnityAsset: '3rdxsypmi0bdkxzrt1qmz1dpa.assetbundle',
|
||||
UnityAssetHash: 'b1946ac92492d2347c6235b4d2611184',
|
||||
UnityAsset2: '5vs37avnijsc98dylvs9pl58v.assetbundle',
|
||||
UnityAsset2Hash: '591785b794601e212b260e25925636fd',
|
||||
},
|
||||
{
|
||||
BodyType: 2,
|
||||
ThumbnailFileName: 'div4cdxc5b7ameui0d5h8ickl.png',
|
||||
UnityAsset: '2azq1ngn5w621qjeb4vxb64j6.assetbundle',
|
||||
UnityAssetHash: '',
|
||||
UnityAsset2: null,
|
||||
UnityAsset2Hash: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(JSON.parse(literal.slice(1, -1))).toEqual({
|
||||
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||
CreatorAccountId: 1,
|
||||
CurrentSaves: [
|
||||
{
|
||||
BodyType: 1,
|
||||
ThumbnailFileName: 'avatar/f2y1ndzuvm5ke2hjmn4cwfwfl.png',
|
||||
UnityAsset: '3rdxsypmi0bdkxzrt1qmz1dpa.assetbundle',
|
||||
UnityAssetHash: '',
|
||||
UnityAsset2: '5vs37avnijsc98dylvs9pl58v.assetbundle',
|
||||
UnityAsset2Hash: '',
|
||||
},
|
||||
{
|
||||
BodyType: 2,
|
||||
ThumbnailFileName: 'avatar/div4cdxc5b7ameui0d5h8ickl.png',
|
||||
UnityAsset: '2azq1ngn5w621qjeb4vxb64j6.assetbundle',
|
||||
UnityAssetHash: '',
|
||||
UnityAsset2: null,
|
||||
UnityAsset2Hash: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('GET /api/customAvatarItems/v1/search serves an imported first-party item as exported', async () => {
|
||||
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||
|
||||
// A first-party item, as the official service exports it: the same record as a player's
|
||||
// shirt with the base-item and filename fields NULL and a built Unity assetbundle per
|
||||
// body type in `CurrentSaves`. The flat table could not hold this at all; the JSON row
|
||||
// stores it verbatim and the search serves it back as-is, saves included.
|
||||
const wings = {
|
||||
Accessibility: 1,
|
||||
BaseAvatarItemColor: null,
|
||||
BaseAvatarItemId: null,
|
||||
CreatedAt: '2024-09-26T21:32:48.523Z',
|
||||
CreatorAccountId: 1,
|
||||
CurrentSaves: [
|
||||
{
|
||||
AdditionalConfiguration:
|
||||
'{"v":1,"d":{"n":"(BB_SkellyWings_Shoulder)","o":100,"b":0,"ir":[6,184,0],"p":"3a74ca85-fa71-44c7-9c28-aabd605908db","a":"09999705-15d4-4a84-9c0a-65cfe5bde8d7"},"ls":1}',
|
||||
BodyType: 0,
|
||||
CreatedAt: '2024-10-02T22:50:38.809Z',
|
||||
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||
CustomAvatarItemSaveId: 1083,
|
||||
Description: null,
|
||||
ModifiedAt: '2024-11-27T02:34:07.523Z',
|
||||
OutfitType: 100,
|
||||
QAState: 2,
|
||||
ThumbnailFileName: 'f2y1ndzuvm5ke2hjmn4cwfwfl.png',
|
||||
UnityAsset: '3rdxsypmi0bdkxzrt1qmz1dpa.assetbundle',
|
||||
UnityAsset2: '5vs37avnijsc98dylvs9pl58v.assetbundle',
|
||||
UnityAsset2Hash: '/QYNs1vk1E+GF6qhVf3eR4YUCb0z4vFVkzPIA6mqjqY=',
|
||||
UnityAssetHash: 'hQjd30phdbC0U7lBMOX/YtpwxEjPwVvVJaQ/4UzKA0Y=',
|
||||
UnityAssetId: '7ba365a7-cf85-4029-80a3-7d0a08ab6ce6',
|
||||
},
|
||||
{
|
||||
AdditionalConfiguration:
|
||||
'{"v":1,"d":{"n":"(FB_SkellyWings_Shoulder)","o":100,"b":1,"ir":[6,186,0],"p":"09999705-15d4-4a84-9c0a-65cfe5bde8d7","a":"3a74ca85-fa71-44c7-9c28-aabd605908db"},"ls":1}',
|
||||
BodyType: 1,
|
||||
CreatedAt: '2024-10-02T22:52:04.891Z',
|
||||
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||
CustomAvatarItemSaveId: 1085,
|
||||
Description: null,
|
||||
ModifiedAt: '2024-11-27T02:34:07.523Z',
|
||||
OutfitType: 100,
|
||||
QAState: 2,
|
||||
ThumbnailFileName: 'div4cdxc5b7ameui0d5h8ickl.png',
|
||||
UnityAsset: '2azq1ngn5w621qjeb4vxb64j6.assetbundle',
|
||||
UnityAsset2: '5ppm948m3znl2chvbvy88j092.assetbundle',
|
||||
UnityAsset2Hash: 'ZtPbhPSKXkBD9bsmajTmO7A99Ew8tCQBYjE1THE+VUk=',
|
||||
UnityAssetHash: 'TCPaQ1+T74pPBWiVChCYDyC/MNHjHbWEYk7leIqVTOU=',
|
||||
UnityAssetId: '03413da3-e63c-4971-8ca9-c726bc90c0e2',
|
||||
},
|
||||
],
|
||||
CustomAvatarItemId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||
CustomBadgeMetadata: null,
|
||||
Description:
|
||||
'This item will have a fix on its connection point to the back on some body types in a future patch.',
|
||||
DesignFilename: null,
|
||||
ForceCannotPublish: false,
|
||||
IsFeatured: false,
|
||||
IsRecRoomApproved: true,
|
||||
ModifiedAt: '2025-02-19T05:19:28.328Z',
|
||||
Name: 'Skeletal Wings',
|
||||
OutfitType: 100,
|
||||
PreviewOrientation: 0,
|
||||
Price: 6000,
|
||||
RankedEntityId: '83fe651f-15b3-46a7-8afc-adec32c35568',
|
||||
RankingContext: null,
|
||||
Tags: [{ TagType: 0, Value: 'export' }],
|
||||
ThumbnailImageFilename: null,
|
||||
}
|
||||
await importCustomAvatarItem(env.DB, wings)
|
||||
// A player-made shirt beside it, to prove the two kinds share one table and one shape.
|
||||
const shirt = await createCustomAvatarItem(env.DB, {
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 205,
|
||||
name: 'Skeleton Tee',
|
||||
description: '',
|
||||
price: 0,
|
||||
baseAvatarItemId: 2184,
|
||||
baseAvatarItemColor: '#F55C1A',
|
||||
accessibility: 1,
|
||||
designFilename: 'design_x.bin',
|
||||
thumbnailImageFilename: 'thumb_x.png',
|
||||
})
|
||||
|
||||
const search = async (query: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/search${query}`)
|
||||
expect(res.status, query).toBe(200)
|
||||
return (await res.json()) as Array<Record<string, unknown>>
|
||||
}
|
||||
|
||||
// The storefront tab's real query finds it (Coach-authored, OutfitType 100 among the
|
||||
// dozen slots asked for) and serves the record exactly as exported, plus the store-side
|
||||
// `PurchaseInfo` the client asked for with `includePurchaseInfos` (null until priced).
|
||||
const store = await search(
|
||||
'?outfitTypes=0&outfitTypes=2&outfitTypes=3&outfitTypes=10&outfitTypes=20&outfitTypes=100' +
|
||||
'&outfitTypes=101&outfitTypes=102&outfitTypes=103&outfitTypes=200&outfitTypes=300' +
|
||||
'&outfitTypes=301&includePurchaseInfos=True&includeCoachItems=True&ordering=0&skip=0' +
|
||||
'&take=100&unityAssetTarget=0&unityAssetVersion=3'
|
||||
)
|
||||
expect(store).toEqual([{ ...wings, PurchaseInfo: null }])
|
||||
|
||||
// The same query from a Quest (`unityAssetTarget=2`, Android/Oculus) serves the same
|
||||
// record with its assetbundles pointed at the `quest/` builds, and nothing else moved.
|
||||
const questStore = await search(
|
||||
'?outfitTypes=100&includePurchaseInfos=True&includeCoachItems=True&ordering=0&skip=0' +
|
||||
'&take=100&unityAssetTarget=2&unityAssetVersion=3'
|
||||
)
|
||||
expect(questStore).toEqual([
|
||||
{
|
||||
...wings,
|
||||
PurchaseInfo: null,
|
||||
CurrentSaves: wings.CurrentSaves.map((save) => ({
|
||||
...save,
|
||||
UnityAsset: `quest/${save.UnityAsset}`,
|
||||
UnityAsset2: `quest/${save.UnityAsset2}`,
|
||||
})),
|
||||
},
|
||||
])
|
||||
|
||||
// Text search matches it by name and by description, and by its id typed whole.
|
||||
expect((await search('?searchQuery=skeletal')).map((i) => i.Name)).toEqual(['Skeletal Wings'])
|
||||
expect((await search('?searchQuery=connection%20point')).map((i) => i.Name)).toEqual([
|
||||
'Skeletal Wings',
|
||||
])
|
||||
expect(
|
||||
(await search('?searchQuery=83FE651F-15B3-46A7-8AFC-ADEC32C35568')).map((i) => i.Name)
|
||||
).toEqual(['Skeletal Wings'])
|
||||
// "skeleton" is not a substring of "skeletal": only the shirt.
|
||||
expect((await search('?searchQuery=skeleton')).map((i) => i.Name)).toEqual(['Skeleton Tee'])
|
||||
|
||||
// The user-generated-content tab's query does not see it: wrong side of the catalog and
|
||||
// wrong slot both.
|
||||
expect(
|
||||
(await search('?outfitTypes=105&includeCoachItems=False')).map((i) => i.CustomAvatarItemId)
|
||||
).toEqual([shirt.CustomAvatarItemId])
|
||||
|
||||
// A player-made shirt still carries the full shape — empty saves, empty tags — so the
|
||||
// client decodes both kinds through one parser.
|
||||
const [tee] = await search('?searchQuery=skeleton')
|
||||
expect(tee).toMatchObject({
|
||||
BaseAvatarItemId: 2184,
|
||||
CurrentSaves: [],
|
||||
Tags: [],
|
||||
CustomBadgeMetadata: null,
|
||||
RankedEntityId: shirt.CustomAvatarItemId,
|
||||
})
|
||||
|
||||
// Re-importing the same id REPLACES the record (a corrected export lands) rather than
|
||||
// duplicating or being ignored.
|
||||
await importCustomAvatarItem(env.DB, { ...wings, Price: 5000 })
|
||||
expect((await search('?searchQuery=skeletal')).map((i) => i.Price)).toEqual([5000])
|
||||
|
||||
// The bulk lookup resolves it too, saves and all.
|
||||
const bulk = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded', ...(await bearer('42')) },
|
||||
body: new URLSearchParams([['customAvatarItemIds', wings.CustomAvatarItemId]]),
|
||||
})
|
||||
expect(bulk.status).toBe(200)
|
||||
const [got] = (await bulk.json()) as Array<{ CurrentSaves: unknown[]; Price: number }>
|
||||
expect(got.CurrentSaves).toHaveLength(2)
|
||||
expect(got.Price).toBe(5000)
|
||||
|
||||
// A caller asking for the Quest build (`unityAssetTarget` 2, Android/Oculus) is pointed at
|
||||
// the Quest builds of the same assetbundles, under `quest/` — whether the target rides on
|
||||
// the query string or in the form. Target 0 (PC) and no target get the names as stored.
|
||||
const bulkAssets = async (query: string, form: string[][] = []) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/bulk${query}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
...(await bearer('42')),
|
||||
},
|
||||
body: new URLSearchParams([['customAvatarItemIds', wings.CustomAvatarItemId], ...form]),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const [item] = (await res.json()) as Array<{
|
||||
CurrentSaves: Array<{ UnityAsset: string; UnityAsset2: string | null }>
|
||||
}>
|
||||
return item.CurrentSaves.map((save) => [save.UnityAsset, save.UnityAsset2])
|
||||
}
|
||||
const stored = wings.CurrentSaves.map((save) => [save.UnityAsset, save.UnityAsset2])
|
||||
const quest = stored.map((names) => names.map((name) => `quest/${name}`))
|
||||
expect(await bulkAssets('')).toEqual(stored)
|
||||
expect(await bulkAssets('?unityAssetTarget=0')).toEqual(stored)
|
||||
expect(await bulkAssets('?unityAssetTarget=2')).toEqual(quest)
|
||||
expect(await bulkAssets('', [['unityAssetTarget', '2']])).toEqual(quest)
|
||||
})
|
||||
|
||||
test('GET /api/customAvatarItems/v2/fromCreator/:id shows unpublished items only to the creator', async () => {
|
||||
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||
const base = {
|
||||
@@ -1705,6 +2067,172 @@ describe('public endpoints', () => {
|
||||
expect(bad.status).toBe(400)
|
||||
})
|
||||
|
||||
test('POST /outfits/bulk strips the selections a wearer does not own', async () => {
|
||||
const bulk = async (ids: number[]) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/bulk`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('42')), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ AccountIds: ids, UnityAssetTarget: null, UnityAssetVersion: null }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
return ((await res.json()) as { OutfitsByAccountId: Record<string, unknown> })
|
||||
.OutfitsByAccountId
|
||||
}
|
||||
const save = async (sub: string, outfit: unknown) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer(sub)), 'content-type': 'application/json' },
|
||||
body: JSON.stringify(outfit),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
}
|
||||
|
||||
// Base assets: one from the free default catalog, the base item custom shirts are built
|
||||
// on, one account 301 BOUGHT (in a colour — the inventory key is the whole descriptor),
|
||||
// and one nobody owns.
|
||||
const free = '5cd08cfb-c729-4c30-96d9-6a99bb934d91'
|
||||
const base = 'c5d70cb4-71dd-4fe4-b719-34fe2073c611'
|
||||
const bought = 'aaaaaaaa-1111-4222-8333-444444444444'
|
||||
const unowned = 'bbbbbbbb-1111-4222-8333-444444444444'
|
||||
await grantItem(env.DB, 301, {
|
||||
AvatarItemType: 0,
|
||||
AvatarItemDesc: `${bought},51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,`,
|
||||
PlatformMask: -1,
|
||||
FriendlyName: 'Bought Hat',
|
||||
Tooltip: '',
|
||||
Rarity: 1,
|
||||
})
|
||||
// Custom items: one 301 made, one they bought from 999, one of 999's they never bought.
|
||||
const custom = async (customAvatarItemId: string, creatorAccountId: number) =>
|
||||
createCustomAvatarItem(env.DB, {
|
||||
customAvatarItemId,
|
||||
creatorAccountId,
|
||||
name: customAvatarItemId,
|
||||
description: '',
|
||||
price: 0,
|
||||
baseAvatarItemId: 2184,
|
||||
baseAvatarItemColor: '#F55C1A',
|
||||
accessibility: 1,
|
||||
designFilename: 'd.png',
|
||||
thumbnailImageFilename: 't.png',
|
||||
})
|
||||
const made = (await custom('cccccccc-1111-4222-8333-444444444444', 301)).CustomAvatarItemId
|
||||
const boughtCustom = (await custom('dddddddd-1111-4222-8333-444444444444', 999))
|
||||
.CustomAvatarItemId
|
||||
const unownedCustom = (await custom('eeeeeeee-1111-4222-8333-444444444444', 999))
|
||||
.CustomAvatarItemId
|
||||
await grantCustomAvatarItem(env.DB, 301, boughtCustom)
|
||||
|
||||
const selection = (PrefabGuid: string, BodyPart: number, customId?: string) => ({
|
||||
PrefabGuid,
|
||||
CombinationGuid: '',
|
||||
BodyPart,
|
||||
...(customId === undefined
|
||||
? {}
|
||||
: {
|
||||
UgcOutfitData: {
|
||||
BaseAvatarItemColor: { r: 0, g: 0, b: 0, a: 0 },
|
||||
CustomAvatarItemId: customId,
|
||||
},
|
||||
}),
|
||||
})
|
||||
// Guid case is not identity — the client is not consistent about it — so the bought
|
||||
// base rides upper-cased in the v2 list.
|
||||
const v2 = {
|
||||
selections: [
|
||||
selection(free, 1),
|
||||
selection(bought.toUpperCase(), 0),
|
||||
selection(unowned, 2),
|
||||
selection(base, 3, made),
|
||||
selection(base, 4, boughtCustom.toUpperCase()),
|
||||
selection(base, 5, unownedCustom),
|
||||
],
|
||||
}
|
||||
// Only the header of `CustomizationSettings` has been observed; whatever selections it
|
||||
// carries are found wherever they sit, by the same rule.
|
||||
const settings = {
|
||||
AvatarVersion: 2,
|
||||
AvatarBodyType: 0,
|
||||
Body: { Items: [selection(free, 1), selection(unowned, 2)] },
|
||||
}
|
||||
const envelope = (
|
||||
SelectionsV1: string,
|
||||
SelectionsV2: string,
|
||||
CustomizationSettings: string
|
||||
) => ({
|
||||
DataVersion: 2,
|
||||
LegacyData: {
|
||||
SelectionsV1,
|
||||
SelectionsV2,
|
||||
FaceFeatures: '{"ver":7}',
|
||||
SkinColor: 'skin',
|
||||
HairColor: 'hair',
|
||||
},
|
||||
CustomizationSettings,
|
||||
Selections: [],
|
||||
Slot: 0,
|
||||
Name: '',
|
||||
Accessibility: 1,
|
||||
ThumbnailFileName: null,
|
||||
})
|
||||
const saved = envelope(
|
||||
`${free},,1;${bought},51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,,0;${unowned},,2;${base},,3`,
|
||||
JSON.stringify(v2),
|
||||
JSON.stringify(settings)
|
||||
)
|
||||
await save('301', saved)
|
||||
|
||||
// Account 303 wears only free things: served byte-for-byte as saved.
|
||||
const allFree = envelope(
|
||||
`${free},,1;${base},,0`,
|
||||
JSON.stringify({ selections: [selection(free, 1)] }),
|
||||
JSON.stringify({ AvatarVersion: 2, AvatarBodyType: 0 })
|
||||
)
|
||||
await save('303', allFree)
|
||||
|
||||
// Account 302 saved through econ's flat shape (`/api/avatar/v3/saved/set`), which lands
|
||||
// in the same table: the flat fields are stripped the same way.
|
||||
const flat = {
|
||||
Slot: 0,
|
||||
PreviewImageName: null,
|
||||
OutfitSelections: `${free},,1;${unowned},,0`,
|
||||
OutfitSelectionsV2: JSON.stringify({
|
||||
selections: [selection(free, 1), selection(unowned, 0)],
|
||||
}),
|
||||
FaceFeatures: '{"ver":6}',
|
||||
SkinColor: 'skin',
|
||||
HairColor: 'hair',
|
||||
CustomAvatarItems: [{ CustomAvatarItemId: unownedCustom }],
|
||||
}
|
||||
await setOutfit(env.DB, 302, flat)
|
||||
|
||||
const outfits = await bulk([301, 302, 303])
|
||||
|
||||
// 301: the unowned base is gone from every form, the unbought custom item too; the
|
||||
// free, bought (either case), made and bought-custom selections stay. The documents
|
||||
// are re-encoded only because something was removed from them.
|
||||
expect(outfits['301']).toEqual(
|
||||
envelope(
|
||||
`${free},,1;${bought},51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,,0;${base},,3`,
|
||||
JSON.stringify({ selections: v2.selections.filter((_, i) => i !== 2 && i !== 5) }),
|
||||
JSON.stringify({ ...settings, Body: { Items: [selection(free, 1)] } })
|
||||
)
|
||||
)
|
||||
expect(outfits['302']).toEqual({
|
||||
...flat,
|
||||
OutfitSelections: `${free},,1`,
|
||||
OutfitSelectionsV2: JSON.stringify({ selections: [selection(free, 1)] }),
|
||||
CustomAvatarItems: [],
|
||||
})
|
||||
expect(outfits['303']).toEqual(allFree)
|
||||
|
||||
// The owner's own read is untouched: what they saved is what they get back.
|
||||
const mine = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||
headers: await bearer('301'),
|
||||
})
|
||||
expect(await mine.json()).toEqual(saved)
|
||||
})
|
||||
|
||||
test('PUT /outfits/me 400s on an unparseable body', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/outfits/me`, {
|
||||
method: 'PUT',
|
||||
@@ -1714,12 +2242,12 @@ describe('public endpoints', () => {
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
test('GET /api/progressionEvents/active is an empty list (no auth)', async () => {
|
||||
// The client reads an empty list as "no event running" and skips the event UI; a 404
|
||||
// would stall its load instead.
|
||||
test('GET /api/progressionEvents/active is a bare 0 (no auth)', async () => {
|
||||
// 0 is the "no active event" value; the client reads it as "no event running" and
|
||||
// skips the event UI. A 404 would stall its load instead.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/progressionEvents/active`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
expect(await res.json()).toBe(0)
|
||||
})
|
||||
|
||||
test('GET /api/rooms/v1/filters returns an object with filter arrays', async () => {
|
||||
@@ -1771,6 +2299,35 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v2/mine serves a v3-published invention as public', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5149')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Published the old way', inventionDataFilename: 'a.inv' }),
|
||||
})
|
||||
const { Invention } = (await save.json()) as InventionSaveResult
|
||||
await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}`,
|
||||
{ headers: await bearer('5149') }
|
||||
)
|
||||
|
||||
// `v3/publish` names no accessibility, so the record keeps the 0 a save mints. The
|
||||
// 2025 client has no `IsPublished` to read, only `Accessibility` — 0 is Private.
|
||||
const mine = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
|
||||
headers: await bearer('5149'),
|
||||
})
|
||||
expect((await mine.json()) as SavedInvention[]).toMatchObject([
|
||||
{ InventionId: Invention.InventionId, IsPublished: true, Accessibility: 1 },
|
||||
])
|
||||
|
||||
// Published, so it would otherwise turn up in the feeds the later tests count.
|
||||
await exports.default.fetch(`${ORIGIN}/api/inventions/v2/delete`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5149')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: Invention.InventionId }),
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v6/save persists the invention and lists it in mine', async () => {
|
||||
const body = {
|
||||
name: '071126 13:10:50',
|
||||
@@ -1840,6 +2397,19 @@ describe('public endpoints', () => {
|
||||
expect(mine.status).toBe(200)
|
||||
const list = (await mine.json()) as SavedInvention[]
|
||||
expect(list.map((i) => i.InventionId)).toContain(saved.InventionId)
|
||||
// The 2025 client decodes this list into its flat `RRInvention`, so every key of
|
||||
// that DTO is present even on a v6 record that stores none of them — and the keys
|
||||
// the older client reads are still there beside them.
|
||||
expect(list.find((i) => i.InventionId === saved.InventionId)).toMatchObject({
|
||||
UgcVersion: 1,
|
||||
LatestVersionNumber: 1,
|
||||
ForceCannotPublish: false,
|
||||
IsRecRoomApproved: false,
|
||||
DisplayMetadataJson: null,
|
||||
Accessibility: 0,
|
||||
IsPublished: false,
|
||||
CurrentVersion: { VersionNumber: 1 },
|
||||
})
|
||||
|
||||
// The saved invention is fetchable by id via the v1 lookup.
|
||||
const one = await exports.default.fetch(
|
||||
@@ -1959,7 +2529,7 @@ describe('public endpoints', () => {
|
||||
expect(res.status).toBe(200)
|
||||
const value = ((await res.json()) as InventionSaveV9Result).Value
|
||||
if (value === null) throw new Error('Value must not be null on a successful save')
|
||||
expect(value.Invention.UgcVersion).toBe(0)
|
||||
expect(value.Invention.UgcVersion).toBe(1)
|
||||
expect(value.Invention.DisplayMetadataJson).toBeNull()
|
||||
// A save always mints a version; the key is nullable only because econ's
|
||||
// `v3/buyInvention` answers in this same envelope and a buy mints none.
|
||||
@@ -2154,6 +2724,158 @@ describe('public endpoints', () => {
|
||||
expect(((await one.json()) as SavedInvention).Name).toBe('Clear Me')
|
||||
})
|
||||
|
||||
// The permission picker's own call. `Permission` is the `GeneralPermission` ladder
|
||||
// number — 40 is EditAndSave — and it decides what OTHER players may do with the
|
||||
// invention, up to editing, re-publishing and charging for it.
|
||||
test('POST /api/inventions/v2/update sets the permission from the body', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Permission Me', inventionDataFilename: 'perm.inv' }),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
expect(inventionId).toBeGreaterThan(0)
|
||||
|
||||
// The body verbatim as the client posts it.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5170')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Permission: 40 }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const result = (await res.json()) as InventionSaveV9Result
|
||||
expect(result.Success).toBe(true)
|
||||
// The UPDATED invention comes back in the envelope: the client re-renders the detail
|
||||
// sheet from `Value.Invention`, so a reply that omitted it would leave the old
|
||||
// permission on screen.
|
||||
expect(result.Value?.Invention.GeneralPermission).toBe(40)
|
||||
|
||||
// And it stuck.
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
|
||||
)
|
||||
expect(((await one.json()) as SavedInvention).GeneralPermission).toBe(40)
|
||||
})
|
||||
|
||||
// THE gate. `GeneralPermission` is what lets another player edit, re-publish or charge
|
||||
// for someone's build, so a write that took the caller's word for the id would let
|
||||
// anyone hand themselves rights over any invention in the game. Refused in-band, the
|
||||
// way `v2/metadata` refuses — this client cannot parse a bare error body — and, the
|
||||
// part that matters, the stored permission must not move.
|
||||
test('POST /api/inventions/v2/update is gated to the invention’s creator', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Not Yours Either', inventionDataFilename: 'nye.inv' }),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
const permissionNow = async () => {
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
|
||||
)
|
||||
return ((await one.json()) as SavedInvention).GeneralPermission
|
||||
}
|
||||
const before = await permissionNow()
|
||||
|
||||
// Another player, granting themselves Unlimited (100) over someone else's build.
|
||||
const theirs = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Permission: 100 }),
|
||||
})
|
||||
expect(theirs.status).toBe(200)
|
||||
expect(await theirs.json()).toEqual({
|
||||
Value: null,
|
||||
Success: false,
|
||||
Error: 'Not your invention',
|
||||
error_id: null,
|
||||
})
|
||||
expect(await permissionNow()).toBe(before)
|
||||
|
||||
// An unknown invention is the same kind of answer.
|
||||
const missing = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: 987655, Permission: 40 }),
|
||||
})
|
||||
expect(missing.status).toBe(200)
|
||||
expect((await missing.json()) as InventionSaveV9Result).toMatchObject({
|
||||
Value: null,
|
||||
Error: 'No such invention',
|
||||
})
|
||||
|
||||
// A missing token is the one refusal that stays a transport failure — but it still
|
||||
// answers the envelope, because an unparseable body crashes the client.
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Permission: 100 }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
expect((await anon.json()) as InventionSaveV9Result).toMatchObject({
|
||||
Value: null,
|
||||
Success: false,
|
||||
})
|
||||
expect(await permissionNow()).toBe(before)
|
||||
})
|
||||
|
||||
// A patch, not a replace — and specifically NOT `v4/publish`'s reading of a null
|
||||
// permission, which is UseOnly. Publishing something without naming a permission is a
|
||||
// decision; editing it without naming one is not, and defaulting here would silently
|
||||
// demote an invention every time the creator renamed it.
|
||||
test('POST /api/inventions/v2/update leaves a null permission alone', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5173')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Keep My Rights', inventionDataFilename: 'keep.inv' }),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
|
||||
// Set it to Publish (60) first, so a fallback to UseOnly (20) would be visible.
|
||||
await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5173')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Permission: 60 }),
|
||||
})
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5173')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Permission: null, Name: 'Renamed' }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const value = ((await res.json()) as InventionSaveV9Result).Value
|
||||
expect(value?.Invention.GeneralPermission).toBe(60)
|
||||
expect(value?.Invention.Name).toBe('Renamed')
|
||||
})
|
||||
|
||||
// The same parser `v1/update`'s query param goes through, so a build that sends the
|
||||
// ladder by NAME lands on the same level as one sending the number.
|
||||
test('POST /api/inventions/v2/update accepts a permission named as a string', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5174')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Named Ladder', inventionDataFilename: 'named.inv' }),
|
||||
})
|
||||
const inventionId = ((await save.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
|
||||
for (const [sent, expected] of [
|
||||
['EditAndSave', 40],
|
||||
['edit_and_save', 40],
|
||||
['60', 60],
|
||||
] as const) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v2/update`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5174')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Permission: sent }),
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(((await res.json()) as InventionSaveV9Result).Value?.Invention.GeneralPermission).toBe(
|
||||
expected
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('PUT /api/inventions/v2/metadata refuses another creator’s invention in-band', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
@@ -2291,7 +3013,7 @@ describe('public endpoints', () => {
|
||||
.run()
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v4/publish keeps an unlisted invention out of the feeds', async () => {
|
||||
test('POST /api/inventions/v4/publish keeps an unlisted invention out of the store, and v2/unpublish undoes it', async () => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v9/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5171')), 'Content-Type': 'application/json' },
|
||||
@@ -2311,8 +3033,8 @@ describe('public endpoints', () => {
|
||||
const value = ((await res.json()) as InventionSaveV9Result).Value
|
||||
expect(value?.Invention.Accessibility).toBe(2)
|
||||
|
||||
// Unlisted is published — it is reachable by id, which is the whole point of it —
|
||||
// but it is not something anyone comes across.
|
||||
// Unlisted is published — on the creator's portfolio and reachable by id — but it is
|
||||
// not in the store: search and the feeds leave it out.
|
||||
const one = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1?inventionId=${inventionId}`
|
||||
)
|
||||
@@ -2326,6 +3048,39 @@ describe('public endpoints', () => {
|
||||
expect(await found.json()).toEqual([])
|
||||
const room = await exports.default.fetch(`${ORIGIN}/api/inventions/v1/room?id=4171`)
|
||||
expect(await room.json()).toEqual([])
|
||||
const portfolio = async (): Promise<number[]> =>
|
||||
(
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?id=5171`)
|
||||
).json()) as SavedInvention[]
|
||||
).map((i) => i.InventionId)
|
||||
expect(await portfolio()).toEqual([inventionId])
|
||||
|
||||
// Unpublishing is creator only, and puts back what a save mints: not published,
|
||||
// Accessibility 0 — off the portfolio, still on the creator's own shelf.
|
||||
const unpublish = async (who: string): Promise<InventionSaveV9Result> =>
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/inventions/v2/unpublish`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(who)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId }),
|
||||
})
|
||||
).json()) as InventionSaveV9Result
|
||||
expect((await unpublish('5199')).Success).toBe(false)
|
||||
expect(await portfolio()).toEqual([inventionId])
|
||||
|
||||
const undone = await unpublish('5171')
|
||||
expect(undone.Success).toBe(true)
|
||||
expect(undone.Value?.Invention.Accessibility).toBe(0)
|
||||
expect(await portfolio()).toEqual([])
|
||||
const stored = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/inventions/v2/mine`, {
|
||||
headers: await bearer('5171'),
|
||||
})
|
||||
).json()) as SavedInvention[]
|
||||
const shelved = stored.find((i) => i.InventionId === inventionId)
|
||||
expect(shelved).toMatchObject({ IsPublished: false, Accessibility: 0 })
|
||||
expect(shelved?.FirstPublishedAt).not.toBeNull()
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v4/publish leaves a price and a first-publish date alone', async () => {
|
||||
@@ -2360,6 +3115,20 @@ describe('public endpoints', () => {
|
||||
// A negative price is dropped rather than stored.
|
||||
expect((await publish({ Price: -5 })).Price).toBe(250)
|
||||
|
||||
// One over the cap refuses the publish in-band, and the price stands.
|
||||
const over = await exports.default.fetch(`${ORIGIN}/api/inventions/v4/publish`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('5172')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ InventionId: inventionId, Price: 1001 }),
|
||||
})
|
||||
expect(over.status).toBe(200)
|
||||
expect(await over.json()).toMatchObject({
|
||||
Success: false,
|
||||
Value: null,
|
||||
Error: 'Price must be <= 1000',
|
||||
})
|
||||
expect((await publish({ Price: 1000 })).Price).toBe(1000)
|
||||
|
||||
// Out of the published catalogue again — see the note in the publish test above.
|
||||
await env.DB.prepare('DELETE FROM invention WHERE id = ?1').bind(inventionId).run()
|
||||
})
|
||||
@@ -2729,6 +3498,103 @@ describe('public endpoints', () => {
|
||||
expect(anon.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /api/chatreport/createChatReport files a report row against the message sender', async () => {
|
||||
const url = `${ORIGIN}/api/chatreport/createChatReport`
|
||||
const post = async (sub: string, fields: Record<string, string>) =>
|
||||
exports.default.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: await bearer(sub),
|
||||
body: new URLSearchParams(fields),
|
||||
})
|
||||
|
||||
// 5150 says something in a DM with 42; 42 reports it. The body names no player — the
|
||||
// reported one is the message's sender.
|
||||
const threadId = await createThread(env.DB, [42, 5150])
|
||||
const message = await postMessage(env.DB, {
|
||||
chatThreadId: threadId,
|
||||
senderPlayerId: 5150,
|
||||
contents: '{"Type":0,"Version":1,"Data":"something awful"}',
|
||||
})
|
||||
const id = String(message.chatMessageId)
|
||||
|
||||
// The body exactly as the client posts it: the category is a NAME, not a number.
|
||||
const res = await post('42', {
|
||||
ChatThreadId: String(threadId),
|
||||
ChatMessageId: id,
|
||||
ReportCategory: 'Discriminatory',
|
||||
ReportDescription: 'details',
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
const row = await env.DB.prepare('SELECT * FROM report WHERE chat_message_id = ?1')
|
||||
.bind(message.chatMessageId)
|
||||
.first<Record<string, unknown>>()
|
||||
expect(row).toMatchObject({
|
||||
reporter_player_id: 42,
|
||||
reported_player_id: 5150, // the message's sender
|
||||
report_category: 102, // `Discriminatory` → CoCDiscrimination
|
||||
details: 'details',
|
||||
chat_message_id: message.chatMessageId,
|
||||
event_id: null, // the id columns are mutually exclusive
|
||||
invention_id: null,
|
||||
custom_avatar_item_id: null,
|
||||
room_id: null,
|
||||
banned: 0, // filed unbanned, like any report
|
||||
})
|
||||
|
||||
// The thread in the body is not what vouches for the message: a wrong one changes
|
||||
// nothing, and a category the server can't place is filed as 0 with its name kept.
|
||||
const odd = await post('42', {
|
||||
ChatThreadId: '999999',
|
||||
ChatMessageId: id,
|
||||
ReportCategory: 'SomethingNew',
|
||||
ReportDescription: 'why',
|
||||
})
|
||||
expect(odd.status).toBe(200)
|
||||
const oddRow = await env.DB.prepare(
|
||||
'SELECT report_category, details FROM report WHERE chat_message_id = ?1 ORDER BY id DESC'
|
||||
)
|
||||
.bind(message.chatMessageId)
|
||||
.first()
|
||||
expect(oddRow).toEqual({ report_category: 0, details: '[SomethingNew] why' })
|
||||
|
||||
// Someone outside the thread can't report a message they can't read — and is told
|
||||
// exactly what they'd be told about a message that doesn't exist.
|
||||
const outsider = await post('777', { ChatMessageId: id, ReportCategory: 'Discriminatory' })
|
||||
expect(outsider.status).toBe(404)
|
||||
expect(await outsider.json()).toEqual({ success: false, error: 'No such message' })
|
||||
const unknown = await post('42', { ChatMessageId: '999999' })
|
||||
expect(unknown.status).toBe(404)
|
||||
expect(await unknown.json()).toEqual({ success: false, error: 'No such message' })
|
||||
|
||||
// A system notice has no sender to file a report against.
|
||||
const notice = await postMessage(env.DB, {
|
||||
chatThreadId: threadId,
|
||||
senderPlayerId: SYSTEM_SENDER_ID,
|
||||
contents: '{"Type":1,"Version":1,"Data":"5150"}',
|
||||
})
|
||||
const system = await post('42', { ChatMessageId: String(notice.chatMessageId) })
|
||||
expect(system.status).toBe(400)
|
||||
|
||||
const noId = await post('42', { ReportDescription: 'x' })
|
||||
expect(noId.status).toBe(400)
|
||||
expect(await noId.json()).toEqual({ success: false, error: 'ChatMessageId is required' })
|
||||
|
||||
// Auth-gated: the reporter comes from the token, so there's no filing one signed out.
|
||||
const anon = await exports.default.fetch(url, {
|
||||
method: 'POST',
|
||||
body: new URLSearchParams({ ChatMessageId: id }),
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
|
||||
// Only the two accepted reports were filed.
|
||||
const count = await env.DB.prepare(
|
||||
'SELECT COUNT(*) AS n FROM report WHERE chat_message_id IS NOT NULL'
|
||||
).first<{ n: number }>()
|
||||
expect(count?.n).toBe(2)
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v6/save 401s without a bearer token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
@@ -2981,6 +3847,55 @@ describe('public endpoints', () => {
|
||||
expect(await miss.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v2/search with `@username` lists that player’s published inventions', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 8082, username: 'DJDevin' }))
|
||||
.run()
|
||||
const invention = (id: number, creator: number, name: string): SavedInvention =>
|
||||
({
|
||||
InventionId: id,
|
||||
ReplicationId: crypto.randomUUID(),
|
||||
CreatorPlayerId: creator,
|
||||
Name: name,
|
||||
Description: '',
|
||||
ImageName: '',
|
||||
CurrentVersionNumber: 1,
|
||||
CurrentVersion: { InventionId: id, VersionNumber: 1, BlobName: '' },
|
||||
IsPublished: true,
|
||||
HideFromPlayer: false,
|
||||
CreatedAt: `2026-07-${String(id - 100).padStart(2, '0')}T00:00:00Z`,
|
||||
}) as unknown as SavedInvention
|
||||
for (const inv of [
|
||||
invention(111, 8082, 'Devin Sofa'),
|
||||
invention(112, 8082, 'Devin Lamp'),
|
||||
// Another creator mentioning the name is NOT this player's invention.
|
||||
{ ...invention(113, 8083, 'Tribute to @djdevin'), Description: 'by @djdevin' },
|
||||
// Unpublished and hidden ones stay private, even to a search for the creator.
|
||||
{ ...invention(114, 8082, 'Devin Draft'), IsPublished: false },
|
||||
{ ...invention(115, 8082, 'Devin Hidden'), HideFromPlayer: true },
|
||||
]) {
|
||||
await env.DB.prepare('INSERT INTO invention (data) VALUES (?1)')
|
||||
.bind(JSON.stringify(inv))
|
||||
.run()
|
||||
}
|
||||
const ids = async (value: string): Promise<number[]> => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v2/search?value=${encodeURIComponent(value)}&skip=0&take=100`
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
return ((await res.json()) as SavedInvention[]).map((i) => i.InventionId)
|
||||
}
|
||||
|
||||
// The player's published inventions, newest first; the username is case-insensitive.
|
||||
expect(await ids('@djdevin')).toEqual([112, 111])
|
||||
expect(await ids('@DJDevin')).toEqual([112, 111])
|
||||
// Further terms narrow within that player's inventions.
|
||||
expect(await ids('@djdevin lamp')).toEqual([112])
|
||||
// An unknown player is nothing — not a text search for the literal "@nobody".
|
||||
expect(await ids('@nobody')).toEqual([])
|
||||
expect(await ids('@')).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v2/search filters and pages in SQL, and does not search tags', async () => {
|
||||
const published = (id: number, name: string, description: string, tags: string[]) =>
|
||||
({
|
||||
@@ -3224,6 +4139,50 @@ describe('public endpoints', () => {
|
||||
expect(noId.status).toBe(400)
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v2/cheer is v1/cheer in the newer client’s envelope', async () => {
|
||||
const saved = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('8210')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Cheerable Sofa', inventionDataFilename: 'cheerable2.inv' }),
|
||||
})
|
||||
const { InventionId } = ((await saved.json()) as InventionSaveResult).Invention
|
||||
const cheer = async (body: unknown, sub?: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/inventions/v2/cheer`, {
|
||||
method: 'POST',
|
||||
headers: { ...(sub ? await bearer(sub) : {}), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const stored = async (): Promise<number> =>
|
||||
(
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/inventions/v1?inventionId=${InventionId}`)
|
||||
).json()) as SavedInvention
|
||||
).CheerCount
|
||||
const ok = { Value: null, Success: true, Error: null, error_id: null }
|
||||
|
||||
const anonymous = await cheer({ InventionId, Cheer: true })
|
||||
expect(anonymous.status).toBe(401)
|
||||
expect(await anonymous.json()).toMatchObject({ Value: null, Success: false })
|
||||
|
||||
expect(await (await cheer({ InventionId, Cheer: true }, '44')).json()).toEqual(ok)
|
||||
expect(await (await cheer({ InventionId, Cheer: true }, '44')).json()).toEqual(ok)
|
||||
expect(await stored()).toBe(1)
|
||||
const personal = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/personaldetails/${InventionId}`,
|
||||
{ headers: await bearer('44') }
|
||||
)
|
||||
expect(await personal.json()).toEqual({ IsCheering: true })
|
||||
|
||||
expect(await (await cheer({ InventionId, Cheer: false }, '44')).json()).toEqual(ok)
|
||||
expect(await stored()).toBe(0)
|
||||
|
||||
// Refusals stay in the envelope, at 200.
|
||||
const unknown = await cheer({ InventionId: 999999, Cheer: true }, '44')
|
||||
expect(unknown.status).toBe(200)
|
||||
expect(await unknown.json()).toMatchObject({ Success: false, Error: 'No such invention' })
|
||||
expect(await (await cheer({ InventionId }, '44')).json()).toMatchObject({ Success: false })
|
||||
})
|
||||
|
||||
test('POST /api/inventions/v1/cheer persists and personaldetails reflects it', async () => {
|
||||
const saved = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
@@ -3540,6 +4499,21 @@ describe('public endpoints', () => {
|
||||
|
||||
expect(await search()).toEqual([Invention.InventionId])
|
||||
|
||||
// A price over the cap refuses the whole publish, leaving the invention as it was.
|
||||
const over = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v3/publish?inventionId=${Invention.InventionId}&price=1001`,
|
||||
{ headers: await bearer('2121') }
|
||||
)
|
||||
expect(over.status).toBe(400)
|
||||
expect(await over.json()).toEqual({ error: 'Price must be <= 1000' })
|
||||
expect(
|
||||
(await env.DB.prepare(
|
||||
`SELECT json_extract(data, '$.Price') AS price FROM invention WHERE id = ?1`
|
||||
)
|
||||
.bind(Invention.InventionId)
|
||||
.first<{ price: number }>())!.price
|
||||
).toBe(250)
|
||||
|
||||
// Publishing with no permissionLevel defaults to UseOnly, and price to 0.
|
||||
const other = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
@@ -3592,26 +4566,142 @@ describe('public endpoints', () => {
|
||||
expect(
|
||||
(await updateprice({ InventionId: Invention.InventionId, Price: 10 }, '9999')).status
|
||||
).toBe(403)
|
||||
|
||||
// Capped at MAX_INVENTION_PRICE, inclusive: one over is refused and the price stands.
|
||||
expect(MAX_INVENTION_PRICE).toBe(1000)
|
||||
const over = await updateprice({ InventionId: Invention.InventionId, Price: 1001 })
|
||||
expect(over.status).toBe(400)
|
||||
expect(await over.json()).toEqual({ error: 'Price must be <= 1000' })
|
||||
const atCap = await updateprice({ InventionId: Invention.InventionId, Price: 1000 })
|
||||
expect(atCap.status).toBe(200)
|
||||
expect(((await atCap.json()) as InventionSaveResult).Invention.Price).toBe(1000)
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/fromcreators is an empty feed for now', async () => {
|
||||
// A stub: the client renders an empty array as "this creator has published nothing",
|
||||
// where a 404 would read as a row that failed to load.
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/inventions/v1/fromcreators?id=207&skip=0&take=100`
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
// The cap arrived after creators had priced inventions. Migration 0033 lowers every row
|
||||
// over it to exactly the cap, published or not, and leaves the rest alone. Tests build the
|
||||
// schema from SCHEMA_DDL rather than the migrations, so the file's UPDATE is run by hand.
|
||||
test('migration 0033 lowers existing invention prices to the cap', async () => {
|
||||
const make = async (name: string, price: number): Promise<number> => {
|
||||
const save = await exports.default.fetch(`${ORIGIN}/api/inventions/v6/save`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('1213')), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, inventionDataFilename: 'a.inv' }),
|
||||
})
|
||||
const id = ((await save.json()) as InventionSaveResult).Invention.InventionId
|
||||
// Written straight into the row: no endpoint will store this any more.
|
||||
await env.DB.prepare(
|
||||
`UPDATE invention SET data = json_set(data, '$.Price', ?2) WHERE id = ?1`
|
||||
)
|
||||
.bind(id, price)
|
||||
.run()
|
||||
return id
|
||||
}
|
||||
const dear = await make('Dear Lamp', 25000)
|
||||
const fair = await make('Fair Lamp', 1000)
|
||||
const cheap = await make('Cheap Lamp', 50)
|
||||
|
||||
// The params are accepted and ignored, including a repeated `id` and none at all.
|
||||
expect(
|
||||
await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?id=1&id=2`)
|
||||
).json()
|
||||
).toEqual([])
|
||||
expect(
|
||||
await (await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators`)).json()
|
||||
).toEqual([])
|
||||
const statements = inventionPriceCapSql
|
||||
.split('\n')
|
||||
.filter((line) => !line.startsWith('--'))
|
||||
.join('\n')
|
||||
.split(';')
|
||||
.map((sql) => sql.trim())
|
||||
.filter(Boolean)
|
||||
expect(statements).toHaveLength(1)
|
||||
await env.DB.prepare(statements[0]!).run()
|
||||
|
||||
const price = async (id: number): Promise<number> =>
|
||||
(await env.DB.prepare(
|
||||
`SELECT json_extract(data, '$.Price') AS price FROM invention WHERE id = ?1`
|
||||
)
|
||||
.bind(id)
|
||||
.first<{ price: number }>())!.price
|
||||
expect(await price(dear)).toBe(1000)
|
||||
expect(await price(fair)).toBe(1000)
|
||||
expect(await price(cheap)).toBe(50)
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/fromcreators lists a creator’s public inventions', async () => {
|
||||
const json = async (creator: string, path: string, body: unknown): Promise<Response> =>
|
||||
exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(creator)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const save = async (creator: string, name: string): Promise<number> => {
|
||||
const res = await json(creator, '/api/inventions/v9/save', {
|
||||
name,
|
||||
inventionDataFilename: 'portfolio.inv',
|
||||
ugcVersion: 1,
|
||||
})
|
||||
const id = ((await res.json()) as InventionSaveV9Result).Value?.Invention.InventionId
|
||||
if (id === undefined) throw new Error('save failed')
|
||||
return id
|
||||
}
|
||||
const publish = (creator: string, id: number, accessibility: number): Promise<Response> =>
|
||||
json(creator, '/api/inventions/v4/publish', {
|
||||
InventionId: id,
|
||||
Permission: 20,
|
||||
Accessibility: accessibility,
|
||||
})
|
||||
|
||||
const draft = await save('5301', 'Portfolio Draft')
|
||||
const listed = await save('5301', 'Portfolio Public')
|
||||
const unlisted = await save('5301', 'Portfolio Unlisted')
|
||||
const legacy = await save('5301', 'Portfolio Legacy')
|
||||
const other = await save('5302', 'Other Creator')
|
||||
await publish('5301', listed, 1)
|
||||
await publish('5301', unlisted, 2)
|
||||
await publish('5302', other, 1)
|
||||
// `v3/publish` names no accessibility, so the record keeps the 0 a save mints.
|
||||
await exports.default.fetch(`${ORIGIN}/api/inventions/v3/publish?inventionId=${legacy}`, {
|
||||
headers: await bearer('5301'),
|
||||
})
|
||||
|
||||
const shelf = async (query: string): Promise<SavedInvention[]> =>
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?${query}`)
|
||||
).json()) as SavedInvention[]
|
||||
|
||||
// Published is what lists an invention here, whatever its accessibility: the draft
|
||||
// is not on the shelf, the Unlisted one is — and the `v3/publish` one, which stores
|
||||
// the 0 a save mints, is served as Public.
|
||||
const mine = await shelf('id=5301&skip=0&take=100')
|
||||
expect(mine.map((i) => i.InventionId)).toEqual([legacy, unlisted, listed])
|
||||
expect(mine.map((i) => i.Accessibility)).toEqual([1, 2, 1])
|
||||
|
||||
// The creator gets the same shelf as anyone: their drafts are `v2/mine`'s alone.
|
||||
const own = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/inventions/v1/fromcreators?id=5301`, {
|
||||
headers: await bearer('5301'),
|
||||
})
|
||||
).json()) as SavedInvention[]
|
||||
expect(own.map((i) => i.InventionId)).not.toContain(draft)
|
||||
|
||||
// `id` repeats, newest first across creators, and skip/take page it.
|
||||
expect((await shelf('id=5301&id=5302')).map((i) => i.InventionId)).toEqual([
|
||||
other,
|
||||
legacy,
|
||||
unlisted,
|
||||
listed,
|
||||
])
|
||||
expect((await shelf('id=5301&id=5302&skip=1&take=1')).map((i) => i.InventionId)).toEqual([
|
||||
legacy,
|
||||
])
|
||||
|
||||
// No creator, or one with nothing public, is an empty shelf rather than a 404.
|
||||
expect(await shelf('')).toEqual([])
|
||||
expect(await shelf('id=999999')).toEqual([])
|
||||
|
||||
// Published, so they would otherwise turn up in the feeds the later tests count.
|
||||
for (const [creator, id] of [
|
||||
['5301', listed],
|
||||
['5301', unlisted],
|
||||
['5301', legacy],
|
||||
['5302', other],
|
||||
] as const) {
|
||||
await json(creator, '/api/inventions/v2/delete', { InventionId: id })
|
||||
}
|
||||
})
|
||||
|
||||
test('GET /api/inventions/v1/toptoday + v1/featured serve the invention feeds', async () => {
|
||||
@@ -3906,11 +4996,16 @@ describe('custom avatar items', () => {
|
||||
BaseAvatarItemColor: '#F55C1A',
|
||||
PreviewOrientation: 0,
|
||||
RankingContext: null,
|
||||
OutfitType: 0,
|
||||
// Filed as a custom shirt, the slot the store's user-generated-content tab searches.
|
||||
OutfitType: 105,
|
||||
// The full shape a first-party item carries, empty: no built saves, no tags.
|
||||
CurrentSaves: [],
|
||||
Tags: [],
|
||||
CustomBadgeMetadata: null,
|
||||
PurchaseInfo: null,
|
||||
})
|
||||
const itemId = body.Value.CustomAvatarItemId as string
|
||||
expect(body.Value.RankedEntityId).toBe(itemId)
|
||||
expect(itemId).toMatch(/^[0-9a-f-]{36}$/)
|
||||
const date = (body.Value.CreatedAt as string).slice(0, 10)
|
||||
expect(body.Value.ThumbnailImageFilename).toBe(`avatar-item/${date}/${itemId}-thumb.png`)
|
||||
@@ -3928,8 +5023,10 @@ describe('custom avatar items', () => {
|
||||
new Uint8Array([4, 5, 6])
|
||||
)
|
||||
|
||||
// The row is the record as JSON, with the queried fields generated off it.
|
||||
const row = await env.DB.prepare(
|
||||
'SELECT name, creator_account_id FROM custom_avatar_item WHERE custom_avatar_item_id = ?1'
|
||||
`SELECT json_extract(data, '$.Name') AS name, creator_account_id
|
||||
FROM custom_avatar_item WHERE custom_avatar_item_id = ?1`
|
||||
)
|
||||
.bind(body.Value.CustomAvatarItemId)
|
||||
.first()
|
||||
@@ -4002,6 +5099,150 @@ describe('custom avatar items', () => {
|
||||
expect(await (await exports.default.fetch(url, { method: 'PUT' })).status).toBe(401)
|
||||
})
|
||||
|
||||
// A player-made item may not sell for more than MAX_PLAYER_ITEM_PRICE (1000) tokens. The
|
||||
// client has no ceiling of its own (`minPriceForPublicItem` is a floor), so both writes a
|
||||
// player has enforce it — refusing, not clamping, so the creator sees what was set.
|
||||
test('the price is capped at 1000 tokens on create and edit', async () => {
|
||||
expect(MAX_PLAYER_ITEM_PRICE).toBe(1000)
|
||||
const create = async (Price: unknown) => {
|
||||
const form = new FormData()
|
||||
form.set(
|
||||
'metadata',
|
||||
JSON.stringify({ Name: 'Pricey', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff', Price })
|
||||
)
|
||||
form.set('thumbnailImage', new File([new Uint8Array([1])], 't.png', { type: 'image/png' }))
|
||||
form.set('design', new File([new Uint8Array([2])], 'd.png', { type: 'image/png' }))
|
||||
return exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('205'),
|
||||
body: form,
|
||||
})
|
||||
}
|
||||
const over = await create(1001)
|
||||
expect(over.status).toBe(400)
|
||||
expect(await over.json()).toMatchObject({
|
||||
Success: false,
|
||||
Value: null,
|
||||
Error: 'Price must be <= 1000',
|
||||
})
|
||||
expect((await create(-1)).status).toBe(400)
|
||||
expect((await create(10.5)).status).toBe(400)
|
||||
// Nothing was written for the refused ones.
|
||||
expect(
|
||||
await env.DB.prepare(
|
||||
`SELECT count(*) AS n FROM custom_avatar_item WHERE name_lower = 'pricey'`
|
||||
)
|
||||
.first<{ n: number }>()
|
||||
.then((r) => r!.n)
|
||||
).toBe(0)
|
||||
|
||||
// The cap itself is allowed, inclusive; a missing price is still 0.
|
||||
const atCap = await create(1000)
|
||||
expect(atCap.status).toBe(200)
|
||||
const { Value } = (await atCap.json()) as {
|
||||
Value: { CustomAvatarItemId: string; Price: number }
|
||||
}
|
||||
expect(Value.Price).toBe(1000)
|
||||
expect(
|
||||
((await (await create(undefined)).json()) as { Value: { Price: number } }).Value.Price
|
||||
).toBe(0)
|
||||
|
||||
// The edit is bound the same way, and a refused edit leaves the row as it was.
|
||||
const url = `${ORIGIN}/api/customAvatarItems/v1/${Value.CustomAvatarItemId}`
|
||||
const edit = async (Price: number) =>
|
||||
exports.default.fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('205')), 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ Name: null, Description: null, Price, Accessibility: null }),
|
||||
})
|
||||
const tooMuch = await edit(1001)
|
||||
expect(tooMuch.status).toBe(400)
|
||||
expect(await tooMuch.json()).toMatchObject({ Success: false, Error: 'Price must be <= 1000' })
|
||||
expect((await edit(-1)).status).toBe(400)
|
||||
expect((await getCustomAvatarItem(env.DB, Value.CustomAvatarItemId))!.Price).toBe(1000)
|
||||
const lowered = await edit(999)
|
||||
expect(lowered.status).toBe(200)
|
||||
expect(((await lowered.json()) as { Value: { Price: number } }).Value.Price).toBe(999)
|
||||
})
|
||||
|
||||
// The cap arrived after players had already priced shirts. Migration 0032 lowers every
|
||||
// PLAYER-MADE row over it to exactly the cap and leaves first-party items — priced from the
|
||||
// storefront dump, several above 1000 — alone; the two are told apart by `BaseAvatarItemId`.
|
||||
// Tests build the schema from SCHEMA_DDL rather than the migrations, so the file's UPDATE
|
||||
// is run here by hand.
|
||||
test('migration 0032 lowers existing player-made prices to the cap', async () => {
|
||||
const shirt = (name: string, price: number) =>
|
||||
createCustomAvatarItem(
|
||||
env.DB,
|
||||
{
|
||||
customAvatarItemId: crypto.randomUUID(),
|
||||
creatorAccountId: 205,
|
||||
name,
|
||||
description: '',
|
||||
price,
|
||||
baseAvatarItemId: 1,
|
||||
baseAvatarItemColor: '#fff',
|
||||
accessibility: 1,
|
||||
designFilename: 'd',
|
||||
thumbnailImageFilename: 't',
|
||||
},
|
||||
new Date('2026-08-01T00:00:00Z')
|
||||
)
|
||||
const dear = await shirt('Dear', 5000)
|
||||
const fair = await shirt('Fair', 1000)
|
||||
const cheap = await shirt('Cheap', 50)
|
||||
// A first-party item over the cap: no base item, Coach-authored.
|
||||
const firstPartyId = crypto.randomUUID()
|
||||
await importCustomAvatarItem(env.DB, {
|
||||
CustomAvatarItemId: firstPartyId,
|
||||
RankedEntityId: firstPartyId,
|
||||
CreatorAccountId: 1,
|
||||
Name: 'Studio Wings',
|
||||
Description: '',
|
||||
Price: 6000,
|
||||
Accessibility: 1,
|
||||
OutfitType: 100,
|
||||
BaseAvatarItemId: null,
|
||||
BaseAvatarItemColor: null,
|
||||
DesignFilename: null,
|
||||
ThumbnailImageFilename: null,
|
||||
ForceCannotPublish: false,
|
||||
IsFeatured: false,
|
||||
IsRecRoomApproved: true,
|
||||
PreviewOrientation: 0,
|
||||
RankingContext: null,
|
||||
CreatedAt: '2024-09-26T21:32:48.523Z',
|
||||
ModifiedAt: '2025-02-19T05:19:28.328Z',
|
||||
CurrentSaves: [],
|
||||
Tags: [],
|
||||
CustomBadgeMetadata: null,
|
||||
})
|
||||
|
||||
const statements = priceCapSql
|
||||
.split('\n')
|
||||
.filter((line) => !line.startsWith('--'))
|
||||
.join('\n')
|
||||
.split(';')
|
||||
.map((sql) => sql.trim())
|
||||
.filter(Boolean)
|
||||
expect(statements).toHaveLength(1)
|
||||
await env.DB.prepare(statements[0]!).run()
|
||||
|
||||
const price = async (id: string) => (await getCustomAvatarItem(env.DB, id))!
|
||||
expect((await price(dear.CustomAvatarItemId)).Price).toBe(1000)
|
||||
// The lowered row is marked modified, as an edit would mark it; the untouched ones aren't.
|
||||
expect((await price(dear.CustomAvatarItemId)).ModifiedAt).not.toBe(dear.ModifiedAt)
|
||||
expect(await price(fair.CustomAvatarItemId)).toMatchObject({
|
||||
Price: 1000,
|
||||
ModifiedAt: fair.ModifiedAt,
|
||||
})
|
||||
expect(await price(cheap.CustomAvatarItemId)).toMatchObject({
|
||||
Price: 50,
|
||||
ModifiedAt: cheap.ModifiedAt,
|
||||
})
|
||||
expect((await price(firstPartyId)).Price).toBe(6000)
|
||||
})
|
||||
|
||||
test('DELETE removes the creator’s item and its bucket objects', async () => {
|
||||
// Create through the endpoint so the objects really exist in the bucket.
|
||||
const form = new FormData()
|
||||
@@ -4104,7 +5345,7 @@ describe('custom avatar items', () => {
|
||||
|
||||
// Neither rejected request may create metadata or leave one of its two objects behind.
|
||||
const row = await env.DB.prepare(
|
||||
"SELECT COUNT(*) AS n FROM custom_avatar_item WHERE name = 'bounded'"
|
||||
"SELECT COUNT(*) AS n FROM custom_avatar_item WHERE json_extract(data, '$.Name') = 'bounded'"
|
||||
).first<{ n: number }>()
|
||||
expect(row?.n).toBe(0)
|
||||
expect((await env.IMAGES.list({ prefix: 'avatar-item/' })).objects).toHaveLength(
|
||||
@@ -4185,6 +5426,24 @@ describe('instant kick', () => {
|
||||
.bind(accountId)
|
||||
.first<{ n: number }>())!.n === 1
|
||||
|
||||
// A kick no longer deletes presence — it moves the player into their own dorm — so
|
||||
// "kicked" is "no longer standing in that session", not "gone".
|
||||
const inSession = async (accountId: number, roomInstanceId: number) =>
|
||||
(await env.DB.prepare(
|
||||
`SELECT COUNT(*) AS n FROM presence
|
||||
WHERE account_id = ?1 AND json_extract(data, '$.roomInstance.roomInstanceId') = ?2`
|
||||
)
|
||||
.bind(accountId, roomInstanceId)
|
||||
.first<{ n: number }>())!.n === 1
|
||||
|
||||
/** The instance a player's presence says they are standing in. */
|
||||
const presenceInstance = async (accountId: number) =>
|
||||
JSON.parse(
|
||||
(await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
|
||||
.bind(accountId)
|
||||
.first<{ data: string }>())!.data
|
||||
).roomInstance as { roomInstanceId: number; roomInstanceType: number; isPrivate: boolean }
|
||||
|
||||
const isFull = async (roomInstanceId: number) =>
|
||||
(await env.DB.prepare('SELECT is_full AS full FROM room_instance WHERE id = ?1')
|
||||
.bind(roomInstanceId)
|
||||
@@ -4216,9 +5475,19 @@ describe('instant kick', () => {
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
// Presence is deleted, so they read offline at once — and only theirs is.
|
||||
expect(await isPresent(205)).toBe(false)
|
||||
expect(await isPresent(206)).toBe(true)
|
||||
// Out of the session — and into their own DORM, rather than having their presence
|
||||
// deleted: with nothing to read the client lands not knowing where it is and loads the
|
||||
// dorm a second time. 206 is untouched.
|
||||
expect(await inSession(205, SESSION)).toBe(false)
|
||||
expect(await isPresent(205)).toBe(true)
|
||||
const dorm = await presenceInstance(205)
|
||||
// The OFFLINE dorm: the sentinel instance id, not a `room_instance` row. A kicked player
|
||||
// is not joining a session, and minting one per kick would put live dorm sessions on the
|
||||
// books that nothing ever joins.
|
||||
expect(dorm.roomInstanceId).toBe(-2)
|
||||
expect(dorm.roomInstanceType).toBe(2) // RoomInstanceType.Dormroom
|
||||
expect(dorm.isPrivate).toBe(true)
|
||||
expect(await inSession(206, SESSION)).toBe(true)
|
||||
// The instance lost a player, so it is no longer full.
|
||||
expect(await isFull(SESSION)).toBe(false)
|
||||
|
||||
@@ -4268,7 +5537,7 @@ describe('instant kick', () => {
|
||||
|
||||
// 43 holds Moderator (20) on the room.
|
||||
expect((await kick({ GameSessionId: SESSION, PlayerIds: [209] }, '43')).status).toBe(200)
|
||||
expect(await isPresent(209)).toBe(false)
|
||||
expect(await inSession(209, SESSION)).toBe(false)
|
||||
|
||||
await standIn(209, SESSION)
|
||||
// 44 is only a Host (10), and 99 holds nothing at all.
|
||||
@@ -4277,7 +5546,7 @@ describe('instant kick', () => {
|
||||
expect(res.status, sub).toBe(403)
|
||||
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
|
||||
}
|
||||
expect(await isPresent(209)).toBe(true)
|
||||
expect(await inSession(209, SESSION)).toBe(true)
|
||||
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/instantKick`, {
|
||||
method: 'POST',
|
||||
@@ -4297,8 +5566,8 @@ describe('instant kick', () => {
|
||||
// 43 (a moderator) names the creator, a fellow moderator and themselves.
|
||||
const res = await kick({ GameSessionId: SESSION, PlayerIds: [42, 43] }, '43')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await isPresent(42)).toBe(true)
|
||||
expect(await isPresent(43)).toBe(true)
|
||||
expect(await inSession(42, SESSION)).toBe(true)
|
||||
expect(await inSession(43, SESSION)).toBe(true)
|
||||
expect(await frames()).toEqual([])
|
||||
})
|
||||
|
||||
@@ -4334,11 +5603,250 @@ describe('instant kick', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('room mod kick', () => {
|
||||
// A session of KickRoom (room 4, owned by 42, co-owned by 45), a second session of the
|
||||
// same room, and a session of RecCenter (room 2, owned by 1) — somebody else's room.
|
||||
const SESSION = 1074859
|
||||
const SAME_ROOM_SESSION = 1074860
|
||||
const OTHER_ROOM_SESSION = 1074861
|
||||
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
|
||||
const seedInstance = async (
|
||||
roomInstanceId: number,
|
||||
roomId: number,
|
||||
maxCapacity = 0,
|
||||
isFull = false
|
||||
) =>
|
||||
env.DB.prepare('INSERT OR REPLACE INTO room_instance (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
roomInstanceId,
|
||||
ownerAccountId: 42,
|
||||
roomId,
|
||||
subRoomId: roomId,
|
||||
maxCapacity,
|
||||
isFull,
|
||||
gameVersion: GAME_VERSION,
|
||||
createdAt: new Date().toISOString(),
|
||||
})
|
||||
)
|
||||
.run()
|
||||
|
||||
const standIn = async (accountId: number, roomInstanceId: number, roomId = 4) =>
|
||||
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId,
|
||||
roomInstance: { roomInstanceId, roomId },
|
||||
expiresAt: Math.floor(Date.now() / 1000) + PRESENCE_TTL_SECONDS,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
|
||||
// A kick no longer deletes presence — it moves the player into their own dorm — so
|
||||
// "kicked" is "no longer standing in that session", not "gone".
|
||||
const inSession = async (accountId: number, roomInstanceId: number) =>
|
||||
(await env.DB.prepare(
|
||||
`SELECT COUNT(*) AS n FROM presence
|
||||
WHERE account_id = ?1 AND json_extract(data, '$.roomInstance.roomInstanceId') = ?2`
|
||||
)
|
||||
.bind(accountId, roomInstanceId)
|
||||
.first<{ n: number }>())!.n === 1
|
||||
|
||||
/** The instance a player's presence says they are standing in. */
|
||||
const presenceInstance = async (accountId: number) =>
|
||||
JSON.parse(
|
||||
(await env.DB.prepare('SELECT data FROM presence WHERE account_id = ?1')
|
||||
.bind(accountId)
|
||||
.first<{ data: string }>())!.data
|
||||
).roomInstance as { roomInstanceId: number; roomInstanceType: number; isPrivate: boolean }
|
||||
|
||||
// The client's form body.
|
||||
const modKick = async (fields: Record<string, string>, sub = '42', roles?: string[]) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/roomModKick`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer(sub, roles)),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: new URLSearchParams(fields).toString(),
|
||||
})
|
||||
|
||||
const frames = async () =>
|
||||
(await (await hub().fetch('http://do/all')).json()) as Array<{
|
||||
playerIds?: number[]
|
||||
ephemeral?: boolean
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
|
||||
test('the room’s owner kicks a player out of the session they are in, with a reason', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
// A full one-seat instance that 205 is filling.
|
||||
await seedInstance(SESSION, 4, 1, true)
|
||||
await standIn(205, SESSION)
|
||||
|
||||
// The client's body, verbatim.
|
||||
const res = await modKick({ PlayerId: '205', GameSessionId: String(SESSION), Reason: 'test' })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
// Out of the session and into their own dorm — not deleted.
|
||||
expect(await inSession(205, SESSION)).toBe(false)
|
||||
const kickedTo = await presenceInstance(205)
|
||||
expect(kickedTo.roomInstanceId).toBe(-2) // the offline dorm sentinel
|
||||
expect(kickedTo.roomInstanceType).toBe(2) // Dormroom
|
||||
const full = await env.DB.prepare('SELECT is_full AS f FROM room_instance WHERE id = ?1')
|
||||
.bind(SESSION)
|
||||
.first<{ f: number }>()
|
||||
expect(full!.f).toBe(0)
|
||||
|
||||
expect(await frames()).toEqual([
|
||||
expect.objectContaining({
|
||||
playerIds: [205],
|
||||
ephemeral: true,
|
||||
data: expect.objectContaining({
|
||||
GameSessionId: SESSION,
|
||||
IsHostKick: true,
|
||||
IsBan: false,
|
||||
PlayerIdReporter: 42,
|
||||
Message: 'You have been kicked from KickRoom. Reason: test',
|
||||
}),
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
// The whole reason for the presence check: authority over one room must never reach a
|
||||
// session somewhere else.
|
||||
test('a player who is not standing in that exact session is not kicked', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
await seedInstance(SESSION, 4)
|
||||
await seedInstance(SAME_ROOM_SESSION, 4)
|
||||
await seedInstance(OTHER_ROOM_SESSION, 2)
|
||||
|
||||
// In another instance of the SAME room, in a different room entirely, and offline.
|
||||
await standIn(206, SAME_ROOM_SESSION)
|
||||
await standIn(207, OTHER_ROOM_SESSION, 2)
|
||||
for (const playerId of ['206', '207', '208']) {
|
||||
const res = await modKick({ PlayerId: playerId, GameSessionId: String(SESSION) }, '42')
|
||||
expect(res.status, playerId).toBe(200)
|
||||
expect(await res.json(), playerId).toEqual({
|
||||
success: false,
|
||||
error: 'That player is not in this game session!',
|
||||
})
|
||||
}
|
||||
expect(await inSession(206, SAME_ROOM_SESSION)).toBe(true)
|
||||
expect(await inSession(207, OTHER_ROOM_SESSION)).toBe(true)
|
||||
|
||||
// Naming the OTHER room's session instead doesn't help: 42 has no authority there.
|
||||
const cross = await modKick(
|
||||
{ PlayerId: '207', GameSessionId: String(OTHER_ROOM_SESSION) },
|
||||
'42'
|
||||
)
|
||||
expect(cross.status).toBe(403)
|
||||
expect(await inSession(207, OTHER_ROOM_SESSION)).toBe(true)
|
||||
expect(await frames()).toEqual([])
|
||||
})
|
||||
|
||||
test('a co-owner or a staff role may kick; a room moderator, a host and a stranger may not', async () => {
|
||||
await seedInstance(SESSION, 4)
|
||||
|
||||
// 45 co-owns the room.
|
||||
await standIn(209, SESSION)
|
||||
expect(
|
||||
await (await modKick({ PlayerId: '209', GameSessionId: String(SESSION) }, '45')).json()
|
||||
).toEqual({ success: true, error: '' })
|
||||
expect(await inSession(209, SESSION)).toBe(false)
|
||||
|
||||
// 999 has no role on the room but carries the staff `moderator` role — and is not the
|
||||
// host, so the frame says so.
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
await standIn(209, SESSION)
|
||||
const staff = await modKick({ PlayerId: '209', GameSessionId: String(SESSION) }, '999', [
|
||||
'gameClient',
|
||||
'moderator',
|
||||
])
|
||||
expect(await staff.json()).toEqual({ success: true, error: '' })
|
||||
expect(await inSession(209, SESSION)).toBe(false)
|
||||
expect((await frames())[0].data).toMatchObject({ IsHostKick: false, PlayerIdReporter: 999 })
|
||||
|
||||
// 43 is the room's Moderator (20) — enough for instantKick, not for this. 44 is a Host,
|
||||
// 99 holds nothing.
|
||||
await standIn(209, SESSION)
|
||||
for (const sub of ['43', '44', '99']) {
|
||||
const res = await modKick({ PlayerId: '209', GameSessionId: String(SESSION) }, sub)
|
||||
expect(res.status, sub).toBe(403)
|
||||
expect(await res.json()).toEqual({ success: false, error: 'Forbidden' })
|
||||
}
|
||||
expect(await inSession(209, SESSION)).toBe(true)
|
||||
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v1/roomModKick`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `PlayerId=209&GameSessionId=${SESSION}`,
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
})
|
||||
|
||||
test('nobody kicks themselves or an owner of the room', async () => {
|
||||
await seedInstance(SESSION, 4)
|
||||
await standIn(42, SESSION)
|
||||
await standIn(45, SESSION)
|
||||
|
||||
expect(
|
||||
await (await modKick({ PlayerId: '42', GameSessionId: String(SESSION) }, '42')).json()
|
||||
).toEqual({ success: false, error: 'You cannot kick yourself!' })
|
||||
// A co-owner cannot throw out the owner, and staff cannot throw out a co-owner.
|
||||
expect(
|
||||
await (await modKick({ PlayerId: '42', GameSessionId: String(SESSION) }, '45')).json()
|
||||
).toEqual({ success: false, error: 'You cannot kick an owner of this room!' })
|
||||
expect(
|
||||
await (
|
||||
await modKick({ PlayerId: '45', GameSessionId: String(SESSION) }, '999', ['moderator'])
|
||||
).json()
|
||||
).toEqual({ success: false, error: 'You cannot kick an owner of this room!' })
|
||||
expect(await inSession(42, SESSION)).toBe(true)
|
||||
expect(await inSession(45, SESSION)).toBe(true)
|
||||
})
|
||||
|
||||
test('an unknown session 404s, and the body must name a session and a player', async () => {
|
||||
const unknown = await modKick({ PlayerId: '205', GameSessionId: '999999' })
|
||||
expect(unknown.status).toBe(404)
|
||||
expect(await unknown.json()).toEqual({
|
||||
success: false,
|
||||
error: 'This game session does not exist!',
|
||||
})
|
||||
|
||||
for (const [fields, error] of [
|
||||
[{ PlayerId: '205' }, 'GameSessionId is required'],
|
||||
[{ PlayerId: '205', GameSessionId: 'nope' }, 'GameSessionId is required'],
|
||||
[{ GameSessionId: String(SESSION) }, 'PlayerId is required'],
|
||||
] as Array<[Record<string, string>, string]>) {
|
||||
const res = await modKick(fields)
|
||||
expect(res.status, error).toBe(400)
|
||||
expect(await res.json()).toEqual({ success: false, error })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('vote to kick', () => {
|
||||
// Two live sessions, so a vote called in one can be checked against a player in the
|
||||
// other. Nothing reads `room_instance` here — the gate is presence alone.
|
||||
// other. The gate is presence alone; `room_instance` is read only to name the room in a
|
||||
// carried vote's kick frame.
|
||||
const SESSION = 1014079
|
||||
const OTHER_SESSION = 1014080
|
||||
// The tally tests get sessions of their own: ballots are appended and never pruned, so
|
||||
// votes cast by the tests above would otherwise be counted into theirs.
|
||||
const TALLY_SESSION = 1014081
|
||||
const REVOTE_SESSION = 1014082
|
||||
const TIE_SESSION = 1014083
|
||||
|
||||
// Every test calls votes as 42, and calling is throttled per caller — with the ballots of
|
||||
// the test before still there, each call after the first would be refused with a 429, or
|
||||
// land as an answer to a vote still open from earlier.
|
||||
beforeEach(async () => {
|
||||
await env.DB.prepare('DELETE FROM room_vote').run()
|
||||
})
|
||||
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
|
||||
@@ -4358,6 +5866,40 @@ describe('vote to kick', () => {
|
||||
)
|
||||
.run()
|
||||
|
||||
const isPresent = async (accountId: number) =>
|
||||
(await env.DB.prepare(
|
||||
"SELECT COUNT(*) AS n FROM presence WHERE json_extract(data, '$.accountId') = ?1"
|
||||
)
|
||||
.bind(accountId)
|
||||
.first<{ n: number }>())!.n === 1
|
||||
|
||||
// A carried vote moves the player into their own dorm rather than deleting their presence,
|
||||
// so "kicked" is "no longer standing in that session".
|
||||
const inSession = async (accountId: number, roomInstanceId: number) =>
|
||||
(await env.DB.prepare(
|
||||
`SELECT COUNT(*) AS n FROM presence
|
||||
WHERE json_extract(data, '$.accountId') = ?1
|
||||
AND json_extract(data, '$.roomInstance.roomInstanceId') = ?2`
|
||||
)
|
||||
.bind(accountId, roomInstanceId)
|
||||
.first<{ n: number }>())!.n === 1
|
||||
|
||||
/** Every ballot cast against one player in one session, oldest first. */
|
||||
const ballots = async (gameSessionId: number, playerId: number) =>
|
||||
(
|
||||
await env.DB.prepare(
|
||||
'SELECT * FROM room_vote WHERE game_session_id = ?1 AND player_id = ?2 ORDER BY id'
|
||||
)
|
||||
.bind(gameSessionId, playerId)
|
||||
.all<{
|
||||
voter_id: number
|
||||
response: number
|
||||
player_id: number
|
||||
voted_at: string
|
||||
init: number
|
||||
}>()
|
||||
).results
|
||||
|
||||
// The body the client posts: `PlayerId=205&Response=True&Reason=…&GameSessionId=…`.
|
||||
const vote = async (fields: Record<string, string>, sub = '42') =>
|
||||
exports.default.fetch(`${ORIGIN}/api/PlayerReporting/v3/voteToKick`, {
|
||||
@@ -4395,17 +5937,20 @@ describe('vote to kick', () => {
|
||||
|
||||
// One frame each for 205 and 206 — the player voted on gets it too (the vote is
|
||||
// called in front of them), the caller does not, and 207 is in another session.
|
||||
// `Data` is an ESCAPED JSON STRING, not a nested object: an object there fails the
|
||||
// client's decoder (`expected:'String Begin Token', actual:'{'`) and takes the whole
|
||||
// notification with it. `PlayerId` inside it is a STRING, as the reference relays it,
|
||||
// and `Response` is empty because the frame is the question, not an answer.
|
||||
//
|
||||
// `FromPlayerId` is the player being VOTED ON (205), NOT the caller (42): the client
|
||||
// raises its prompt about whoever that names, so the caller there asks the room to kick
|
||||
// the wrong player. `Data` is the REASON as plain text — it is what the prompt shows,
|
||||
// and it used to be a JSON blob of the ids, which players saw printed on the prompt. It
|
||||
// is still a string on the wire: an object fails the client's decoder
|
||||
// (`expected:'String Begin Token', actual:'{'`) and takes the whole notification with it.
|
||||
const message = {
|
||||
ephemeral: true,
|
||||
notificationType: 2, // NotificationType.MessageReceived
|
||||
data: {
|
||||
FromPlayerId: 42,
|
||||
FromPlayerId: 205,
|
||||
Type: MessageType.VoteToKick,
|
||||
Data: `{"PlayerId":"205","Response":"","GameSessionId":${SESSION}}`,
|
||||
Data: 'Inactive in games (AFK)',
|
||||
},
|
||||
}
|
||||
const sent = await frames()
|
||||
@@ -4422,6 +5967,109 @@ describe('vote to kick', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('a vote with no reason still goes out, with an empty Data', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
await standIn(42, SESSION)
|
||||
await standIn(205, SESSION)
|
||||
await standIn(206, SESSION)
|
||||
|
||||
const { Reason: _reason, ...noReason } = FIELDS
|
||||
expect((await vote(noReason)).status).toBe(200)
|
||||
const sent = await frames()
|
||||
expect(sent).toHaveLength(2)
|
||||
expect(sent.every((f) => f.data.Data === '')).toBe(true)
|
||||
// Still about the player voted on, reason or no reason.
|
||||
expect(sent.every((f) => f.data.FromPlayerId === 205)).toBe(true)
|
||||
})
|
||||
|
||||
// A vote is recorded whether or not it carries — the row is the record of who asked.
|
||||
test('the caller’s ballot is recorded', async () => {
|
||||
await standIn(42, SESSION)
|
||||
await standIn(205, SESSION)
|
||||
await standIn(206, SESSION)
|
||||
const before = await ballots(SESSION, 205)
|
||||
|
||||
expect((await vote(FIELDS)).status).toBe(200)
|
||||
const after = await ballots(SESSION, 205)
|
||||
expect(after).toHaveLength(before.length + 1)
|
||||
expect(after.at(-1)).toMatchObject({ voter_id: 42, response: 1, player_id: 205 })
|
||||
expect(typeof after.at(-1)!.voted_at).toBe('string')
|
||||
|
||||
// `Response=False` is recorded as a no rather than dropped.
|
||||
expect((await vote({ ...FIELDS, Response: 'False' }, '206')).status).toBe(200)
|
||||
expect((await ballots(SESSION, 205)).at(-1)).toMatchObject({ voter_id: 206, response: 0 })
|
||||
})
|
||||
|
||||
// The point of the tally: enough of the room says yes and the player goes, without any
|
||||
// moderator being involved.
|
||||
test('a majority of the room carries the vote and kicks the player', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
// Three in the session: 42 calls it on 210, 211 is the other voter.
|
||||
for (const id of [42, 210, 211]) await standIn(id, TALLY_SESSION)
|
||||
const fields = { ...FIELDS, PlayerId: '210', GameSessionId: String(TALLY_SESSION) }
|
||||
|
||||
// One yes of three is not a majority — the room is asked instead.
|
||||
expect((await vote(fields)).status).toBe(200)
|
||||
expect(await inSession(210, TALLY_SESSION)).toBe(true)
|
||||
expect((await frames()).every((f) => f.notificationType === 2)).toBe(true)
|
||||
|
||||
// The second yes carries it: 2 of 3 is strictly more than half.
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
expect((await vote(fields, '211')).status).toBe(200)
|
||||
|
||||
// Kicked exactly as a staff kick does it — out of the session and into their own dorm,
|
||||
// their presence rewritten rather than deleted...
|
||||
expect(await inSession(210, TALLY_SESSION)).toBe(false)
|
||||
expect(await isPresent(210)).toBe(true)
|
||||
expect(await inSession(210, -2)).toBe(true) // the offline dorm sentinel
|
||||
// ...and ONE ephemeral ModerationKick, no vote prompt: the question is answered.
|
||||
const sent = await frames()
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]).toMatchObject({
|
||||
playerIds: [210],
|
||||
ephemeral: true,
|
||||
notificationType: 22, // NotificationType.ModerationKick
|
||||
data: {
|
||||
ReportCategory: 10, // KickReportCategory.VoteKick — the ROOM removed them
|
||||
IsHostKick: false,
|
||||
IsBan: false, // nothing stops them coming back
|
||||
Duration: 0,
|
||||
GameSessionId: TALLY_SESSION,
|
||||
PlayerIdReporter: 211, // whose ballot carried it
|
||||
VoteKickReason: 'Inactive in games (AFK)',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// Otherwise one player could carry a vote alone by posting it repeatedly.
|
||||
test('re-posting a vote cannot carry it, and a voter may change their mind', async () => {
|
||||
for (const id of [42, 212, 213]) await standIn(id, REVOTE_SESSION)
|
||||
const fields = { ...FIELDS, PlayerId: '212', GameSessionId: String(REVOTE_SESSION) }
|
||||
|
||||
// Three yes votes from one voter is still one voter.
|
||||
for (let i = 0; i < 3; i++) expect((await vote(fields)).status).toBe(200)
|
||||
expect(await ballots(REVOTE_SESSION, 212)).toHaveLength(3)
|
||||
expect(await inSession(212, REVOTE_SESSION)).toBe(true)
|
||||
|
||||
// 213 says yes — 2 of 3 — and it carries.
|
||||
expect((await vote(fields, '213')).status).toBe(200)
|
||||
expect(await inSession(212, REVOTE_SESSION)).toBe(false)
|
||||
})
|
||||
|
||||
test('a tie is not a majority', async () => {
|
||||
// Four in the session, so two yes votes are exactly half.
|
||||
for (const id of [42, 214, 215, 216]) await standIn(id, TIE_SESSION)
|
||||
const fields = { ...FIELDS, PlayerId: '214', GameSessionId: String(TIE_SESSION) }
|
||||
|
||||
expect((await vote(fields)).status).toBe(200)
|
||||
expect((await vote(fields, '215')).status).toBe(200)
|
||||
expect(await inSession(214, TIE_SESSION)).toBe(true)
|
||||
|
||||
// The third yes is more than half, and carries.
|
||||
expect((await vote(fields, '216')).status).toBe(200)
|
||||
expect(await inSession(214, TIE_SESSION)).toBe(false)
|
||||
})
|
||||
|
||||
test('both players have to be standing in the session', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
await standIn(42, OTHER_SESSION)
|
||||
@@ -4489,6 +6137,80 @@ describe('vote to kick', () => {
|
||||
// 205 is still in the session, so they still hear it — only the caller is dropped.
|
||||
expect((await frames()).map((f) => f.playerId)).toEqual([205])
|
||||
})
|
||||
|
||||
// The client posts the call and every answer alike; only the call raises the prompt.
|
||||
test('the first ballot calls the vote, and answers to it put nothing to the room', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
for (const id of [42, 205, 206, 207, 208]) await standIn(id, SESSION)
|
||||
|
||||
expect((await vote(FIELDS)).status).toBe(200)
|
||||
expect(await frames()).toHaveLength(4)
|
||||
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
expect((await vote({ ...FIELDS, Response: 'False' }, '206')).status).toBe(200)
|
||||
expect((await vote(FIELDS, '207')).status).toBe(200)
|
||||
expect(await frames()).toEqual([])
|
||||
|
||||
expect((await ballots(SESSION, 205)).map((b) => [b.voter_id, b.init])).toEqual([
|
||||
[42, 1],
|
||||
[206, 0],
|
||||
[207, 0],
|
||||
])
|
||||
})
|
||||
|
||||
test('a player may not call vote after vote', async () => {
|
||||
for (const id of [42, 205, 206, 207, 208]) await standIn(id, SESSION)
|
||||
|
||||
expect((await vote(FIELDS)).status).toBe(200)
|
||||
// Another vote, on another player — still a CALL, so still refused.
|
||||
const again = await vote({ ...FIELDS, PlayerId: '206' })
|
||||
expect(again.status).toBe(429)
|
||||
expect(await again.json()).toEqual({
|
||||
success: false,
|
||||
error: 'You are calling votes too quickly!',
|
||||
})
|
||||
// Answering someone else's vote is never throttled...
|
||||
expect((await vote({ ...FIELDS, PlayerId: '206' }, '207')).status).toBe(200)
|
||||
expect((await vote({ ...FIELDS, PlayerId: '206' })).status).toBe(200)
|
||||
// ...and neither is re-voting in their own.
|
||||
expect((await vote(FIELDS)).status).toBe(200)
|
||||
|
||||
// Once the cooldown has passed, they may call again.
|
||||
await env.DB.prepare(
|
||||
"UPDATE room_vote SET voted_at = '2000-01-01T00:00:00.000Z' WHERE voter_id = 42 AND init = 1"
|
||||
).run()
|
||||
expect((await vote({ ...FIELDS, PlayerId: '208' })).status).toBe(200)
|
||||
})
|
||||
|
||||
test('a "no" cannot call a vote', async () => {
|
||||
await hub().fetch('http://do/all', { method: 'DELETE' })
|
||||
await standIn(42, SESSION)
|
||||
await standIn(205, SESSION)
|
||||
await standIn(206, SESSION)
|
||||
|
||||
const res = await vote({ ...FIELDS, Response: 'False' })
|
||||
expect(res.status).toBe(409)
|
||||
expect(await res.json()).toEqual({ success: false, error: 'There is no vote in progress!' })
|
||||
expect(await ballots(SESSION, 205)).toEqual([])
|
||||
expect(await frames()).toEqual([])
|
||||
})
|
||||
|
||||
// Otherwise yes ballots from a vote that failed would carry the next one on the same player.
|
||||
test('an expired vote’s ballots do not count toward the next', async () => {
|
||||
for (const id of [42, 217, 218, 219]) await standIn(id, TALLY_SESSION)
|
||||
const fields = { ...FIELDS, PlayerId: '217', GameSessionId: String(TALLY_SESSION) }
|
||||
|
||||
// 42 calls it: one yes of four. Then the vote expires.
|
||||
expect((await vote(fields)).status).toBe(200)
|
||||
await env.DB.prepare("UPDATE room_vote SET voted_at = '2000-01-01T00:00:00.000Z'").run()
|
||||
|
||||
// 218 calls a fresh one — counting 42's old yes, that would be two of four... and 219's
|
||||
// yes would make three and carry it. Without it, 219 makes two of four: a tie.
|
||||
expect((await vote(fields, '218')).status).toBe(200)
|
||||
expect((await vote(fields, '219')).status).toBe(200)
|
||||
expect(await inSession(217, TALLY_SESSION)).toBe(true)
|
||||
expect((await ballots(TALLY_SESSION, 217)).map((b) => b.init)).toEqual([1, 1, 0])
|
||||
})
|
||||
})
|
||||
|
||||
describe('player reports', () => {
|
||||
@@ -4650,6 +6372,235 @@ describe('player reports', () => {
|
||||
expect(await banFromReport(env.DB, 999_999)).toBeNull()
|
||||
})
|
||||
|
||||
// The audit trail 0020 added: who handed the ban down, and when. `banned_at` is not
|
||||
// bookkeeping — it is what the client's block screen counts the ban from (see
|
||||
// `banBlockDetails`), which is why a lift has to clear it along with the flag.
|
||||
test('banFromReport records the acting moderator and the instant it landed', async () => {
|
||||
await submit({ PlayerIdReported: '216' }, await bearer())
|
||||
const [row] = await getReportsAgainst(env.DB, 216)
|
||||
// An unactioned report carries neither.
|
||||
expect(row).toMatchObject({ banned_by_player_id: null, banned_at: null })
|
||||
|
||||
const before = Date.now()
|
||||
const banned = await banFromReport(env.DB, row!.id, { bannedBy: 9001 })
|
||||
expect(banned!.banned_by_player_id).toBe(9001)
|
||||
expect(Date.parse(banned!.banned_at!)).toBeGreaterThanOrEqual(before)
|
||||
// Stamped by the write, so it is the ban's own time rather than the report's.
|
||||
expect(banned!.banned_at).not.toBe(row!.created_at)
|
||||
|
||||
// A lift clears all four together. Leaving `banned_at` behind on an unbanned row
|
||||
// would read as a ban to anything checking the timestamp.
|
||||
const lifted = await banFromReport(env.DB, row!.id, { banned: false })
|
||||
expect(lifted).toMatchObject({
|
||||
banned: 0,
|
||||
ban_expires: null,
|
||||
banned_by_player_id: null,
|
||||
banned_at: null,
|
||||
})
|
||||
})
|
||||
|
||||
// The moderation READS — searchReports / getTopReported / getBansInForce. No endpoint
|
||||
// on this worker calls them: they back the staff panel `www` serves (`/api/staff/*`),
|
||||
// which imports them, because the panel is a recflare addition with no counterpart in
|
||||
// the real service while the SQL belongs with the table. Tested here, where the table
|
||||
// and its migrations live.
|
||||
describe('moderation reads', () => {
|
||||
test('searchReports filters, orders newest-first and counts the full match', async () => {
|
||||
const target = 3300
|
||||
await createReport(env.DB, {
|
||||
reporterPlayerId: 3301,
|
||||
reportedPlayerId: target,
|
||||
reportCategory: 101,
|
||||
details: 'one',
|
||||
})
|
||||
await createReport(env.DB, {
|
||||
reporterPlayerId: 3302,
|
||||
reportedPlayerId: target,
|
||||
reportCategory: 102,
|
||||
details: 'two',
|
||||
})
|
||||
await createReport(env.DB, { reporterPlayerId: 3301, reportedPlayerId: 3399 })
|
||||
|
||||
const byPlayer = await searchReports(env.DB, { reportedPlayerId: target })
|
||||
expect(byPlayer.total).toBe(2)
|
||||
// `ORDER BY id DESC` — the id is AUTOINCREMENT, so it is already chronological
|
||||
// and is the primary key, where `created_at` is unindexed.
|
||||
expect(byPlayer.reports.map((r) => r.details)).toEqual(['two', 'one'])
|
||||
|
||||
expect((await searchReports(env.DB, { reporterPlayerId: 3302 })).total).toBe(1)
|
||||
expect(
|
||||
(await searchReports(env.DB, { reportedPlayerId: target, reportCategory: 101 })).total
|
||||
).toBe(1)
|
||||
|
||||
// `take` pages the ROWS; `total` stays the unpaged count of the same predicate, so
|
||||
// a pager can say "3–4 of 9" without asking twice.
|
||||
const page = await searchReports(env.DB, { reportedPlayerId: target }, { take: 1 })
|
||||
expect(page.reports).toHaveLength(1)
|
||||
expect(page.total).toBe(2)
|
||||
})
|
||||
|
||||
test('searchReports filters on the ban FLAG, expired bans included', async () => {
|
||||
const report = await createReport(env.DB, {
|
||||
reporterPlayerId: 3311,
|
||||
reportedPlayerId: 3310,
|
||||
})
|
||||
// Banned, and the ban has since run out — so it is not in force, but it IS a
|
||||
// report that was actioned, which is what a moderator reviewing their own work
|
||||
// is looking for.
|
||||
await banFromReport(env.DB, report.id, { banExpires: '2020-01-01T00:00:00.000Z' })
|
||||
|
||||
expect((await searchReports(env.DB, { reportedPlayerId: 3310, banned: true })).total).toBe(1)
|
||||
expect((await searchReports(env.DB, { reportedPlayerId: 3310, banned: false })).total).toBe(0)
|
||||
// An omitted filter is BOTH, not false.
|
||||
expect((await searchReports(env.DB, { reportedPlayerId: 3310 })).total).toBe(1)
|
||||
})
|
||||
|
||||
test('searchReports windows on created_at, with an exclusive upper bound', async () => {
|
||||
const report = await createReport(env.DB, {
|
||||
reporterPlayerId: 3321,
|
||||
reportedPlayerId: 3320,
|
||||
})
|
||||
const filed = '2024-06-15T12:00:00.000Z'
|
||||
await env.DB.prepare('UPDATE report SET created_at = ?2 WHERE id = ?1')
|
||||
.bind(report.id, filed)
|
||||
.run()
|
||||
|
||||
const inWindow = await searchReports(env.DB, {
|
||||
reportedPlayerId: 3320,
|
||||
from: '2024-06-01T00:00:00.000Z',
|
||||
to: '2024-07-01T00:00:00.000Z',
|
||||
})
|
||||
expect(inWindow.total).toBe(1)
|
||||
|
||||
// `from` is inclusive, `to` exclusive — which is what makes day-boundary windows
|
||||
// composable rather than double-counting the row on the seam.
|
||||
expect((await searchReports(env.DB, { reportedPlayerId: 3320, from: filed })).total).toBe(1)
|
||||
expect((await searchReports(env.DB, { reportedPlayerId: 3320, to: filed })).total).toBe(0)
|
||||
})
|
||||
|
||||
test('getTopReported ranks distinct reporters ahead of raw count', async () => {
|
||||
// Four reports from one person, against one player…
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await createReport(env.DB, { reporterPlayerId: 3331, reportedPlayerId: 3330 })
|
||||
}
|
||||
// …and three reports from three different people, against another.
|
||||
for (const reporter of [3341, 3342, 3343]) {
|
||||
await createReport(env.DB, { reporterPlayerId: reporter, reportedPlayerId: 3340 })
|
||||
}
|
||||
|
||||
const rows = await getTopReported(env.DB, { minReports: 3 })
|
||||
const feud = rows.find((r) => r.playerId === 3330)
|
||||
const real = rows.find((r) => r.playerId === 3340)
|
||||
expect(feud).toMatchObject({ reports: 4, distinctReporters: 1 })
|
||||
expect(real).toMatchObject({ reports: 3, distinctReporters: 3 })
|
||||
// Fewer reports, but from more people — so ahead. One player filing twenty
|
||||
// reports about someone they are feuding with must not top the queue.
|
||||
expect(rows.indexOf(real!)).toBeLessThan(rows.indexOf(feud!))
|
||||
})
|
||||
|
||||
test('getTopReported windows, thresholds, and flags a ban in force', async () => {
|
||||
for (const reporter of [3351, 3352, 3353]) {
|
||||
await createReport(env.DB, { reporterPlayerId: reporter, reportedPlayerId: 3350 })
|
||||
}
|
||||
// Backdate them all a year, so only an all-time read sees them. The default
|
||||
// window is 30 days precisely so the queue is who is a problem NOW — an account
|
||||
// that collected reports last year and stopped would otherwise sit at the top
|
||||
// forever.
|
||||
await env.DB.prepare('UPDATE report SET created_at = ?1 WHERE reported_player_id = 3350')
|
||||
.bind(new Date(Date.now() - 365 * 86_400_000).toISOString())
|
||||
.run()
|
||||
|
||||
expect(
|
||||
(await getTopReported(env.DB, { minReports: 1 })).map((r) => r.playerId)
|
||||
).not.toContain(3350)
|
||||
const allTime = await getTopReported(env.DB, { sinceDays: null, minReports: 1 })
|
||||
expect(allTime.map((r) => r.playerId)).toContain(3350)
|
||||
expect(allTime.find((r) => r.playerId === 3350)?.bannedNow).toBe(false)
|
||||
|
||||
// A ban in force is decided in the same statement, so the queue can skip the
|
||||
// ones already dealt with.
|
||||
const [row] = await getReportsAgainst(env.DB, 3350)
|
||||
await banFromReport(env.DB, row!.id)
|
||||
const banned = await getTopReported(env.DB, { sinceDays: null, minReports: 1 })
|
||||
expect(banned.find((r) => r.playerId === 3350)?.bannedNow).toBe(true)
|
||||
})
|
||||
|
||||
// The standing-bans list exists to catch MISTAKES, so it is ordered by when the ban
|
||||
// landed: the one most likely to be wrong is the one just handed down. Ordering by
|
||||
// severity instead would bury a fresh one-day ban under every permanent ban ever
|
||||
// issued.
|
||||
test('getBansInForce puts the most recently handed-down ban first', async () => {
|
||||
const first = await createReport(env.DB, { reporterPlayerId: 3371, reportedPlayerId: 3370 })
|
||||
const second = await createReport(env.DB, { reporterPlayerId: 3373, reportedPlayerId: 3372 })
|
||||
const third = await createReport(env.DB, { reporterPlayerId: 3375, reportedPlayerId: 3374 })
|
||||
|
||||
// Handed down oldest-first, and deliberately mixed in severity: the permanent one
|
||||
// is banned FIRST, so an order by "longest lasting" would float it to the top.
|
||||
await banFromReport(env.DB, first.id)
|
||||
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
|
||||
.bind(first.id, '2024-01-01T00:00:00.000Z')
|
||||
.run()
|
||||
await banFromReport(env.DB, second.id, { banExpires: '2999-01-01T00:00:00.000Z' })
|
||||
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
|
||||
.bind(second.id, '2024-06-01T00:00:00.000Z')
|
||||
.run()
|
||||
await banFromReport(env.DB, third.id, { banExpires: '2999-01-01T00:00:00.000Z' })
|
||||
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
|
||||
.bind(third.id, '2025-01-01T00:00:00.000Z')
|
||||
.run()
|
||||
|
||||
const ordered = (await getBansInForce(env.DB))
|
||||
.map((b) => b.id)
|
||||
.filter((id) => [first.id, second.id, third.id].includes(id))
|
||||
expect(ordered).toEqual([third.id, second.id, first.id])
|
||||
})
|
||||
|
||||
// A ban set before 0020 added `banned_at` has only its report's date to sort by —
|
||||
// the same fallback the block screen uses. Without the coalesce every such row would
|
||||
// sort as NULL: all at one end, in no order at all.
|
||||
test('getBansInForce falls back to created_at for a ban with no banned_at', async () => {
|
||||
const old = await createReport(env.DB, { reporterPlayerId: 3381, reportedPlayerId: 3380 })
|
||||
const recent = await createReport(env.DB, { reporterPlayerId: 3383, reportedPlayerId: 3382 })
|
||||
// The pre-migration shape: banned, with no record of when.
|
||||
await env.DB.prepare(
|
||||
'UPDATE report SET banned = 1, banned_at = NULL, created_at = ?2 WHERE id = ?1'
|
||||
)
|
||||
.bind(old.id, '2023-01-01T00:00:00.000Z')
|
||||
.run()
|
||||
await banFromReport(env.DB, recent.id)
|
||||
|
||||
const ordered = (await getBansInForce(env.DB))
|
||||
.map((b) => b.id)
|
||||
.filter((id) => [old.id, recent.id].includes(id))
|
||||
// The undated one sorts by its report's date, so it lands behind today's ban
|
||||
// rather than at an arbitrary end of the list.
|
||||
expect(ordered).toEqual([recent.id, old.id])
|
||||
})
|
||||
|
||||
test('getBansInForce lists in-force bans only, and getReportById reads one row', async () => {
|
||||
const permanent = await createReport(env.DB, {
|
||||
reporterPlayerId: 3361,
|
||||
reportedPlayerId: 3360,
|
||||
})
|
||||
const expired = await createReport(env.DB, {
|
||||
reporterPlayerId: 3363,
|
||||
reportedPlayerId: 3362,
|
||||
})
|
||||
await banFromReport(env.DB, permanent.id, { bannedBy: 9002 })
|
||||
await banFromReport(env.DB, expired.id, { banExpires: '2020-01-01T00:00:00.000Z' })
|
||||
|
||||
const ids = (await getBansInForce(env.DB)).map((b) => b.id)
|
||||
expect(ids).toContain(permanent.id)
|
||||
// A ban that has served its time is not in force, though the row stays as the
|
||||
// record that it happened — the same rule `getActiveBan` applies.
|
||||
expect(ids).not.toContain(expired.id)
|
||||
|
||||
const one = await getReportById(env.DB, permanent.id)
|
||||
expect(one).toMatchObject({ id: permanent.id, banned: 1, banned_by_player_id: 9002 })
|
||||
expect(await getReportById(env.DB, 999_999)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// What the banned player is TOLD. The block screen reads this; it's the same row
|
||||
// matchmake and login refuse on, described rather than merely enforced.
|
||||
describe('moderationBlockDetails', () => {
|
||||
@@ -4701,10 +6652,9 @@ describe('player reports', () => {
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
// Duration and TimeoutStartedAt are a pair in the client — the block runs from the
|
||||
// start for the duration. The start is the report's created_at (nothing records when
|
||||
// the ban itself landed), and a permanent ban runs for the largest span the int
|
||||
// holds.
|
||||
// `Duration` is the seconds LEFT — the client counts it down from receipt. A permanent
|
||||
// ban runs for the largest span the int holds, and `TimeoutStartedAt` stays null: the
|
||||
// client does not pair it with `Duration` for a ban.
|
||||
test('describes a permanent ban', async () => {
|
||||
await submit(
|
||||
{ PlayerIdReported: '221', ReportCategory: '102', Details: 'slurs' },
|
||||
@@ -4722,26 +6672,78 @@ describe('player reports', () => {
|
||||
// the reporter is not shown to the player they reported (PlayerIdReporter
|
||||
// stays null).
|
||||
Message: 'Rule violation',
|
||||
TimeoutStartedAt: row!.created_at,
|
||||
})
|
||||
})
|
||||
|
||||
// A timed ban's Duration is the seconds from the start to the expiry, so the pair
|
||||
// sums to `ban_expires` — not the seconds left as of the request.
|
||||
test('describes a timed ban as its report’s created_at plus the span to expiry', async () => {
|
||||
// A fresh timed ban: the seconds left are (within the write's latency) the whole ban.
|
||||
test('describes a timed ban as the seconds left as of the request', async () => {
|
||||
await submit({ PlayerIdReported: '222', ReportCategory: '103' }, await bearer())
|
||||
const [row] = await getReportsAgainst(env.DB, 222)
|
||||
const banExpires = new Date(Date.parse(row!.created_at) + 3600 * 1000)
|
||||
const banExpires = new Date(Date.now() + 3600 * 1000)
|
||||
await banFromReport(env.DB, row!.id, { banExpires: banExpires.toISOString() })
|
||||
|
||||
expect(await details('GET', '222')).toEqual({
|
||||
...NOT_BLOCKED,
|
||||
// `Duration` is asserted as a range below, so it is left out of the object match
|
||||
// — NOT_BLOCKED carries a 0 for it, which would win over the real value.
|
||||
const { Duration: _duration, ...unblocked } = NOT_BLOCKED
|
||||
const body = (await details('GET', '222')) as Record<string, unknown>
|
||||
expect(body).toMatchObject({
|
||||
...unblocked,
|
||||
ReportCategory: 103,
|
||||
Duration: 3600,
|
||||
IsBan: true,
|
||||
Message: 'Rule violation',
|
||||
TimeoutStartedAt: row!.created_at,
|
||||
TimeoutStartedAt: null,
|
||||
})
|
||||
// Within a second of the hour: however long the write took has already elapsed.
|
||||
expect(body.Duration).toBeGreaterThan(3595)
|
||||
expect(body.Duration).toBeLessThanOrEqual(3600)
|
||||
})
|
||||
|
||||
// The case the bug lived in: a player signing in AFTER the ban was handed down. The
|
||||
// client counts `Duration` down from the moment it receives it, so it must be what is
|
||||
// left NOW — not the ban's full span from `banned_at`, which read as the whole ban
|
||||
// still to run at every sign-in (and only looked right in the mid-session kick frame,
|
||||
// sent at the instant the span and the remainder are the same).
|
||||
test('counts down: a ban handed down a day ago has a day less to run', async () => {
|
||||
await submit({ PlayerIdReported: '224', ReportCategory: '102' }, await bearer())
|
||||
const [row] = await getReportsAgainst(env.DB, 224)
|
||||
// A seven-day ban handed down yesterday: six days left.
|
||||
const banned = await banFromReport(env.DB, row!.id, {
|
||||
banExpires: new Date(Date.now() + 6 * 86_400_000).toISOString(),
|
||||
})
|
||||
await env.DB.prepare('UPDATE report SET banned_at = ?2 WHERE id = ?1')
|
||||
.bind(row!.id, new Date(Date.now() - 86_400_000).toISOString())
|
||||
.run()
|
||||
|
||||
const body = (await details('GET', '224')) as Record<string, unknown>
|
||||
expect(body.IsBan).toBe(true)
|
||||
expect(body.Duration).toBeGreaterThan(6 * 86_400 - 5)
|
||||
expect(body.Duration).toBeLessThanOrEqual(6 * 86_400)
|
||||
// The start is not on the wire at all: null, not `banned_at`.
|
||||
expect(body.TimeoutStartedAt).toBeNull()
|
||||
expect(banned!.banned_at).not.toBeNull()
|
||||
})
|
||||
|
||||
// A row banned BEFORE 0020 added `banned_at` reads the same as any other: the
|
||||
// remainder needs only `ban_expires`.
|
||||
test('reads a ban with no recorded banned_at the same way', async () => {
|
||||
await submit({ PlayerIdReported: '225', ReportCategory: '101' }, await bearer())
|
||||
const [row] = await getReportsAgainst(env.DB, 225)
|
||||
await env.DB.prepare(
|
||||
`UPDATE report SET banned = 1, ban_expires = ?2, banned_at = NULL WHERE id = ?1`
|
||||
)
|
||||
.bind(row!.id, new Date(Date.now() + 3600 * 1000).toISOString())
|
||||
.run()
|
||||
|
||||
const { Duration: _duration, ...unblocked } = NOT_BLOCKED
|
||||
const body = (await details('GET', '225')) as Record<string, unknown>
|
||||
expect(body).toMatchObject({
|
||||
...unblocked,
|
||||
ReportCategory: 101,
|
||||
IsBan: true,
|
||||
Message: 'Rule violation',
|
||||
})
|
||||
expect(body.Duration).toBeGreaterThan(3595)
|
||||
expect(body.Duration).toBeLessThanOrEqual(3600)
|
||||
})
|
||||
|
||||
// A ban that has served its time is not a block, even though the row still says
|
||||
@@ -4904,11 +6906,11 @@ describe('images', () => {
|
||||
// A metadata row was created, and it's readable by name via /api/images/v6.
|
||||
const meta = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
|
||||
).json()) as { ImageName: string; PlayerId: number; SavedImageId: number; CheerCount: number }
|
||||
).json()) as { ImageName: string; PlayerId: number; Id: number; CheerCount: number }
|
||||
expect(meta.ImageName).toBe(ImageName)
|
||||
expect(meta.PlayerId).toBe(42)
|
||||
// `SavedImageId`, not `Id` — v6 renames like the player lists do.
|
||||
expect(typeof meta.SavedImageId).toBe('number')
|
||||
// `Id`, not `SavedImageId` — v6 serves the raw record, unlike the player lists.
|
||||
expect(typeof meta.Id).toBe('number')
|
||||
expect(meta.CheerCount).toBe(0)
|
||||
})
|
||||
|
||||
@@ -4955,6 +6957,37 @@ describe('images', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// The feed is anonymous, so a private photo (Accessibility 0) must never reach it —
|
||||
// there is no viewer it could be unlocked for. It once accepted 0 alongside 1.
|
||||
test('GET /api/images/v1/slideshow leaves private photos out', async () => {
|
||||
const pub = await createImage(env.DB, {
|
||||
imageName: 'slidepublic.jpg',
|
||||
playerId: 42,
|
||||
accessibility: 1,
|
||||
})
|
||||
const priv = await createImage(env.DB, {
|
||||
imageName: 'slideprivate.jpg',
|
||||
playerId: 42,
|
||||
accessibility: 0,
|
||||
})
|
||||
// A non-photo (room thumbnail) that happens to be public stays out too.
|
||||
const thumb = await createImage(env.DB, {
|
||||
imageName: 'slidethumb.jpg',
|
||||
playerId: 42,
|
||||
type: 3,
|
||||
accessibility: 1,
|
||||
})
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/slideshow?take=100`)
|
||||
expect(res.status).toBe(200)
|
||||
const ids = ((await res.json()) as { Images: Array<{ SavedImageId: number }> }).Images.map(
|
||||
(i) => i.SavedImageId
|
||||
)
|
||||
expect(ids).toContain(pub.Id)
|
||||
expect(ids).not.toContain(priv.Id)
|
||||
expect(ids).not.toContain(thumb.Id)
|
||||
})
|
||||
|
||||
// The feed is public and unauthenticated, so `take` is clamped rather than trusted:
|
||||
// without the cap a single anonymous request could pull the whole image table through
|
||||
// the two joins behind it.
|
||||
@@ -5116,6 +7149,13 @@ describe('images', () => {
|
||||
// The setting is per-player.
|
||||
expect(await (await read('710')).text()).toBe('2')
|
||||
|
||||
// Re-posting the stored value writes nothing to KV: the raw value is seeded with
|
||||
// whitespace JSON.stringify never produces, and it survives the PUT untouched.
|
||||
const padded = '{ "Recroom.OOBE": "77", "playerPhotoTaggingSetting": "1" }'
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put('player:712', padded)
|
||||
expect(await (await write('712', { Setting: 1 })).text()).toBe('1')
|
||||
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:712', 'text')).toBe(padded)
|
||||
|
||||
// A body with no readable Setting leaves the stored value alone rather than writing 0
|
||||
// — and answers what the player still has.
|
||||
expect(await (await write('710', { Nothing: true })).text()).toBe('2')
|
||||
@@ -5215,7 +7255,7 @@ describe('images', () => {
|
||||
expect(entries.filter((e) => e.IsCheered)).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('GET /api/images/v6 serves the metadata projection, nothing nullable', async () => {
|
||||
test('GET /api/images/v6 serves the raw SavedImage by name', async () => {
|
||||
const img = await createImage(env.DB, {
|
||||
imageName: 'v6shape.jpg',
|
||||
playerId: 7301,
|
||||
@@ -5223,24 +7263,98 @@ describe('images', () => {
|
||||
})
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v6?name=v6shape.jpg`)
|
||||
expect(res.status).toBe(200)
|
||||
// The client's 13-key DTO: `Id` and `Type`, `TaggedPlayerIds`, nulls left null — the
|
||||
// same record `v6/:id` serves.
|
||||
expect(await res.json()).toEqual({
|
||||
SavedImageId: img.Id,
|
||||
Id: img.Id,
|
||||
ImageName: 'v6shape.jpg',
|
||||
Description: null,
|
||||
PlayerId: 7301,
|
||||
// Nulls on the row come out as 0 / "" — the client's DTO has no null to put there.
|
||||
RoomId: 0,
|
||||
PlayerEventId: 0,
|
||||
ClubId: 0,
|
||||
Description: '',
|
||||
RoomId: null,
|
||||
PlayerEventId: null,
|
||||
Accessibility: 1,
|
||||
AccessibilityLocked: false,
|
||||
SavedImageType: 1,
|
||||
Type: 1,
|
||||
CreatedAt: img.CreatedAt,
|
||||
TaggedPlayerIds: [],
|
||||
CheerCount: 0,
|
||||
CommentCount: 0,
|
||||
})
|
||||
})
|
||||
|
||||
test('POST /api/images/v1/:id/report files a bodiless report against the photo’s author', async () => {
|
||||
const img = await createImage(env.DB, { imageName: 'reported.jpg', playerId: 7310 })
|
||||
const report = async (id: number, sub?: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/images/v1/${id}/report`, {
|
||||
method: 'POST',
|
||||
headers: sub ? await bearer(sub) : undefined,
|
||||
})
|
||||
|
||||
expect((await report(img.Id)).status).toBe(401)
|
||||
const unknown = await report(99999999, '7311')
|
||||
expect(unknown.status).toBe(404)
|
||||
expect(await unknown.json()).toEqual({ success: false, error: 'No such image' })
|
||||
|
||||
const res = await report(img.Id, '7311')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
const { results } = await env.DB.prepare('SELECT * FROM report WHERE image_id = ?1')
|
||||
.bind(img.Id)
|
||||
.all()
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]).toMatchObject({
|
||||
reporter_player_id: 7311,
|
||||
reported_player_id: 7310,
|
||||
image_id: img.Id,
|
||||
report_category: 0,
|
||||
details: null,
|
||||
event_id: null,
|
||||
invention_id: null,
|
||||
custom_avatar_item_id: null,
|
||||
chat_message_id: null,
|
||||
})
|
||||
})
|
||||
|
||||
test('GET /api/images/v6/:id serves the raw SavedImage, private ones to their owner only', async () => {
|
||||
const img = await createImage(env.DB, { imageName: 'v6byid.jpg', playerId: 7302 })
|
||||
const get = async (id: number, sub?: string) =>
|
||||
exports.default.fetch(
|
||||
`${ORIGIN}/api/images/v6/${id}`,
|
||||
sub ? { headers: await bearer(sub) } : undefined
|
||||
)
|
||||
|
||||
// The 13-key record, nulls left null.
|
||||
const res = await get(img.Id)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
Id: img.Id,
|
||||
ImageName: 'v6byid.jpg',
|
||||
Description: null,
|
||||
PlayerId: 7302,
|
||||
RoomId: null,
|
||||
PlayerEventId: null,
|
||||
Accessibility: 1,
|
||||
AccessibilityLocked: false,
|
||||
Type: 1,
|
||||
CreatedAt: img.CreatedAt,
|
||||
TaggedPlayerIds: [],
|
||||
CheerCount: 0,
|
||||
CommentCount: 0,
|
||||
})
|
||||
expect((await get(99999999)).status).toBe(404)
|
||||
|
||||
// Made private, it is its owner's alone — ids are sequential.
|
||||
await env.DB.prepare(
|
||||
"UPDATE image SET data = json_set(data, '$.Accessibility', 0) WHERE id = ?1"
|
||||
)
|
||||
.bind(img.Id)
|
||||
.run()
|
||||
expect((await get(img.Id)).status).toBe(404)
|
||||
expect((await get(img.Id, '7303')).status).toBe(404)
|
||||
expect((await get(img.Id, '7302')).status).toBe(200)
|
||||
})
|
||||
|
||||
test('GET /api/images/v6 400s without a name and 404s for an unknown one', async () => {
|
||||
expect((await exports.default.fetch(`${ORIGIN}/api/images/v6`)).status).toBe(400)
|
||||
expect(
|
||||
@@ -5272,23 +7386,20 @@ describe('images', () => {
|
||||
const meta = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
|
||||
).json()) as {
|
||||
SavedImageType: number
|
||||
Type: number
|
||||
RoomId: number
|
||||
Accessibility: number
|
||||
PlayerEventId: number
|
||||
ClubId: number
|
||||
Description: string
|
||||
PlayerEventId: number | null
|
||||
TaggedPlayerIds: number[]
|
||||
}
|
||||
expect(meta.SavedImageType).toBe(1)
|
||||
expect(meta.Type).toBe(1)
|
||||
expect(meta.RoomId).toBe(777)
|
||||
expect(meta.Accessibility).toBe(2)
|
||||
// v6 carries no TaggedPlayerIds — the upload still records them, which the stored row
|
||||
// below proves. Nothing on this projection is nullable: a "none" event reads 0, not
|
||||
// null, and the club (which nothing here sets) reads 0 too.
|
||||
expect(meta).not.toHaveProperty('TaggedPlayerIds')
|
||||
expect(meta.PlayerEventId).toBe(0)
|
||||
expect(meta.ClubId).toBe(0)
|
||||
expect(meta.Description).toBe('')
|
||||
// v6 serves the stored record as it is: the tagged players ride along, a "none"
|
||||
// event is null rather than 0, and there is no `ClubId`.
|
||||
expect(meta.TaggedPlayerIds).toEqual([5, 6])
|
||||
expect(meta.PlayerEventId).toBeNull()
|
||||
expect(meta).not.toHaveProperty('ClubId')
|
||||
|
||||
// The tagged players and the null event id, as actually stored.
|
||||
const row = await env.DB.prepare('SELECT data FROM image WHERE image_name = ?1')
|
||||
@@ -5299,7 +7410,7 @@ describe('images', () => {
|
||||
PlayerEventId: number | null
|
||||
}
|
||||
expect(stored.TaggedPlayerIds).toEqual([5, 6])
|
||||
// playerEventId 0 means "none" → stored as null, and serialized back out as 0.
|
||||
// playerEventId 0 means "none" → stored as null.
|
||||
expect(stored.PlayerEventId).toBeNull()
|
||||
})
|
||||
|
||||
@@ -5329,7 +7440,7 @@ describe('images', () => {
|
||||
expect(JSON.parse(row!.data).profileImage).toBe(ImageName)
|
||||
})
|
||||
|
||||
test('DELETE /api/images/v1/deletesaved removes the owner’s image (row + cheers + R2)', async () => {
|
||||
test('POST /api/images/v1/deletesaved removes the owner’s image (row + cheers + R2)', async () => {
|
||||
const ImageName = 'sharecamera/2026-07-17/delete-me.jpg'
|
||||
await env.IMAGES.put(ImageName, new Uint8Array([1, 2, 3]))
|
||||
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
|
||||
@@ -5357,7 +7468,7 @@ describe('images', () => {
|
||||
|
||||
const del = (headers: Record<string, string>) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, {
|
||||
method: 'DELETE',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify({ ImageName }),
|
||||
})
|
||||
@@ -5369,7 +7480,7 @@ describe('images', () => {
|
||||
|
||||
// Unknown image → 404.
|
||||
const unknown = await exports.default.fetch(`${ORIGIN}/api/images/v1/deletesaved`, {
|
||||
method: 'DELETE',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(await bearer('42')) },
|
||||
body: JSON.stringify({ ImageName: 'sharecamera/nope.jpg' }),
|
||||
})
|
||||
@@ -5385,6 +7496,113 @@ describe('images', () => {
|
||||
expect(cheers!.n).toBe(0)
|
||||
})
|
||||
|
||||
/** Seed one image row owned by the default bearer account (42). */
|
||||
const seedOwnedImage = async (
|
||||
Id: number,
|
||||
ImageName: string,
|
||||
extra: Record<string, unknown> = {}
|
||||
) =>
|
||||
env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
Id,
|
||||
Type: 1,
|
||||
Accessibility: 1,
|
||||
AccessibilityLocked: false,
|
||||
ImageName,
|
||||
Description: null,
|
||||
PlayerId: 42,
|
||||
TaggedPlayerIds: [],
|
||||
RoomId: null,
|
||||
PlayerEventId: null,
|
||||
CreatedAt: new Date().toISOString(),
|
||||
CheerCount: 0,
|
||||
CommentCount: 0,
|
||||
...extra,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
|
||||
const postImageJson = (path: string, headers: Record<string, string>, body: unknown) =>
|
||||
exports.default.fetch(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
test('POST /api/images/v2/modifyaccessibility flips the owner’s image and gates on the owner', async () => {
|
||||
const ImageName = 'sharecamera/2026-09-13/flip-me.jpg'
|
||||
await seedOwnedImage(8101, ImageName)
|
||||
const path = '/api/images/v2/modifyaccessibility'
|
||||
|
||||
// No token → 401; a different account → 403; the image is untouched.
|
||||
expect((await postImageJson(path, {}, { ImageName, Accessibility: 0 })).status).toBe(401)
|
||||
expect(
|
||||
(await postImageJson(path, await bearer('43'), { ImageName, Accessibility: 0 })).status
|
||||
).toBe(403)
|
||||
expect((await getImageByName(env.DB, ImageName))!.Accessibility).toBe(1)
|
||||
|
||||
// Unknown image → 404; a bad accessibility or no name → 400.
|
||||
const owner = await bearer('42')
|
||||
expect(
|
||||
(await postImageJson(path, owner, { ImageName: 'sharecamera/nope.jpg', Accessibility: 0 }))
|
||||
.status
|
||||
).toBe(404)
|
||||
expect((await postImageJson(path, owner, { ImageName, Accessibility: 7 })).status).toBe(400)
|
||||
expect((await postImageJson(path, owner, { Accessibility: 0 })).status).toBe(400)
|
||||
|
||||
// Owner → 200 and the record is now private; back to 1 makes it public again.
|
||||
const res = await postImageJson(path, owner, { ImageName, Accessibility: 0 })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
expect((await getImageByName(env.DB, ImageName))!.Accessibility).toBe(0)
|
||||
expect((await postImageJson(path, owner, { ImageName, Accessibility: 1 })).status).toBe(200)
|
||||
expect((await getImageByName(env.DB, ImageName))!.Accessibility).toBe(1)
|
||||
|
||||
// A locked image refuses even its owner.
|
||||
const locked = 'sharecamera/2026-09-13/locked.jpg'
|
||||
await seedOwnedImage(8102, locked, { AccessibilityLocked: true, Accessibility: 0 })
|
||||
expect((await postImageJson(path, owner, { ImageName: locked, Accessibility: 1 })).status).toBe(
|
||||
403
|
||||
)
|
||||
expect((await getImageByName(env.DB, locked))!.Accessibility).toBe(0)
|
||||
})
|
||||
|
||||
test('POST /api/images/v1/modifydescription sets the owner’s caption and gates on the owner', async () => {
|
||||
const ImageName = 'sharecamera/2026-09-13/caption-me.jpg'
|
||||
await seedOwnedImage(8103, ImageName)
|
||||
const path = '/api/images/v1/modifydescription'
|
||||
|
||||
// No token → 401; a different account → 403; the caption is untouched.
|
||||
expect((await postImageJson(path, {}, { ImageName, Description: 'x' })).status).toBe(401)
|
||||
expect(
|
||||
(await postImageJson(path, await bearer('43'), { ImageName, Description: 'x' })).status
|
||||
).toBe(403)
|
||||
expect((await getImageByName(env.DB, ImageName))!.Description).toBeNull()
|
||||
|
||||
// Unknown image → 404; a non-string description or no name → 400.
|
||||
const owner = await bearer('42')
|
||||
expect(
|
||||
(await postImageJson(path, owner, { ImageName: 'sharecamera/nope.jpg', Description: 'x' }))
|
||||
.status
|
||||
).toBe(404)
|
||||
expect((await postImageJson(path, owner, { ImageName, Description: 5 })).status).toBe(400)
|
||||
expect((await postImageJson(path, owner, { Description: 'x' })).status).toBe(400)
|
||||
|
||||
// Owner → 200 and the caption is stored; the rest of the record is untouched.
|
||||
const res = await postImageJson(path, owner, { ImageName, Description: 'tet' })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true })
|
||||
const after = await getImageByName(env.DB, ImageName)
|
||||
expect(after!.Description).toBe('tet')
|
||||
expect(after!.Accessibility).toBe(1)
|
||||
expect(after!.PlayerId).toBe(42)
|
||||
|
||||
// An empty description clears the caption back to null.
|
||||
expect((await postImageJson(path, owner, { ImageName, Description: '' })).status).toBe(200)
|
||||
expect((await getImageByName(env.DB, ImageName))!.Description).toBeNull()
|
||||
})
|
||||
|
||||
test('POST /api/images/v4/uploadsaved 401s without a bearer token', async () => {
|
||||
const fd = new FormData()
|
||||
fd.append('image', new File([new Uint8Array([1, 2, 3])], 'avatar.png', { type: 'image/png' }))
|
||||
@@ -5515,6 +7733,10 @@ describe('images', () => {
|
||||
}),
|
||||
// Unrelated to 700 → in neither.
|
||||
seed({ Id: 205, PlayerId: 999, TaggedPlayerIds: [111] }),
|
||||
// 700's outfit thumbnail: an image under their id, but not a photo → in neither,
|
||||
// not even in their own view (207 is private, as the client stores such things).
|
||||
seed({ Id: 200, PlayerId: 700, Type: 2, CreatedAt: '2026-06-01T00:00:00.000Z' }),
|
||||
seed({ Id: 207, PlayerId: 700, Type: 3, Accessibility: 0 }),
|
||||
])
|
||||
|
||||
// The lists serve the client's ImagesPlayer projection: the id and type are
|
||||
@@ -5566,6 +7788,44 @@ describe('images', () => {
|
||||
expect(
|
||||
await (await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/424242`)).json()
|
||||
).toEqual([])
|
||||
|
||||
// The owner's view: 700 asking for their own list gets the private 203 too, on v4,
|
||||
// v5 and the feed alike. 203 has the seed's default CreatedAt (January), so it sorts
|
||||
// last — the private photo takes its place in the order, it isn't pinned anywhere.
|
||||
const own = await bearer('700')
|
||||
for (const path of ['v4/player/700', 'v5/player/700?sort=0']) {
|
||||
const list = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/${path}`, { headers: own })
|
||||
).json()) as Array<ImagesPlayer & { Accessibility: number }>
|
||||
expect(list.map((i) => i.SavedImageId)).toEqual([202, 201, 203])
|
||||
expect(list[2]?.Accessibility).toBe(0)
|
||||
}
|
||||
const ownFeed = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/700`, { headers: own })
|
||||
).json()) as ImagesPlayer[]
|
||||
expect(ownFeed.map((i) => i.SavedImageId)).toEqual([204, 202, 201, 203])
|
||||
|
||||
// Being tagged in someone else's private photo does NOT surface it in the feed —
|
||||
// the taker decides its privacy, not the tag.
|
||||
await seed({ Id: 206, PlayerId: 999, TaggedPlayerIds: [700], Accessibility: 0 }).run()
|
||||
const ownFeedAgain = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v3/feed/player/700`, { headers: own })
|
||||
).json()) as ImagesPlayer[]
|
||||
expect(ownFeedAgain.map((i) => i.SavedImageId)).not.toContain(206)
|
||||
|
||||
// Anyone else's token — a friend, staff — is the public list, same as no token.
|
||||
const other = await bearer('999', ['developer', 'moderator'])
|
||||
const asOther = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/images/v4/player/700`, { headers: other })
|
||||
).json()) as ImagesPlayer[]
|
||||
expect(asOther.map((i) => i.SavedImageId)).toEqual([202, 201])
|
||||
|
||||
// A garbage token isn't a 401 on a public list; it reads as nobody.
|
||||
const junk = await exports.default.fetch(`${ORIGIN}/api/images/v4/player/700`, {
|
||||
headers: { Authorization: 'Bearer not.a.jwt' },
|
||||
})
|
||||
expect(junk.status).toBe(200)
|
||||
expect(((await junk.json()) as ImagesPlayer[]).map((i) => i.SavedImageId)).toEqual([202, 201])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5829,8 +8089,11 @@ describe('relationships', () => {
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '770', 771)
|
||||
|
||||
// Both sides hear about it, each seeing the other player and their own side's
|
||||
// type: the sender Sent (1), the recipient Received (2).
|
||||
expect(await sentNotifications()).toEqual([
|
||||
// type: the sender Sent (1), the recipient Received (2). The target ALSO gets the
|
||||
// request itself as a FriendInvite (4) message — that is what the client shows.
|
||||
const sent = await sentNotifications()
|
||||
expect(sent).toHaveLength(3)
|
||||
expect(sent.slice(0, 2)).toEqual([
|
||||
{
|
||||
playerId: 770,
|
||||
notificationType: 1,
|
||||
@@ -5842,6 +8105,34 @@ describe('relationships', () => {
|
||||
data: { PlayerID: 770, RelationshipType: 2, Favorited: 0, Ignored: 0, Muted: 0 },
|
||||
},
|
||||
])
|
||||
expect(sent[2]?.playerId).toBe(771)
|
||||
expect(sent[2]?.notificationType).toBe(2) // NotificationType.MessageReceived
|
||||
expect(sent[2]?.data).toMatchObject({ FromPlayerId: 770, ToPlayerId: 771, Type: 4, Data: null })
|
||||
|
||||
// The message is stored under the same id, so an offline target finds it on login.
|
||||
const frameId = (sent[2]?.data as unknown as { Id: number }).Id
|
||||
expect(frameId).toBeGreaterThan(0)
|
||||
const inbox = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`, { headers: await bearer('771') })
|
||||
).json()) as Array<{ Id: number; FromPlayerId: number; Type: number }>
|
||||
expect(inbox.find((m) => m.Id === frameId)).toMatchObject({ FromPlayerId: 770, Type: 4 })
|
||||
})
|
||||
|
||||
test('a re-sent request says nothing; a crossing request tells the requester it was accepted', async () => {
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '772', 773)
|
||||
await resetNotifications()
|
||||
// Re-sending an outstanding request is a no-op: nothing is pushed at all.
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '772', 773)
|
||||
expect(await sentNotifications()).toEqual([])
|
||||
// The crossing request auto-accepts: both hear Friend, and the ORIGINAL requester
|
||||
// (772) is told their request was accepted rather than asked anything.
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '773', 772)
|
||||
const sent = await sentNotifications()
|
||||
expect(sent).toHaveLength(3)
|
||||
expect(sent.slice(0, 2).every((n) => n.notificationType === 1)).toBe(true)
|
||||
expect(sent[2]?.playerId).toBe(772)
|
||||
expect(sent[2]?.notificationType).toBe(2) // NotificationType.MessageReceived
|
||||
expect(sent[2]?.data).toMatchObject({ FromPlayerId: 773, ToPlayerId: 772, Type: 40 })
|
||||
})
|
||||
|
||||
test('accepting notifies both players as Friend', async () => {
|
||||
@@ -5849,8 +8140,13 @@ describe('relationships', () => {
|
||||
await resetNotifications()
|
||||
await mutate('/api/relationships/v2/acceptfriendrequest', '781', 780)
|
||||
|
||||
const sent = await sentNotifications()
|
||||
expect(sent).toHaveLength(2)
|
||||
const all = await sentNotifications()
|
||||
expect(all).toHaveLength(3)
|
||||
// The requester also gets the acceptance as a FriendRequestAccepted (40) message.
|
||||
expect(all[2]?.playerId).toBe(780)
|
||||
expect(all[2]?.notificationType).toBe(2) // NotificationType.MessageReceived
|
||||
expect(all[2]?.data).toMatchObject({ FromPlayerId: 781, ToPlayerId: 780, Type: 40, Data: null })
|
||||
const sent = all.slice(0, 2)
|
||||
// Friend (3) is symmetric, so both sides see the same type, each pointing at the other.
|
||||
expect(sent).toEqual(
|
||||
expect.arrayContaining([
|
||||
@@ -5897,8 +8193,10 @@ describe('relationships', () => {
|
||||
await mutate('/api/relationships/v2/sendfriendrequest', '821', 820)
|
||||
|
||||
const sent = await sentNotifications()
|
||||
expect(sent).toHaveLength(2)
|
||||
for (const n of sent) expect(n.data.RelationshipType).toBe(3)
|
||||
expect(sent).toHaveLength(3)
|
||||
for (const n of sent.slice(0, 2)) expect(n.data.RelationshipType).toBe(3)
|
||||
// ...and 820, whose request this crossed, is told it was accepted.
|
||||
expect(sent[2]).toMatchObject({ playerId: 820, notificationType: 2, data: { Type: 40 } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5996,7 +8294,17 @@ describe('messages', () => {
|
||||
type Sent = {
|
||||
playerId: number
|
||||
notificationType: number
|
||||
data: { FromPlayerId: number; ToPlayerId: number; Type: number; Data: string }
|
||||
// The frame carries the STORED message, so `Id` and `SentTime` come off the row.
|
||||
data: {
|
||||
Id: number
|
||||
FromPlayerId: number
|
||||
ToPlayerId: number
|
||||
SentTime: string
|
||||
Type: number
|
||||
Data: string | null
|
||||
RoomId: number | null
|
||||
PlayerEventId: number | null
|
||||
}
|
||||
}
|
||||
const hub = () => env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
const pushed = async (): Promise<Sent[]> =>
|
||||
@@ -6014,26 +8322,60 @@ describe('messages', () => {
|
||||
// NotificationType.MessageReceived — the same frame the Coach broadcast uses.
|
||||
const MESSAGE_RECEIVED = 2
|
||||
|
||||
/** One player's inbox, as the client reads it on login. */
|
||||
const inboxOf = async (playerId: string) =>
|
||||
(await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`, {
|
||||
headers: await bearer(playerId),
|
||||
})
|
||||
).json()) as Array<{
|
||||
Id: number
|
||||
FromPlayerId: number
|
||||
ToPlayerId: number
|
||||
SentTime: string
|
||||
Type: number
|
||||
Data: string | null
|
||||
}>
|
||||
|
||||
const del = async (body: unknown, headers?: Record<string, string>) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/messages/v3/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
test('POST /api/messages/v2/send pushes MessageReceived to the recipient', async () => {
|
||||
const res = await send({ ToPlayerId: '2', Type: '10', Data: '' }, await bearer('42'))
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ success: true, error: '' })
|
||||
|
||||
expect(await pushed()).toEqual([
|
||||
{
|
||||
// Delivered to the recipient, not the sender.
|
||||
playerId: 2,
|
||||
notificationType: MESSAGE_RECEIVED,
|
||||
// FromPlayerId is the token's subject, not a body field.
|
||||
data: { FromPlayerId: 42, ToPlayerId: 2, Type: 10, Data: '' },
|
||||
},
|
||||
])
|
||||
const sent = await pushed()
|
||||
expect(sent).toHaveLength(1)
|
||||
// Delivered to the recipient, not the sender.
|
||||
expect(sent[0]?.playerId).toBe(2)
|
||||
expect(sent[0]?.notificationType).toBe(MESSAGE_RECEIVED)
|
||||
// FromPlayerId is the token's subject, not a body field. The frame carries the
|
||||
// STORED message, so it has the row's Id and SentTime on it too.
|
||||
expect(sent[0]?.data).toMatchObject({
|
||||
FromPlayerId: 42,
|
||||
ToPlayerId: 2,
|
||||
Type: 10,
|
||||
Data: '',
|
||||
RoomId: null,
|
||||
PlayerEventId: null,
|
||||
})
|
||||
const frameId = sent[0]!.data.Id
|
||||
expect(frameId).toBeGreaterThan(0)
|
||||
|
||||
// ...and the recipient reads the same message, under the same id, from their inbox.
|
||||
const inbox = await inboxOf('2')
|
||||
expect(inbox[0]).toMatchObject({ Id: frameId, FromPlayerId: 42, Type: 10, Data: '' })
|
||||
})
|
||||
|
||||
test('POST /api/messages/v2/send defaults Type and Data when omitted', async () => {
|
||||
const res = await send({ ToPlayerId: '2' }, await bearer('42'))
|
||||
expect(res.status).toBe(200)
|
||||
expect((await pushed())[0]?.data).toEqual({
|
||||
expect((await pushed())[0]?.data).toMatchObject({
|
||||
FromPlayerId: 42,
|
||||
ToPlayerId: 2,
|
||||
Type: 0,
|
||||
@@ -6073,18 +8415,17 @@ describe('messages', () => {
|
||||
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' },
|
||||
},
|
||||
])
|
||||
const sent = await pushed()
|
||||
expect(sent.map((n) => n.playerId)).toEqual([205, 206])
|
||||
expect(sent.map((n) => n.notificationType)).toEqual([MESSAGE_RECEIVED, MESSAGE_RECEIVED])
|
||||
expect(sent[0]?.data).toMatchObject({ FromPlayerId: 42, ToPlayerId: 205, Type: 20, Data: 'hi' })
|
||||
expect(sent[1]?.data).toMatchObject({ FromPlayerId: 42, ToPlayerId: 206, Type: 20, Data: 'hi' })
|
||||
|
||||
// One STORED message each, with ids of their own — not one message shared.
|
||||
const ids = sent.map((n) => n.data.Id)
|
||||
expect(new Set(ids).size).toBe(2)
|
||||
expect((await inboxOf('205'))[0]).toMatchObject({ Id: ids[0], Data: 'hi' })
|
||||
expect((await inboxOf('206'))[0]).toMatchObject({ Id: ids[1], Data: 'hi' })
|
||||
})
|
||||
|
||||
test('POST /api/messages/v1/sendMultiple defaults Type and Data, and de-duplicates ids', async () => {
|
||||
@@ -6093,7 +8434,7 @@ describe('messages', () => {
|
||||
|
||||
const sent = await pushed()
|
||||
expect(sent).toHaveLength(1)
|
||||
expect(sent[0]?.data).toEqual({ FromPlayerId: 42, ToPlayerId: 205, Type: 0, Data: '' })
|
||||
expect(sent[0]?.data).toMatchObject({ FromPlayerId: 42, ToPlayerId: 205, Type: 0, Data: '' })
|
||||
})
|
||||
|
||||
test('POST /api/messages/v1/sendMultiple 400s with no usable recipient, pushing nothing', async () => {
|
||||
@@ -6111,19 +8452,66 @@ describe('messages', () => {
|
||||
expect(await pushed()).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /api/messages/v3/delete accepts anything with an empty 200', async () => {
|
||||
// No message store, so no id can be real and nothing is gated — an unknown id, an
|
||||
// empty list and a missing body all land the same way.
|
||||
for (const body of [{ MessageIds: [1787377235629] }, { MessageIds: [] }, {}]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/messages/v3/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
test('GET /api/messages/v2/get serves the caller’s inbox, newest first', async () => {
|
||||
// Two messages to 9600, one to somebody else — an inbox is the caller's own.
|
||||
await send({ ToPlayerId: '9600', Data: 'first' }, await bearer('42'))
|
||||
await send({ ToPlayerId: '9600', Data: 'second' }, await bearer('43'))
|
||||
await send({ ToPlayerId: '9601', Data: 'not yours' }, await bearer('42'))
|
||||
|
||||
const inbox = await inboxOf('9600')
|
||||
expect(inbox.map((m) => m.Data)).toEqual(['second', 'first'])
|
||||
expect(inbox[0]).toMatchObject({ FromPlayerId: 43, ToPlayerId: 9600, Type: 0 })
|
||||
// Every message carries the full Message model, ids and all.
|
||||
expect(Date.parse(inbox[0].SentTime)).not.toBeNaN()
|
||||
expect(inbox.every((m) => m.Id > 0)).toBe(true)
|
||||
// The other player's message is not in it.
|
||||
expect(inbox.some((m) => m.Data === 'not yours')).toBe(false)
|
||||
})
|
||||
|
||||
test('GET /api/messages/v2/get is auth-gated', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`)
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /api/messages/v3/delete removes only the caller’s messages', async () => {
|
||||
await send({ ToPlayerId: '9610', Data: 'keep' }, await bearer('42'))
|
||||
await send({ ToPlayerId: '9610', Data: 'drop' }, await bearer('42'))
|
||||
await send({ ToPlayerId: '9611', Data: 'someone else' }, await bearer('42'))
|
||||
|
||||
const before = await inboxOf('9610')
|
||||
const drop = before.find((m) => m.Data === 'drop')!
|
||||
const theirs = (await inboxOf('9611'))[0]
|
||||
|
||||
// The caller deletes their own message, and takes a swing at another player's id
|
||||
// in the same call — an id names a message globally, so this must not reach it.
|
||||
const res = await del({ MessageIds: [drop.Id, theirs.Id] }, await bearer('9610'))
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toBe('')
|
||||
|
||||
expect((await inboxOf('9610')).map((m) => m.Data)).toEqual(['keep'])
|
||||
expect((await inboxOf('9611')).map((m) => m.Data)).toEqual(['someone else'])
|
||||
})
|
||||
|
||||
test('POST /api/messages/v3/delete takes a bare array, and shrugs off the rest', async () => {
|
||||
await send({ ToPlayerId: '9620', Data: 'bare' }, await bearer('42'))
|
||||
const message = (await inboxOf('9620'))[0]
|
||||
|
||||
// The client posts a bare array; the documented `{ MessageIds }` object works too.
|
||||
expect((await del([message.Id], await bearer('9620'))).status).toBe(200)
|
||||
expect(await inboxOf('9620')).toEqual([])
|
||||
|
||||
// An unknown id, an empty list and a missing body are all an empty 200 — the client
|
||||
// removes its rows locally and re-reads the list either way.
|
||||
for (const body of [{ MessageIds: [1787377235629] }, { MessageIds: [] }, {}, []]) {
|
||||
const res = await del(body, await bearer('9620'))
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
test('POST /api/messages/v3/delete is auth-gated', async () => {
|
||||
expect((await del({ MessageIds: [1] })).status).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutual friends', () => {
|
||||
@@ -6499,10 +8887,10 @@ describe('player events', () => {
|
||||
expect(body.Result).toBe(0)
|
||||
expect(body.PlayerEvent.Name).toBe('Enveloped')
|
||||
// The tags ride inline on the event AND in TagModifyResult. Inline they take the
|
||||
// caller's build shape — this token names no build, so the 2023 `{ Tag, Type }` pairs
|
||||
// (PascalCase: not the lowercase pairs the v1 read's `tags` serves). TagModifyResult
|
||||
// is names to every build.
|
||||
expect(body.PlayerEvent.Tags).toEqual([{ Tag: 'music', Type: 0 }])
|
||||
// caller's build shape — this token names no build, so the 2023 bare names (a newer
|
||||
// build gets the PascalCase `{ Tag, Type }` pairs). TagModifyResult is names to every
|
||||
// build.
|
||||
expect(body.PlayerEvent.Tags).toEqual(['music'])
|
||||
expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['music'] })
|
||||
// No `State`, and the broadcast instance is present and null.
|
||||
expect(body.PlayerEvent).not.toHaveProperty('State')
|
||||
@@ -6511,7 +8899,7 @@ describe('player events', () => {
|
||||
|
||||
test('the v2 envelope shapes PlayerEvent.Tags per the caller’s build', async () => {
|
||||
// Rec Room reshaped this field without minting a new path, so one endpoint owes two
|
||||
// shapes: the 2023 build parses `{ Tag, Type }` pairs, the 2025 build bare names.
|
||||
// shapes: the 2023 build parses bare names, anything newer `{ Tag, Type }` pairs.
|
||||
// Serving either to the wrong build empties the event's chips instead of erroring.
|
||||
// A tag of this test's own: the `#tag` search tests assert exact result sets, and the
|
||||
// four events below would join any set they share a tag with.
|
||||
@@ -6531,16 +8919,18 @@ describe('player events', () => {
|
||||
return (await res.json()) as PlayerEventResult
|
||||
}
|
||||
|
||||
// Newer than 20230414 is the 2025 client; that build, an older one, and a token naming
|
||||
// no build at all are all the 2023 client. Builds are date-stamped, so they compare as
|
||||
// strings.
|
||||
expect((await created('20250718.01')).PlayerEvent.Tags).toEqual(NAMES)
|
||||
expect((await created('20230414')).PlayerEvent.Tags).toEqual(PAIRS)
|
||||
expect((await created('20220101')).PlayerEvent.Tags).toEqual(PAIRS)
|
||||
// Newer than 20230414 gets the pairs; that build, an older one, and a token naming
|
||||
// no build at all are all the 2023 client and get names. Builds are date-stamped, so
|
||||
// they compare as strings.
|
||||
expect((await created('20250718.01')).PlayerEvent.Tags).toEqual(PAIRS)
|
||||
expect((await created('20230414')).PlayerEvent.Tags).toEqual(NAMES)
|
||||
expect((await created('20220101')).PlayerEvent.Tags).toEqual(NAMES)
|
||||
const legacy = await created()
|
||||
expect(legacy.PlayerEvent.Tags).toEqual(PAIRS)
|
||||
// Only the inline field moves: TagModifyResult carries names to both builds.
|
||||
expect(legacy.PlayerEvent.Tags).toEqual(NAMES)
|
||||
// Only the inline field moves: TagModifyResult carries names to both builds — it
|
||||
// really is a List<string> on the client, so the pairs must never land there.
|
||||
expect(legacy.TagModifyResult).toEqual({ Result: 0, Tags: NAMES })
|
||||
expect((await created('20250718.01')).TagModifyResult).toEqual({ Result: 0, Tags: NAMES })
|
||||
|
||||
// The gate is on the ENVELOPE, not on the create: the read and the field edits answer
|
||||
// the same shape, so a client that made an event and one opening it cold agree.
|
||||
@@ -6553,8 +8943,8 @@ describe('player events', () => {
|
||||
})
|
||||
).json()) as PlayerEventResult
|
||||
).PlayerEvent.Tags
|
||||
expect(await read('20250718.01')).toEqual(NAMES)
|
||||
expect(await read()).toEqual(PAIRS)
|
||||
expect(await read('20250718.01')).toEqual(PAIRS)
|
||||
expect(await read()).toEqual(NAMES)
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v2/:eventId serves the same envelope as the write', async () => {
|
||||
@@ -6740,9 +9130,7 @@ describe('player events', () => {
|
||||
upcoming.PlayerEventId,
|
||||
])
|
||||
// The same base projection the POST serves: one path, one shape.
|
||||
expect(events.find((e) => e.PlayerEventId === upcoming.PlayerEventId)).toEqual(
|
||||
asBase(upcoming)
|
||||
)
|
||||
expect(events.find((e) => e.PlayerEventId === upcoming.PlayerEventId)).toEqual(asBase(upcoming))
|
||||
|
||||
// No ids is an empty list, not every event.
|
||||
expect(await (await get('/api/playerevents/v1/bulk')).json()).toEqual([])
|
||||
@@ -6774,6 +9162,69 @@ describe('player events', () => {
|
||||
expect(await search('?skip=1&take=1')).toEqual([all[1]])
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/search?sort=Attendance ranks by Going replies', async () => {
|
||||
const search = async (qs: string): Promise<PlayerEvent[]> =>
|
||||
(await (await get(`/api/playerevents/v1/search${qs}`)).json()) as PlayerEvent[]
|
||||
const respond = (id: number, type: number, sub: string): Promise<Response> =>
|
||||
post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: type }, sub)
|
||||
|
||||
// Three events sharing a searchable word, created soonest-first so the default
|
||||
// order is the OPPOSITE of the attendance order — the sort has to do something.
|
||||
const quiet = await create({ RoomId: 3, Name: 'Sortable Quiet', StartTime: at(2 * HOUR) })
|
||||
const busy = await create({ RoomId: 3, Name: 'Sortable Busy', StartTime: at(3 * HOUR) })
|
||||
const packed = await create({ RoomId: 3, Name: 'Sortable Packed', StartTime: at(4 * HOUR) })
|
||||
// Every creator is Going from create (1 each). `busy` gains one more Going; `packed`
|
||||
// gains two. Interested and Can't-go on `quiet` don't move it: only a Yes counts.
|
||||
for (const [id, type, sub] of [
|
||||
[busy.PlayerEventId, 0, '43'],
|
||||
[packed.PlayerEventId, 0, '43'],
|
||||
[packed.PlayerEventId, 0, '44'],
|
||||
[quiet.PlayerEventId, 1, '43'],
|
||||
[quiet.PlayerEventId, 1, '44'],
|
||||
[quiet.PlayerEventId, 2, '45'],
|
||||
] as const) {
|
||||
expect((await respond(id, type, sub)).status).toBe(200)
|
||||
}
|
||||
|
||||
// Default and `StartTime`: soonest first.
|
||||
const soonest = [quiet.PlayerEventId, busy.PlayerEventId, packed.PlayerEventId]
|
||||
expect((await search('?query=sortable')).map((e) => e.PlayerEventId)).toEqual(soonest)
|
||||
expect((await search('?query=sortable&sort=StartTime')).map((e) => e.PlayerEventId)).toEqual(
|
||||
soonest
|
||||
)
|
||||
|
||||
// `Attendance`: most Going first (3, 2, 1), case-insensitively.
|
||||
const byAttendance = await search('?query=sortable&sort=Attendance')
|
||||
expect(byAttendance.map((e) => e.PlayerEventId)).toEqual([
|
||||
packed.PlayerEventId,
|
||||
busy.PlayerEventId,
|
||||
quiet.PlayerEventId,
|
||||
])
|
||||
expect(byAttendance.map((e) => e.AttendeeCount)).toEqual([3, 2, 1])
|
||||
expect((await search('?query=sortable&sort=attendance')).map((e) => e.PlayerEventId)).toEqual(
|
||||
byAttendance.map((e) => e.PlayerEventId)
|
||||
)
|
||||
|
||||
// A tie in attendance falls back to soonest first: another 1-Going event, later
|
||||
// than `quiet`, lands after it.
|
||||
const late = await create({ RoomId: 3, Name: 'Sortable Late', StartTime: at(5 * HOUR) })
|
||||
expect((await search('?query=sortable&sort=Attendance')).map((e) => e.PlayerEventId)).toEqual([
|
||||
packed.PlayerEventId,
|
||||
busy.PlayerEventId,
|
||||
quiet.PlayerEventId,
|
||||
late.PlayerEventId,
|
||||
])
|
||||
|
||||
// Paging walks the sorted order, and an unknown sort is the default, not a 400.
|
||||
expect(await search('?query=sortable&sort=Attendance&skip=1&take=1')).toEqual([byAttendance[1]])
|
||||
const unknown = await get('/api/playerevents/v1/search?query=sortable&sort=Popularity')
|
||||
expect(unknown.status).toBe(200)
|
||||
expect(((await unknown.json()) as PlayerEvent[]).map((e) => e.PlayerEventId)).toEqual([
|
||||
...soonest,
|
||||
late.PlayerEventId,
|
||||
])
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1 serves the browse feed as listings', async () => {
|
||||
const res = await get('/api/playerevents/v1')
|
||||
expect(res.status).toBe(200)
|
||||
@@ -6994,6 +9445,77 @@ describe('player events', () => {
|
||||
expect(theirs.Created.map((e) => e.PlayerEventId)).toEqual([liveEvent.PlayerEventId])
|
||||
})
|
||||
|
||||
test('GET /api/playerevents/v1/all/:playerId serves a player’s PUBLIC events and Going RSVPs', async () => {
|
||||
const respond = (id: number, type: number, sub: string): Promise<Response> =>
|
||||
post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: type }, sub)
|
||||
interface Profile {
|
||||
Created: PlayerEvent[]
|
||||
Responses: Array<{
|
||||
PlayerEvent: Record<string, unknown>
|
||||
PlayerEventResponse: Record<string, unknown>
|
||||
}>
|
||||
}
|
||||
const profile = async (playerId: number | string): Promise<Profile> => {
|
||||
// No token: the read is public.
|
||||
const res = await get(`/api/playerevents/v1/all/${playerId}`)
|
||||
expect(res.status).toBe(200)
|
||||
return (await res.json()) as Profile
|
||||
}
|
||||
|
||||
// 610 hosts a public event, a private one and a finished one; 611 is Going to the
|
||||
// first two, Interested in a third public one, and went to the finished one.
|
||||
const open = await create({ RoomId: 3, Name: 'Profile Open', StartTime: at(2 * HOUR) }, '610')
|
||||
const closed = await create(
|
||||
{ RoomId: 3, Name: 'Profile Closed', StartTime: at(3 * HOUR), Accessibility: 0 },
|
||||
'610'
|
||||
)
|
||||
const maybe = await create({ RoomId: 3, Name: 'Profile Maybe', StartTime: at(HOUR) }, '610')
|
||||
const over = await create(
|
||||
{ RoomId: 3, Name: 'Profile Over', StartTime: at(-3 * HOUR), EndTime: at(-2 * HOUR) },
|
||||
'610'
|
||||
)
|
||||
await respond(open.PlayerEventId, 0, '611')
|
||||
await respond(closed.PlayerEventId, 0, '611')
|
||||
await respond(maybe.PlayerEventId, 1, '611')
|
||||
await respond(over.PlayerEventId, 0, '611')
|
||||
|
||||
// The attendee: exactly the public event they are Going to, as the pair.
|
||||
const guest = await profile(611)
|
||||
expect(guest.Created).toEqual([])
|
||||
expect(guest.Responses).toHaveLength(1)
|
||||
const [entry] = guest.Responses
|
||||
expect(Object.keys(entry).sort()).toEqual(['PlayerEvent', 'PlayerEventResponse'])
|
||||
expect(entry.PlayerEventResponse).toEqual({
|
||||
PlayerEventResponseId: expect.any(Number),
|
||||
PlayerEventId: open.PlayerEventId,
|
||||
PlayerId: 611,
|
||||
CreatedAt: expect.any(String),
|
||||
Type: 0,
|
||||
})
|
||||
// The 17-key base event: no `State`, `ImageName` a string, the broadcast key present.
|
||||
expect(Object.keys(entry.PlayerEvent)).toHaveLength(17)
|
||||
expect(entry.PlayerEvent).toMatchObject({
|
||||
PlayerEventId: open.PlayerEventId,
|
||||
Name: 'Profile Open',
|
||||
AttendeeCount: 2,
|
||||
Accessibility: 1,
|
||||
ImageName: '',
|
||||
BroadcastingRoomInstanceId: null,
|
||||
})
|
||||
expect(entry.PlayerEvent).not.toHaveProperty('State')
|
||||
|
||||
// The host: their public unfinished events, soonest first, under both keys.
|
||||
const host = await profile(610)
|
||||
const ids = [maybe.PlayerEventId, open.PlayerEventId]
|
||||
expect(host.Created.map((e) => e.PlayerEventId)).toEqual(ids)
|
||||
expect(Object.keys(host.Created[0])).toHaveLength(17)
|
||||
expect(host.Responses.map((r) => r.PlayerEvent.PlayerEventId)).toEqual(ids)
|
||||
|
||||
// Nobody's events, and a non-numeric id doesn't match.
|
||||
expect(await profile(999999)).toEqual({ Created: [], Responses: [] })
|
||||
expect((await get('/api/playerevents/v1/all/abc')).status).toBe(404)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/respond records an RSVP and recounts attendees', async () => {
|
||||
const respond = async (body: unknown, sub = '42'): Promise<Response> =>
|
||||
post('/api/playerevents/v1/respond', body, sub)
|
||||
@@ -7149,7 +9671,7 @@ describe('player events', () => {
|
||||
).toBe(401)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/bulkInvite adds invitees as Going without overwriting answers', async () => {
|
||||
test('POST /api/playerevents/v1/bulkInvite adds invitees as Pending without overwriting answers', async () => {
|
||||
const event = await create({ RoomId: 3, Name: 'Invite Test', StartTime: at(HOUR) })
|
||||
const id = event.PlayerEventId
|
||||
|
||||
@@ -7165,8 +9687,9 @@ describe('player events', () => {
|
||||
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)
|
||||
// Only the creator: an invite is Pending, not Going, so the two newly invited don't
|
||||
// count until they answer — and 43 keeps their decline.
|
||||
expect(body.PlayerEvent.AttendeeCount).toBe(1)
|
||||
|
||||
const responses = (await (await get(`/api/playerevents/v1/${id}/responses`)).json()) as Array<{
|
||||
PlayerId: number
|
||||
@@ -7175,10 +9698,10 @@ describe('player events', () => {
|
||||
expect(
|
||||
responses.sort((a, b) => a.PlayerId - b.PlayerId).map((r) => [r.PlayerId, r.Type])
|
||||
).toEqual([
|
||||
[2, 0],
|
||||
[2, 3],
|
||||
[42, 0],
|
||||
[43, 2],
|
||||
[187, 0],
|
||||
[187, 3],
|
||||
])
|
||||
|
||||
// Re-inviting is a no-op, not a reset: 43 still declines and the count holds.
|
||||
@@ -7187,14 +9710,23 @@ describe('player events', () => {
|
||||
{ PlayerEventId: id, InvitedPlayerIds: [187, 43] },
|
||||
'42'
|
||||
)
|
||||
expect(((await again.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(3)
|
||||
expect(((await again.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(1)
|
||||
|
||||
// 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)
|
||||
expect(((await none.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(1)
|
||||
|
||||
// Accepting the invite is an ordinary respond: Pending becomes Going and counts.
|
||||
const accepted = await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 0 }, '2')
|
||||
expect(((await accepted.json()) as PlayerEventResult).PlayerEvent.AttendeeCount).toBe(2)
|
||||
|
||||
// Pending is the server's mark, not an answer a player can give.
|
||||
expect(
|
||||
(await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 3 }, '187')).status
|
||||
).toBe(400)
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/bulkInvite notifies only the players it actually added', async () => {
|
||||
@@ -7212,17 +9744,57 @@ describe('player events', () => {
|
||||
data: Record<string, Record<string, unknown>>
|
||||
}>
|
||||
|
||||
// One frame, to the one player who gained a row. 43 kept their answer, so nothing
|
||||
// ONE frame, to the one player who gained a row: the invitation Message. No
|
||||
// PlayerEventResponseChanged — a Pending row is not a response; that frame is the
|
||||
// responder's, sent by `respond` once they answer. 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(2) // MessageReceived
|
||||
|
||||
// A PlayerEventInvitation (81) Message from the inviter, naming the event on
|
||||
// PlayerEventId and as a string in Data.
|
||||
expect(sent[0]!.data).toMatchObject({
|
||||
FromPlayerId: 42,
|
||||
ToPlayerId: 2,
|
||||
Type: 81,
|
||||
Data: String(id),
|
||||
PlayerEventId: id,
|
||||
})
|
||||
// Stored under the same id, so an offline invitee finds it on login.
|
||||
const frameId = (sent[0]!.data as unknown as { Id: number }).Id
|
||||
expect(frameId).toBeGreaterThan(0)
|
||||
const inbox = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/messages/v2/get`, { headers: await bearer('2') })
|
||||
).json()) as Array<{ Id: number; Type: number; PlayerEventId: number | null }>
|
||||
expect(inbox.find((m) => m.Id === frameId)).toMatchObject({ Type: 81, PlayerEventId: id })
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/respond pushes PlayerEventResponseChanged to the responder', async () => {
|
||||
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
const event = await create({ RoomId: 3, Name: 'RSVP Frames', StartTime: at(HOUR) })
|
||||
const id = event.PlayerEventId
|
||||
await post('/api/playerevents/v1/bulkInvite', { PlayerEventId: id, InvitedPlayerIds: [2] })
|
||||
|
||||
// The invitee accepts: THIS is where their response frame comes from — the frame
|
||||
// that populates the event on their client — carrying their row as it now stands.
|
||||
await hub.fetch('http://do/all', { method: 'DELETE' })
|
||||
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 0 }, '2')
|
||||
const sent = (await (await hub.fetch('http://do/all')).json()) as Array<{
|
||||
playerId: number
|
||||
notificationType: number
|
||||
data: Record<string, Record<string, unknown>>
|
||||
}>
|
||||
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.
|
||||
// The event carries the count as recomputed AFTER the answer.
|
||||
expect(sent[0]!.data.PlayerEvent).toMatchObject({
|
||||
playerEventId: id,
|
||||
name: 'Invite Frames',
|
||||
name: 'RSVP Frames',
|
||||
attendeeCount: 2,
|
||||
})
|
||||
expect(sent[0]!.data.PlayerEventResponse).toEqual({
|
||||
@@ -7232,6 +9804,106 @@ describe('player events', () => {
|
||||
CreatedAt: expect.stringMatching(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/),
|
||||
Type: 0,
|
||||
})
|
||||
|
||||
// Changing the answer pushes again, with the same row id and the new Type — the
|
||||
// upsert updates in place, so the frame's id matches what the RSVP list serves.
|
||||
await hub.fetch('http://do/all', { method: 'DELETE' })
|
||||
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 2 }, '2')
|
||||
const again = (await (await hub.fetch('http://do/all')).json()) as typeof sent
|
||||
expect(again).toHaveLength(1)
|
||||
expect(again[0]!.data.PlayerEventResponse).toMatchObject({
|
||||
PlayerEventResponseId: sent[0]!.data.PlayerEventResponse!.PlayerEventResponseId,
|
||||
Type: 2,
|
||||
})
|
||||
expect(again[0]!.data.PlayerEvent).toMatchObject({ attendeeCount: 1 })
|
||||
})
|
||||
|
||||
test('a deleted event’s id is never reused by a later create', async () => {
|
||||
// Ids used to be MAX(id) + 1 over the table, so deleting the newest event handed its
|
||||
// id to the next one created — which then inherited everything still filed under
|
||||
// that id outside the event's own rows (an invitation message, the client's cache).
|
||||
const first = await create({ RoomId: 3, Name: 'First', StartTime: at(HOUR) })
|
||||
expect(
|
||||
(await post(`/api/playerevents/v2/delete/${first.PlayerEventId}`, undefined)).status
|
||||
).toBe(200)
|
||||
const second = await create({ RoomId: 3, Name: 'Second', StartTime: at(HOUR) })
|
||||
expect(second.PlayerEventId).toBeGreaterThan(first.PlayerEventId)
|
||||
// And the replacement starts with its own guest list: just its creator.
|
||||
const responses = (await (
|
||||
await get(`/api/playerevents/v1/${second.PlayerEventId}/responses`)
|
||||
).json()) as Array<{ PlayerId: number }>
|
||||
expect(responses.map((r) => r.PlayerId)).toEqual([42])
|
||||
})
|
||||
|
||||
test('editing an event pushes PlayerEventUpdated to everyone Going, and only them', async () => {
|
||||
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
const event = await create({ RoomId: 3, Name: 'Before', StartTime: at(HOUR) })
|
||||
const id = event.PlayerEventId
|
||||
// 2 is Going, 43 is Interested, 44 declined, 45 is invited and still Pending.
|
||||
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 0 }, '2')
|
||||
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 1 }, '43')
|
||||
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 2 }, '44')
|
||||
await post('/api/playerevents/v1/bulkInvite', { PlayerEventId: id, InvitedPlayerIds: [45] })
|
||||
|
||||
const frames = async () =>
|
||||
(await (await hub.fetch('http://do/all')).json()) as Array<{
|
||||
playerId: number
|
||||
notificationType: number
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
|
||||
// The whole-event update: the creator (Going from create) and 2 hear about it, with
|
||||
// the event as it now stands in the camelCase projection PlayerEventCreated uses.
|
||||
await hub.fetch('http://do/all', { method: 'DELETE' })
|
||||
const res = await post(`/api/playerevents/v2/${id}`, { Name: 'After' })
|
||||
expect(res.status).toBe(200)
|
||||
const sent = await frames()
|
||||
expect(sent.map((s) => [s.playerId, s.notificationType])).toEqual([
|
||||
[2, 81], // PlayerEventUpdated
|
||||
[42, 81],
|
||||
])
|
||||
for (const frame of sent) {
|
||||
expect(frame.data).toMatchObject({ playerEventId: id, name: 'After', attendeeCount: 2 })
|
||||
}
|
||||
|
||||
// A single-field edit pushes the same frame to the same audience.
|
||||
await hub.fetch('http://do/all', { method: 'DELETE' })
|
||||
const renamed = await exports.default.fetch(`${ORIGIN}/api/playerevents/v2/${id}/name`, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await bearer('42')), 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ name: 'Renamed' }),
|
||||
})
|
||||
expect(renamed.status).toBe(200)
|
||||
const again = await frames()
|
||||
expect(again.map((s) => [s.playerId, s.notificationType])).toEqual([
|
||||
[2, 81],
|
||||
[42, 81],
|
||||
])
|
||||
expect(again[0]!.data).toMatchObject({ name: 'Renamed' })
|
||||
})
|
||||
|
||||
test('deleting an event pushes PlayerEventDeleted to everyone who was Going, and only them', async () => {
|
||||
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName('global')
|
||||
const event = await create({ RoomId: 3, Name: 'Doomed', StartTime: at(HOUR) })
|
||||
const id = event.PlayerEventId
|
||||
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 0 }, '2')
|
||||
await post('/api/playerevents/v1/respond', { PlayerEventId: id, Type: 1 }, '43')
|
||||
await post('/api/playerevents/v1/bulkInvite', { PlayerEventId: id, InvitedPlayerIds: [45] })
|
||||
|
||||
await hub.fetch('http://do/all', { method: 'DELETE' })
|
||||
const res = await post(`/api/playerevents/v2/delete/${id}`, undefined)
|
||||
expect(res.status).toBe(200)
|
||||
const sent = (await (await hub.fetch('http://do/all')).json()) as Array<{
|
||||
playerId: number
|
||||
notificationType: number
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
// The audience is read before the RSVP rows are deleted with the event; the frame
|
||||
// is just the id, there being no event left to carry.
|
||||
expect(sent).toEqual([
|
||||
{ playerId: 2, notificationType: 82, data: { PlayerEventId: id } }, // PlayerEventDeleted
|
||||
{ playerId: 42, notificationType: 82, data: { PlayerEventId: id } },
|
||||
])
|
||||
})
|
||||
|
||||
test('POST /api/playerevents/v1/bulkInvite is gated on the caller being on the event', async () => {
|
||||
@@ -7498,11 +10170,9 @@ describe('player events', () => {
|
||||
const path = `/api/playerevents/v2/${event.PlayerEventId}/tags`
|
||||
|
||||
// A bare JSON array, not an object — and a replace, not a merge, so `meetup` goes.
|
||||
// Names, since this token names no build (a newer build would get the pairs).
|
||||
const tagged = await edited(await putJson(path, ['tag1', '#Class']))
|
||||
expect(tagged.Tags).toEqual([
|
||||
{ Tag: 'class', Type: 0 },
|
||||
{ Tag: 'tag1', Type: 0 },
|
||||
])
|
||||
expect(tagged.Tags).toEqual(['class', 'tag1'])
|
||||
// The envelope's TagModifyResult reports the same set the client redraws chips from.
|
||||
const body = (await (await putJson(path, ['workshops'])).json()) as PlayerEventResult
|
||||
expect(body.TagModifyResult).toEqual({ Result: 0, Tags: ['workshops'] })
|
||||
@@ -7558,6 +10228,8 @@ describe('player events', () => {
|
||||
expect((await putForm(path, { name: 'n'.repeat(64) })).status).toBe(200)
|
||||
})
|
||||
|
||||
// One create, fourteen gated calls, and a read. The default 5s budget is what
|
||||
// this crossed on CI; the checks themselves are the same creator gate as above.
|
||||
test('the single-field edits are creator-only, like the whole-event update', async () => {
|
||||
const event = await create({ RoomId: 5, Name: 'Guarded' })
|
||||
const id = event.PlayerEventId
|
||||
@@ -7578,7 +10250,7 @@ describe('player events', () => {
|
||||
|
||||
// Nothing moved.
|
||||
expect(await (await get(`/api/playerevents/v1/${id}`)).json()).toEqual(asRecord(event, null))
|
||||
})
|
||||
}, 20_000)
|
||||
})
|
||||
|
||||
describe('openapi', () => {
|
||||
@@ -7606,7 +10278,6 @@ describe('openapi', () => {
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'DELETE /api/customAvatarItems/v1/{id}',
|
||||
'DELETE /api/images/v1/deletesaved',
|
||||
'DELETE /api/playerevents/v2/delete/{eventId}',
|
||||
'GET /api/CircuitChipLists/{list}',
|
||||
'GET /api/PlayerReporting/v1/moderationBlockDetails',
|
||||
@@ -7638,6 +10309,7 @@ describe('openapi', () => {
|
||||
'GET /api/images/v5/cheered/bulk',
|
||||
'GET /api/images/v5/player/{playerId}',
|
||||
'GET /api/images/v6',
|
||||
'GET /api/images/v6/{id}',
|
||||
'GET /api/inventions/v1',
|
||||
'GET /api/inventions/v1/details',
|
||||
'GET /api/inventions/v1/featured',
|
||||
@@ -7663,6 +10335,7 @@ describe('openapi', () => {
|
||||
'GET /api/playerReputation/v2/bulk',
|
||||
'GET /api/playerevents/v1',
|
||||
'GET /api/playerevents/v1/all',
|
||||
'GET /api/playerevents/v1/all/{playerId}',
|
||||
'GET /api/playerevents/v1/bulk',
|
||||
'GET /api/playerevents/v1/club/{clubId}',
|
||||
'GET /api/playerevents/v1/clubs',
|
||||
@@ -7707,16 +10380,22 @@ describe('openapi', () => {
|
||||
'POST /api/PlayerReporting/v1/instantKick',
|
||||
'POST /api/PlayerReporting/v1/moderationBlockDetails',
|
||||
'POST /api/PlayerReporting/v1/referee',
|
||||
'POST /api/PlayerReporting/v1/roomModKick',
|
||||
'POST /api/PlayerReporting/v3/create',
|
||||
'POST /api/PlayerReporting/v3/voteToKick',
|
||||
'POST /api/avatar/v1/lockeditems/bulk',
|
||||
'POST /api/avatar/v2/gifts/generate',
|
||||
'POST /api/chatreport/createChatReport',
|
||||
'POST /api/customAvatarItems/GetCustomAvatarItemCurrentSavesForLegacyAvatarItems',
|
||||
'POST /api/customAvatarItems/v1',
|
||||
'POST /api/customAvatarItems/v1/bulk',
|
||||
'POST /api/customAvatarItems/v1/{id}/report',
|
||||
'POST /api/gamesight/event',
|
||||
'POST /api/images/v1/cheer',
|
||||
'POST /api/images/v1/deletesaved',
|
||||
'POST /api/images/v1/modifydescription',
|
||||
'POST /api/images/v1/{id}/report',
|
||||
'POST /api/images/v2/modifyaccessibility',
|
||||
'POST /api/images/v4/uploadsaved',
|
||||
'POST /api/images/v5/cheered/bulk',
|
||||
'POST /api/inventions/v1/cheer',
|
||||
@@ -7724,7 +10403,10 @@ describe('openapi', () => {
|
||||
'POST /api/inventions/v1/settags',
|
||||
'POST /api/inventions/v1/update',
|
||||
'POST /api/inventions/v1/updateprice',
|
||||
'POST /api/inventions/v2/cheer',
|
||||
'POST /api/inventions/v2/delete',
|
||||
'POST /api/inventions/v2/unpublish',
|
||||
'POST /api/inventions/v2/update',
|
||||
'POST /api/inventions/v4/publish',
|
||||
'POST /api/inventions/v6/save',
|
||||
'POST /api/inventions/v9/save',
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Vote-to-kick ballots on the shared `recflare` D1 database.
|
||||
*
|
||||
* One row per vote cast: who was voted on, in which game session, by whom, and which way.
|
||||
* Append-only like the `report` and `warning` tables (see reports-db.ts), so a vote is a
|
||||
* record of what happened rather than a tally that gets rewritten — the tally is computed
|
||||
* from the rows.
|
||||
*
|
||||
* The rows outlive the vote, but not by much: the `rooms` worker's cron deletes ballots
|
||||
* older than 5 minutes (its `ROOM_VOTE_RETENTION_MS`), which is the longest anything here
|
||||
* looks back — `VOTE_CALL_COOLDOWN_MS`. Nothing deletes them when a session ends, and
|
||||
* nothing needs to: a ballot is keyed to its `game_session_id`, and instance ids are not
|
||||
* reused, so an old session's votes can never be counted into a new one's. Raising the
|
||||
* cooldown means raising that retention too.
|
||||
*
|
||||
* The `api` worker owns this schema/migration (migrations/0023_room_vote.sql and
|
||||
* 0026_room_vote_init.sql, applied under
|
||||
* its own `migrations_table` so it doesn't clash with the other workers' migrations that
|
||||
* share the database).
|
||||
*/
|
||||
|
||||
/** Schema DDL (mirror of migrations/0023_room_vote.sql + 0026_room_vote_init.sql). */
|
||||
export const SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS room_vote (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
game_session_id INTEGER NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
response INTEGER NOT NULL,
|
||||
voter_id INTEGER NOT NULL,
|
||||
voted_at TEXT NOT NULL,
|
||||
init INTEGER NOT NULL DEFAULT 0
|
||||
)`,
|
||||
// The tally's own query: every ballot cast against one player in one session.
|
||||
`CREATE INDEX IF NOT EXISTS idx_room_vote_subject ON room_vote (game_session_id, player_id)`,
|
||||
// The caller throttle's query: the last vote one player CALLED.
|
||||
`CREATE INDEX IF NOT EXISTS idx_room_vote_caller ON room_vote (voter_id, voted_at) WHERE init = 1`,
|
||||
]
|
||||
|
||||
/**
|
||||
* How long a vote stays open once it is called. Every ballot posted inside it is an answer to
|
||||
* THAT vote; the first one posted after it is a new vote, with a prompt of its own.
|
||||
*/
|
||||
export const VOTE_WINDOW_MS = 60_000
|
||||
|
||||
/** How long a player must wait after calling a vote before they may call another, anywhere. */
|
||||
export const VOTE_CALL_COOLDOWN_MS = 5 * 60_000
|
||||
|
||||
/**
|
||||
* One cast ballot. `response` is 1 for yes (kick them) and 0 for no. `init` is 1 on the ballot
|
||||
* that CALLED the vote — the first cast against this player in this session once no vote was
|
||||
* open — and 0 on every answer to it. The client posts both to the same endpoint and nothing
|
||||
* in the body tells them apart, so the row is where a vote's start is recorded.
|
||||
*/
|
||||
export interface RoomVoteRow {
|
||||
id: number
|
||||
game_session_id: number
|
||||
player_id: number
|
||||
response: number
|
||||
voter_id: number
|
||||
voted_at: string
|
||||
init: number
|
||||
}
|
||||
|
||||
/** A ballot as cast — the timestamp is the table's. */
|
||||
export interface NewRoomVote {
|
||||
gameSessionId: number
|
||||
/** The player being voted on. */
|
||||
playerId: number
|
||||
voterId: number
|
||||
/** True to kick them. */
|
||||
response: boolean
|
||||
/** True when this ballot calls the vote rather than answering one. */
|
||||
init: boolean
|
||||
}
|
||||
|
||||
/** Record one cast ballot, returning the stored row. */
|
||||
export async function recordRoomVote(db: D1Database, input: NewRoomVote): Promise<RoomVoteRow> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO room_vote (game_session_id, player_id, response, voter_id, voted_at, init)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
RETURNING *`
|
||||
)
|
||||
.bind(
|
||||
input.gameSessionId,
|
||||
input.playerId,
|
||||
input.response ? 1 : 0,
|
||||
input.voterId,
|
||||
new Date().toISOString(),
|
||||
input.init ? 1 : 0
|
||||
)
|
||||
.first<RoomVoteRow>()
|
||||
// RETURNING always yields the inserted row.
|
||||
return row!
|
||||
}
|
||||
|
||||
/**
|
||||
* The ballot that called the vote currently open on this player in this session — the latest
|
||||
* `init` row, if it was cast inside `VOTE_WINDOW_MS` — or null when no vote is open.
|
||||
*/
|
||||
export async function getOpenVote(
|
||||
db: D1Database,
|
||||
gameSessionId: number,
|
||||
playerId: number,
|
||||
now = Date.now()
|
||||
): Promise<RoomVoteRow | null> {
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT * FROM room_vote
|
||||
WHERE game_session_id = ?1 AND player_id = ?2 AND init = 1 AND voted_at > ?3
|
||||
ORDER BY id DESC LIMIT 1`
|
||||
)
|
||||
.bind(gameSessionId, playerId, new Date(now - VOTE_WINDOW_MS).toISOString())
|
||||
.first<RoomVoteRow>()
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `voterId` called a vote — any vote, in any session — within `VOTE_CALL_COOLDOWN_MS`.
|
||||
* Only CALLING is throttled: answering a vote someone else called is never refused.
|
||||
*/
|
||||
export async function isOnVoteCooldown(
|
||||
db: D1Database,
|
||||
voterId: number,
|
||||
now = Date.now()
|
||||
): Promise<boolean> {
|
||||
const row = await db
|
||||
.prepare(`SELECT 1 FROM room_vote WHERE voter_id = ?1 AND init = 1 AND voted_at > ?2 LIMIT 1`)
|
||||
.bind(voterId, new Date(now - VOTE_CALL_COOLDOWN_MS).toISOString())
|
||||
.first()
|
||||
return row !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* How many DISTINCT players have voted to kick this player out of this session.
|
||||
*
|
||||
* Distinct voters, not rows, and only each voter's LATEST ballot: the table is append-only,
|
||||
* so a player who votes twice leaves two rows, and counting rows would let one person carry
|
||||
* a vote on their own by posting it repeatedly. Counting their latest also lets someone
|
||||
* change their mind — a yes followed by a no is a no.
|
||||
*
|
||||
* `MAX(id)` picks the latest rather than `MAX(voted_at)`: two ballots cast in the same
|
||||
* millisecond carry the same timestamp, and the id is the order they were actually recorded
|
||||
* in.
|
||||
*
|
||||
* Only ballots from `sinceId` on count — the id of the `init` row that called THIS vote. A
|
||||
* vote that expired without carrying leaves its yes ballots behind, and without the bound
|
||||
* they would carry the next vote called on the same player.
|
||||
*/
|
||||
export async function countKickVotes(
|
||||
db: D1Database,
|
||||
gameSessionId: number,
|
||||
playerId: number,
|
||||
sinceId: number
|
||||
): Promise<number> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS yes FROM (
|
||||
SELECT response FROM room_vote
|
||||
WHERE game_session_id = ?1 AND player_id = ?2
|
||||
AND id IN (
|
||||
SELECT MAX(id) FROM room_vote
|
||||
WHERE game_session_id = ?1 AND player_id = ?2 AND id >= ?3
|
||||
GROUP BY voter_id
|
||||
)
|
||||
) WHERE response = 1`
|
||||
)
|
||||
.bind(gameSessionId, playerId, sinceId)
|
||||
.first<{ yes: number }>()
|
||||
return row?.yes ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `yesVotes` carries a vote in a session holding `playerCount` players: STRICTLY more
|
||||
* than half. A tie is not a majority, and a session whose presence count has somehow reached
|
||||
* zero can never carry one — `0 > 0` is false — rather than every vote passing unopposed.
|
||||
*/
|
||||
export const isKickMajority = (yesVotes: number, playerCount: number): boolean =>
|
||||
playerCount > 0 && yesVotes > playerCount / 2
|
||||
@@ -382,5 +382,8 @@
|
||||
"CloudRegion": "us",
|
||||
"CrcCheckEnabled": false,
|
||||
"EnableServerTracingAfterDisconnect": false
|
||||
},
|
||||
"RoomKeyConfig": {
|
||||
"MaxKeysPerRoom": 10
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"OutfitSelections": "1fd69ef8-0b74-4962-af5a-67f0bf0358f2,,0;d0a9262f-5504-46a7-bb10-7507503db58e,,1",
|
||||
"FaceFeatures": "{\"ver\":-1,\"eyeId\":\"AjGMoJhEcEehacRZjUMuDg\",\"eyePos\":{\"x\":0.0,\"y\":0.0},\"eyeScl\":0.0,\"mouthId\":\"FrZBRanXEEK29yKJ4jiMjg\",\"mouthPos\":{\"x\":0.0,\"y\":0.0},\"mouthScl\":0.0,\"hairPrimaryColorId\":\"\",\"hairSecondaryColorId\":\"0e_jaaObREWTf1AorAZ95g\",\"hairPatternId\":\"\",\"beardColorId\":\"0e_jaaObREWTf1AorAZ95g\",\"beardSecondaryColorId\":\"0e_jaaObREWTf1AorAZ95g\",\"beardPatternId\":\"\",\"faceShapeId\":\"yR4oYZr_AUSynXCgwS2lGw\",\"bodyShapeId\":\"bY1RGIph0kiAxbd6Shn9tQ\",\"useHatAnchorParams\":true,\"useHelmetHair\":1,\"hideEars\":true,\"hatAnchorParams\":{\"NormalizedPosition\":{\"x\":0.5,\"y\":0.5},\"HemisphereOffsets\":{\"x\":0.0,\"y\":0.0,\"z\":0.0},\"HemisphereRotations\":{\"x\":0.0,\"y\":0.0,\"z\":0.0}},\"baseAvatarType\":\"\"}",
|
||||
"SkinColor": "Xac-W_R330KfOz-pQla9qg",
|
||||
"HairColor": "0e_jaaObREWTf1AorAZ95g"
|
||||
}
|
||||
@@ -1,2582 +0,0 @@
|
||||
[
|
||||
{
|
||||
"AvatarItemDesc": "0088603e-ec3b-4478-8694-e6fb1989b3f2,,,",
|
||||
"AvatarItemId": 620,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Angled Bob Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "04e348b7-107f-47b4-9331-ae3b9fa184ca,,,",
|
||||
"AvatarItemId": 1980,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Braided Bun Hair ",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,,,",
|
||||
"AvatarItemId": 1004,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Cat-Eye Glasses (Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,cypMpMaVeUuRjm4FLH4czQ,PIjEKhXiCUqtA8FBhLBcmA,",
|
||||
"AvatarItemId": 1883,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Cat-Eye Glasses (Teal)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,LTXy6g4xe0abuFzOLpVDUg,PIjEKhXiCUqtA8FBhLBcmA,",
|
||||
"AvatarItemId": 1881,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Cat-Eye Glasses (Dark Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,tMsCUhDslkmdNBfogeSz7w,PIjEKhXiCUqtA8FBhLBcmA,",
|
||||
"AvatarItemId": 1884,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Cat-Eye Glasses (Black)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "06306723-ca20-4aa6-b7b3-917113f41ac3,whWKAUSGp0qprsJomq9Nwg,PIjEKhXiCUqtA8FBhLBcmA,",
|
||||
"AvatarItemId": 1882,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Cat-Eye Glasses (Pink)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,",
|
||||
"AvatarItemId": 2337,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Scarf (Beige)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,_T4zEki_wk-UIPdhSaBr_Q",
|
||||
"AvatarItemId": 2341,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Scarf (Green)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,5z3jZhYvq0CGmdJ9IcUUsg",
|
||||
"AvatarItemId": 2340,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Scarf (Brown)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,EgEoJzG7A0aJ92qfInbX7g",
|
||||
"AvatarItemId": 2342,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Scarf (Orange)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,NETGR8iysUyxcZM_oGmrwQ",
|
||||
"AvatarItemId": 2344,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Scarf (White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,OlfQDuz1NUOr88AL2n0jXg",
|
||||
"AvatarItemId": 2343,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Scarf (Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "071f179a-dde2-407f-92b2-f0c16fcb4e39,VudVdOAjZUibHmtFeNkvSw",
|
||||
"AvatarItemId": 2339,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Scarf (Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "0753d7a4-8247-4fca-a6fc-359c26086140,,,",
|
||||
"AvatarItemId": 993,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Fonzie Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "09177621-9ecd-4f6a-b6a5-64490139141d,,,",
|
||||
"AvatarItemId": 81,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Flat Top Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "0abb6b08-20ce-444f-879e-0d1344df096c,,,",
|
||||
"AvatarItemId": 1620,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Round Earrings",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "0d1ae26e-aaa4-4914-ad70-c9ab8cfabc61,,,",
|
||||
"AvatarItemId": 1979,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Box Braids with Cuff Hair ",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,,,",
|
||||
"AvatarItemId": 1740,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Basic Belt (Black)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,6fmUy748hU2dgwgaijSkUA,rL9p5JY83Eyxj00Dieg7oA,",
|
||||
"AvatarItemId": 1739,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Basic Belt (Green)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,buuNFd8sK0eap78TL1gMNQ,-UFHTNvJs0CGDStb0PFkbQ,",
|
||||
"AvatarItemId": 1741,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Basic Belt (Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,dBR1uNgk206eVoWntVzVAg,rL9p5JY83Eyxj00Dieg7oA,",
|
||||
"AvatarItemId": 1738,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Basic Belt (Brown)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,LIgagNf4gk68EYQdlDruxg,rL9p5JY83Eyxj00Dieg7oA,",
|
||||
"AvatarItemId": 1736,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Basic Belt (Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "12ec292f-0ccd-4c8a-a49c-3598f827659d,zPGCmxM_b0yTt7tqMaomcw,rL9p5JY83Eyxj00Dieg7oA,",
|
||||
"AvatarItemId": 1737,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Basic Belt (Tan)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,,,",
|
||||
"AvatarItemId": 25,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Baseball Cap (White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 983,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Baseball Cap (Black)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 29,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Baseball Cap (Light Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 31,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Baseball Cap (Orange)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 30,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Baseball Cap (Yellow)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,724c79a3-822c-422f-9462-a5374ee0211c,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 26,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Baseball Cap (White))",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 669,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Baseball Cap (Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "14ef6b00-debf-4a85-9755-b4d37df496d3,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 670,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Baseball Cap (Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "193a3bf9-abc0-4d78-8d63-92046908b1c5,,,",
|
||||
"AvatarItemId": 991,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Emo Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "1940c740-695d-4481-a802-bb11b6561635,",
|
||||
"AvatarItemId": 2116,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room Untucked Tee",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "1a71064b-794f-40fa-9109-8ad36602b6e1,,,",
|
||||
"AvatarItemId": 95,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Shagg Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "1d27b674-f9e2-4ffc-9d8c-a58a1be06457,,,",
|
||||
"AvatarItemId": 986,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Afro Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "1fd69ef8-0b74-4962-af5a-67f0bf0358f2,,,",
|
||||
"AvatarItemId": 998,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Ponytail Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "21599b51-c50f-43d8-ac5f-62c30cd02ca5,,,",
|
||||
"AvatarItemId": 994,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Lori Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "21caa68e-c3fa-474c-af5e-af1e742b7a60,,,",
|
||||
"AvatarItemId": 1021,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Tennis Skirt (Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "21caa68e-c3fa-474c-af5e-af1e742b7a60,758752bd-db2f-43d2-b580-55b3e1efffd5,b75ef67d-00c3-4ac1-9b72-212032460294,",
|
||||
"AvatarItemId": 682,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Tennis Skirt (Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "21caa68e-c3fa-474c-af5e-af1e742b7a60,c5deba2a-6e35-4b13-8e94-8ba5457f39df,b75ef67d-00c3-4ac1-9b72-212032460294,",
|
||||
"AvatarItemId": 681,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Tennis Skirt (Yellow)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,,,",
|
||||
"AvatarItemId": 1022,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Zipper Dress (Yellow)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,6d703981-2734-4c45-8983-cdd5f328902f,cfabdefe-0890-436e-b2a3-b5c712e22955,",
|
||||
"AvatarItemId": 255,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Zipper Dress (Green)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,830be2fa-60a5-48cc-931f-34b670eae4bd,cfabdefe-0890-436e-b2a3-b5c712e22955,",
|
||||
"AvatarItemId": 254,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Zipper Dress (Purple)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "2296ed0d-df56-4d46-b33a-aae9230a47fc,bbfa08e3-8e6b-4e0f-b264-1b398d7cd44a,cfabdefe-0890-436e-b2a3-b5c712e22955,",
|
||||
"AvatarItemId": 253,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Zipper Dress (White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,",
|
||||
"AvatarItemId": 2345,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Scarf Halo (Turquoise)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,0p_eXn2gXUKrshSEmrQx6Q",
|
||||
"AvatarItemId": 2350,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Scarf Halo (Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,3cPbhpDX-EqDQy6eN1E5Ug",
|
||||
"AvatarItemId": 2346,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Scarf Halo (Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,fAmEEm6ziUO2ZXTscQWtSg",
|
||||
"AvatarItemId": 2351,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Scarf Halo (White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,gEuE-X67b0-q5C0Tv11_nA",
|
||||
"AvatarItemId": 2348,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Scarf Halo (Green)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "22a857bb-270a-4278-adbd-2df41181b36f,gk_mQ-jsakmxhZYYNlRl_w",
|
||||
"AvatarItemId": 2349,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Scarf Halo (Orange)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "241506f6-bf88-4b46-b5fe-513a225421f4,,,",
|
||||
"AvatarItemId": 417,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Half Up Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "24a240f4-1574-420b-b898-a7e91f170759,,,",
|
||||
"AvatarItemId": 582,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Back Bun Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "2822aa50-4344-434d-b46f-fd9443f865b9,,,",
|
||||
"AvatarItemId": 1981,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Curly Bob Hair ",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "2cb4f372-3372-4583-8b57-c4e3988e3c28,,,",
|
||||
"AvatarItemId": 999,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Punky Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,,,",
|
||||
"AvatarItemId": 1009,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Collared Shirt (Plaid, Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,0iSsaY-HgkmLaRHCn5vEdw,PioQ0o3yP0a6szPZ4EKs2A,",
|
||||
"AvatarItemId": 1014,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Collared Shirt (Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,55901f12-d5b5-4fa8-b4c8-e479689ee39d,f600037d-c9c0-43fa-b45b-02f456f9dd5f,",
|
||||
"AvatarItemId": 674,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Collared Shirt (Denim)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,bf82f2f6-9af8-431e-a296-0890dea48ba7,d015cae7-a905-49e4-8823-6dec069689a6,",
|
||||
"AvatarItemId": 672,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Collared Shirt (Argyle)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,EfdMcnfHt0mr0PQ_maaYOg,DRJcNhkqvkKFEaZpOguR6w,",
|
||||
"AvatarItemId": 1016,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Collared Shirt (Flowers, Green)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,FAviMCQ_EE2Mpt6QPo5OEw,PioQ0o3yP0a6szPZ4EKs2A,",
|
||||
"AvatarItemId": 1015,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Collared Shirt (Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,jGj28vhq8EGwP2RuM074aQ,PioQ0o3yP0a6szPZ4EKs2A,",
|
||||
"AvatarItemId": 1012,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Collared Shirt (Yellow)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,kmj5zOjcwku_WWKroCeiVQ,PioQ0o3yP0a6szPZ4EKs2A,",
|
||||
"AvatarItemId": 1011,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Collared Shirt (Pink)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "2e59d8d0-91a0-4449-bfdc-a5d663fd9343,MFrcSQ1DYUm8imvy4ypgvw,PioQ0o3yP0a6szPZ4EKs2A,",
|
||||
"AvatarItemId": 1013,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Collared Shirt (White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "2e7cbdb0-cf22-4938-964f-acca57d221b7,",
|
||||
"AvatarItemId": 2878,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Hearing Aids (BTE Earmold - Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "2e7cbdb0-cf22-4938-964f-acca57d221b7,aEsXeTI_SEOclbm82n17tg",
|
||||
"AvatarItemId": 2880,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Hearing Aids (BTE Earmold - Gray)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "2e7cbdb0-cf22-4938-964f-acca57d221b7,pZDvYABTN06KdJ5Ck60XLA",
|
||||
"AvatarItemId": 2879,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Hearing Aids (BTE Earmold - Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "36b2ccf4-4f42-4dc4-aeaf-f3d10b2a6d30,,,",
|
||||
"AvatarItemId": 1982,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Etched Fade Hair ",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "37f7b94a-92ba-4f5e-b94c-de00acfa4722,,,",
|
||||
"AvatarItemId": 1983,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Etched Flattop Hair ",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,,,",
|
||||
"AvatarItemId": 6,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Headband (White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,0ecb8a2a-cffc-47db-aeda-fb0684aef1e5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 11,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Headband (Grey)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 663,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Headband (Black)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 12,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Headband (Light Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,018a5c07-e956-457d-a540-a5e2cd68da09,",
|
||||
"AvatarItemId": 667,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Headband (Orange, White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 13,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Headband (Orange)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 16,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Headband (Yellow)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,6dd95046-acf8-42fe-ab78-80a334096a9d,56a92c8d-af53-413e-929e-4a9a3cfad780,",
|
||||
"AvatarItemId": 668,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Headband (Red, White, Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 664,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Headband (Cream)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,8377ab96-c908-457f-9fee-b784c9a759f3,018a5c07-e956-457d-a540-a5e2cd68da09,",
|
||||
"AvatarItemId": 20,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Headband (Red, White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 7,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Headband (Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,018a5c07-e956-457d-a540-a5e2cd68da09,",
|
||||
"AvatarItemId": 666,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Headband (Pink, White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 14,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Headband (Pink)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,018a5c07-e956-457d-a540-a5e2cd68da09,",
|
||||
"AvatarItemId": 665,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Headband (Blue, White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 15,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Headband (Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "40528de7-38a3-4a7c-8f93-6d3bfa5573f2,f8b0cfe8-e129-4578-8bb5-f60af5d38599,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 10,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Headband (Green)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "45eaab67-19c2-4601-8f80-3565a4dceba4,,,",
|
||||
"AvatarItemId": 1199,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Pompadour Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "45f5e714-8a5f-4385-a97f-675066167011,,,",
|
||||
"AvatarItemId": 112,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Seventies Stache",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "48fdfbc6-fca4-424c-aa57-3fcea41e23dc,,,",
|
||||
"AvatarItemId": 1742,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Leather Necklace (Black)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "48fdfbc6-fca4-424c-aa57-3fcea41e23dc,gsHBUqA1gE2XNTPA3VvBLQ,FwjsTo0nb0uNU6l8XvllNA,",
|
||||
"AvatarItemId": 1745,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Leather Necklace (Brown)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "48fdfbc6-fca4-424c-aa57-3fcea41e23dc,JUQn0_PLIkytCrkAg0FflQ,FwjsTo0nb0uNU6l8XvllNA,",
|
||||
"AvatarItemId": 1744,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Leather Necklace (Tan)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "4d507dfa-4a99-4ac0-8537-229e9dc0eb4a,,,",
|
||||
"AvatarItemId": 1017,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room Tank Top (Orange)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "4eaaad39-aa44-4791-8b67-2eafa8305850,,,",
|
||||
"AvatarItemId": 1977,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Afro Puffs Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "5beeb4c4-f276-4eae-87aa-9302e45b05b7,,,",
|
||||
"AvatarItemId": 1480,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Cornrows Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "5cd08cfb-c729-4c30-96d9-6a99bb934d91,,,",
|
||||
"AvatarItemId": 124,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room Sash",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "62ce4109-8dee-4895-bf1b-bfa143db4c7e,,,",
|
||||
"AvatarItemId": 149,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Slim Blazer (Teal)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "62ce4109-8dee-4895-bf1b-bfa143db4c7e,ad61c418-6d77-4a99-8ac5-9f10f5a3d42f,0f36bb97-c61b-4281-929f-ff1d0d11be86,",
|
||||
"AvatarItemId": 468,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Slim Blazer (Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "62ce4109-8dee-4895-bf1b-bfa143db4c7e,cd5d7285-202d-42d0-b93f-04245875793e,0f36bb97-c61b-4281-929f-ff1d0d11be86,",
|
||||
"AvatarItemId": 469,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Slim Blazer (Green)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,,,",
|
||||
"AvatarItemId": 131,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Long Scarf (Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,5c4a2b35-0e1c-44de-8c3a-96d4a6458b1b,cf119781-5bd9-4b85-9a0b-12e82e988c23,",
|
||||
"AvatarItemId": 461,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Long Scarf (Purple)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,6dd95046-acf8-42fe-ab78-80a334096a9d,cf119781-5bd9-4b85-9a0b-12e82e988c23,",
|
||||
"AvatarItemId": 460,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Long Scarf (White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "6b9e022c-0b68-48fd-8eca-da8573c18900,d6edbc00-3c1d-4f49-8412-3ef8c7c5f4c2,cf119781-5bd9-4b85-9a0b-12e82e988c23,",
|
||||
"AvatarItemId": 459,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Long Scarf (Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,,,",
|
||||
"AvatarItemId": 193,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Collared Dress (Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,6564acf1-4d70-4f92-92ac-08e2b76dbb6b,2c8924aa-68f8-4912-9759-18992f72f08a,",
|
||||
"AvatarItemId": 196,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Collared Dress (Purple)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,d66aa400-aa5a-4539-a25d-5f8ce94dc281,2c8924aa-68f8-4912-9759-18992f72f08a,",
|
||||
"AvatarItemId": 195,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Collared Dress (Green)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "6d815b35-6f68-4ed4-817d-70f141e1a571,f750de46-3758-4f7d-9709-0a84b1027009,2c8924aa-68f8-4912-9759-18992f72f08a,",
|
||||
"AvatarItemId": 194,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Collared Dress (Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "6e48363d-cc62-40a8-87f9-a69fae90c002,,,",
|
||||
"AvatarItemId": 1978,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Box Braid Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "71921831-ba6f-408b-a00e-2fd97663636f,,,",
|
||||
"AvatarItemId": 1024,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wrist Tape (White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "71921831-ba6f-408b-a00e-2fd97663636f,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 297,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wrist Tape (Black)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "71921831-ba6f-408b-a00e-2fd97663636f,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 296,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wrist Tape (Cream)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "77d3c585-4928-4471-a425-89036efe7299,,,",
|
||||
"AvatarItemId": 1001,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Spiky Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "79b90274-6eec-4664-acfb-4a123334661e,,,",
|
||||
"AvatarItemId": 997,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Pig Tails Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,,,",
|
||||
"AvatarItemId": 1020,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "T-Shirt",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 230,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Tank Top (Black)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,48abd952-214f-48b2-a8f1-1146f6f69aa2,b78008e8-abbd-4ece-be34-9a911f721fcc,",
|
||||
"AvatarItemId": 677,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Tank Top (Zebra)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 227,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Tank Top (Orange)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,5c4a2b35-0e1c-44de-8c3a-96d4a6458b1b,9c03f381-7357-4d0f-8cda-8737d4c43d25,",
|
||||
"AvatarItemId": 679,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Tank Top (Rainbow)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,6d703981-2734-4c45-8983-cdd5f328902f,a0271cd0-e172-4d3f-aa2f-9806f21a82d2,",
|
||||
"AvatarItemId": 676,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Tank Top (Camo)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 228,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Tank Top (Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,ad61c418-6d77-4a99-8ac5-9f10f5a3d42f,b292eb4b-07e3-4a48-99b5-3c6587a1e02e,",
|
||||
"AvatarItemId": 678,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Tank Top (Dots)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "7b857a8c-92ad-4028-a2c2-b3c20cdab5f2,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 229,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Tank Top (Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "7dd6f7b0-7ba0-429f-a04f-e32d3a79ee61,,,",
|
||||
"AvatarItemId": 585,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Short Wavy Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "849cabc9-db40-4448-b923-defe4e700072",
|
||||
"AvatarItemId": 5954,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Bride of Frank Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,,,",
|
||||
"AvatarItemId": 198,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Stoll Dress (Pink)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,64850553-cdfe-455a-ac00-dafbe63d613e,91a451c1-b285-4c48-b14d-59ded8cc006f,",
|
||||
"AvatarItemId": 199,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Stoll Dress (Orange)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,761a3193-60f0-4190-80c7-285b8192e794,91a451c1-b285-4c48-b14d-59ded8cc006f,",
|
||||
"AvatarItemId": 200,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Stoll Dress (Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "84cd594c-1cd8-4b4d-8409-85c8fd5fb02a,a819f49b-6c7a-49d3-9e6a-d9d79ef5019f,91a451c1-b285-4c48-b14d-59ded8cc006f,",
|
||||
"AvatarItemId": 201,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Stoll Dress (Green)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "880a3cc0-7407-4b61-b759-f9dd890fe9e5,,,",
|
||||
"AvatarItemId": 987,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Bob Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "88b6ddeb-a455-460d-91d9-a4569ef6903c,,,",
|
||||
"AvatarItemId": 1621,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Square Earrings ",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "8aa79563-ace1-4ba7-ad0c-f3210a78142f,,,",
|
||||
"AvatarItemId": 1018,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room Shirt (V-Neck, White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "8aa79563-ace1-4ba7-ad0c-f3210a78142f,95e4cc30-cb68-473d-a395-feadf5b51512,05f0ee6e-c824-470e-9178-5ed576c6fe0c,",
|
||||
"AvatarItemId": 675,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room T-Shirt (V-Neck, Orange)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "8b9f1413-e786-4a30-946c-9292f207875a,,,",
|
||||
"AvatarItemId": 91,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Pulp Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "8c35c804-e8d5-49d2-8d5a-ea19fb70bfa6,,,",
|
||||
"AvatarItemId": 1478,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Pencil Bun Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "8d10cc78-6b00-45f3-affb-205e9cc5b03f,,,",
|
||||
"AvatarItemId": 1006,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Beard (Close)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "92302d9d-c527-418c-ac5d-1fa869727505,,,",
|
||||
"AvatarItemId": 996,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Part Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "95ab7a7c-c35d-4da5-9955-0921064470b6,,,",
|
||||
"AvatarItemId": 83,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Gekko Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "9b5bde11-7408-4798-9fcb-c7ec175444df,,,",
|
||||
"AvatarItemId": 1619,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Hoop Earrings",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "9bf5d259-7774-4cbe-a90f-7f188cc0dce7,,,",
|
||||
"AvatarItemId": 361,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Thick Goatee",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "9bf75b23-84e9-436b-bbb8-4180ccd86b94,,,",
|
||||
"AvatarItemId": 1985,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wolf Cut Hair) ",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "9c20fdd5-acf1-426c-b85a-2220ceee9472,,,",
|
||||
"AvatarItemId": 1987,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Short Curly Twists Hair ",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,,,",
|
||||
"AvatarItemId": 150,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Button Top (Pink)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,49f5864f-9d40-497c-88c8-e87f64d41d74,dafa658e-753b-46cb-bd85-85c1de5e6ea7,",
|
||||
"AvatarItemId": 151,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Button Top (Tan)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,c5deba2a-6e35-4b13-8e94-8ba5457f39df,dafa658e-753b-46cb-bd85-85c1de5e6ea7,",
|
||||
"AvatarItemId": 153,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Button Top (Yellow)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "9c8fc7f0-8f99-4aad-a34f-8d979f6ae352,e0397982-c2c2-4733-9a40-46e18675b5af,dafa658e-753b-46cb-bd85-85c1de5e6ea7,",
|
||||
"AvatarItemId": 152,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Button Top (Orange)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "9d9fadb6-97eb-480e-a224-4e0179082071,,,",
|
||||
"AvatarItemId": 995,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Meatball Buns Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "9db35412-7a08-49dd-b80d-59c34a4616ea,",
|
||||
"AvatarItemId": 2328,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Brimless Cap (White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "9db35412-7a08-49dd-b80d-59c34a4616ea,hgLq0WyUAUSl2sboUrpTKQ",
|
||||
"AvatarItemId": 2330,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Brimless Cap (Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "9db35412-7a08-49dd-b80d-59c34a4616ea,JzLPlVNG20G9mxG_4PF8Fg",
|
||||
"AvatarItemId": 2332,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Brimless Cap (Navy)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "9db35412-7a08-49dd-b80d-59c34a4616ea,M5uFQQpoQE-5CKMJnDv_LA",
|
||||
"AvatarItemId": 2331,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Brimless Cap (Green)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "9db35412-7a08-49dd-b80d-59c34a4616ea,VuMDnpnK50mzgUMwJ4F6Jw",
|
||||
"AvatarItemId": 2333,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Brimless Cap (Orange)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "a0e6fe4d-991d-4bd1-a1dc-ac4c7375fa4d,",
|
||||
"AvatarItemId": 2359,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Wrap (White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "a0e6fe4d-991d-4bd1-a1dc-ac4c7375fa4d,0zldWzi4H0OQwsWPt6gl-w",
|
||||
"AvatarItemId": 2364,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Wrap (Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "a0e6fe4d-991d-4bd1-a1dc-ac4c7375fa4d,a0A1q_e1q06qquiu2KaZhA",
|
||||
"AvatarItemId": 2361,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Wrap (Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "a0e6fe4d-991d-4bd1-a1dc-ac4c7375fa4d,M_BhFve7SkC7IJoymLiCLg",
|
||||
"AvatarItemId": 2363,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Wrap (Orange)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "a0e6fe4d-991d-4bd1-a1dc-ac4c7375fa4d,rwESUxIrQEKA2hdQpcaPaA",
|
||||
"AvatarItemId": 2362,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Wrap (Green)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "a12f724f-4a73-4ab8-aad4-6bfc662b4dd6,,,",
|
||||
"AvatarItemId": 1002,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Undercut Long Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "a52353bf-8693-45fb-a04a-19cf62a06482",
|
||||
"AvatarItemId": 5955,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Curlers Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "a6cbfe76-534a-4655-a8a8-3fed13d001c7,,,",
|
||||
"AvatarItemId": 726,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Bald Top Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b148cb1e-df81-442f-aea6-ab1727aad00e,,,",
|
||||
"AvatarItemId": 990,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Chunky Afro Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,,,",
|
||||
"AvatarItemId": 1772,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Pocket Tee (Black) ",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,Aex0Zh1RQ0u9oTDFF1cFIg,_o3mI1mjE02vekPVyJg8Aw,",
|
||||
"AvatarItemId": 1781,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Pocket Tee (Yellow)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,El0s9F6cfkywcKOkqwbJtQ,_o3mI1mjE02vekPVyJg8Aw,",
|
||||
"AvatarItemId": 1779,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Pocket Tee (Grey)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,J9cK9BKtHUinG-DwAoPi3g,_o3mI1mjE02vekPVyJg8Aw,",
|
||||
"AvatarItemId": 1774,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Pocket Tee (Dreen)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,N0fBxjrCekO-HUqmc3MufQ,_o3mI1mjE02vekPVyJg8Aw,",
|
||||
"AvatarItemId": 1775,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Pocket Tee (Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,oNoEl-nDqk-HLsfz1538oA,_o3mI1mjE02vekPVyJg8Aw,",
|
||||
"AvatarItemId": 1776,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Pocket Tee (White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,pQ_3mZI7yEaxMw9n_Fr4Sg,_o3mI1mjE02vekPVyJg8Aw,",
|
||||
"AvatarItemId": 1778,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Pocket Tee (Pink)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,TpFWTdEW2kqtpvc0sPv-dw,_o3mI1mjE02vekPVyJg8Aw,",
|
||||
"AvatarItemId": 1777,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Pocket Tee (Purple)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,-VF3K4miGE28LxMgqVehRQ,_o3mI1mjE02vekPVyJg8Aw,",
|
||||
"AvatarItemId": 1773,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Pocket Tee (Orange)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b3b59410-d3df-464b-a75b-500a9e5ea154,vxZVeH2b_E6XlqyqCW0hBA,_o3mI1mjE02vekPVyJg8Aw,",
|
||||
"AvatarItemId": 1780,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Pocket Tee (Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,3a1oW3_MY0e4lDN2V1X4ig,za8Mz14Ww0KtC6wavUPXdg,",
|
||||
"AvatarItemId": 1771,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room Hoodie - Pride (Nonbinary Pride)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,9jB8U3yxjE6hanEZgMdvtA",
|
||||
"AvatarItemId": 2202,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room Hoodie - AAPI (AAPI Heritage Month)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,D_Xmo0rOzkS-kgq1CYXt3g,tnCJp2eDI0SwjVfJMhk3LQ,",
|
||||
"AvatarItemId": 1458,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room Hoodie - Pride (Trans Pride)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,DIeasJUIj0KNetAzPzuIrg,o3Q2gADhh06dgaipkkdMsA,",
|
||||
"AvatarItemId": 1757,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room Hoodie - Pride (Bi Pride)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,EkA09Hj2m0u3c5STynn3Bw",
|
||||
"AvatarItemId": 2887,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room Hoodie - ANAM (Autism & Neurodiversity Awareness Month)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,G6Udm6kMdE28vmRY5KPanA,Fw79pqDPP0C8H07AnTY-Vg,",
|
||||
"AvatarItemId": 1756,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room Hoodie - Pride (Lesbian Pride)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,MfF0wizDjUa7RUE-AVJpvA",
|
||||
"AvatarItemId": 2194,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room Hoodie - Pride (Intersex Pride)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,PIwziHk1BUuYkAf6kuK6wA",
|
||||
"AvatarItemId": 2059,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room Hoodie - BHM (Black History Month)\t",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,PRLBrEEZhUumm3VnYVJP6Q",
|
||||
"AvatarItemId": 2192,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room Hoodie - Pride (Aromantic Pride)\t",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,sf962XJ0tkis0svkMkZCXQ,4bI7QRvdnEansPkwx8Di9A,",
|
||||
"AvatarItemId": 1791,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room Hoodie - Pride (Pan Pride)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,sxUE0iOSZEmezm54T7xI3Q,tlpa7195x0CkmSjpR1RArQ,",
|
||||
"AvatarItemId": 1457,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room Hoodie - Pride (Rainbow Pride)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,tm7Cnd3LzkqWHA1JMrgoeA,nbLr3c3lOkiWhNg9IYbt6Q,",
|
||||
"AvatarItemId": 1770,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room Hoodie - Pride (Ace Pride)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b6rLwzD4NkKV7xKn9ZYVkA,Xt3Ndm8mC0qsLClPIbgwUw",
|
||||
"AvatarItemId": 2193,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room Hoodie - Pride (Genderfluid Pride)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b8e485d4-b5c2-480e-908c-d9b500375759,",
|
||||
"AvatarItemId": 2881,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Hearing Aids (Cochlear Implant - Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b8e485d4-b5c2-480e-908c-d9b500375759,7ZRNVpxhWk-j8R3oj0oVXg",
|
||||
"AvatarItemId": 2883,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Hearing Aids (Cochlear Implant - Gray)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "b8e485d4-b5c2-480e-908c-d9b500375759,8WJI7RAuaEy4KvQiQhoKBw",
|
||||
"AvatarItemId": 2882,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Hearing Aids (Cochlear Implant - Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,",
|
||||
"AvatarItemId": 2352,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Scarf Twist (Beige)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,irw2uj4c2Eil0ndPCTqodQ",
|
||||
"AvatarItemId": 2355,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Scarf Twist (Yellow)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,pPZ3sDw3B06mKiYsAiAt9w",
|
||||
"AvatarItemId": 2358,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Scarf Twist (White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,sO8e2epLrUud243Xi9odXg",
|
||||
"AvatarItemId": 2357,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Scarf Twist (Orange)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,s-QTVT5eTECKHL9FnHFCAQ",
|
||||
"AvatarItemId": 2354,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Scarf Twist (Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "c414588b-29b3-4a1c-ae9e-7ad251901429,UnHAtXkd2kCdamzw6B3Mvg",
|
||||
"AvatarItemId": 2356,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Head Scarf Twist (Turquoise)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "c45ed7b8-99bd-4a4b-a9ff-e16edf5d7a18,,,",
|
||||
"AvatarItemId": 583,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "High Pony Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "c6c08eb5-381a-4193-9722-80da95d62abe,,,",
|
||||
"AvatarItemId": 1008,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Business Tie (Black)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "c70005d5-6276-4a98-acb3-6a77bc19379a,,,",
|
||||
"AvatarItemId": 1005,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Glasses (Teal)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "c70005d5-6276-4a98-acb3-6a77bc19379a,5H0I1anlkkuUYIM28_ZOlA,l_iJLV14q0eNgOsOI1u0pw,",
|
||||
"AvatarItemId": 1879,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Glasses (Purple)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "c70005d5-6276-4a98-acb3-6a77bc19379a,9tZq7Xvfj0WhhLBAuAacxA,l_iJLV14q0eNgOsOI1u0pw,",
|
||||
"AvatarItemId": 1878,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Glasses (Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "c70005d5-6276-4a98-acb3-6a77bc19379a,BqNgpjP7a0qmm3I3DNbKLA,l_iJLV14q0eNgOsOI1u0pw,",
|
||||
"AvatarItemId": 1880,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Glasses (Yellow)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "c855dcc3-96cb-470d-b159-d37a025a47d1,,,",
|
||||
"AvatarItemId": 1200,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Dutch Braid Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "cc96f8a5-bc5b-4f89-83b7-ecd53905ada7,,,",
|
||||
"AvatarItemId": 1007,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Beard (Thick)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "CTcrvbo3OEepIV4oW8bx4w,,,",
|
||||
"AvatarItemId": 727,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Receding Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,,,",
|
||||
"AvatarItemId": 1019,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room Shirt (Crew Neck, White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,941c046e-4e95-49f8-a7d7-19071fcc3c94,0440f08f-ef1d-49d8-942b-523056e8bb45,703ff56b-560d-4ff4-8c63-d195e879a328",
|
||||
"AvatarItemId": 571,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Alpaca Shirt",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": -1,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,",
|
||||
"AvatarItemId": 1749,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room T-Shirt (Crew Neck, Orange)\t",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "d0a9262f-5504-46a7-bb10-7507503db58e,95e4cc30-cb68-473d-a395-feadf5b51512,0440f08f-ef1d-49d8-942b-523056e8bb45,0c496f32-0011-4c4d-9778-59446f70c032",
|
||||
"AvatarItemId": 565,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Rec Room T-Shirt (Crew Neck, Orange)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba,3JrW3FFUvEe6HZ9VjOVjMw,ynPJe7asTk-x_GtcEBQbgA,",
|
||||
"AvatarItemId": 1971,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Sweat Suit (Pink)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba,jeEWPFvzaEGmXaUrxTFvwg,ynPJe7asTk-x_GtcEBQbgA,",
|
||||
"AvatarItemId": 1974,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Sweat Suit (Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba,NjCLn5A5h0-V8jB72etCNQ,ynPJe7asTk-x_GtcEBQbgA,",
|
||||
"AvatarItemId": 1973,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Sweat Suit (Black)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba,OT5APcpbpEiMNNah8n4Txw,ynPJe7asTk-x_GtcEBQbgA,",
|
||||
"AvatarItemId": 1972,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Sweat Suit (Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "d766c8d3-76e8-4021-b0a4-24b78fb7eaba,t2fOeVWloUOKDlIaf4Sxnw,ynPJe7asTk-x_GtcEBQbgA,",
|
||||
"AvatarItemId": 1970,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Sweat Suit (Yellow)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "d7730a9e-78a1-4356-bc09-6b066615850b,,,",
|
||||
"AvatarItemId": 1477,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Afro Updo Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "d8280c0c-d803-4513-be10-a0ba96d8821e,,,",
|
||||
"AvatarItemId": 1000,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Flowhawk Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "d84c0ff9-8fbe-4ed8-abf3-7996e81888ab,,,",
|
||||
"AvatarItemId": 556,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Large Afro Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "da4e7b34-2095-4a9e-801e-4f409039e0dd,,,",
|
||||
"AvatarItemId": 989,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Buzz Cut Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,,,",
|
||||
"AvatarItemId": 143,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Blazer (Blue, White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,05ac07e1-67f0-486c-abf5-a62866475abb,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
|
||||
"AvatarItemId": 144,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Blazer (Black, Cream)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,0ffad843-d6c9-425a-8686-7217009c867e,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
|
||||
"AvatarItemId": 148,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Blazer (Green, Black)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,272fe8eb-5061-4729-a7a8-414ff667a82f,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
|
||||
"AvatarItemId": 145,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Blazer (Grey, White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,6f2e74bf-1e95-463d-97db-d5d1a53b2c28,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
|
||||
"AvatarItemId": 147,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Blazer (Black, White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "de0ac50d-2adb-4114-bd2e-68953b13d706,9374bf66-2ee5-493b-8439-efce4b201904,be2b9293-1d3c-4b1c-b4c5-fad3ab16cf54,",
|
||||
"AvatarItemId": 146,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Blazer (Grey, Black)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "e286863c-2967-4d00-b837-b49487b9484a,,,",
|
||||
"AvatarItemId": 992,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Fauxhawk Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "e36bcd98-7e85-43fa-89f8-57e4ec33823a,,,",
|
||||
"AvatarItemId": 988,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Bob with Bangs Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "e5b83dfc-b2e1-4dcb-a4ab-9d3a4c8a34ae,,,",
|
||||
"AvatarItemId": 584,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Long Wavy Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ea254491-116c-41c4-a9ca-1fa1fca76374,",
|
||||
"AvatarItemId": 2875,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Hearing Aids (BTE - Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ea254491-116c-41c4-a9ca-1fa1fca76374,1n-ekJos-EeViiz_zG4jEg",
|
||||
"AvatarItemId": 2876,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Hearing Aids (BTE - Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "eb9611c6-bb50-41a2-93e9-7f959815a846,,,",
|
||||
"AvatarItemId": 587,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Dreads Long Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ec474314-fd79-441d-852b-d8999274fe57,,,",
|
||||
"AvatarItemId": 1986,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wavy Middle Part Hair ",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,,,",
|
||||
"AvatarItemId": 1023,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,0ecb8a2a-cffc-47db-aeda-fb0684aef1e5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 288,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Grey)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,1b1d08f2-12ca-43dd-a44f-ea2820b919b4,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 684,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Black)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,484b6c13-af22-4ad5-8c43-34c0de095d49,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 289,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Light Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,51ef8d39-2b94-4f9e-9620-07b6b0a913a5,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 685,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Orange)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,5rLtRfNWO0qT7X-0sJkUXg,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 1859,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Lime)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,67bcca75-4ab1-4964-8688-9908c464d355,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 287,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Gold)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,7d8e55fe-3c34-4b4b-9753-0021f6cc6454,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 292,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Cream)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,8377ab96-c908-457f-9fee-b784c9a759f3,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 683,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,Ai85jH88H06ze74VdWolfw,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 1863,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Pure Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,cbe29e9f-f2ac-47fb-97e1-8bad16abb89d,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 687,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Pink)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,dee70c38-7a99-4c2b-9181-665f1bf75aca,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 686,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,Dg3DxHr8GU2e190TGveXqw,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 1858,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Baby Pink)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,DiJGFIO-5Ue8rkEhvF5-Mg,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 1860,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Sienna)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,f8b0cfe8-e129-4578-8bb5-f60af5d38599,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 294,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Green)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,fWxn_AarpESryNA9UN8wvA,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 1857,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Teal)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,M0nOQl44DkG_GjTXzAEQCQ,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 1862,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Yellow)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,NRjJuinZ4ki8qaEPvP84tw,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 1864,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Mint)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,sEKIlMfbJ0eED6VtcXcQrw,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 1856,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Army Green)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,Sg5yKkqJEEulQeUvRhFgJg,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 1861,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Prussian Blue)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,x3kGXCD0UEWkjfXqu_kuxg,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 1855,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Purple)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ecc1dbe6-ca06-4564-b2a6-30956194d1e9,Z2L6AqDdcUqxiN1mVw-X9g,0b2395e1-ebcc-47e9-aaf1-faf9e9cec4cd,",
|
||||
"AvatarItemId": 1865,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Wristbands (Light Purple)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "edc7888c-dc42-48ab-8815-884d227760af,,,",
|
||||
"AvatarItemId": 1984,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Short Locs Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "f9dd08f8-16d3-4c39-af4f-89f7bb6e80d3,,,",
|
||||
"AvatarItemId": 1003,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Undercut Short Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "fcfcaf63-deb4-45f7-b711-c051c9ea45cb,,,",
|
||||
"AvatarItemId": 390,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Top Bun Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "fe15ca53-c5b8-4acf-9309-ff3f4e610fc9,,,",
|
||||
"AvatarItemId": 1459,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Winged Hat - Pride (Rainbow Pride)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "fe15ca53-c5b8-4acf-9309-ff3f4e610fc9,knXPidb-Rkayfc3kSHfZeQ,1yMyo6oTjU-VAygoeWaohQ,",
|
||||
"AvatarItemId": 1460,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Winged Hat - Pride (Trans Pride)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "feb8b50b-d3da-4e45-b4d0-69aeeb5cfeb3,",
|
||||
"AvatarItemId": 2334,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Headdress (White)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "feb8b50b-d3da-4e45-b4d0-69aeeb5cfeb3,1WBsT99QM0anps6ZoWE59Q",
|
||||
"AvatarItemId": 2336,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Headdress (Red)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ffdda4d3-a768-4335-a883-aa3f9dc897bf,",
|
||||
"AvatarItemId": 2995,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Trihawk Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "ffea7a65-613f-4835-921e-6dd15f357b7e,,,",
|
||||
"AvatarItemId": 621,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Long Bangs Hair",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "oYvOo47DJE-DEPlpn5xNUw,whs9olNz9Uani7Gou31mvw,y6aASPUwDkas0W5Yl27ydQ,",
|
||||
"AvatarItemId": 1450,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Belt Bag (Rainbow Pride)",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
},
|
||||
{
|
||||
"AvatarItemDesc": "-twtjyBdQ02EAdOfBGTiEw,,,",
|
||||
"AvatarItemId": 728,
|
||||
"AvatarItemType": 0,
|
||||
"FriendlyName": "Van Dyke Beard",
|
||||
"IsBaseAvatarItem": false,
|
||||
"PlatformMask": -1,
|
||||
"Rarity": 0,
|
||||
"ToolTip": ""
|
||||
}
|
||||
]
|
||||
@@ -66,10 +66,7 @@
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 1 // 100%
|
||||
}
|
||||
"enabled": false
|
||||
},
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
|
||||
+23
-10
@@ -5,16 +5,19 @@ authenticates players and issues the JWTs every other worker verifies.
|
||||
|
||||
## Routes
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------------------------------------ | ------------------------------------------------------ |
|
||||
| GET | `/eac/challenge` | EAC handshake; a constant, JSON-quoted, as text |
|
||||
| GET | `/cachedlogin/forplatformid/:platform/:id` | Accounts linked to a platform id, for the login screen |
|
||||
| POST | `/cachedlogin/forplatformids` | Bulk cached-login lookup (friends resolution) |
|
||||
| POST | `/connect/token` | OAuth token endpoint; issues a JWT + refresh token |
|
||||
| POST | `/account/me/changepassword` | Change the caller's password (auth-gated) |
|
||||
| GET | `/role/developer/:id` | Developer role lookup; a bare JSON boolean |
|
||||
| GET | `/role/moderator/:id` | Moderator role lookup; a bare JSON boolean |
|
||||
| GET | `/openapi.json` | Generated OpenAPI 3.1 spec (see below) |
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------------------------------------ | ------------------------------------------------------------------------------------------------ |
|
||||
| GET | `/eac/challenge` | EAC handshake; a constant, JSON-quoted, as text |
|
||||
| GET | `/cachedlogin/forplatformid/:platform/:id` | Accounts linked to a platform id, for the login screen |
|
||||
| POST | `/cachedlogin/forplatformids` | Bulk cached-login lookup (friends resolution) |
|
||||
| POST | `/connect/deviceauthorization` | Studio device login: mint a user code and point the browser at WWW `/device` |
|
||||
| POST | `/connect/device/approve` | Website approves that code for the signed-in account |
|
||||
| POST | `/connect/device/deny` | Website refuses it |
|
||||
| POST | `/connect/token` | OAuth token endpoint; issues a JWT + refresh token. Studio polls this with the device-code grant |
|
||||
| POST | `/account/me/changepassword` | Change the caller's password (auth-gated) |
|
||||
| GET | `/role/developer/:id` | Developer role lookup; a bare JSON boolean |
|
||||
| GET | `/role/moderator/:id` | Moderator role lookup; a bare JSON boolean |
|
||||
| GET | `/openapi.json` | Generated OpenAPI 3.1 spec (see below) |
|
||||
|
||||
## API documentation
|
||||
|
||||
@@ -45,6 +48,10 @@ route without documenting it fails rather than silently shipping an incomplete s
|
||||
identity `platform_auth` proves.
|
||||
- **`refresh_token`** — redeems a stored single-use refresh token, rotating it.
|
||||
30-day TTL; platform and platform id come from what was stored at issue time.
|
||||
- **`urn:ietf:params:oauth:grant-type:device_code`** — Rec Room Studio polling a
|
||||
code from `POST /connect/deviceauthorization`. While the player has not approved
|
||||
it on the website, the response is HTTP 400 `authorization_pending`. Studio
|
||||
treats `expires_in` on the token as seconds.
|
||||
- **`password`** — the fallback for any unrecognised or absent `grant_type`. Identifies
|
||||
the account by `username` or numeric `account_id` and requires the matching password
|
||||
(PBKDF2-SHA256, `salt:hash`). An account with no stored hash cannot be logged into at
|
||||
@@ -55,6 +62,12 @@ Access tokens live for 1 hour (`TOKEN_TTL_SECONDS` in `@repo/jwt`) and carry a `
|
||||
claim, so developer/moderator powers refresh on every login and every refresh grant.
|
||||
Grant those flags with `runx admin grant-developer` / `grant-moderator`.
|
||||
|
||||
`betastudio` is a separate claim. RecFlare Studio treats it as permission to upload,
|
||||
and developer does not include it. It is stamped from the account's `hasStudio` flag.
|
||||
Staff add and remove people at `/settings/recroomstudio` on the website (or
|
||||
`runx admin grant-studio`). A player already signed into Studio keeps the old token
|
||||
until the next sign-in or refresh.
|
||||
|
||||
### Verifiable platforms: Steam and Meta
|
||||
|
||||
Only an identity we can _prove_ is ever bound to an account, so any grant that
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Expose the account's Rec Room Plus flag (`hasPlus` in the JSON blob) as an indexed
|
||||
-- generated column, so "every Plus member" is an index walk rather than a scan of every
|
||||
-- account's JSON. The reader is the operator's periodic token reload
|
||||
-- (`just reload-plus`, packages/tools/src/cmd/admin.cmd.ts), which credits the
|
||||
-- `balance` row of each Plus member in one statement.
|
||||
--
|
||||
-- The index is PARTIAL: it holds only the rows where the flag is set, so it is as small
|
||||
-- as the subscriber list and never grows with the account table. A query must say
|
||||
-- exactly `has_plus = 1` to use it. `json_extract` yields 1 for JSON true, 0 for false
|
||||
-- and NULL when the key is absent, so neither a revoked nor a never-granted account is
|
||||
-- in it. Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts (and its mirror
|
||||
-- in apps/econ/src/avatar-db.ts).
|
||||
|
||||
ALTER TABLE account ADD COLUMN has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL;
|
||||
CREATE INDEX IF NOT EXISTS idx_account_has_plus ON account (has_plus) WHERE has_plus = 1;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Record the roles a linked identity held in its platform's community, for the one
|
||||
-- platform that has any: `PlatformType.Discord` (101), written by the website's benefits
|
||||
-- claim from the member record Discord itself served
|
||||
-- (`GET /users/@me/guilds/{guild}/member`). Steam and Meta links leave it NULL — there is
|
||||
-- no such thing there — so the column is nullable and has no default.
|
||||
--
|
||||
-- Until now the claim asked Discord for the member's roles, tested them against the
|
||||
-- configured qualifying list, and threw the answer away; all that survived was `hasPlus`,
|
||||
-- which records "held A qualifying role once" and not WHICH one. That is the question an
|
||||
-- operator has to answer by hand in Discord for every player who writes in — and the only
|
||||
-- way to tell a supporter from a booster from staff after the fact, or to see that a tier
|
||||
-- was granted under a role that has since been retired.
|
||||
--
|
||||
-- A JSON ARRAY of role id snowflakes (`["1077…001","1077…002"]`), not one id: a member
|
||||
-- holds as many roles as they hold, and the claim qualifies on ANY of them, so storing a
|
||||
-- single one would have to pick arbitrarily. Snowflakes stay STRINGS — one exceeds 2^53,
|
||||
-- so a JSON number would round and stop matching the real role. The list is the whole
|
||||
-- member record's roles, not just the qualifying ones: it is a snapshot of who they were
|
||||
-- in the guild, and filtering it to the config of the day would make it unreadable the
|
||||
-- moment the config changed.
|
||||
--
|
||||
-- It is a SNAPSHOT, taken at claim time and refreshed only when the same player claims
|
||||
-- again. Nothing sweeps it: reading a member's roles needs that member's own OAuth token
|
||||
-- (this design deliberately holds no bot token — see apps/www/src/discord.ts), so the
|
||||
-- server cannot re-read them unprompted. Read it as "the roles they held when they
|
||||
-- claimed", the same tense `hasPlus` carries, and never as "the roles they hold".
|
||||
--
|
||||
-- No index. Nothing queries BY role yet; when something does, it will want
|
||||
-- `json_each(role)` and an expression index built for that query, not a plain one over
|
||||
-- the array text, which could only serve an exact-whole-array match. Kept in sync with
|
||||
-- PLATFORM_SCHEMA_DDL in src/platform-db.ts.
|
||||
--
|
||||
-- Safe to run before or after the deploy that ships it: an added nullable column is
|
||||
-- invisible to the currently-deployed code, which names its columns explicitly and never
|
||||
-- SELECTs *.
|
||||
|
||||
ALTER TABLE platform_account ADD COLUMN role TEXT;
|
||||
@@ -0,0 +1,19 @@
|
||||
-- Device-login grants (owned by the auth worker). Named generically: Rec Room
|
||||
-- Studio is the first device to use it, but any device-code login lands here. Studio
|
||||
-- posts POST /connect/deviceauthorization here, then polls /connect/token with
|
||||
-- grant_type=urn:ietf:params:oauth:grant-type:device_code. The browser page that
|
||||
-- approves the code lives on www (`/device`); this table is only the handshake.
|
||||
--
|
||||
-- The device_code is a bearer secret until it is consumed, so only its SHA-256
|
||||
-- is stored. The user_code is the short code the player sees and types, so it
|
||||
-- has to be stored as itself. Kept in sync with STUDIO_DEVICE_SCHEMA_DDL in
|
||||
-- src/studio-device.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS device_grant (
|
||||
device_code_hash TEXT PRIMARY KEY,
|
||||
user_code TEXT NOT NULL UNIQUE,
|
||||
account_id INTEGER,
|
||||
status TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Expose the account's influencer flag (`isInfluencer` in the JSON blob) as an indexed
|
||||
-- generated column, so "every influencer" is an index walk rather than a scan of every
|
||||
-- account's JSON. The reader is econ's `GET /api/influencerpartnerprogram/influencers`,
|
||||
-- which the client asks for the whole list on every launch so it can badge influencers
|
||||
-- wherever they turn up.
|
||||
--
|
||||
-- Same construction as `has_plus` (0009): a PARTIAL index holding only the rows where the
|
||||
-- flag is set, so it is as small as the influencer list. A query must say exactly
|
||||
-- `is_influencer = 1` to use it. `json_extract` yields 1 for JSON true, 0 for false and
|
||||
-- NULL when the key is absent, so neither a revoked nor a never-granted account is in it.
|
||||
-- Kept in sync with SCHEMA_DDL in @repo/domain's accounts-db.ts (and its mirror in
|
||||
-- apps/econ/src/avatar-db.ts).
|
||||
|
||||
ALTER TABLE account ADD COLUMN is_influencer INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInfluencer')) VIRTUAL;
|
||||
CREATE INDEX IF NOT EXISTS idx_account_is_influencer ON account (is_influencer) WHERE is_influencer = 1;
|
||||
+479
-77
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
countAccountsByDeviceId,
|
||||
countAccountsBySignupIp,
|
||||
createAccount,
|
||||
GAME_VERSION,
|
||||
@@ -21,8 +22,10 @@ import {
|
||||
subRoomDataBlob,
|
||||
updateAccount,
|
||||
verifyPassword,
|
||||
writeAuditLog,
|
||||
} from '@repo/domain'
|
||||
import {
|
||||
flagVar,
|
||||
intVar,
|
||||
logger,
|
||||
withCleanSpec,
|
||||
@@ -35,11 +38,16 @@ import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo
|
||||
// The account-wide ban lives on a `report` row, whose table the api worker owns; its db
|
||||
// module is plain D1 queries with no runtime deps, so it imports cleanly here.
|
||||
import { banEvasionMatch, resolveBan } from '../../api/src/bans-db'
|
||||
import { buildEndpoints } from '../../ns/src/endpoints'
|
||||
import { verifyMetaNonce } from './meta-nonce'
|
||||
import {
|
||||
CachedLogin,
|
||||
ChangePasswordRequest,
|
||||
ChangePasswordResponse,
|
||||
DeviceAuthorizationRequest,
|
||||
DeviceAuthorizationResponse,
|
||||
DeviceDecisionRequest,
|
||||
DeviceDecisionResponse,
|
||||
FakeCachedLogin,
|
||||
form,
|
||||
json,
|
||||
@@ -47,6 +55,7 @@ import {
|
||||
PlatformIdsRequest,
|
||||
PlatformType,
|
||||
RestrictionDto,
|
||||
roleFilter,
|
||||
roleLookup,
|
||||
TokenRequest,
|
||||
TokenResponse,
|
||||
@@ -60,10 +69,18 @@ import {
|
||||
} from './platform-db'
|
||||
import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
|
||||
import { verifySteamTicket } from './steam-ticket'
|
||||
import {
|
||||
approveDeviceGrant,
|
||||
beginDeviceGrant,
|
||||
denyDeviceGrant,
|
||||
DEVICE_CODE_GRANT,
|
||||
pollDeviceGrant,
|
||||
studioClientMatches,
|
||||
} from './studio-device'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { Account } from '@repo/domain'
|
||||
import type { App } from './context'
|
||||
import type { App, Env } from './context'
|
||||
import type { PlatformLink } from './platform-db'
|
||||
|
||||
/** OAuth scopes granted by `/connect/token`. */
|
||||
@@ -71,8 +88,8 @@ 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 grant is refused with when the caller's account is not itself
|
||||
* banned but shares an identity with one that is (see bans-db's linked arms). A fixed
|
||||
* The `error_description` a SIGNUP is refused with when it shares an identity with a banned
|
||||
* account (see bans-db's linked arms). A fixed
|
||||
* sentence, because `www`'s shared auth-messages table keys on this exact string to put a
|
||||
* real sentence in front of a player — anything varying would fall through to the generic
|
||||
* "you could not be signed in". Keep the two in sync. Deliberately vague: the account
|
||||
@@ -80,7 +97,9 @@ const TOKEN_SCOPE =
|
||||
* account is banned" would be a lie, and naming the account we matched them to would hand
|
||||
* out somebody else's moderation record.
|
||||
*
|
||||
* A DIRECTLY banned account is not refused here at all — see the token grant.
|
||||
* Only `create_account` is refused this way. SIGNING IN is never refused for a ban, its own
|
||||
* or a linked one — see the token grant: the client needs a token to reach the block screen,
|
||||
* and `match` is what holds a blocked player to their dorm.
|
||||
*/
|
||||
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
|
||||
|
||||
@@ -112,24 +131,46 @@ const FAKE_OCULUS_CACHED_LOGIN = {
|
||||
* Signup caps, enforced on create_account only (never on login — an existing account
|
||||
* always stays reachable, however many accounts its owner has since accumulated).
|
||||
*
|
||||
* Two independent arms, because they fail in opposite ways:
|
||||
* Three independent arms, because they fail in different ways:
|
||||
* - Per verified platform id (a Steam-proven SteamID64). The sharp one: it can't be
|
||||
* spoofed and can't be reset by changing networks. Only binds on a platform
|
||||
* create_account — the password/anonymous path has no platform identity to count,
|
||||
* so the IP arm is the only thing standing between it and bulk signup.
|
||||
* so the other two arms are all that stands between it and bulk signup.
|
||||
* - Per signup IP. Coarse: households, NAT and shared campus/mobile networks put many
|
||||
* legitimate players behind one address, so this WILL be the arm that produces false
|
||||
* positives. It counts `signupIp` (immutable), so an abuser can't reset their own
|
||||
* count by hopping networks.
|
||||
* count by hopping networks — but a VPN gives them a fresh counter per exit, which
|
||||
* is exactly how one player made 150 accounts, six per address, in a weekend.
|
||||
* - Per client device id. Narrower than an IP (a household shares an address, not an
|
||||
* install) and stable across VPN hops, which is the hole in the IP arm it covers. It
|
||||
* counts the last-seen `deviceId`, which a login moves; see the field's notes in
|
||||
* accounts-db for why an immutable copy isn't worth keeping. Client-chosen and
|
||||
* unverified, so it catches a stock client, not a script that fabricates ids. Skipped
|
||||
* when the signup posts no `device_id` (the website's don't).
|
||||
*
|
||||
* These are the defaults. An operator overrides either arm with the matching worker var
|
||||
* (`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` in wrangler.jsonc `vars`), and
|
||||
* setting one to 0 disables that arm entirely — which a small private server that trusts
|
||||
* everyone it invites will want, and which the IP arm in particular is worth reaching for
|
||||
* if a shared network is being locked out.
|
||||
* These are the defaults. An operator overrides any arm with the matching worker var
|
||||
* (`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` / `MAX_ACCOUNTS_PER_DEVICE_ID`
|
||||
* in wrangler.jsonc `vars`), and setting one to 0 disables that arm entirely — which a
|
||||
* small private server that trusts everyone it invites will want, and which the IP arm in
|
||||
* particular is worth reaching for if a shared network is being locked out.
|
||||
*/
|
||||
const DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID = 3
|
||||
const DEFAULT_MAX_ACCOUNTS_PER_IP = 3
|
||||
const DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID = 3
|
||||
|
||||
/**
|
||||
* Whether a `create_account` with no platform — a password account, which is what the
|
||||
* website mints — is accepted at all (`PASSWORD_SIGNUP`; see context.ts). Off by default:
|
||||
* such an account has no identity but the address it came from, so the caps above are
|
||||
* the only thing limiting it, and a VPN or Tor exit resets the IP arm every N accounts.
|
||||
* That is how one player made 150 accounts in a weekend. A game-client signup always
|
||||
* carries a Steam or Meta identity and is untouched by this switch.
|
||||
*
|
||||
* Refused as `invalid_grant` with this description, which `www` translates for the form
|
||||
* (see its auth-messages.ts) — keep the two in step.
|
||||
*/
|
||||
const DEFAULT_PASSWORD_SIGNUP = false
|
||||
const PASSWORD_SIGNUP_DISABLED_DESCRIPTION = 'password signup is disabled'
|
||||
|
||||
/** New players start in the Orientation room (RoomId 13) — the new-user flow. */
|
||||
const ORIENTATION_ROOM_ID = 13
|
||||
@@ -200,22 +241,78 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
||||
return validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
}
|
||||
|
||||
/** Whether one account row holds a role. Absent/false both mean no role. */
|
||||
function holdsRole(account: Account, role: 'developer' | 'moderator'): boolean {
|
||||
return (role === 'developer' ? account.isDeveloper : account.isModerator) === true
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONE-player lookup (`/role/{role}/{id}`): a BARE JSON boolean — the client reads the body
|
||||
* as a bool, not as an object — or an empty 404 for a player this server has no account for,
|
||||
* mirroring the reference API. An id that is not a number is answered as unknown (404) rather
|
||||
* than as a 400: `/role/developer/nonsense` is just an id that matches nothing.
|
||||
*/
|
||||
async function roleAnswer(c: Context<App>, role: 'developer' | 'moderator', id: string) {
|
||||
logger.info(`${role} role lookup`, { id })
|
||||
const accountId = Number.parseInt(id, 10)
|
||||
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
|
||||
if (!account) return c.body(null, 404)
|
||||
return c.json(holdsRole(account, role))
|
||||
}
|
||||
|
||||
/**
|
||||
* The BULK form (`/role/{role}?id=1&id=2`): WHICH of the players asked about hold the role, as
|
||||
* an array of ints. A different question from the single lookup and so a different shape —
|
||||
* never that route's boolean.
|
||||
*
|
||||
* A FILTER, so nothing here is an error: an id that names no account, or names one without the
|
||||
* role, is left out, and a query naming no ids answers `[]`. A 404 would be wrong — the
|
||||
* question "which of these are developers" has the answer "none of them", not "no such thing".
|
||||
*
|
||||
* Answered in the order the query asked, de-duplicated, so a caller can read the result
|
||||
* against its own list. One round trip however many ids are named: `getAccountsByIds` splits
|
||||
* the read across statements when it exceeds D1's bound-parameter limit.
|
||||
*/
|
||||
async function roleFilterAnswer(c: Context<App>, role: 'developer' | 'moderator') {
|
||||
// `queries` and not `query`: the ids arrive as a REPEATED parameter, and `query` would
|
||||
// collapse them to the first and silently answer about one player out of the set.
|
||||
const asked = c.req.queries('id') ?? []
|
||||
const ids = [...new Set(asked.map((v) => Number.parseInt(v, 10)).filter(Number.isInteger))]
|
||||
logger.info(`${role} role filter`, { asked: asked.length, ids: ids.length })
|
||||
if (ids.length === 0) return c.json([])
|
||||
|
||||
const accounts = await getAccountsByIds(c.env.DB, ids)
|
||||
const holders = new Set(accounts.filter((a) => holdsRole(a, role)).map((a) => a.accountId))
|
||||
return c.json(ids.filter((id) => holders.has(id)))
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* extras, plus `junior` off the account's own `isJunior` flag.
|
||||
*
|
||||
* `betastudio` rides on the account's `hasStudio` flag, and developer does not imply it.
|
||||
* RecFlare Studio treats that exact claim as Full access, which is what lets the editor
|
||||
* upload; staff set the flag from the website. `influencer` rides on `isInfluencer`
|
||||
* (`runx admin grant-influencer`) and lives only in the token — no `/role/*` lookup
|
||||
* answers it. Order is stable so tokens are deterministic: screenshare, developer,
|
||||
* moderator, junior, betastudio, then influencer.
|
||||
*/
|
||||
function accountRoles(
|
||||
account: Pick<Account, 'isDeveloper' | 'isModerator' | 'isJunior'> | null
|
||||
account: Pick<
|
||||
Account,
|
||||
'isDeveloper' | 'isModerator' | 'isJunior' | 'hasStudio' | 'isInfluencer'
|
||||
> | 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')
|
||||
if (account.hasStudio) roles.push('betastudio')
|
||||
if (account.isInfluencer) roles.push('influencer')
|
||||
return roles
|
||||
}
|
||||
|
||||
@@ -241,6 +338,120 @@ function accountPlatform(account: Pick<Account, 'platform'>): number {
|
||||
return account.platform ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The browser page Studio opens lives on WWW, not on this worker. `ns` is what
|
||||
* told the editor where WWW is, so the URI has to be built from the same domain
|
||||
* and the same subdomain override or the tab opens on a host the player doesn't have.
|
||||
*/
|
||||
function studioVerificationUri(env: Env, userCode: string): { uri: string; complete: string } {
|
||||
const www = buildEndpoints(env.DOMAIN, env.SUBDOMAINS).WWW
|
||||
const uri = `${www}/device`
|
||||
return { uri, complete: `${uri}?user_code=${encodeURIComponent(userCode)}` }
|
||||
}
|
||||
|
||||
function oauthError(
|
||||
c: Context<App>,
|
||||
error: string,
|
||||
error_description: string,
|
||||
status: 400 | 500 = 400
|
||||
) {
|
||||
return c.json({ error, error_description }, status)
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue the same token body every other grant returns, for an account that has
|
||||
* already been authenticated — here, because the player approved a Studio code
|
||||
* on the website. Studio reads `expires_in` as seconds (`TimeSpan.FromSeconds`).
|
||||
*/
|
||||
async function issueSession(c: Context<App>, accountId: number, grantType: string) {
|
||||
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
||||
const ban = await resolveBan(c.env.DB, accountId, {
|
||||
identity: { ip: clientIp },
|
||||
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
|
||||
})
|
||||
if (ban) {
|
||||
logger.info('token issued to a blocked account', {
|
||||
accountId,
|
||||
grantType,
|
||||
via: ban.via,
|
||||
bannedAccountId: ban.bannedAccountId,
|
||||
reportId: ban.ban.id,
|
||||
})
|
||||
}
|
||||
|
||||
const jwtSecret = await c.env.JWT_SECRET.get()
|
||||
if (jwtSecret === '') {
|
||||
logger.error('refusing to issue token: JWT_SECRET is empty')
|
||||
return oauthError(c, 'server_error', 'token signing is not configured', 500)
|
||||
}
|
||||
|
||||
const roleAccount = await getAccount(c.env.DB, accountId)
|
||||
if (!roleAccount) return oauthError(c, 'invalid_grant', 'account no longer exists')
|
||||
|
||||
const accessToken = await generateToken(
|
||||
String(accountId),
|
||||
roleAccount.platformId ?? '',
|
||||
accountPlatform(roleAccount),
|
||||
jwtSecret,
|
||||
accountRoles(roleAccount),
|
||||
accountPrivileges(roleAccount),
|
||||
GAME_VERSION,
|
||||
roleAccount.hasPlus === true
|
||||
)
|
||||
const refreshToken = await issueRefreshToken(c.env.DB, accountId)
|
||||
await setLastLoginTime(c.env.DB, accountId, new Date().toISOString())
|
||||
await setLoginContext(c.env.DB, accountId, { ip: clientIp })
|
||||
|
||||
const staffRoles = (['developer', 'moderator'] as const).filter((role) =>
|
||||
holdsRole(roleAccount, role)
|
||||
)
|
||||
if (staffRoles.length > 0) {
|
||||
try {
|
||||
await writeAuditLog(c.env.DB, {
|
||||
playerId: accountId,
|
||||
action: 'staff_login',
|
||||
data: {
|
||||
ip: clientIp,
|
||||
roles: staffRoles,
|
||||
grantType,
|
||||
username: roleAccount.username ?? null,
|
||||
deviceId: '',
|
||||
deviceClass: 0,
|
||||
version: null,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('could not write a staff_login audit log row', {
|
||||
accountId,
|
||||
grantType,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
access_token: accessToken,
|
||||
expires_in: TOKEN_TTL_SECONDS,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: refreshToken,
|
||||
scope: TOKEN_SCOPE,
|
||||
key: '8oQ+e+WQaOBPbEcakhqs3dwZZdOmmyDUmJSD9u4AHMY=',
|
||||
})
|
||||
}
|
||||
|
||||
async function decideDeviceCode(c: Context<App>, decide: typeof approveDeviceGrant) {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const userCode = typeof body.user_code === 'string' ? body.user_code : ''
|
||||
const result = await decide(c.env.DB, userCode, id)
|
||||
if (result.kind === 'ok') return c.json({ ok: true as const })
|
||||
if (result.kind === 'expired')
|
||||
return oauthError(c, 'expired_token', 'that code has expired. Start the login in Studio again.')
|
||||
if (result.kind === 'used') return oauthError(c, 'invalid_grant', 'that code was already used.')
|
||||
return oauthError(c, 'invalid_grant', 'that code was not found.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Project a linked account into the client's CachedLogin DTO — the account-picker
|
||||
* entry on the login screen. The client posts the chosen `accountId` back as a
|
||||
@@ -547,6 +758,83 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// Studio editor device login. The POST the editor makes is
|
||||
// `https://auth.<domain>/connect/deviceauthorization` — Auth is service enum 0,
|
||||
// and the path is the literal `connect/deviceauthorization`. The browser half
|
||||
// of the handshake is the verification_uri, which points at WWW `/device`.
|
||||
.post(
|
||||
'/connect/deviceauthorization',
|
||||
describeRoute({
|
||||
tags: ['Token'],
|
||||
summary: 'Start a Rec Room Studio device login',
|
||||
description: [
|
||||
'RFC 8628 device authorization, called by Rec Room Studio (not the game).',
|
||||
'The editor POSTs `client_id=recroom.studio` and its embedded client secret',
|
||||
'to the Auth host. The response `verification_uri` is the website (`WWW /device`);',
|
||||
'`verification_uri_complete` is that page with `?user_code=` and is what Studio',
|
||||
'opens in the browser. Studio then polls `POST /connect/token` with',
|
||||
'`grant_type=urn:ietf:params:oauth:grant-type:device_code`.',
|
||||
].join(' '),
|
||||
requestBody: form(DeviceAuthorizationRequest, '`client_id` and `client_secret`'),
|
||||
responses: {
|
||||
200: json(DeviceAuthorizationResponse, 'Codes and the WWW page to open'),
|
||||
400: json(OAuthError, 'client_id or client_secret was not the Studio editor'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
if (!studioClientMatches(body)) {
|
||||
return oauthError(c, 'invalid_client', 'unknown studio client')
|
||||
}
|
||||
const grant = await beginDeviceGrant(c.env.DB)
|
||||
const { uri, complete } = studioVerificationUri(c.env, grant.userCode)
|
||||
return c.json({
|
||||
device_code: grant.deviceCode,
|
||||
user_code: grant.userCode,
|
||||
verification_uri: uri,
|
||||
verification_uri_complete: complete,
|
||||
expires_in: grant.expiresIn,
|
||||
interval: grant.interval,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
.post(
|
||||
'/connect/device/approve',
|
||||
describeRoute({
|
||||
tags: ['Token'],
|
||||
summary: 'Approve a Studio device code',
|
||||
description:
|
||||
'Called by the website (`WWW /device`) with the signed-in player’s bearer token and the `user_code` Studio is showing. The editor’s next poll of `/connect/token` then receives that account’s token.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: form(DeviceDecisionRequest, 'The user_code from the editor'),
|
||||
responses: {
|
||||
200: json(DeviceDecisionResponse, 'The code is approved'),
|
||||
400: json(OAuthError, 'Unknown, expired, or already used code'),
|
||||
401: { description: 'Missing or invalid bearer token (empty body)' },
|
||||
},
|
||||
}),
|
||||
(c) => decideDeviceCode(c, approveDeviceGrant)
|
||||
)
|
||||
|
||||
.post(
|
||||
'/connect/device/deny',
|
||||
describeRoute({
|
||||
tags: ['Token'],
|
||||
summary: 'Deny a Studio device code',
|
||||
description:
|
||||
'Same caller as approve. Studio’s next poll fails with `access_denied` instead of signing in.',
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: form(DeviceDecisionRequest, 'The user_code from the editor'),
|
||||
responses: {
|
||||
200: json(DeviceDecisionResponse, 'The code is denied'),
|
||||
400: json(OAuthError, 'Unknown, expired, or already used code'),
|
||||
401: { description: 'Missing or invalid bearer token (empty body)' },
|
||||
},
|
||||
}),
|
||||
(c) => decideDeviceCode(c, denyDeviceGrant)
|
||||
)
|
||||
|
||||
// OAuth token endpoint — accepts a form-urlencoded body and issues a JWT.
|
||||
.post(
|
||||
'/connect/token',
|
||||
@@ -559,9 +847,13 @@ const app = new Hono<App>()
|
||||
'',
|
||||
'**`create_account`** — mints a new account with an auto-assigned random username',
|
||||
'(players do not pick one initially) and places it in the Orientation room. A posted',
|
||||
'`password` becomes the login credential. Subject to two independent signup caps,',
|
||||
'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /',
|
||||
'`MAX_ACCOUNTS_PER_IP`; either disabled by setting it to 0). If it asserts a',
|
||||
'`password` becomes the login credential. Without a `platform` this is a PASSWORD',
|
||||
'account (what the website makes), accepted only while `PASSWORD_SIGNUP` is on —',
|
||||
'off by default, refused as `invalid_grant` "password signup is disabled". Subject',
|
||||
'to three independent signup caps,',
|
||||
'per verified platform id, per signup IP and per client `device_id`',
|
||||
'(`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` / `MAX_ACCOUNTS_PER_DEVICE_ID`;',
|
||||
'any disabled by setting it to 0). If it asserts a',
|
||||
'`platform`, that platform must be verifiable (Steam or Meta) and its `platform_auth`',
|
||||
'must verify.',
|
||||
'',
|
||||
@@ -601,26 +893,32 @@ const app = new Hono<App>()
|
||||
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
|
||||
'powers refresh on every login and every refresh grant. `junior` rides along for an',
|
||||
'account flagged `isJunior`, and `screenshare` is on every token — it is a feature',
|
||||
'gate the client reads, not a privilege anyone is granted. A junior also carries',
|
||||
'gate the client reads, not a privilege anyone is granted. `betastudio` is stamped',
|
||||
'from the account’s `hasStudio` flag (staff set it on the website). RecFlare Studio',
|
||||
'treats that claim as permission to upload; `developer` does not confer it. A junior',
|
||||
'also carries',
|
||||
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
|
||||
'client reads it as a claim beside `role`, and it is absent for everyone else.',
|
||||
'',
|
||||
'**Bans.** A BANNED account still gets a token — every grant, including a refresh.',
|
||||
'**Bans.** A BLOCKED account still gets a token — every grant, including a refresh,',
|
||||
'and whether the ban is its own or reaches it through the evasion arms.',
|
||||
'A ban is a `report` row with `banned` set (the `api` worker owns that table); it',
|
||||
'lifts on its own when `ban_expires` passes, and never if that is null. The token',
|
||||
'is what lets the client reach `api`’s `/api/PlayerReporting/v1/moderationBlockDetails`',
|
||||
'and show the player the block screen that explains the ban; the ban itself is',
|
||||
'enforced by `match`, which refuses every matchmake for a banned player, so a token',
|
||||
'gets them as far as that screen and no further.',
|
||||
'enforced by `match`, which refuses every matchmake but the caller’s own dorm, so a',
|
||||
'token gets them as far as that screen and no further.',
|
||||
'',
|
||||
'What IS refused here (`invalid_grant`) is ban EVASION: an account that shares a',
|
||||
'PROVEN platform identity (a `platform_account` link) or an IP (`signupIp`/',
|
||||
'`lastLoginIp`, or the address this request came from) with a banned one, and a',
|
||||
'`create_account` carrying either, which is refused BEFORE it mints anything. Such',
|
||||
'an account has no ban of its own for the block screen to describe, so there is',
|
||||
'nothing to let it in for. Those two arms are the operator’s `BAN_EVASION_MATCH`',
|
||||
'knob (`ip`, `platform`, or `off`). The description is deliberately vague — the',
|
||||
'account refused may belong to a housemate of the banned player rather than to them.',
|
||||
'Ban EVASION — an account sharing a PROVEN platform identity (a `platform_account`',
|
||||
'link) or an IP (`signupIp`/`lastLoginIp`, or the address this request came from)',
|
||||
'with a banned one — signs in the same way, and is stopped at matchmaking like any',
|
||||
'other blocked account. Those two arms are the operator’s `BAN_EVASION_MATCH` knob',
|
||||
'(`ip`, `platform`, or `off`).',
|
||||
'',
|
||||
'What IS refused here (`invalid_grant`) is a `create_account` carrying either link,',
|
||||
'refused BEFORE it mints anything: making a NEW account to evade with is a different',
|
||||
'act from signing into one. The description is deliberately vague — the signup',
|
||||
'refused may belong to a housemate of the banned player rather than to them.',
|
||||
].join('\n'),
|
||||
requestBody: form(
|
||||
TokenRequest,
|
||||
@@ -651,6 +949,32 @@ const app = new Hono<App>()
|
||||
// form body.
|
||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||
const grantType = typeof body.grant_type === 'string' ? body.grant_type : ''
|
||||
// Studio's poll. Handled before the password fallback, which would otherwise
|
||||
// treat this grant_type as a username/password login and 400 it.
|
||||
if (grantType === DEVICE_CODE_GRANT) {
|
||||
if (!studioClientMatches(body)) {
|
||||
return oauthError(c, 'invalid_client', 'unknown studio client')
|
||||
}
|
||||
const deviceCode = typeof body.device_code === 'string' ? body.device_code : ''
|
||||
const polled = await pollDeviceGrant(c.env.DB, deviceCode)
|
||||
if (polled.kind === 'pending') {
|
||||
return oauthError(
|
||||
c,
|
||||
'authorization_pending',
|
||||
'the player has not approved this login yet'
|
||||
)
|
||||
}
|
||||
if (polled.kind === 'denied') {
|
||||
return oauthError(c, 'access_denied', 'the player denied this login')
|
||||
}
|
||||
if (polled.kind === 'expired') {
|
||||
return oauthError(c, 'expired_token', 'the device code expired')
|
||||
}
|
||||
if (polled.kind !== 'approved') {
|
||||
return oauthError(c, 'invalid_grant', 'unknown device code')
|
||||
}
|
||||
return issueSession(c, polled.accountId, DEVICE_CODE_GRANT)
|
||||
}
|
||||
// `platform`/`platform_id` come from the body for a fresh login; a refresh
|
||||
// grant overrides them below with what was stored when the token was issued.
|
||||
let platformId = typeof body.platform_id === 'string' ? body.platform_id : ''
|
||||
@@ -700,7 +1024,7 @@ const app = new Hono<App>()
|
||||
// - cached_login authenticates purely by platform identity.
|
||||
// - create_account that asserts a platform: we won't bind an identity we can't
|
||||
// prove. (create_account with NO platform is the password-account path —
|
||||
// allowed, but binds no platformId.)
|
||||
// binds no platformId, and is open only while PASSWORD_SIGNUP says so.)
|
||||
//
|
||||
// A password grant is NOT gated: the password already proved who it is. It posts
|
||||
// its platform proof too, and if that verifies we LINK the identity to the account
|
||||
@@ -789,6 +1113,17 @@ const app = new Hono<App>()
|
||||
// via create_account or /account/me/changepassword.
|
||||
let accountId: string
|
||||
if (grantType === 'create_account') {
|
||||
// The password-account path (no platform) is a switch, off by default: see
|
||||
// DEFAULT_PASSWORD_SIGNUP. Checked first, before anything is read or minted —
|
||||
// a closed door costs no D1 round trip and burns no cap slot.
|
||||
if (!platformAsserted && !flagVar(c.env.PASSWORD_SIGNUP, DEFAULT_PASSWORD_SIGNUP)) {
|
||||
logger.info('signup refused: password signup is disabled', { ip: clientIp })
|
||||
return c.json(
|
||||
{ error: 'invalid_grant', error_description: PASSWORD_SIGNUP_DISABLED_DESCRIPTION },
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// A banned player's next move is a new account, so the evasion arms are checked
|
||||
// BEFORE one is minted — against the only identity a signup has, the IP it came
|
||||
// from and the platform identity it just proved. Refusing after the fact (as the
|
||||
@@ -820,7 +1155,7 @@ const app = new Hono<App>()
|
||||
|
||||
// Signup caps. Checked before minting anything, so a rejected signup leaves no
|
||||
// account behind. Each arm is skipped when it's disabled (var <= 0) or when its
|
||||
// identity is unknown (no verified platform id / no client IP) — an unattributable
|
||||
// identity is unknown (no verified platform id / no client IP / no device id) — an unattributable
|
||||
// signup can't be counted against anyone, and lumping them together would lock out
|
||||
// real players. The disabled check comes first so a disabled arm costs no D1 read.
|
||||
const maxPerPlatformId = intVar(
|
||||
@@ -828,6 +1163,10 @@ const app = new Hono<App>()
|
||||
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
|
||||
)
|
||||
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
|
||||
const maxPerDeviceId = intVar(
|
||||
c.env.MAX_ACCOUNTS_PER_DEVICE_ID,
|
||||
DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID
|
||||
)
|
||||
if (
|
||||
maxPerPlatformId > 0 &&
|
||||
verifiedPlatformId !== null &&
|
||||
@@ -862,6 +1201,20 @@ const app = new Hono<App>()
|
||||
400
|
||||
)
|
||||
}
|
||||
if (
|
||||
maxPerDeviceId > 0 &&
|
||||
deviceId !== '' &&
|
||||
(await countAccountsByDeviceId(c.env.DB, deviceId)) >= maxPerDeviceId
|
||||
) {
|
||||
logger.info('signup rejected: per-device account limit', { deviceId, ip: clientIp })
|
||||
return c.json(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: 'too many accounts created from this device',
|
||||
},
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// Bind the platform identity ONLY when the platform proved it (a Steam ticket or
|
||||
// a Meta-validated nonce). A password/anonymous create_account (no platform)
|
||||
@@ -987,33 +1340,36 @@ const app = new Hono<App>()
|
||||
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
||||
}
|
||||
|
||||
// A DIRECTLY banned account still gets its token. The client needs one to reach
|
||||
// `api`'s moderationBlockDetails, which is where the player is TOLD they are banned
|
||||
// (category, time left, "Rule violation") — refused here, they would only ever see
|
||||
// a failed sign-in. The ban is enforced by matchmaking instead, which refuses every
|
||||
// matchmake for a banned player, so the token gets them as far as the block screen
|
||||
// and no further. Logged, so the operator can see a banned player signing in.
|
||||
// EVERY account gets its token, banned or not — a ban of its own, or a link to
|
||||
// somebody else's through the evasion arms. The client needs one to reach `api`'s
|
||||
// moderationBlockDetails, which is where a banned player is TOLD they are banned
|
||||
// (category, time left, "Rule violation"); refused here they would only ever see a
|
||||
// sign-in that fails, with nothing saying why. The ban is enforced by `match`, which
|
||||
// refuses every matchmake but the caller's own dorm, so a token gets them as far as
|
||||
// the block screen and no further.
|
||||
//
|
||||
// Ban EVASION is still refused here: an account that merely shares a device or
|
||||
// network with a banned one has no ban of its own for that screen to describe, so
|
||||
// there is nothing to let it in for — and letting it in is exactly what the evader
|
||||
// wants. Checked once here, after the grant has resolved an account, so it covers
|
||||
// every grant: password, cached_login and a refresh_token redeemed by a client that
|
||||
// has been running since before the ban. Deliberately AFTER the credential checks —
|
||||
// a wrong password is still "invalid account_id or password", so this can't be
|
||||
// used to probe whether an account exists or is banned without knowing it.
|
||||
// That includes ban EVASION, which this used to refuse. An evader signing in lands
|
||||
// in their dorm and can enter no room — the same place the ban leaves them — and
|
||||
// refusing the token instead only ever produced a client stuck on a failed login.
|
||||
// `create_account` is still refused before it mints anything (above): that is making
|
||||
// a NEW account to evade with, which is a different act from signing into one.
|
||||
//
|
||||
// Logged either way, and the arm is logged with it: "banned" and "shares a network
|
||||
// with somebody banned" are very different things to be reading in a log.
|
||||
//
|
||||
// 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.
|
||||
// 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.
|
||||
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 && ban.via !== 'account') {
|
||||
logger.info('token refused: ban evasion', {
|
||||
if (ban) {
|
||||
logger.info('token issued to a blocked account', {
|
||||
accountId,
|
||||
grantType,
|
||||
via: ban.via,
|
||||
@@ -1021,15 +1377,6 @@ const app = new Hono<App>()
|
||||
reportId: ban.ban.id,
|
||||
banExpires: ban.ban.ban_expires,
|
||||
})
|
||||
return c.json({ error: 'invalid_grant', error_description: BLOCKED_DESCRIPTION }, 400)
|
||||
}
|
||||
if (ban) {
|
||||
logger.info('token issued to banned account', {
|
||||
accountId,
|
||||
grantType,
|
||||
reportId: ban.ban.id,
|
||||
banExpires: ban.ban.ban_expires,
|
||||
})
|
||||
}
|
||||
|
||||
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
|
||||
@@ -1076,6 +1423,58 @@ const app = new Hono<App>()
|
||||
// grant_type=refresh_token). A refresh grant thus rotates its token.
|
||||
const refreshToken = await issueRefreshToken(c.env.DB, Number(accountId))
|
||||
|
||||
// A STAFF sign-in is an audit event in its own right. What these accounts then do is
|
||||
// recorded against them on the `audit_log` table (`notify` files every `/internal/*`
|
||||
// call there); this is the row saying the session those actions came from began — and
|
||||
// from what device, build and IP, which is the first thing anyone asks when an action
|
||||
// further down the log looks wrong. The flags themselves are the trigger, so an account
|
||||
// granted the role between logins starts being recorded the next time it signs in.
|
||||
//
|
||||
// A real sign-in only: `refresh_token` renews the SAME session every TOKEN_TTL_SECONDS,
|
||||
// and recording those would bury the logins under hourly repeats that say nothing new
|
||||
// about who came in or from where.
|
||||
//
|
||||
// Written after the token is minted, so the row means a login that actually happened,
|
||||
// and caught rather than awaited into the response: a failed audit insert must not turn
|
||||
// a staffer's successful sign-in into a 500. It leaves a loud line in the worker log
|
||||
// instead — the one thing a gap in an audit trail should never do quietly.
|
||||
const staffRoles =
|
||||
roleAccount && grantType !== 'refresh_token'
|
||||
? (['developer', 'moderator'] as const).filter((role) => holdsRole(roleAccount, role))
|
||||
: []
|
||||
if (staffRoles.length > 0) {
|
||||
try {
|
||||
await writeAuditLog(c.env.DB, {
|
||||
playerId: Number(accountId),
|
||||
action: 'staff_login',
|
||||
// The IP leads: it is the first thing anyone asks of a staff sign-in, and the one
|
||||
// field here that says WHERE the session came from rather than what it could do.
|
||||
// Empty outside the Cloudflare edge — recorded as the empty string it is rather
|
||||
// than omitted, so a row can't be read as "we didn't record one".
|
||||
//
|
||||
// Then: which powers the session carries, HOW they signed in, and the same
|
||||
// unverified-but-recorded device/build context the account row keeps — kept here
|
||||
// too so the row stands on its own months later, after the account's
|
||||
// `lastLoginIp` and device have been overwritten by every login since.
|
||||
data: {
|
||||
ip: clientIp,
|
||||
roles: staffRoles,
|
||||
grantType,
|
||||
username: roleAccount?.username ?? null,
|
||||
deviceId,
|
||||
deviceClass,
|
||||
version: version ?? null,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('could not write a staff_login audit log row', {
|
||||
accountId,
|
||||
grantType,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
access_token: accessToken,
|
||||
expires_in: TOKEN_TTL_SECONDS,
|
||||
@@ -1172,26 +1571,29 @@ const app = new Hono<App>()
|
||||
// is off by default and only an operator grants it (via `runx admin grant-developer`,
|
||||
// which sets the account's isDeveloper flag); it also rides in the token's `role`
|
||||
// claim (see accountRoles).
|
||||
.get('/role/developer/:id', describeRoute(roleLookup('developer')), async (c) => {
|
||||
const { id } = c.req.param()
|
||||
logger.info('developer role lookup', { id })
|
||||
const accountId = Number.parseInt(id, 10)
|
||||
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
|
||||
if (!account) return c.body(null, 404)
|
||||
return c.json(account.isDeveloper === true)
|
||||
})
|
||||
.get('/role/developer/:id', describeRoute(roleLookup('developer')), (c) =>
|
||||
roleAnswer(c, 'developer', c.req.param('id'))
|
||||
)
|
||||
|
||||
// The BULK form — `/role/developer?id=2&id=3` answers WHICH of those players are developers,
|
||||
// as an array of ints. A route of its own rather than an optional `:id?` segment, and a
|
||||
// different answer shape from the single lookup above: both are kept because the client asks
|
||||
// both ways (the path form is the 2023 one).
|
||||
.get('/role/developer', describeRoute(roleFilter('developer')), (c) =>
|
||||
roleFilterAnswer(c, 'developer')
|
||||
)
|
||||
|
||||
// Moderator role lookup, mirroring developer (bare boolean, 404 for unknown player).
|
||||
// Operator-granted only (via `runx admin grant-moderator`); the flag also rides in
|
||||
// the token's `role` claim.
|
||||
.get('/role/moderator/:id', describeRoute(roleLookup('moderator')), async (c) => {
|
||||
const { id } = c.req.param()
|
||||
logger.info('moderator role lookup', { id })
|
||||
const accountId = Number.parseInt(id, 10)
|
||||
const account = Number.isNaN(accountId) ? null : await getAccount(c.env.DB, accountId)
|
||||
if (!account) return c.body(null, 404)
|
||||
return c.json(account.isModerator === true)
|
||||
})
|
||||
.get('/role/moderator/:id', describeRoute(roleLookup('moderator')), (c) =>
|
||||
roleAnswer(c, 'moderator', c.req.param('id'))
|
||||
)
|
||||
|
||||
// Moderator's bulk form, kept in step with developer's above.
|
||||
.get('/role/moderator', describeRoute(roleFilter('moderator')), (c) =>
|
||||
roleFilterAnswer(c, 'moderator')
|
||||
)
|
||||
|
||||
// @guess Oculus nonce. The client asks for this before a Meta login; the exact shape
|
||||
// it expects hasn't been observed, so this mints a fresh 64-char hex nonce (the length
|
||||
|
||||
@@ -26,6 +26,17 @@ export type Env = SharedHonoEnv & {
|
||||
// read them through `intVar`, never as a bare number.
|
||||
MAX_ACCOUNTS_PER_PLATFORM_ID?: string | number
|
||||
MAX_ACCOUNTS_PER_IP?: string | number
|
||||
MAX_ACCOUNTS_PER_DEVICE_ID?: string | number
|
||||
/**
|
||||
* Whether `create_account` accepts a signup with NO platform identity — the password
|
||||
* account the website makes (`www` `/api/signup`), or anything else that posts the
|
||||
* grant without a `platform`. `on`/`off` (or true/false, 1/0); unset means OFF, so a
|
||||
* server opens web signup deliberately. Read through `flagVar`. The `www` worker reads
|
||||
* the same knob to hide its form and refuse its endpoint, so the two agree for free.
|
||||
* Platform signups (Steam, Meta) are never governed by this — they always have an
|
||||
* identity to authenticate with later, and the caps above are what limit them.
|
||||
*/
|
||||
PASSWORD_SIGNUP?: string | number | boolean
|
||||
/**
|
||||
* Which linked arms a ban is enforced through, as a comma-separated list out of `ip`
|
||||
* and `platform` — or `off` for neither. Unset means BOTH: a ban reaches the accounts
|
||||
@@ -38,6 +49,17 @@ export type Env = SharedHonoEnv & {
|
||||
* Read through `banEvasionMatch`; the `match` worker reads the same knob.
|
||||
*/
|
||||
BAN_EVASION_MATCH?: string
|
||||
/**
|
||||
* Base domain, e.g. `recflare.net`. Injected for every worker at deploy time
|
||||
* (`--var DOMAIN`). Studio's device-login response points the browser at
|
||||
* `www.<domain>/device`, so auth has to know the same domain `ns` advertises.
|
||||
*/
|
||||
DOMAIN: string
|
||||
/**
|
||||
* The same `SUBDOMAINS` JSON `ns` applies. A `www` override has to move the
|
||||
* verification URI with the host the editor was told WWW lives on.
|
||||
*/
|
||||
SUBDOMAINS: string
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
@@ -105,7 +105,16 @@ export const FakeCachedLogin = CachedLogin.extend({
|
||||
|
||||
/** OAuth-shaped error body. Always HTTP 400 except `server_error` (500). */
|
||||
export const OAuthError = z.object({
|
||||
error: z.enum(['invalid_grant', 'invalid_request', 'server_error']),
|
||||
error: z.enum([
|
||||
'invalid_grant',
|
||||
'invalid_request',
|
||||
'invalid_client',
|
||||
'server_error',
|
||||
// RFC 8628. Studio polls until it sees something other than authorization_pending.
|
||||
'authorization_pending',
|
||||
'expired_token',
|
||||
'access_denied',
|
||||
]),
|
||||
error_description: z.string(),
|
||||
})
|
||||
|
||||
@@ -128,8 +137,17 @@ export const TokenResponse = z.object({
|
||||
*/
|
||||
export const TokenRequest = z.object({
|
||||
grant_type: z
|
||||
.enum(['create_account', 'cached_login', 'refresh_token', 'password'])
|
||||
.describe('Anything unrecognised (including absent) is treated as a password grant'),
|
||||
.enum([
|
||||
'create_account',
|
||||
'cached_login',
|
||||
'refresh_token',
|
||||
'password',
|
||||
'urn:ietf:params:oauth:grant-type:device_code',
|
||||
])
|
||||
.describe(
|
||||
'Anything unrecognised (including absent) is treated as a password grant, except the ' +
|
||||
'Studio device-code URN, which polls a code from POST /connect/deviceauthorization'
|
||||
),
|
||||
account_id: z.string().optional().describe('Numeric account id, as a string'),
|
||||
username: z
|
||||
.string()
|
||||
@@ -156,6 +174,15 @@ export const TokenRequest = z.object({
|
||||
'Meta: `{"Nonce":…,"AppId":…,"Source":…}`'
|
||||
),
|
||||
refresh_token: z.string().optional().describe('Required on a refresh_token grant'),
|
||||
client_id: z.string().optional().describe('Studio device grant: `recroom.studio`'),
|
||||
client_secret: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Studio device grant: the secret embedded in the editor'),
|
||||
device_code: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Studio device-code grant: the device_code from /connect/deviceauthorization'),
|
||||
device_id: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -171,6 +198,33 @@ export const TokenRequest = z.object({
|
||||
),
|
||||
})
|
||||
|
||||
/** `POST /connect/deviceauthorization` form body. Studio's editor sends exactly these two. */
|
||||
export const DeviceAuthorizationRequest = z.object({
|
||||
client_id: z.string().describe('`recroom.studio`'),
|
||||
client_secret: z.string().describe('The secret compiled into Rec Room Studio'),
|
||||
})
|
||||
|
||||
/** RFC 8628 response. Property names match Studio's `DeviceAuthorizationResponse`. */
|
||||
export const DeviceAuthorizationResponse = z.object({
|
||||
device_code: z.string(),
|
||||
user_code: z.string().describe('Shown in the editor; also on verification_uri_complete'),
|
||||
verification_uri: z.string().describe('WWW `/device`, without the code'),
|
||||
verification_uri_complete: z
|
||||
.string()
|
||||
.describe('The URL Studio opens in the browser, `verification_uri?user_code=`'),
|
||||
expires_in: z.int().describe('Seconds until the code dies. Studio treats this as seconds'),
|
||||
interval: z.int().describe('Seconds Studio waits between polls'),
|
||||
})
|
||||
|
||||
/** `POST /connect/device/approve` and `/connect/device/deny` form body. */
|
||||
export const DeviceDecisionRequest = z.object({
|
||||
user_code: z.string().describe('The code Studio is showing, spaces and hyphens ignored'),
|
||||
})
|
||||
|
||||
export const DeviceDecisionResponse = z.object({
|
||||
ok: z.literal(true),
|
||||
})
|
||||
|
||||
/** `POST /account/me/changepassword` form body. */
|
||||
export const ChangePasswordRequest = z.object({
|
||||
newPassword: z.string().describe('Required; empty is rejected'),
|
||||
@@ -239,7 +293,9 @@ export function roleLookup(role: 'developer' | 'moderator') {
|
||||
description:
|
||||
`Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` +
|
||||
`granted only by an operator via \`runx admin grant-${role}\`. The same flag also rides ` +
|
||||
`in the access token's \`role\` claim, so the client rarely needs this route.`,
|
||||
`in the access token's \`role\` claim, so the client rarely needs this route.\n\n` +
|
||||
`This is the ONE-player form. \`/role/${role}?id=\` is a different question with a ` +
|
||||
`different answer shape — which of several players hold the role, as an array.`,
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
@@ -256,6 +312,45 @@ export function roleLookup(role: 'developer' | 'moderator') {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The BULK form: `/role/{role}?id=1&id=2` answers which of those players hold the role, as an
|
||||
* array of ints. Not the single lookup's boolean — a different question, so a different shape;
|
||||
* the two live side by side because the client asks both ways.
|
||||
*/
|
||||
export function roleFilter(role: 'developer' | 'moderator') {
|
||||
return {
|
||||
tags: ['Roles'],
|
||||
summary: `Which of these players have the ${role} role`,
|
||||
description:
|
||||
`The subset of the accounts named by \`?id=\` that hold the ${role} role, as an ARRAY ` +
|
||||
`OF INTS — not a boolean, and not an object. Ids are repeated (\`?id=1&id=2\`), not ` +
|
||||
`comma-joined.\n\n` +
|
||||
`It is a FILTER, so a player who does not hold the role is simply absent from the ` +
|
||||
`answer, as is an id matching no account — neither is an error, and a query naming no ` +
|
||||
`ids answers \`[]\`. Results keep the order the query asked in, and a repeated id ` +
|
||||
`yields at most one entry.\n\n` +
|
||||
`The role is off by default and granted only by an operator via ` +
|
||||
`\`runx admin grant-${role}\`.`,
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'query' as const,
|
||||
required: false,
|
||||
description:
|
||||
'An account id to test, repeated once per player. Ids that are non-numeric, unknown ' +
|
||||
'or lack the role are left out of the answer rather than erroring.',
|
||||
style: 'form' as const,
|
||||
explode: true,
|
||||
schema: { type: 'array' as const, items: { type: 'string' as const } },
|
||||
example: ['2', '42'],
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(z.int().array(), `The ids, of those asked for, that hold the ${role} role`),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Bulk cached-login lookup form body: repeated `id=` fields. */
|
||||
export const PlatformIdsRequest = z.object({
|
||||
id: z.union([z.string(), z.array(z.string())]).describe('Repeated `id=` form fields'),
|
||||
|
||||
@@ -27,17 +27,28 @@
|
||||
* `verifyPlatformProof` answers `unsupported` for anything but Steam and Meta, so a
|
||||
* `cached_login` naming platform 101 is refused — and the picker must not offer it
|
||||
* either. See {@link CACHED_LOGIN_PLATFORMS}, which is what keeps those two in step.
|
||||
*
|
||||
* That entitlement is also the one thing a link carries beyond the identity itself: the
|
||||
* `role` column (migration 0010) records the Discord roles the claim read, so what a
|
||||
* player was granted Plus FOR survives the claim instead of collapsing into a bare
|
||||
* `hasPlus`. The `www` worker's daily cron re-reads them through a bot token when one is
|
||||
* configured (apps/www/src/discord-roles.ts). See {@link PlatformLink.roles}.
|
||||
*/
|
||||
|
||||
import { PlatformType } from '@repo/domain/src/enums'
|
||||
|
||||
/** Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill). */
|
||||
/**
|
||||
* Schema DDL (mirror of migrations/0007_platform_accounts.sql, sans the backfill, plus
|
||||
* 0010's `role` column — which is an ALTER on prod and a plain column here, since this
|
||||
* builds the table from nothing).
|
||||
*/
|
||||
export const PLATFORM_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS platform_account (
|
||||
account_id INTEGER NOT NULL,
|
||||
platform INTEGER NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
linked_at TEXT NOT NULL,
|
||||
role TEXT,
|
||||
PRIMARY KEY (platform, platform_id, account_id)
|
||||
)`,
|
||||
// The picker's lookup: "which accounts does this identity open?". Covered by the
|
||||
@@ -113,6 +124,19 @@ export interface PlatformLink {
|
||||
platformId: string
|
||||
/** ISO-8601 time the link was made. */
|
||||
linkedAt: string
|
||||
/**
|
||||
* The roles this identity held in its platform's community when the link was last
|
||||
* written — Discord role id snowflakes, kept as strings (one exceeds 2^53). Empty for
|
||||
* every platform that has no such thing, which is all of them but Discord.
|
||||
*
|
||||
* A SNAPSHOT, not a live read. The benefits claim writes it from the member record the
|
||||
* player's own OAuth token served, and the `www` worker's daily cron re-reads every
|
||||
* Discord link through a bot token when the operator has configured one
|
||||
* (apps/www/src/discord-roles.ts) — so with a bot it trails the guild by at most a day,
|
||||
* and without one it says what they held when they claimed. Stored as a JSON array in
|
||||
* the `role` column (migration 0010).
|
||||
*/
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
interface LinkRow {
|
||||
@@ -120,10 +144,39 @@ interface LinkRow {
|
||||
platform: number
|
||||
platformId: string
|
||||
linkedAt: string
|
||||
/** The `role` column: a JSON array of role ids, or NULL for a link that has none. */
|
||||
role: string | null
|
||||
}
|
||||
|
||||
const SELECT_LINK = `SELECT account_id AS accountId, platform, platform_id AS platformId,
|
||||
linked_at AS linkedAt FROM platform_account`
|
||||
linked_at AS linkedAt, role FROM platform_account`
|
||||
|
||||
/**
|
||||
* A stored `role` array back into role ids. Tolerant by design — every failure mode is
|
||||
* "this link has no roles recorded", which is the truth for every Steam and Meta link and
|
||||
* the honest answer for a row written before 0010. A NULL, a value that isn't JSON, or a
|
||||
* JSON value that isn't an array of strings all land there rather than throwing: the roles
|
||||
* are a record, and no caller's correctness depends on them, so a bad one must not take
|
||||
* down the login picker that reads the same rows.
|
||||
*/
|
||||
const parseRoles = (raw: string | null): string[] => {
|
||||
if (raw === null || raw === '') return []
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed.filter((r): r is string => typeof r === 'string') : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** One row as the rest of the codebase sees it. */
|
||||
const toLink = (row: LinkRow): PlatformLink => ({
|
||||
accountId: row.accountId,
|
||||
platform: row.platform,
|
||||
platformId: row.platformId,
|
||||
linkedAt: row.linkedAt,
|
||||
roles: parseRoles(row.role),
|
||||
})
|
||||
|
||||
/**
|
||||
* Link a verified platform identity to an account. Idempotent — re-logging in on the
|
||||
@@ -132,24 +185,89 @@ const SELECT_LINK = `SELECT account_id AS accountId, platform, platform_id AS pl
|
||||
*
|
||||
* Callers must pass an identity the platform itself proved. Nothing in here can tell
|
||||
* a verified id from a spoofed one.
|
||||
*
|
||||
* `roles` records what that identity held in its platform's community — Discord role ids,
|
||||
* from the benefits claim's member read. Omitting it (every login path: Steam and Meta
|
||||
* have no such thing) leaves the column NULL rather than writing an empty array, so
|
||||
* "never had roles" stays distinguishable in the table from "claimed holding none". An
|
||||
* EMPTY array is a real value and is stored as one.
|
||||
*
|
||||
* Unlike the rest of the row, roles are REFRESHED on a repeat link: they're a snapshot of
|
||||
* a membership that moves, and the later claim is the fresher reading. That is also why
|
||||
* the update is separate from the insert rather than an upsert — the insert's
|
||||
* `meta.changes` is what tells a new link from an existing one, and an
|
||||
* `ON CONFLICT DO UPDATE` reports a change either way, which would make every re-login
|
||||
* look like a first link to {@link linkLoginIdentity}'s primary-identity write. The second
|
||||
* statement only runs on the Discord path, and only when the row was already there.
|
||||
*/
|
||||
export async function linkPlatformIdentity(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number,
|
||||
platformId: string
|
||||
platformId: string,
|
||||
roles?: readonly string[]
|
||||
): Promise<boolean> {
|
||||
if (platformId === '') return false
|
||||
const role = roles === undefined ? null : JSON.stringify([...roles])
|
||||
const res = await db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at, role)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)`
|
||||
)
|
||||
.bind(accountId, platform, platformId, new Date().toISOString(), role)
|
||||
.run()
|
||||
if (res.meta.changes > 0) return true
|
||||
|
||||
if (roles !== undefined) {
|
||||
await setPlatformLinkRoles(db, accountId, platform, platformId, roles)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrite the roles recorded on an EXISTING link. The daily sweep's write
|
||||
* (apps/www/src/discord-roles.ts), and the second half of {@link linkPlatformIdentity}'s
|
||||
* re-claim. Returns whether a link matched: the sweep reads links and writes them back, so
|
||||
* a false here means the row went away in between, which is worth a log line and nothing
|
||||
* more.
|
||||
*/
|
||||
export async function setPlatformLinkRoles(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number,
|
||||
platformId: string,
|
||||
roles: readonly string[]
|
||||
): Promise<boolean> {
|
||||
if (platformId === '') return false
|
||||
const res = await db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO platform_account (account_id, platform, platform_id, linked_at)
|
||||
VALUES (?1, ?2, ?3, ?4)`
|
||||
`UPDATE platform_account SET role = ?4
|
||||
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3`
|
||||
)
|
||||
.bind(accountId, platform, platformId, new Date().toISOString())
|
||||
.bind(accountId, platform, platformId, JSON.stringify([...roles]))
|
||||
.run()
|
||||
return res.meta.changes > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Every link on one platform, oldest first — the sweep's read, which walks all of them.
|
||||
*
|
||||
* Unfiltered by {@link CACHED_LOGIN_PLATFORMS} on purpose — its one caller asks about
|
||||
* Discord, the platform that filter exists to exclude. It is not a lookup by identity, so
|
||||
* the privacy argument for the filter (see {@link getLinksForPlatformId}) doesn't apply:
|
||||
* nothing here is reachable from a request.
|
||||
*/
|
||||
export async function getLinksForPlatform(
|
||||
db: D1Database,
|
||||
platform: number
|
||||
): Promise<PlatformLink[]> {
|
||||
const { results } = await db
|
||||
.prepare(`${SELECT_LINK} WHERE platform = ?1 ORDER BY linked_at, account_id`)
|
||||
.bind(platform)
|
||||
.all<LinkRow>()
|
||||
return results.map(toLink)
|
||||
}
|
||||
|
||||
/**
|
||||
* The accounts a platform identity opens — what the login-screen picker lists.
|
||||
* Ordered oldest link first so the list is stable between launches (D1 row order
|
||||
@@ -170,7 +288,7 @@ export async function getLinksForPlatformIdentity(
|
||||
)
|
||||
.bind(platform, platformId)
|
||||
.all<LinkRow>()
|
||||
return results
|
||||
return results.map(toLink)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,7 +311,7 @@ export async function getLinksForPlatformId(
|
||||
)
|
||||
.bind(platformId, ...CACHED_LOGIN_PLATFORMS)
|
||||
.all<LinkRow>()
|
||||
return results
|
||||
return results.map(toLink)
|
||||
}
|
||||
|
||||
/** Every platform identity linked to an account (a player's PC and headset, say). */
|
||||
@@ -205,7 +323,7 @@ export async function getLinksForAccount(
|
||||
.prepare(`${SELECT_LINK} WHERE account_id = ?1 ORDER BY linked_at, platform`)
|
||||
.bind(accountId)
|
||||
.all<LinkRow>()
|
||||
return results
|
||||
return results.map(toLink)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Rec Room Studio's device-login handshake.
|
||||
*
|
||||
* The editor (RecRoom.Studio.Editor.Core, `MKLLKHBNNLP`) POSTs
|
||||
* `connect/deviceauthorization` to the **Auth** service — service enum value 0,
|
||||
* the host `ns` advertises as `Auth` — with `client_id=recroom.studio` and the
|
||||
* client secret embedded in that assembly. It then opens `verification_uri_complete`
|
||||
* in the browser and polls `POST connect/token` on the same Auth host with
|
||||
* `grant_type=urn:ietf:params:oauth:grant-type:device_code` until the code is
|
||||
* approved or `expires_in` seconds pass.
|
||||
*
|
||||
* `GET account/me` after a successful poll is a different service: enum 11,
|
||||
* **Accounts**. This module does not serve that.
|
||||
*
|
||||
* The page the editor opens is **WWW** `/device`. Auth only mints the codes and,
|
||||
* once that page has approved one, the token.
|
||||
*/
|
||||
|
||||
/** What Studio posts as `client_id` on the device grant. */
|
||||
export const STUDIO_CLIENT_ID = 'recroom.studio'
|
||||
|
||||
/**
|
||||
* The matching `client_secret` compiled into the Studio editor. It is not a
|
||||
* server secret — anyone with the editor binary has it — but checking it keeps
|
||||
* a random POST from minting codes.
|
||||
*/
|
||||
export const STUDIO_CLIENT_SECRET = '54oc5C4BH7jpqp42KhChkNZOLP0U3yTx'
|
||||
|
||||
/** `grant_type` Studio polls with. RFC 8628. */
|
||||
export const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'
|
||||
|
||||
/** How long the player has to approve, in seconds. Studio reads this as seconds. */
|
||||
export const DEVICE_TTL_SECONDS = 10 * 60
|
||||
|
||||
/** Minimum poll spacing Studio is told to use, in seconds. */
|
||||
export const DEVICE_POLL_INTERVAL_SECONDS = 5
|
||||
|
||||
/** Unambiguous alphabet. Studio pretty-prints a code only when it is exactly 6 chars. */
|
||||
const USER_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
const USER_CODE_LENGTH = 8
|
||||
|
||||
export const STUDIO_DEVICE_SCHEMA_DDL: string[] = [
|
||||
`CREATE TABLE IF NOT EXISTS device_grant (
|
||||
device_code_hash TEXT PRIMARY KEY,
|
||||
user_code TEXT NOT NULL UNIQUE,
|
||||
account_id INTEGER,
|
||||
status TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
)`,
|
||||
]
|
||||
|
||||
type GrantStatus = 'pending' | 'approved' | 'denied' | 'consumed'
|
||||
|
||||
export interface DeviceGrant {
|
||||
userCode: string
|
||||
deviceCode: string
|
||||
expiresIn: number
|
||||
interval: number
|
||||
}
|
||||
|
||||
export type DevicePoll =
|
||||
| { kind: 'pending' }
|
||||
| { kind: 'denied' }
|
||||
| { kind: 'expired' }
|
||||
| { kind: 'invalid' }
|
||||
| { kind: 'approved'; accountId: number }
|
||||
|
||||
interface GrantRow {
|
||||
account_id: number | null
|
||||
status: GrantStatus
|
||||
expires_at: number
|
||||
}
|
||||
|
||||
function nowSeconds(): number {
|
||||
return Math.floor(Date.now() / 1000)
|
||||
}
|
||||
|
||||
async function sha256(value: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
|
||||
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
function randomUserCode(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(USER_CODE_LENGTH))
|
||||
let code = ''
|
||||
for (const byte of bytes) code += USER_CODE_ALPHABET[byte % USER_CODE_ALPHABET.length]
|
||||
return code
|
||||
}
|
||||
|
||||
function randomDeviceCode(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32))
|
||||
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
/** Strip spaces and hyphens and uppercase, so a code typed as Studio displays it still matches. */
|
||||
export function normalizeUserCode(raw: string): string {
|
||||
return raw.replace(/[\s-]/g, '').toUpperCase()
|
||||
}
|
||||
|
||||
export function studioClientMatches(body: Record<string, unknown>): boolean {
|
||||
return body.client_id === STUDIO_CLIENT_ID && body.client_secret === STUDIO_CLIENT_SECRET
|
||||
}
|
||||
|
||||
export async function beginDeviceGrant(db: D1Database): Promise<DeviceGrant> {
|
||||
const now = nowSeconds()
|
||||
const deviceCode = randomDeviceCode()
|
||||
const deviceCodeHash = await sha256(deviceCode)
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const userCode = randomUserCode()
|
||||
try {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO device_grant
|
||||
(device_code_hash, user_code, account_id, status, created_at, expires_at)
|
||||
VALUES (?1, ?2, NULL, 'pending', ?3, ?4)`
|
||||
)
|
||||
.bind(deviceCodeHash, userCode, now, now + DEVICE_TTL_SECONDS)
|
||||
.run()
|
||||
return {
|
||||
userCode,
|
||||
deviceCode,
|
||||
expiresIn: DEVICE_TTL_SECONDS,
|
||||
interval: DEVICE_POLL_INTERVAL_SECONDS,
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (!message.includes('UNIQUE')) throw err
|
||||
}
|
||||
}
|
||||
throw new Error('could not allocate a studio device code')
|
||||
}
|
||||
|
||||
async function readByHash(db: D1Database, deviceCode: string): Promise<GrantRow | null> {
|
||||
return db
|
||||
.prepare(`SELECT account_id, status, expires_at FROM device_grant WHERE device_code_hash = ?1`)
|
||||
.bind(await sha256(deviceCode))
|
||||
.first<GrantRow>()
|
||||
}
|
||||
|
||||
/**
|
||||
* One poll from Studio. An approved code is consumed exactly once: two polls
|
||||
* racing each other cannot both walk away with a session.
|
||||
*/
|
||||
export async function pollDeviceGrant(db: D1Database, deviceCode: string): Promise<DevicePoll> {
|
||||
const row = await readByHash(db, deviceCode)
|
||||
if (!row) return { kind: 'invalid' }
|
||||
if (row.expires_at <= nowSeconds()) return { kind: 'expired' }
|
||||
if (row.status === 'pending') return { kind: 'pending' }
|
||||
if (row.status === 'denied') return { kind: 'denied' }
|
||||
if (row.status !== 'approved' || row.account_id == null) return { kind: 'invalid' }
|
||||
|
||||
const consumed = await db
|
||||
.prepare(
|
||||
`UPDATE device_grant SET status = 'consumed'
|
||||
WHERE device_code_hash = ?1 AND status = 'approved' AND expires_at > ?2`
|
||||
)
|
||||
.bind(await sha256(deviceCode), nowSeconds())
|
||||
.run()
|
||||
if ((consumed.meta.changes ?? 0) < 1) return { kind: 'invalid' }
|
||||
return { kind: 'approved', accountId: row.account_id }
|
||||
}
|
||||
|
||||
export type DeviceDecision =
|
||||
{ kind: 'ok' } | { kind: 'missing' } | { kind: 'expired' } | { kind: 'used' }
|
||||
|
||||
async function decide(
|
||||
db: D1Database,
|
||||
userCode: string,
|
||||
accountId: number,
|
||||
status: 'approved' | 'denied'
|
||||
): Promise<DeviceDecision> {
|
||||
const code = normalizeUserCode(userCode)
|
||||
if (code === '') return { kind: 'missing' }
|
||||
const updated = await db
|
||||
.prepare(
|
||||
`UPDATE device_grant
|
||||
SET status = ?2, account_id = ?3
|
||||
WHERE user_code = ?1 AND status = 'pending' AND expires_at > ?4`
|
||||
)
|
||||
.bind(code, status, accountId, nowSeconds())
|
||||
.run()
|
||||
if ((updated.meta.changes ?? 0) > 0) return { kind: 'ok' }
|
||||
|
||||
const row = await db
|
||||
.prepare(`SELECT status, expires_at, account_id FROM device_grant WHERE user_code = ?1`)
|
||||
.bind(code)
|
||||
.first<{ status: GrantStatus; expires_at: number; account_id: number | null }>()
|
||||
if (!row) return { kind: 'missing' }
|
||||
if (row.expires_at <= nowSeconds()) return { kind: 'expired' }
|
||||
// Approving again from the same account is the player double-clicking Allow.
|
||||
if (status === 'approved' && row.status === 'approved' && row.account_id === accountId)
|
||||
return { kind: 'ok' }
|
||||
return { kind: 'used' }
|
||||
}
|
||||
|
||||
export function approveDeviceGrant(
|
||||
db: D1Database,
|
||||
userCode: string,
|
||||
accountId: number
|
||||
): Promise<DeviceDecision> {
|
||||
return decide(db, userCode, accountId, 'approved')
|
||||
}
|
||||
|
||||
export function denyDeviceGrant(
|
||||
db: D1Database,
|
||||
userCode: string,
|
||||
accountId: number
|
||||
): Promise<DeviceDecision> {
|
||||
return decide(db, userCode, accountId, 'denied')
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
||||
import '../../auth.app'
|
||||
|
||||
import {
|
||||
AUDIT_LOG_SCHEMA_DDL,
|
||||
GAME_VERSION,
|
||||
getAccountsByDeviceId,
|
||||
hashPassword,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
SCHEMA_DDL,
|
||||
seedRoomWithSubRooms,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
updateAccount,
|
||||
} from '@repo/domain'
|
||||
import { TOKEN_TTL_SECONDS } from '@repo/jwt'
|
||||
|
||||
@@ -30,6 +32,11 @@ import {
|
||||
PLATFORM_SCHEMA_DDL,
|
||||
} from '../../platform-db'
|
||||
import { consumeRefreshToken, issueRefreshToken, REFRESH_SCHEMA_DDL } from '../../refresh-db'
|
||||
import {
|
||||
STUDIO_CLIENT_ID,
|
||||
STUDIO_CLIENT_SECRET,
|
||||
STUDIO_DEVICE_SCHEMA_DDL,
|
||||
} from '../../studio-device'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
@@ -70,6 +77,9 @@ beforeAll(async () => {
|
||||
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Presence table (owned by the rooms worker) — signup seeds the Orientation row.
|
||||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// The audit trail a staff sign-in is recorded on. Owned by the `api` worker, whose
|
||||
// migrations don't run here, so build it from the mirrored DDL.
|
||||
for (const stmt of AUDIT_LOG_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Seed the accounts the credential-login tests use, each with LOGIN_PASSWORD set.
|
||||
const hash = await hashPassword(LOGIN_PASSWORD)
|
||||
@@ -92,6 +102,7 @@ beforeAll(async () => {
|
||||
// Report table (owned by the api worker) — a ban is a report row with `banned` set;
|
||||
// the token grant reads it for the evasion arms.
|
||||
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of STUDIO_DEVICE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -469,6 +480,60 @@ describe('auth worker routes', () => {
|
||||
).resolves.toBe(1)
|
||||
})
|
||||
|
||||
// The `role` column (migration 0010): what the benefits claim read about a member, kept
|
||||
// so "why does this account have Plus" is answerable later. Exercised through the helper
|
||||
// rather than the claim route, which runs through discord.com.
|
||||
test('records the roles a claimed Discord identity held, and refreshes them on a re-claim', async () => {
|
||||
const discordId = '308994132968210434'
|
||||
const supporter = '1077000000000000001'
|
||||
const booster = '1077000000000000002'
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 31400, username: 'RoleClaimer', hasPlus: true }))
|
||||
.run()
|
||||
|
||||
await linkPlatformIdentity(env.DB, 31400, PlatformType.Discord, discordId, [supporter])
|
||||
const first = await getLinksForAccount(env.DB, 31400)
|
||||
// Snowflakes come back as STRINGS — parsed as numbers they'd round past 2^53 and stop
|
||||
// matching the real role.
|
||||
expect(first.map((l) => l.roles)).toEqual([[supporter]])
|
||||
|
||||
// A re-claim is the same row (INSERT OR IGNORE, so `linkedAt` is the first claim's)
|
||||
// with a FRESH role list: a membership moves, and this reading is the later one.
|
||||
await linkPlatformIdentity(env.DB, 31400, PlatformType.Discord, discordId, [supporter, booster])
|
||||
const second = await getLinksForAccount(env.DB, 31400)
|
||||
expect(second).toHaveLength(1)
|
||||
expect(second[0]?.roles).toEqual([supporter, booster])
|
||||
expect(second[0]?.linkedAt).toBe(first[0]?.linkedAt)
|
||||
|
||||
// Losing every role still stores the reading. Empty is a fact about the claim, not an
|
||||
// absence of one — and it is what distinguishes this from a Steam link, below.
|
||||
await linkPlatformIdentity(env.DB, 31400, PlatformType.Discord, discordId, [])
|
||||
expect((await getLinksForAccount(env.DB, 31400))[0]?.roles).toEqual([])
|
||||
const stored = await env.DB.prepare(
|
||||
'SELECT role FROM platform_account WHERE account_id = 31400'
|
||||
).first<{ role: string | null }>()
|
||||
expect(stored?.role).toBe('[]')
|
||||
})
|
||||
|
||||
// Every login path omits the argument, because Steam and Meta have no such thing. The
|
||||
// column stays NULL there rather than becoming `[]`, so the table can still tell "never
|
||||
// had roles" from "claimed holding none".
|
||||
test('leaves the role column null for a link with no roles to record', async () => {
|
||||
const steamId = '76561197962463299'
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 31401, username: 'NoRoles' }))
|
||||
.run()
|
||||
await linkPlatformIdentity(env.DB, 31401, PlatformType.Steam, steamId)
|
||||
|
||||
const row = await env.DB.prepare(
|
||||
'SELECT role FROM platform_account WHERE account_id = 31401'
|
||||
).first<{ role: string | null }>()
|
||||
expect(row?.role).toBeNull()
|
||||
// A NULL reads back as no roles rather than throwing — which is also how a row written
|
||||
// before 0010 reads.
|
||||
expect((await getLinksForAccount(env.DB, 31401))[0]?.roles).toEqual([])
|
||||
})
|
||||
|
||||
// The 20250424.01 build POSTs the picker lookup with a platform-attestation form body
|
||||
// instead of GETting it. Nothing reads that body yet, so both methods must answer the
|
||||
// same list — otherwise the newer client's login screen comes up empty.
|
||||
@@ -623,6 +688,8 @@ describe('auth worker routes', () => {
|
||||
expect(payload.role).not.toContain('developer')
|
||||
expect(payload.role).not.toContain('moderator')
|
||||
expect(payload.role).not.toContain('junior')
|
||||
// Studio upload access is a whitelist, not a default role.
|
||||
expect(payload.role).not.toContain('betastudio')
|
||||
// No privileges to carry, so the claim is absent rather than an empty array.
|
||||
expect(payload['rn.privilege']).toBeUndefined()
|
||||
// Same for Plus: omitted rather than `false`, so a non-subscriber's token is
|
||||
@@ -664,6 +731,91 @@ describe('auth worker routes', () => {
|
||||
.run()
|
||||
const payload = await tokenFor(`account_id=91&password=${LOGIN_PASSWORD}`)
|
||||
expect(payload.role).toEqual(expect.arrayContaining(['gameClient', 'developer', 'moderator']))
|
||||
// developer is a different grant. It does not set the account's hasStudio flag.
|
||||
expect(payload.role).not.toContain('betastudio')
|
||||
})
|
||||
|
||||
// RecFlare Studio treats the role claim `betastudio` as permission to upload.
|
||||
// It is stamped from the account's hasStudio flag on every mint path — password,
|
||||
// refresh, and the Studio device grant — and a refresh after a revoke drops it. A
|
||||
// token that kept the role for the rest of the day would leave a removed player
|
||||
// uploading.
|
||||
test('POST /connect/token stamps betastudio only while the account has hasStudio', async () => {
|
||||
// 9811/9812, not 9601–9605: those ids belong to the staff-login audit tests,
|
||||
// which insert with OR IGNORE and then count `staff_login` rows.
|
||||
await seedAccount(9811, 'StudioPlayer')
|
||||
expect((await tokenFor(`account_id=9811&password=${LOGIN_PASSWORD}`)).role).not.toContain(
|
||||
'betastudio'
|
||||
)
|
||||
|
||||
await updateAccount(env.DB, 9811, { hasStudio: true })
|
||||
expect((await tokenFor(`account_id=9811&password=${LOGIN_PASSWORD}`)).role).toEqual([
|
||||
'gameClient',
|
||||
'screenshare',
|
||||
'betastudio',
|
||||
])
|
||||
|
||||
// developer and moderator stay in front of betastudio, so the claim order is stable.
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: 9812,
|
||||
username: 'StudioStaff',
|
||||
passwordHash: await hashPassword(LOGIN_PASSWORD),
|
||||
isDeveloper: true,
|
||||
isModerator: true,
|
||||
hasStudio: true,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
expect((await tokenFor(`account_id=9812&password=${LOGIN_PASSWORD}`)).role).toEqual([
|
||||
'gameClient',
|
||||
'screenshare',
|
||||
'developer',
|
||||
'moderator',
|
||||
'betastudio',
|
||||
])
|
||||
|
||||
const client = `client_id=${STUDIO_CLIENT_ID}&client_secret=${STUDIO_CLIENT_SECRET}`
|
||||
const started = await exports.default.fetch(`${ORIGIN}/connect/deviceauthorization`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: client,
|
||||
})
|
||||
expect(started.status).toBe(200)
|
||||
const codes = (await started.json()) as { device_code: string; user_code: string }
|
||||
const session = await postToken(
|
||||
`grant_type=password&username=StudioPlayer&password=${LOGIN_PASSWORD}`
|
||||
)
|
||||
expect(session.status).toBe(200)
|
||||
const approved = await exports.default.fetch(`${ORIGIN}/connect/device/approve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Bearer ${session.json.access_token as string}`,
|
||||
},
|
||||
body: `user_code=${codes.user_code}`,
|
||||
})
|
||||
expect(approved.status).toBe(200)
|
||||
const signedIn = await postToken(
|
||||
`${client}&grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:device_code')}&device_code=${codes.device_code}`
|
||||
)
|
||||
expect(signedIn.status).toBe(200)
|
||||
expect(decodePayload(signedIn.json.access_token as string).role).toEqual([
|
||||
'gameClient',
|
||||
'screenshare',
|
||||
'betastudio',
|
||||
])
|
||||
|
||||
// Revoke between issue and refresh. The refresh re-reads the account.
|
||||
const login = await postToken(`account_id=9811&password=${LOGIN_PASSWORD}`)
|
||||
await updateAccount(env.DB, 9811, { hasStudio: false })
|
||||
await updateAccount(env.DB, 9812, { hasStudio: false })
|
||||
const refreshed = await postToken(
|
||||
`grant_type=refresh_token&refresh_token=${encodeURIComponent(login.json.refresh_token as string)}`
|
||||
)
|
||||
expect(refreshed.status).toBe(200)
|
||||
expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio')
|
||||
})
|
||||
|
||||
// Rec Room Plus rides on the token as `rn.plus`, stamped from `account.hasPlus` — which
|
||||
@@ -743,6 +895,29 @@ describe('auth worker routes', () => {
|
||||
expect(payload.scope).not.toContain('rn.privilege')
|
||||
})
|
||||
|
||||
test('POST /connect/token stamps the influencer role for an isInfluencer account', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: 95,
|
||||
username: 'InfluencerPlayer',
|
||||
passwordHash: await hashPassword(LOGIN_PASSWORD),
|
||||
isInfluencer: true,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
const payload = await tokenFor(`account_id=95&password=${LOGIN_PASSWORD}`)
|
||||
expect(payload.role).toEqual(
|
||||
expect.arrayContaining(['gameClient', 'screenshare', 'influencer'])
|
||||
)
|
||||
expect(payload.role).not.toContain('developer')
|
||||
|
||||
// Off the flag, off the token — no other flag implies it.
|
||||
await updateAccount(env.DB, 95, { isInfluencer: false })
|
||||
const revoked = await tokenFor(`account_id=95&password=${LOGIN_PASSWORD}`)
|
||||
expect(revoked.role).not.toContain('influencer')
|
||||
})
|
||||
|
||||
test('POST /connect/token 400s when no account_id is posted (never defaults to 1)', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, { method: 'POST' })
|
||||
expect(res.status).toBe(400)
|
||||
@@ -864,6 +1039,74 @@ describe('auth worker routes', () => {
|
||||
expect(row!.data).not.toContain('2.0')
|
||||
})
|
||||
|
||||
// The grant the live client actually logs in with, and the one path whose device
|
||||
// recording had no test — `create_account` and the password grant were both pinned,
|
||||
// cached_login only happened to work. The body is the shape the 2025 client posts (see
|
||||
// the capture the device id below comes from): device_id alongside the platform
|
||||
// attestation, on every sign-in.
|
||||
//
|
||||
// Nothing verifies a device id — the client picks it — so it is recorded and never
|
||||
// authorized on. It is stored for ban EVASION to read later: an account logging in from
|
||||
// the same install as a banned one. See `getAccountsByDeviceId`.
|
||||
test('POST /connect/token cached_login stores the login device on the account', async () => {
|
||||
const metaId = '27061366730201234'
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: 5151,
|
||||
username: 'CachedDevicePlayer',
|
||||
platform: 1,
|
||||
platformId: metaId,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
await linkPlatformIdentity(env.DB, 5151, 1, metaId)
|
||||
|
||||
const deviceId = '69640e6ae1b54ae5b0ca8eeb4a8872ec6cf8fd88'
|
||||
const res = await metaLogin(
|
||||
`grant_type=cached_login&account_id=5151&platform=1&platform_id=${metaId}` +
|
||||
`&platform_auth=${encodeURIComponent(metaPlatformAuth())}` +
|
||||
`&device_id=${deviceId}&device_class=2&isInitialLogin=true&locale=en`,
|
||||
true
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
|
||||
.bind(5151)
|
||||
.first<{ data: string }>()
|
||||
const account = JSON.parse(row!.data) as { deviceId: string; deviceClass: number }
|
||||
expect(account.deviceId).toBe(deviceId)
|
||||
expect(account.deviceClass).toBe(2)
|
||||
// And findable by it, which is the whole reason it is kept.
|
||||
expect((await getAccountsByDeviceId(env.DB, deviceId)).map((a) => a.accountId)).toContain(5151)
|
||||
})
|
||||
|
||||
// A refresh is NOT a login: the client redeems a stored token and re-attests nothing,
|
||||
// so there is no device to believe and the grant records none. Pinned because the
|
||||
// alternative is worse than it looks — trusting a device_id posted alongside a refresh
|
||||
// would let a client move an account's recorded device without ever proving anything,
|
||||
// which is exactly the signal ban evasion is meant to read.
|
||||
test('POST /connect/token refresh_token leaves the recorded device alone', async () => {
|
||||
const first = await postToken(
|
||||
`grant_type=password&username=Player77&password=${LOGIN_PASSWORD}` +
|
||||
'&device_id=dev-77-real&device_class=2'
|
||||
)
|
||||
expect(first.status).toBe(200)
|
||||
|
||||
const refreshed = await postToken(
|
||||
`grant_type=refresh_token&refresh_token=${first.json.refresh_token as string}` +
|
||||
'&device_id=dev-77-spoofed&device_class=9'
|
||||
)
|
||||
expect(refreshed.status).toBe(200)
|
||||
|
||||
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
|
||||
.bind(77)
|
||||
.first<{ data: string }>()
|
||||
const account = JSON.parse(row!.data) as { deviceId: string; deviceClass: number }
|
||||
expect(account.deviceId).toBe('dev-77-real')
|
||||
expect(account.deviceClass).toBe(2)
|
||||
})
|
||||
|
||||
test('POST /connect/token refreshes the stored device on a credential login', async () => {
|
||||
// Account 42 was seeded with no device; a later login records the one it came from.
|
||||
const res = await postToken(
|
||||
@@ -909,6 +1152,100 @@ describe('auth worker routes', () => {
|
||||
expect(other.status).toBe(200)
|
||||
})
|
||||
|
||||
test('POST /connect/token caps the accounts created from one device', async () => {
|
||||
// One install, many networks: the VPN-hopping signup the IP cap can't see. Each
|
||||
// signup posts a fresh IP so only the device arm can be what refuses it.
|
||||
const deviceId = 'dev-capped-install'
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const ok = await postToken(
|
||||
`grant_type=create_account&platform_id=steam-devcap${i}&device_id=${deviceId}&device_class=2`,
|
||||
`198.51.100.${40 + i}`
|
||||
)
|
||||
expect(ok.status).toBe(200)
|
||||
}
|
||||
// The 4th signup from that device is refused — the cap is 3.
|
||||
const capped = await postToken(
|
||||
`grant_type=create_account&platform_id=steam-devcap3&device_id=${deviceId}&device_class=2`,
|
||||
'198.51.100.43'
|
||||
)
|
||||
expect(capped.status).toBe(400)
|
||||
expect(capped.json.error).toBe('invalid_grant')
|
||||
expect(capped.json.error_description).toMatch(/device/)
|
||||
|
||||
// Another device is unaffected, and a signup that posts no device id (the website's)
|
||||
// can't be attributed to one, so the arm skips it rather than lumping it in.
|
||||
const other = await postToken(
|
||||
'grant_type=create_account&platform_id=steam-devcap4&device_id=dev-other-install&device_class=2',
|
||||
'198.51.100.44'
|
||||
)
|
||||
expect(other.status).toBe(200)
|
||||
const deviceless = await postToken(
|
||||
'grant_type=create_account&platform_id=steam-devcap5',
|
||||
'198.51.100.45'
|
||||
)
|
||||
expect(deviceless.status).toBe(200)
|
||||
|
||||
// The capped device can still LOG IN to what it has — the cap is on signup only.
|
||||
const login = await postToken(
|
||||
`grant_type=password&username=Player42&password=${LOGIN_PASSWORD}&device_id=${deviceId}&device_class=2`,
|
||||
'198.51.100.46'
|
||||
)
|
||||
expect(login.status).toBe(200)
|
||||
})
|
||||
|
||||
test('PASSWORD_SIGNUP=off refuses a no-platform create_account and mints nothing', async () => {
|
||||
// The switch the website's signup hangs off. Every other create_account test here
|
||||
// runs with it ON (see vitest.config.ts); this one is the closed door.
|
||||
const original = env.PASSWORD_SIGNUP
|
||||
try {
|
||||
env.PASSWORD_SIGNUP = 'off'
|
||||
const before = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
|
||||
n: number
|
||||
}>()
|
||||
const refused = await postToken(
|
||||
'grant_type=create_account&password=hunter2&device_id=dev-web&device_class=2',
|
||||
'198.51.100.60'
|
||||
)
|
||||
expect(refused.status).toBe(400)
|
||||
expect(refused.json.error).toBe('invalid_grant')
|
||||
expect(refused.json.error_description).toBe('password signup is disabled')
|
||||
const after = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
|
||||
n: number
|
||||
}>()
|
||||
expect(after?.n).toBe(before?.n)
|
||||
|
||||
// Logging in is not what the switch gates: an existing password account still gets
|
||||
// its token, whatever the switch says.
|
||||
const login = await postToken(
|
||||
`grant_type=password&username=Player42&password=${LOGIN_PASSWORD}`,
|
||||
'198.51.100.60'
|
||||
)
|
||||
expect(login.status).toBe(200)
|
||||
|
||||
// A platform signup is a different door: it still reaches platform verification
|
||||
// (and fails THERE, on the missing ticket) rather than being refused by the switch.
|
||||
const steam = await postToken(
|
||||
'grant_type=create_account&platform=0&platform_id=76561198000000001',
|
||||
'198.51.100.60'
|
||||
)
|
||||
expect(steam.status).toBe(400)
|
||||
expect(steam.json.error_description).not.toBe('password signup is disabled')
|
||||
|
||||
// Unset is off too — the default an operator has to opt out of.
|
||||
delete env.PASSWORD_SIGNUP
|
||||
const unset = await postToken('grant_type=create_account&password=hunter2', '198.51.100.61')
|
||||
expect(unset.status).toBe(400)
|
||||
expect(unset.json.error_description).toBe('password signup is disabled')
|
||||
|
||||
// And `on` reopens it.
|
||||
env.PASSWORD_SIGNUP = 'on'
|
||||
const open = await postToken('grant_type=create_account&password=hunter2', '198.51.100.62')
|
||||
expect(open.status).toBe(200)
|
||||
} finally {
|
||||
env.PASSWORD_SIGNUP = original
|
||||
}
|
||||
})
|
||||
|
||||
test('the signup caps come from vars, and 0 disables an arm', async () => {
|
||||
// The cap an operator actually runs is the `MAX_ACCOUNTS_PER_IP` var; the constant in
|
||||
// auth.app.ts is only the fallback. `env` is shared by every test in this file, so the
|
||||
@@ -930,6 +1267,24 @@ describe('auth worker routes', () => {
|
||||
} finally {
|
||||
env.MAX_ACCOUNTS_PER_IP = original
|
||||
}
|
||||
|
||||
// The device arm reads its own var the same way.
|
||||
const originalDevice = env.MAX_ACCOUNTS_PER_DEVICE_ID
|
||||
try {
|
||||
env.MAX_ACCOUNTS_PER_DEVICE_ID = 1
|
||||
const device = '&device_id=dev-var-install&device_class=2'
|
||||
const first = await postToken(`grant_type=create_account&platform_id=steam-dvar0${device}`)
|
||||
expect(first.status).toBe(200)
|
||||
const capped = await postToken(`grant_type=create_account&platform_id=steam-dvar1${device}`)
|
||||
expect(capped.status).toBe(400)
|
||||
expect(capped.json.error_description).toMatch(/device/)
|
||||
|
||||
env.MAX_ACCOUNTS_PER_DEVICE_ID = 0
|
||||
const uncapped = await postToken(`grant_type=create_account&platform_id=steam-dvar2${device}`)
|
||||
expect(uncapped.status).toBe(200)
|
||||
} finally {
|
||||
env.MAX_ACCOUNTS_PER_DEVICE_ID = originalDevice
|
||||
}
|
||||
})
|
||||
|
||||
test('POST /connect/token does not cap logins, only signups', async () => {
|
||||
@@ -1328,11 +1683,155 @@ describe('auth worker routes', () => {
|
||||
expect((await exports.default.fetch(`${ORIGIN}/role/moderator/99999`)).status).toBe(404)
|
||||
})
|
||||
|
||||
// `/role/{role}?id=` is a BULK FILTER — which of these players hold the role, as an array
|
||||
// of ints — not the single lookup's boolean. Different question, different shape.
|
||||
test('GET /role/developer?id= answers which of the players are developers', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 4242, username: 'DevPlayer', isDeveloper: true }))
|
||||
.run()
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 4244, username: 'DevPlayer2', isDeveloper: true }))
|
||||
.run()
|
||||
|
||||
// INTS, not strings, and not a boolean.
|
||||
const one = await exports.default.fetch(`${ORIGIN}/role/developer?id=4242`)
|
||||
expect(one.status).toBe(200)
|
||||
expect(await one.json()).toEqual([4242])
|
||||
|
||||
// The subset that holds the role: 42 exists without the flag and 99999 not at all, and
|
||||
// neither is an error — a filter answers "none of them", never 404.
|
||||
const many = await exports.default.fetch(
|
||||
`${ORIGIN}/role/developer?id=42&id=4242&id=99999&id=4244`
|
||||
)
|
||||
expect(many.status).toBe(200)
|
||||
expect(await many.json()).toEqual([4242, 4244])
|
||||
|
||||
// Ordered as asked, so a caller can read the answer against its own list.
|
||||
const reversed = await exports.default.fetch(`${ORIGIN}/role/developer?id=4244&id=4242`)
|
||||
expect(await reversed.json()).toEqual([4244, 4242])
|
||||
|
||||
// A repeated id yields at most one entry.
|
||||
const repeated = await exports.default.fetch(`${ORIGIN}/role/developer?id=4242&id=4242`)
|
||||
expect(await repeated.json()).toEqual([4242])
|
||||
})
|
||||
|
||||
test('GET /role/developer?id= answers [] rather than erroring', async () => {
|
||||
// No ids, only unknown ids, only un-flagged ids, and a non-numeric id all answer an empty
|
||||
// array with a 200 — nothing about this question is a 400 or a 404.
|
||||
for (const query of ['', '?id=99999', '?id=42', '?id=nonsense', '?id=']) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/role/developer${query}`)
|
||||
expect(res.status, query).toBe(200)
|
||||
expect(await res.json(), query).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
// The repeated parameter is the whole point: reading only the first would silently answer
|
||||
// about one player out of the set.
|
||||
test('GET /role/developer?id= reads every repeated id, not just the first', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 4242, username: 'DevPlayer', isDeveloper: true }))
|
||||
.run()
|
||||
const res = await exports.default.fetch(`${ORIGIN}/role/developer?id=42&id=4242`)
|
||||
expect(await res.json()).toEqual([4242])
|
||||
})
|
||||
|
||||
test('GET /role/moderator?id= filters on the moderator flag alone', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 4343, username: 'ModPlayer', isModerator: true }))
|
||||
.run()
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 4242, username: 'DevPlayer', isDeveloper: true }))
|
||||
.run()
|
||||
// The two roles are independent flags: a developer is not implicitly a moderator.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/role/moderator?id=4242&id=4343&id=42`)
|
||||
expect(await res.json()).toEqual([4343])
|
||||
})
|
||||
|
||||
// The single lookup keeps its own shape — the bulk form must not have changed it.
|
||||
test('GET /role/developer/:id is still a bare boolean, not an array', async () => {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(JSON.stringify({ accountId: 4242, username: 'DevPlayer', isDeveloper: true }))
|
||||
.run()
|
||||
expect(await (await exports.default.fetch(`${ORIGIN}/role/developer/4242`)).json()).toBe(true)
|
||||
expect(await (await exports.default.fetch(`${ORIGIN}/role/developer/42`)).json()).toBe(false)
|
||||
expect((await exports.default.fetch(`${ORIGIN}/role/developer/99999`)).status).toBe(404)
|
||||
})
|
||||
|
||||
test('unknown path returns 404', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/nope`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test('Studio device login is approved on the website and polled on auth', async () => {
|
||||
const client = `client_id=${STUDIO_CLIENT_ID}&client_secret=${STUDIO_CLIENT_SECRET}`
|
||||
const bad = await exports.default.fetch(`${ORIGIN}/connect/deviceauthorization`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'client_id=recroom.studio&client_secret=nope',
|
||||
})
|
||||
expect(bad.status).toBe(400)
|
||||
|
||||
const started = await exports.default.fetch(`${ORIGIN}/connect/deviceauthorization`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: client,
|
||||
})
|
||||
expect(started.status).toBe(200)
|
||||
const codes = (await started.json()) as {
|
||||
device_code: string
|
||||
user_code: string
|
||||
verification_uri: string
|
||||
verification_uri_complete: string
|
||||
expires_in: number
|
||||
interval: number
|
||||
}
|
||||
// The editor opens this in a browser. It is the website, not the auth API.
|
||||
expect(codes.verification_uri).toBe('https://www.rec.example.com/device')
|
||||
expect(codes.verification_uri_complete).toBe(
|
||||
`https://www.rec.example.com/device?user_code=${codes.user_code}`
|
||||
)
|
||||
expect(codes.expires_in).toBeGreaterThan(0)
|
||||
expect(codes.interval).toBeGreaterThan(0)
|
||||
|
||||
const pending = await postToken(
|
||||
`${client}&grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:device_code')}&device_code=${codes.device_code}`
|
||||
)
|
||||
expect(pending.status).toBe(400)
|
||||
expect(pending.json.error).toBe('authorization_pending')
|
||||
|
||||
const session = await postToken(
|
||||
`grant_type=password&username=Player42&password=${LOGIN_PASSWORD}`
|
||||
)
|
||||
expect(session.status).toBe(200)
|
||||
const approved = await exports.default.fetch(`${ORIGIN}/connect/device/approve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Bearer ${session.json.access_token as string}`,
|
||||
},
|
||||
body: `user_code=${codes.user_code}`,
|
||||
})
|
||||
expect(approved.status).toBe(200)
|
||||
|
||||
const signedIn = await postToken(
|
||||
`${client}&grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:device_code')}&device_code=${codes.device_code}`
|
||||
)
|
||||
expect(signedIn.status).toBe(200)
|
||||
expect(typeof signedIn.json.access_token).toBe('string')
|
||||
expect(signedIn.json.expires_in).toBe(TOKEN_TTL_SECONDS)
|
||||
// Studio treats expires_in as seconds, same as the game client.
|
||||
const devicePayload = decodePayload(signedIn.json.access_token as string)
|
||||
expect(devicePayload.sub).toBe('42')
|
||||
// Player42 has no hasStudio flag, so a Studio login is still Limited.
|
||||
expect(devicePayload.role).not.toContain('betastudio')
|
||||
|
||||
const again = await postToken(
|
||||
`${client}&grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:device_code')}&device_code=${codes.device_code}`
|
||||
)
|
||||
expect(again.status).toBe(400)
|
||||
expect(again.json.error).toBe('invalid_grant')
|
||||
})
|
||||
|
||||
test('GET /openapi.json documents every route', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -1358,11 +1857,16 @@ describe('auth worker routes', () => {
|
||||
'GET /eac/challenge',
|
||||
'GET /oculus/nonce',
|
||||
'GET /privileges/me/restrictions',
|
||||
'GET /role/developer',
|
||||
'GET /role/developer/{id}',
|
||||
'GET /role/moderator',
|
||||
'GET /role/moderator/{id}',
|
||||
'POST /account/me/changepassword',
|
||||
'POST /cachedlogin/forplatformid/{platform}/{id}',
|
||||
'POST /cachedlogin/forplatformids',
|
||||
'POST /connect/device/approve',
|
||||
'POST /connect/device/deny',
|
||||
'POST /connect/deviceauthorization',
|
||||
'POST /connect/token',
|
||||
])
|
||||
|
||||
@@ -1518,12 +2022,12 @@ describe('banned accounts', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// The ban follows the player past the account it was written on: a login from an account
|
||||
// that shares a proven platform identity or an IP with a banned one is refused, and a
|
||||
// signup carrying either is refused before it mints anything. Unlike the banned account
|
||||
// itself, such an account has no ban of its own for the block screen to describe, so
|
||||
// there is nothing to let it in for. See the api worker's bans-db.ts for the arms and
|
||||
// the BAN_EVASION_MATCH knob.
|
||||
// The ban follows the player past the account it was written on. SIGNING IN is not where
|
||||
// that is enforced: a linked account gets its token like any other, and `match` holds it to
|
||||
// its own dorm — refusing the token only ever left the client stuck on a failed login. What
|
||||
// IS refused here is a SIGNUP carrying either link, before it mints anything: making a new
|
||||
// account to evade with is a different act from signing into one. 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> = {}) => {
|
||||
@@ -1542,38 +2046,38 @@ describe('ban evasion at the token endpoint', () => {
|
||||
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 () => {
|
||||
test('an account sharing a banned account’s platform identity still signs 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')
|
||||
|
||||
// A token, like anyone else: the client has to load in before it can be told anything,
|
||||
// and `match` is what keeps this account out of every room but its dorm.
|
||||
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')
|
||||
expect(res.status).toBe(200)
|
||||
expect(decodePayload(res.json.access_token as string).sub).toBe('6302')
|
||||
})
|
||||
|
||||
test('an account sharing a banned account’s IP cannot log in', async () => {
|
||||
test('an account sharing a banned account’s IP still signs 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')
|
||||
expect((await login(6304)).status).toBe(200)
|
||||
})
|
||||
|
||||
// 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 () => {
|
||||
// The address the request arrives from counts, so a SIGNUP from the banned network is
|
||||
// caught on the first attempt rather than the second.
|
||||
test('the request’s own IP is matched at signup 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 postToken('grant_type=create_account', '203.0.113.31')).status).toBe(400)
|
||||
// Signing in from that same network is not refused — only minting a new account is.
|
||||
expect((await login(6306, '203.0.113.31')).status).toBe(200)
|
||||
expect((await login(6306, '198.51.100.31')).status).toBe(200)
|
||||
})
|
||||
|
||||
@@ -1602,7 +2106,8 @@ describe('ban evasion at the token endpoint', () => {
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
// The knob an operator reaches for when the IP arm locks out real players.
|
||||
// The knob an operator reaches for when the IP arm locks out real players. It governs the
|
||||
// linked arms wherever they are applied — at signup here, and at every matchmake in `match`.
|
||||
test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the platform one', async () => {
|
||||
const original = env.BAN_EVASION_MATCH
|
||||
await account(6320, 'KnobBanned', { signupIp: '203.0.113.50' })
|
||||
@@ -1614,18 +2119,141 @@ describe('ban evasion at the token endpoint', () => {
|
||||
|
||||
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.
|
||||
// Signup from the banned IP is open again, since that arm is off...
|
||||
expect((await postToken('grant_type=create_account', '203.0.113.50')).status).toBe(200)
|
||||
// ...while the platform arm still refuses one from the banned headset.
|
||||
expect(
|
||||
(await postToken('grant_type=create_account&platform=0&platform_id=steam-knobevader'))
|
||||
.status
|
||||
).toBe(400)
|
||||
|
||||
env.BAN_EVASION_MATCH = 'off'
|
||||
expect((await login(6322)).status).toBe(200)
|
||||
// The banned account itself signs in whatever the knob says — its ban is
|
||||
// enforced at matchmake, and the knob only governs the linked arms.
|
||||
expect((await login(6320)).status).toBe(200)
|
||||
// Signing in is not what the knob gates: every one of these gets a token whatever it
|
||||
// says — the housemate, the evader, and the banned account itself.
|
||||
for (const arms of ['platform', 'off']) {
|
||||
env.BAN_EVASION_MATCH = arms
|
||||
for (const id of [6320, 6321, 6322]) {
|
||||
expect((await login(id)).status, `${arms}/${id}`).toBe(200)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
env.BAN_EVASION_MATCH = original
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// A staff sign-in is recorded on the `audit_log` table — the same table `notify` files
|
||||
// every `/internal/*` call on. What those accounts then do is attributed to them; this is
|
||||
// the row that says the session it came from began, and from what device, build and IP.
|
||||
describe('staff login audit', () => {
|
||||
interface AuditRow {
|
||||
player_id: number
|
||||
action: string
|
||||
data: string | null
|
||||
date: string
|
||||
}
|
||||
|
||||
const staffLogins = async (accountId: number): Promise<AuditRow[]> => {
|
||||
const { results } = await env.DB.prepare(
|
||||
`SELECT player_id, action, data, date FROM audit_log
|
||||
WHERE player_id = ?1 AND action = 'staff_login' ORDER BY audit_log_id`
|
||||
)
|
||||
.bind(accountId)
|
||||
.all<AuditRow>()
|
||||
return results
|
||||
}
|
||||
|
||||
/** Seed an account with the given flags and a known password. */
|
||||
const seedAccount = async (accountId: number, data: Record<string, unknown>) =>
|
||||
env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId,
|
||||
username: `Staff${accountId}`,
|
||||
passwordHash: await hashPassword(LOGIN_PASSWORD),
|
||||
...data,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
|
||||
test('a login by an isModerator account is recorded, with the context of the sign-in', async () => {
|
||||
await seedAccount(9601, { username: 'ModPlayer', isModerator: true })
|
||||
const res = await postToken(
|
||||
`grant_type=password&account_id=9601&password=${LOGIN_PASSWORD}` +
|
||||
'&device_id=dev-mod&device_class=2&ver=20230414',
|
||||
'203.0.113.9'
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const rows = await staffLogins(9601)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(Number.isNaN(Date.parse(rows[0].date))).toBe(false)
|
||||
const data = JSON.parse(rows[0].data!) as Record<string, unknown>
|
||||
expect(data).toMatchObject({
|
||||
ip: '203.0.113.9',
|
||||
roles: ['moderator'],
|
||||
grantType: 'password',
|
||||
username: 'ModPlayer',
|
||||
deviceId: 'dev-mod',
|
||||
deviceClass: 2,
|
||||
version: '20230414',
|
||||
})
|
||||
// The IP LEADS the payload: it is the first thing anyone asks of a staff sign-in, and
|
||||
// the reader of a raw row should not have to hunt for it.
|
||||
expect(Object.keys(data)[0]).toBe('ip')
|
||||
})
|
||||
|
||||
// Both flags on one account record both — the row says which powers the session carries,
|
||||
// not merely that it carries some.
|
||||
test('an account holding both flags records both roles', async () => {
|
||||
await seedAccount(9602, { isDeveloper: true, isModerator: true })
|
||||
expect((await postToken(`account_id=9602&password=${LOGIN_PASSWORD}`)).status).toBe(200)
|
||||
|
||||
const rows = await staffLogins(9602)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect((JSON.parse(rows[0].data!) as { roles: string[] }).roles).toEqual([
|
||||
'developer',
|
||||
'moderator',
|
||||
])
|
||||
})
|
||||
|
||||
// The flags are the whole trigger. An ordinary player signing in is not an audit event,
|
||||
// and recording every login would bury the ones that are.
|
||||
test('an ordinary login records nothing', async () => {
|
||||
await seedAccount(9603, {})
|
||||
expect((await postToken(`account_id=9603&password=${LOGIN_PASSWORD}`)).status).toBe(200)
|
||||
expect(await staffLogins(9603)).toHaveLength(0)
|
||||
})
|
||||
|
||||
// A refresh renews the SAME session every TOKEN_TTL_SECONDS. Recording those would bury
|
||||
// the actual sign-ins under hourly repeats saying nothing new about who came in or from
|
||||
// where — so the login is recorded once, and the refreshes after it are not.
|
||||
test('a refresh_token grant is not a login and is not recorded', async () => {
|
||||
await seedAccount(9604, { isDeveloper: true })
|
||||
const first = await postToken(`account_id=9604&password=${LOGIN_PASSWORD}`)
|
||||
expect(first.status).toBe(200)
|
||||
expect(await staffLogins(9604)).toHaveLength(1)
|
||||
|
||||
const refreshed = await postToken(
|
||||
`grant_type=refresh_token&refresh_token=${first.json.refresh_token as string}`
|
||||
)
|
||||
expect(refreshed.status).toBe(200)
|
||||
expect(await staffLogins(9604)).toHaveLength(1)
|
||||
})
|
||||
|
||||
// The flag is read at LOGIN, exactly as `rn.plus` and the token's `role` claim are, so
|
||||
// granting the role starts the trail at their next sign-in rather than retroactively.
|
||||
test('an account granted the role starts being recorded on its next login', async () => {
|
||||
await seedAccount(9605, {})
|
||||
expect((await postToken(`account_id=9605&password=${LOGIN_PASSWORD}`)).status).toBe(200)
|
||||
expect(await staffLogins(9605)).toHaveLength(0)
|
||||
|
||||
await env.DB.prepare(
|
||||
`UPDATE account SET data = json_set(data, '$.isModerator', json('true')) WHERE account_id = ?1`
|
||||
)
|
||||
.bind(9605)
|
||||
.run()
|
||||
|
||||
expect((await postToken(`account_id=9605&password=${LOGIN_PASSWORD}`)).status).toBe(200)
|
||||
expect(await staffLogins(9605)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,6 +8,10 @@ export default defineConfig({
|
||||
miniflare: {
|
||||
bindings: {
|
||||
ENVIRONMENT: 'VITEST',
|
||||
// Password (no-platform) signup is OFF by default, and most create_account tests
|
||||
// post no `platform` — they'd all test the closed door. The switch itself is
|
||||
// covered by a test that flips it off and back.
|
||||
PASSWORD_SIGNUP: 'on',
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
// history separate from the rooms worker's migrations on the same database. The
|
||||
// "local" placeholder is replaced with the real id from RECFLARE_D1 (see .env) at
|
||||
// deploy time.
|
||||
//
|
||||
// It also WRITES one table it doesn't own: a sign-in by an account holding the
|
||||
// isDeveloper/isModerator flag is recorded on `audit_log` as a `staff_login`
|
||||
// (apps/api/migrations/0024_audit_log.sql, owned by the `api` worker). That is the row
|
||||
// saying the session every later audited action came from began, and from what IP.
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
@@ -43,10 +48,7 @@
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 1 // 100%
|
||||
}
|
||||
"enabled": false
|
||||
},
|
||||
// The signup caps (MAX_ACCOUNTS_PER_PLATFORM_ID, MAX_ACCOUNTS_PER_IP) are deliberately
|
||||
// NOT set here. They're injected at deploy time from the gitignored .env
|
||||
@@ -55,6 +57,10 @@
|
||||
// constants in src/auth.app.ts.
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
"SENTRY_RELEASE": "unknown" // overridden during deployment
|
||||
"SENTRY_RELEASE": "unknown", // overridden during deployment
|
||||
// Overridden at deploy time, same as `ns`. The device-login response builds
|
||||
// the browser URL from these (`https://www.<DOMAIN>/device`).
|
||||
"DOMAIN": "rec.example.com",
|
||||
"SUBDOMAINS": "{}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,7 @@
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 1 // 100%
|
||||
}
|
||||
"enabled": false
|
||||
},
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
|
||||
+10
-3
@@ -15,13 +15,20 @@ route of its own, bundled rather than an asset, because its file is named differ
|
||||
its path.
|
||||
|
||||
Objects are keyed by prefix — `sigs/` (anti-cheat signatures), `room/` (saved room
|
||||
scenes, and room images by their bare `ImageName`), `invention/` (invention data) — and
|
||||
served as `application/octet-stream`; the worker never interprets what it hands back.
|
||||
scenes, and room images by their bare `ImageName`), `invention/` (invention data), `data/`
|
||||
(generic client uploads), `avatar/` (custom avatar item assetbundles) — and served as `application/octet-stream`; the worker never interprets what it hands back.
|
||||
`/unityasset/{filename}` is the exception: a Rec Room Studio room bundle is stored under
|
||||
`studio/<roomId>/` (the game's own `unity/<roomId>/` layout, under another prefix), its key
|
||||
recorded as the filename of a `unity_asset` row (the rooms worker owns that table; the
|
||||
studio worker writes the rows), and the route serves only a filename that table records.
|
||||
`HEAD` on that path is 200 when a bundle is stored under the name.
|
||||
Reads are unauthenticated: a caller needs the exact key, which only comes from an
|
||||
authenticated call to another worker.
|
||||
|
||||
This worker only reads. Uploads go through `storage`, which writes the same bucket, and
|
||||
images are served by `img`.
|
||||
images are served by `img`. Studio room bundles are uploaded by the `studio` worker into
|
||||
the same bucket. The worker binds the shared D1 database to resolve those filenames and
|
||||
does not migrate it.
|
||||
|
||||
## API documentation
|
||||
|
||||
|
||||
+148
-4
@@ -329,6 +329,126 @@ const app = new Hono<App>()
|
||||
(c) => serveAsset(c, `data/${c.req.param('id')}`)
|
||||
)
|
||||
|
||||
// Custom avatar item assetbundles by name. A first-party custom avatar item (see the
|
||||
// `api` worker's `custom_avatar_item`) is rendered from its `CurrentSaves`, each of
|
||||
// which names a built Unity assetbundle by bare filename (`UnityAsset`, e.g.
|
||||
// `anx442dm1a79kp9n4kugkbgd0.assetbundle`) that the client fetches from `/avatar/`.
|
||||
// Streamed from R2 under `avatar/`. The rest of the path is matched so a foldered
|
||||
// upload would resolve the same way as the other blob prefixes.
|
||||
.get(
|
||||
'/avatar/:asset{.+}',
|
||||
describeRoute({
|
||||
tags: ['Assets'],
|
||||
summary: 'Serve a custom avatar item assetbundle',
|
||||
description: [
|
||||
'Streams the object stored under `avatar/<asset>` — the built Unity assetbundle the',
|
||||
'client downloads to render a first-party custom avatar item on one body type. The',
|
||||
'name comes from a save’s `UnityAsset` (or `UnityAsset2`) in the item’s `CurrentSaves`',
|
||||
'(see the `api` worker), a bare filename such as `anx442dm1a79kp9n4kugkbgd0.assetbundle`.',
|
||||
'The worker does not interpret the bytes.',
|
||||
].join(' '),
|
||||
parameters: [keyParam('asset', 'The assetbundle filename.', true), ...CONDITIONAL_HEADERS],
|
||||
responses: assetResponses('The assetbundle'),
|
||||
}),
|
||||
(c) => serveAsset(c, `avatar/${c.req.param('asset')}`)
|
||||
)
|
||||
|
||||
// Rec Room Studio room bundles. The filename is the one a room save or
|
||||
// `GET …/unityasset` advertises — the R2 key itself (`studio/<roomId>/…`), as
|
||||
// `unity_asset.filename` records it. Only a recorded filename is served, so this
|
||||
// checks the table before streaming the key. Reads stay unauthenticated, same as
|
||||
// `/avatar/` and `/room/`. HEAD is the editor's "already uploaded?" check: 200
|
||||
// exists, 404 does not.
|
||||
.on(
|
||||
'HEAD',
|
||||
'/unityasset/:filename{.+}',
|
||||
describeRoute({
|
||||
tags: ['Assets'],
|
||||
summary: 'Check that a Rec Room Studio room bundle exists',
|
||||
description: [
|
||||
'200 when `filename` names a stored Studio bundle, 404 otherwise. The',
|
||||
'Studio editor treats this HEAD as “already uploaded?” before it downloads the bytes',
|
||||
'with GET. No body.',
|
||||
].join(' '),
|
||||
parameters: [keyParam('filename', 'The bundle filename from the room save.', true)],
|
||||
responses: {
|
||||
200: { description: 'The bundle exists (no body)' },
|
||||
400: { description: 'The filename contains `..` (no body)' },
|
||||
404: { description: 'No single stored bundle has this filename' },
|
||||
},
|
||||
}),
|
||||
(c) => studioUnityAssetHead(c.env, c.req.param('filename'))
|
||||
)
|
||||
.get(
|
||||
'/unityasset/:filename{.+}',
|
||||
describeRoute({
|
||||
tags: ['Assets'],
|
||||
summary: 'Serve a Rec Room Studio room bundle',
|
||||
description: [
|
||||
'Streams the Studio asset bundle stored for `filename`. The name comes from a',
|
||||
'room save’s `UnitySubAssets` (or from `GET /rooms/{roomId}/subrooms/{subRoomId}/unityasset`',
|
||||
'on the rooms worker) and is the R2 key the studio upload stored the bytes under.',
|
||||
'Only a filename recorded in `unity_asset` is served: anything else is a 404, as is',
|
||||
'a missing table. The worker does not interpret the bytes.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
keyParam('filename', 'The bundle filename from the room save.', true),
|
||||
...CONDITIONAL_HEADERS,
|
||||
],
|
||||
responses: assetResponses('The asset bundle'),
|
||||
}),
|
||||
(c) => serveUnityAsset(c)
|
||||
)
|
||||
|
||||
/**
|
||||
* The R2 key for a Studio bundle filename — the filename itself, once `unity_asset` says
|
||||
* a build is stored under it. Null when no build is, or the table has not been migrated
|
||||
* yet (the rooms worker owns it).
|
||||
*/
|
||||
async function unityAssetR2Key(db: D1Database, filename: string): Promise<string | null> {
|
||||
try {
|
||||
// Inlined rather than imported so this worker does not take a dependency on the
|
||||
// domain package.
|
||||
const row = await db
|
||||
.prepare(`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`)
|
||||
.bind(filename)
|
||||
.first<{ stored: number }>()
|
||||
return row ? filename : null
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (message.toLowerCase().includes('no such table')) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** GET `/unityasset/:filename` — the bytes, with the same range handling as other blobs. */
|
||||
async function serveUnityAsset(c: Context<App>) {
|
||||
const name = c.req.param('filename') ?? ''
|
||||
if (name.includes('..')) return c.body(null, 400)
|
||||
const key = await unityAssetR2Key(c.env.DB, name)
|
||||
if (!key) return c.notFound()
|
||||
return serveAsset(c, key)
|
||||
}
|
||||
|
||||
/** Build the Studio bundle HEAD response outside Hono's HEAD-to-GET fallback. */
|
||||
async function studioUnityAssetHead(env: StudioAssetEnv, name: string): Promise<Response> {
|
||||
if (name.includes('..')) return new Response(null, { status: 400 })
|
||||
const key = await unityAssetR2Key(env.DB, name)
|
||||
if (!key) return new Response(null, { status: 404 })
|
||||
const head = await env.CDN_ASSETS.head(key)
|
||||
if (!head) return new Response(null, { status: 404 })
|
||||
|
||||
const headers = new Headers()
|
||||
head.writeHttpMetadata(headers)
|
||||
headers.set('etag', head.httpEtag)
|
||||
headers.set('accept-ranges', 'bytes')
|
||||
headers.set('cache-control', CACHE_CONTROL)
|
||||
headers.set('access-control-allow-origin', '*')
|
||||
const response = new Response(null, { status: 200, headers, encodeBody: 'manual' })
|
||||
response.headers.set('content-length', String(head.size))
|
||||
return response
|
||||
}
|
||||
|
||||
// The generated spec. Documentation only — no request is validated against it (see
|
||||
// openapi.ts). `hide: true` keeps this route out of its own output.
|
||||
app.get(
|
||||
@@ -343,14 +463,18 @@ app.get(
|
||||
description: [
|
||||
'Binary asset delivery for recflare, a private-server reimplementation of the Rec',
|
||||
'Room backend. Streams the blobs the client downloads while playing — anti-cheat',
|
||||
'signatures, saved room scenes, invention data and generic client uploads — out of',
|
||||
'signatures, saved room scenes, invention data, generic client uploads and custom',
|
||||
'avatar item assetbundles — out of',
|
||||
'the shared `recflare-cdn` R2 bucket, plus the JSON config files the client reads',
|
||||
'from `/config/`.',
|
||||
'',
|
||||
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`) and served as',
|
||||
'Everything is keyed by prefix (`sigs/`, `room/`, `invention/`, `data/`, `avatar/`) and',
|
||||
'served as',
|
||||
'`application/octet-stream`; the worker never interprets what it hands back. Reads',
|
||||
'are unauthenticated — a caller needs the exact key, which only comes from an',
|
||||
'authenticated call to another worker.',
|
||||
'authenticated call to another worker. `/unityasset/{filename}` is a Studio room',
|
||||
'bundle: the filename is the R2 key, streamed once `unity_asset` records it.',
|
||||
'`HEAD` on that path is 200 when a bundle is stored under the name.',
|
||||
'',
|
||||
'This worker only READS. Uploads go through the `storage` worker, which writes the',
|
||||
'same bucket, and images are served by `img` rather than from here.',
|
||||
@@ -370,4 +494,24 @@ app.get(
|
||||
)
|
||||
)
|
||||
|
||||
export default app
|
||||
/**
|
||||
* What the Studio HEAD needs from the environment. Narrower than `Env` on purpose: the
|
||||
* `mono` worker mounts this app with its own (superset) Env, which has no `ASSETS`
|
||||
* binding, and the wrapper below must stay mountable there.
|
||||
*/
|
||||
type StudioAssetEnv = Pick<Env, 'DB' | 'CDN_ASSETS'>
|
||||
|
||||
export default {
|
||||
fetch(
|
||||
request: Request,
|
||||
env: StudioAssetEnv,
|
||||
executionCtx: ExecutionContext
|
||||
): Response | Promise<Response> {
|
||||
const url = new URL(request.url)
|
||||
if (request.method === 'HEAD' && url.pathname.startsWith('/unityasset/')) {
|
||||
const filename = decodeURIComponent(url.pathname.slice('/unityasset/'.length))
|
||||
return studioUnityAssetHead(env, filename)
|
||||
}
|
||||
return app.fetch(request, env, executionCtx)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ export type Env = SharedHonoEnv & {
|
||||
// with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens
|
||||
// signed by `auth` verify here.
|
||||
JWT_SECRET: SecretsStoreSecret
|
||||
// Shared `recflare` database, read-only here. Studio bundle downloads check that
|
||||
// `unity_asset` records the requested filename (which is the R2 key) before
|
||||
// streaming it. The rooms worker owns that table's migration; this binding has no
|
||||
// migrations_dir.
|
||||
DB: D1Database
|
||||
// R2 bucket holding CDN binaries: signature blobs under `sigs/<name>` and
|
||||
// room build data under `room/<name>`.
|
||||
CDN_ASSETS: R2Bucket
|
||||
|
||||
@@ -271,6 +271,69 @@ describe('cdn endpoints', () => {
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /avatar/:asset streams the custom avatar item assetbundle from R2', async () => {
|
||||
// A bare filename — what a first-party item's `CurrentSaves[].UnityAsset` names.
|
||||
const name = 'anx442dm1a79kp9n4kugkbgd0.assetbundle'
|
||||
await env.CDN_ASSETS.put(`avatar/${name}`, new Uint8Array([7, 8, 9]))
|
||||
const res = await exports.default.fetch(`${ORIGIN}/avatar/${name}`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toBe('application/octet-stream')
|
||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([7, 8, 9]))
|
||||
})
|
||||
|
||||
test('GET /avatar/:asset 404s when the assetbundle is absent', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/avatar/missing.assetbundle`)
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET and HEAD /unityasset/:filename stream a stored studio bundle', async () => {
|
||||
await env.DB.prepare('DROP TABLE IF EXISTS unity_asset').run()
|
||||
const absent = await exports.default.fetch(`${ORIGIN}/unityasset/not-stored.assetbundle`)
|
||||
expect(absent.status).toBe(404)
|
||||
|
||||
// The rooms worker's table (UNITY_ASSET_SCHEMA_DDL in @repo/domain), inlined like
|
||||
// the route's own lookup is.
|
||||
await env.DB.prepare(
|
||||
`CREATE TABLE IF NOT EXISTS unity_asset (
|
||||
unity_asset_id TEXT NOT NULL,
|
||||
target INTEGER NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL DEFAULT 'main',
|
||||
filename TEXT NOT NULL,
|
||||
hash TEXT NOT NULL,
|
||||
PRIMARY KEY (unity_asset_id, target, version, kind)
|
||||
)`
|
||||
).run()
|
||||
// The filename IS the R2 key.
|
||||
const filename = 'studio/20930/abc111.windows.main.assetbundle'
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO unity_asset (unity_asset_id, target, version, kind, filename, hash)
|
||||
VALUES ('asset-a', 0, 1, 'main', ?1, 'n2SnR+G5fxMfq7a0Rylsm28CAeefs8U1bmx36JtqgGo=')`
|
||||
)
|
||||
.bind(filename)
|
||||
.run()
|
||||
await env.CDN_ASSETS.put(filename, new Uint8Array([1, 2, 3, 4]))
|
||||
// A key in the bucket that no row records is not served.
|
||||
await env.CDN_ASSETS.put('studio/20930/unrecorded.assetbundle', new Uint8Array([9]))
|
||||
|
||||
const res = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toBe('application/octet-stream')
|
||||
expect(new Uint8Array(await res.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4]))
|
||||
|
||||
const head = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, { method: 'HEAD' })
|
||||
expect(head.status).toBe(200)
|
||||
expect(head.headers.get('content-length')).toBe('4')
|
||||
expect(await head.arrayBuffer()).toEqual(new ArrayBuffer(0))
|
||||
|
||||
expect((await exports.default.fetch(`${ORIGIN}/unityasset/other.assetbundle`)).status).toBe(404)
|
||||
expect(
|
||||
(await exports.default.fetch(`${ORIGIN}/unityasset/studio/20930/unrecorded.assetbundle`))
|
||||
.status
|
||||
).toBe(404)
|
||||
expect((await exports.default.fetch(`${ORIGIN}/unityasset/a..b`)).status).toBe(400)
|
||||
})
|
||||
|
||||
test('GET /openapi.json documents every route', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/openapi.json`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -293,12 +356,15 @@ describe('cdn endpoints', () => {
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'GET /',
|
||||
'GET /avatar/{asset}',
|
||||
'GET /config/LoadingScreenTipData',
|
||||
'GET /config/{name}',
|
||||
'GET /data/{id}',
|
||||
'GET /invention/{dataBlob}',
|
||||
'GET /room/{dataBlob}',
|
||||
'GET /sigs/{sigName}',
|
||||
'GET /unityasset/{filename}',
|
||||
'HEAD /unityasset/{filename}',
|
||||
])
|
||||
|
||||
// Every operation carries a summary — a path present but undescribed is not
|
||||
|
||||
+12
-4
@@ -32,6 +32,17 @@
|
||||
"directory": "./static",
|
||||
"run_worker_first": true
|
||||
},
|
||||
// Shared `recflare` DB, read so `/unityasset/:filename` can resolve a Studio
|
||||
// bundle's public filename to the R2 key the studio worker stored. Studio owns
|
||||
// the migration (`apps/studio/migrations`); this binding deliberately has no
|
||||
// migrations_dir, so deploying cdn does not try to create those tables.
|
||||
"d1_databases": [
|
||||
{
|
||||
"binding": "DB",
|
||||
"database_name": "recflare",
|
||||
"database_id": "local"
|
||||
}
|
||||
],
|
||||
// CDN binaries (signature blobs + room build data) are stored as R2 objects
|
||||
// and streamed back by key. Keys are prefixed `sigs/` and `room/`.
|
||||
"r2_buckets": [
|
||||
@@ -52,10 +63,7 @@
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 1 // 100%
|
||||
}
|
||||
"enabled": false
|
||||
},
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@2toad/profanity": "3.3.0",
|
||||
"@repo/domain": "workspace:*",
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"@standard-community/standard-json": "0.3.5",
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { mergePlayerSettings as mergeStoredPlayerSettings, readPlayerSettings } from '@repo/domain'
|
||||
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
@@ -201,10 +202,7 @@ async function getPlayerSettings(
|
||||
env: Env,
|
||||
accountId: number
|
||||
): Promise<Record<string, string> | null> {
|
||||
return env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||
`player:${accountId}`,
|
||||
'json'
|
||||
).catch(() => null)
|
||||
return readPlayerSettings(env.RECFLARE_PLAYER_SETTINGS, accountId).catch(() => null)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,15 +249,15 @@ async function writeChatPrivacy(
|
||||
* Merge keys into the player's settings map, the way the `playersettings` worker's own PUT
|
||||
* does. Never a whole-map write: the bag holds every setting the player has (OOBE state,
|
||||
* tutorial mask, …) and storing one key on its own would wipe the rest. Values are strings,
|
||||
* which is what that worker stores and what its GET serves back.
|
||||
* which is what that worker stores and what its GET serves back. Nothing is written when
|
||||
* the patch changes no value — the client re-posts its privacy settings freely.
|
||||
*/
|
||||
async function mergePlayerSettings(
|
||||
env: Env,
|
||||
accountId: number,
|
||||
patch: Record<string, string>
|
||||
): Promise<void> {
|
||||
const merged: Record<string, string> = { ...(await getPlayerSettings(env, accountId)), ...patch }
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(`player:${accountId}`, JSON.stringify(merged))
|
||||
await mergeStoredPlayerSettings(env.RECFLARE_PLAYER_SETTINGS, accountId, patch)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -956,6 +956,17 @@ describe('PUT /thread/chatPrivacySetting', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Re-posting the stored value writes nothing to KV — writes are the cost, and the client
|
||||
// posts these freely. The raw value is seeded with whitespace JSON.stringify never
|
||||
// produces; surviving the PUT untouched means no write happened.
|
||||
it('does not write KV when the value is already stored', async () => {
|
||||
const padded = '{ "Recroom.OOBE": "77", "directMessagePrivacySetting": "Favorites" }'
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put('player:887010', padded)
|
||||
const res = await put(887010, { directMessagePrivacySetting: 'Favorites' })
|
||||
expect(await res.json()).toMatchObject({ directMessagePrivacySetting: 1 })
|
||||
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:887010', 'text')).toBe(padded)
|
||||
})
|
||||
|
||||
it('accepts the enum by ordinal too, and is case-insensitive about the name', async () => {
|
||||
expect(await (await put(887005, { directMessagePrivacySetting: '2' })).json()).toMatchObject({
|
||||
directMessagePrivacySetting: 2,
|
||||
|
||||
@@ -51,10 +51,7 @@
|
||||
},
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 1 // 100%
|
||||
}
|
||||
"enabled": false
|
||||
},
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
|
||||
+174
-27
@@ -8,6 +8,7 @@ import {
|
||||
ClubMembershipType,
|
||||
ClubVisibility,
|
||||
countClubsByCreator,
|
||||
countClubSubscribers,
|
||||
createClub,
|
||||
createClubAnnouncement,
|
||||
deleteClub,
|
||||
@@ -19,6 +20,10 @@ import {
|
||||
getClubsByMember,
|
||||
getHomeClub,
|
||||
getMembership,
|
||||
getOrCreateSubscriptionClub,
|
||||
getPublicClubsByMember,
|
||||
getSubscriptionClub,
|
||||
getSubscriptionMemberships,
|
||||
glyphLength,
|
||||
joinClub,
|
||||
leaveClub,
|
||||
@@ -30,6 +35,7 @@ import {
|
||||
setClubAdditionalImage,
|
||||
setHomeClub,
|
||||
setMemberType,
|
||||
subscribeToClub,
|
||||
updateClub,
|
||||
} from '@repo/domain'
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
@@ -62,7 +68,11 @@ import {
|
||||
ModifyClubRequest,
|
||||
NullEnvelope,
|
||||
SubscriberCountResponse,
|
||||
SubscribeRequest,
|
||||
SubscriptionDetailsResponse,
|
||||
SubscriptionErrorEnvelope,
|
||||
SubscriptionIdEnvelope,
|
||||
SubscriptionMembershipsResponse,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
} from './openapi'
|
||||
|
||||
@@ -73,8 +83,11 @@ import type { App } from './context'
|
||||
* Clubs Worker. Hosts the club endpoints the game client calls on the `clubs` host:
|
||||
* club creation and editing, membership (join / ask-to-join / leave / ban tiers),
|
||||
* search, announcements, the club gallery, a club's clubhouse room, and each player's
|
||||
* home club. Everything is D1-backed (the shared `recflare` database); the
|
||||
* `/subscription/*` routes are stubs, since there are no subscription clubs yet.
|
||||
* home club, plus subscriptions. A creator's subscribers are the members of their
|
||||
* SUBSCRIPTION CLUB (`ClubType` 1, one per account, reached only through
|
||||
* `/subscription/*`): subscribing is joining it. Everything is D1-backed (the shared
|
||||
* `recflare` database). A subscription club is provisioned by the first
|
||||
* `/subscription/details/:accountId` read or `POST /subscription/:accountId` for that account.
|
||||
*
|
||||
* Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
|
||||
*/
|
||||
@@ -104,7 +117,6 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
||||
*/
|
||||
const DEFAULT_MAX_CLUBS_PER_ACCOUNT = 10
|
||||
|
||||
|
||||
/**
|
||||
* The tiers `members/invite` may grant — the real member roles only. Creator (100) is
|
||||
* excluded so an invite can't mint a second owner, and the pending/none/banned states
|
||||
@@ -310,33 +322,56 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// A real Rec Room client endpoint with no backing implementation yet. The
|
||||
// client calls it on the clubs host at /subscription/mine/member (no /club
|
||||
// prefix) and sends no auth header, so it isn't gated. Returns an empty
|
||||
// array = no club subscription memberships (the client chokes on null).
|
||||
// The caller's subscriptions — the subscription clubs they're a member of, one row
|
||||
// each with the creator, the club and its subscriber count; the subscription-club
|
||||
// counterpart of `/club/mine/member`. The client calls it on the clubs host with no
|
||||
// /club prefix, and has been seen calling it WITHOUT an auth header: a missing or
|
||||
// invalid token answers `[]` rather than 401, since the client chokes on null and
|
||||
// this is not worth failing a launch over.
|
||||
.get(
|
||||
'/subscription/mine/member',
|
||||
describeRoute({
|
||||
tags: ['Subscriptions'],
|
||||
summary: 'The caller’s club-subscription memberships',
|
||||
summary: 'The caller’s subscriptions',
|
||||
description: [
|
||||
'A real client endpoint with no backing implementation yet. The client calls it on',
|
||||
'the clubs host at `/subscription/mine/member` (no `/club` prefix) and sends no auth',
|
||||
'header, so it isn’t gated. Always `[]` — no subscription memberships (the client',
|
||||
'chokes on null).',
|
||||
'The subscription clubs the caller is a member of (`POST /subscription/{accountId}`),',
|
||||
'oldest first — the creator, the club and its subscriber count; the subscription-club',
|
||||
'counterpart of `/club/mine/member`. A creator is a member of their own subscription',
|
||||
'club, so it is listed for them too. The client has been seen calling this without',
|
||||
'an auth header, so a missing or invalid token answers `[]` rather than 401 (the',
|
||||
'client chokes on null).',
|
||||
].join(' '),
|
||||
responses: { 200: json(JsonArray, 'Always empty for now') },
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(
|
||||
SubscriptionMembershipsResponse,
|
||||
'The caller’s subscriptions; `[]` when unauthenticated'
|
||||
),
|
||||
},
|
||||
}),
|
||||
(c) => c.json([])
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.json([])
|
||||
return c.json(await getSubscriptionMemberships(c.env.DB, id))
|
||||
}
|
||||
)
|
||||
|
||||
// Subscription details for an account (numeric id) — simulated: no club, no subs.
|
||||
// Subscription details for an account: their subscription club and its subscriber
|
||||
// count. This is where subscription clubs come from — the first read for an account
|
||||
// that has none PROVISIONS it (named after the creator, with them as its Creator-tier
|
||||
// member), so the client's "subscribe" button always has a club to post to. Only a
|
||||
// real account gets one; an unknown id reads as club 0. Public.
|
||||
.get(
|
||||
'/subscription/details/:accountId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Subscriptions'],
|
||||
summary: 'Subscription details for an account',
|
||||
description: 'Simulated — no subscription club, no subscribers.',
|
||||
description: [
|
||||
'The account’s subscription club and its subscriber count (the creator not counted).',
|
||||
'The first read for an account with no subscription club PROVISIONS one — a',
|
||||
'`ClubType` 1 club named after the creator — so `clubId` is only 0 for an id that',
|
||||
'isn’t an account. Public.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
name: 'accountId',
|
||||
@@ -346,14 +381,17 @@ const app = new Hono<App>()
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(SubscriptionDetailsResponse, 'Zeroed subscription details') },
|
||||
responses: { 200: json(SubscriptionDetailsResponse, 'The account’s subscription details') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
accountId: Number.parseInt(c.req.param('accountId'), 10),
|
||||
clubId: 0,
|
||||
subscriberCount: 0,
|
||||
async (c) => {
|
||||
const accountId = Number.parseInt(c.req.param('accountId'), 10)
|
||||
const club = await getOrCreateSubscriptionClub(c.env.DB, accountId)
|
||||
return c.json({
|
||||
accountId,
|
||||
clubId: club?.ClubId ?? 0,
|
||||
subscriberCount: club ? await countClubSubscribers(c.env.DB, club.ClubId) : 0,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Details for a named subscription (e.g. `rrplus`). The client deserializes this
|
||||
@@ -381,13 +419,85 @@ const app = new Hono<App>()
|
||||
(c) => c.json({})
|
||||
)
|
||||
|
||||
// Subscriber count for an account. No club subscriptions yet → 0.
|
||||
// Subscribe to a creator: the caller joins the SUBSCRIPTION CLUB of the account
|
||||
// `accountId` — the `ClubType` 1 club provisioned for them (here too, if nobody has
|
||||
// read their `details` yet) — becoming a Member of it outright. Idempotent:
|
||||
// resubscribing answers the existing membership's id. Refused for one's own club and
|
||||
// for a banned account; an id that isn't an account is a 404. The `roomId` form field
|
||||
// (the room the subscribe was posted from) is accepted and unused. The envelope is the
|
||||
// reference's, with `error` NULL on success rather than the club routes' `""`; `value`
|
||||
// is the membership row's id, which is a guess at what the reference's opaque id was.
|
||||
.post(
|
||||
'/subscription/:accountId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Subscriptions'],
|
||||
summary: 'Subscribe to a creator',
|
||||
description: [
|
||||
'The caller joins the subscription club of the account `accountId` as a Member,',
|
||||
'provisioning the club first if that account has none yet. Idempotent: subscribing',
|
||||
'again answers the existing membership’s id. Refused for the caller’s own club and',
|
||||
'for a banned account; an id that isn’t an account is a 404. `roomId` is accepted',
|
||||
'and unused. Note the envelope is the reference’s — `error` and `errorId` are null',
|
||||
'on success, not `""`.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'accountId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The creator to subscribe to (digits only)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
requestBody: form(SubscribeRequest, 'The room the subscribe was posted from'),
|
||||
responses: {
|
||||
200: json(SubscriptionIdEnvelope, 'The subscription’s id'),
|
||||
400: json(
|
||||
SubscriptionErrorEnvelope,
|
||||
'The caller’s own club, or the caller is banned from it'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
404: { description: 'No such account' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const club = await getOrCreateSubscriptionClub(
|
||||
c.env.DB,
|
||||
Number.parseInt(c.req.param('accountId'), 10)
|
||||
)
|
||||
if (club === null) return c.notFound()
|
||||
const result = await subscribeToClub(c.env.DB, club.ClubId, id)
|
||||
if (result === null) return c.notFound()
|
||||
|
||||
const refusal: Record<string, string | undefined> = {
|
||||
notSubscriptionClub: 'That is not a subscription club.',
|
||||
own: 'You cannot subscribe to yourself.',
|
||||
banned: 'You cannot subscribe to this creator.',
|
||||
}
|
||||
const message = refusal[result.outcome]
|
||||
if (message !== undefined) {
|
||||
return c.json({ value: null, success: false, errorId: null, error: message }, 400)
|
||||
}
|
||||
return c.json({ value: result.membershipId ?? 0, success: true, errorId: null, error: null })
|
||||
}
|
||||
)
|
||||
|
||||
// Subscriber count for an account: their subscription club's members, the creator
|
||||
// not counted. Public, a bare JSON integer; an account with no subscription club yet
|
||||
// (nobody has read its `details` or subscribed) has 0 — this read doesn't provision one.
|
||||
.get(
|
||||
'/subscription/subscriberCount/:accountId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Subscriptions'],
|
||||
summary: 'Subscriber count for an account',
|
||||
description: 'No club subscriptions yet, so this is always 0. A bare JSON integer.',
|
||||
description: [
|
||||
'How many subscribers the account’s subscription club has, its creator not counted.',
|
||||
'Public. A bare JSON integer; an account with no subscription club has 0.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
name: 'accountId',
|
||||
@@ -397,9 +507,15 @@ const app = new Hono<App>()
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(SubscriberCountResponse, 'Always 0') },
|
||||
responses: { 200: json(SubscriberCountResponse, 'The subscriber count') },
|
||||
}),
|
||||
(c) => c.json(0)
|
||||
async (c) => {
|
||||
const club = await getSubscriptionClub(
|
||||
c.env.DB,
|
||||
Number.parseInt(c.req.param('accountId'), 10)
|
||||
)
|
||||
return c.json(club ? await countClubSubscribers(c.env.DB, club.ClubId) : 0)
|
||||
}
|
||||
)
|
||||
|
||||
// The player's clubs that have unread announcements (MyClubsWithUnread-
|
||||
@@ -570,6 +686,35 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// The clubs shown on another player's profile: the PUBLIC clubs the account is a
|
||||
// member of. Public — a private club is left out for everyone, the account's own
|
||||
// owner included (they read theirs from /club/mine/member). An unknown account
|
||||
// simply has no clubs.
|
||||
.get(
|
||||
'/account/:accountId{[0-9]+}/clubs',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'The public clubs an account is a member of',
|
||||
description: [
|
||||
'The account’s memberships from `club_member`, oldest club first, PUBLIC clubs only',
|
||||
'(private clubs, subscription clubs and pending/denied/banned rows excluded). Public',
|
||||
'(no auth). An unknown account has `[]`.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
name: 'accountId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Account id (digits only)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(ClubDto.array(), 'The account’s public clubs') },
|
||||
}),
|
||||
async (c) =>
|
||||
c.json(await getPublicClubsByMember(c.env.DB, Number.parseInt(c.req.param('accountId'), 10)))
|
||||
)
|
||||
|
||||
// Club search / browse. Public, non-subscription clubs; `category` filters to that
|
||||
// category, `query` matches the name or description, `sort` picks the order (1 =
|
||||
// newest, 2 = by name, default = most members first), and `count` caps the page
|
||||
@@ -1566,7 +1711,9 @@ app.get(
|
||||
'membership (join / ask-to-join / leave, with the ban and pending tiers),',
|
||||
'search, announcements, the club gallery and clubhouse room, and each player’s home',
|
||||
'club. Everything is D1-backed on the shared `recflare` database; the',
|
||||
'`/subscription/*` routes are stubs, since there are no subscription clubs yet.',
|
||||
'A creator’s subscribers are the members of their subscription club (`ClubType` 1,',
|
||||
'one per account, provisioned by the first `details` read or subscribe for that account);',
|
||||
'the `/subscription/*` routes read and join those.',
|
||||
'',
|
||||
'Most writes answer the `{ error, success, value }` envelope with HTTP 200, and the',
|
||||
'ones the client re-renders a club screen from carry the club’s FULL details as',
|
||||
|
||||
@@ -245,11 +245,52 @@ export const ClubSearchResponse = z.object({
|
||||
TotalClubs: z.int().describe('How many clubs matched, not the page size'),
|
||||
})
|
||||
|
||||
/** `GET /subscription/details/:accountId` — simulated: no club, no subscribers. */
|
||||
/**
|
||||
* `GET /subscription/mine/member` — one of the caller's subscriptions (recovered from
|
||||
* the client's decoder: `AccountId` int, `ClubId` long, `SubscriberCount` int).
|
||||
*/
|
||||
export const SubscriptionMembershipDto = z.object({
|
||||
AccountId: z.int().describe('The creator subscribed to (the subscription club’s creator)'),
|
||||
ClubId: z.int().describe('Their subscription club'),
|
||||
SubscriberCount: z.int().describe('How many subscribers that club has, its creator not counted'),
|
||||
})
|
||||
|
||||
/** `GET /subscription/mine/member` — the caller's subscriptions, oldest first. */
|
||||
export const SubscriptionMembershipsResponse = z.array(SubscriptionMembershipDto)
|
||||
|
||||
/** `GET /subscription/details/:accountId` — an account's subscription club and its size. */
|
||||
export const SubscriptionDetailsResponse = z.object({
|
||||
accountId: z.int(),
|
||||
clubId: z.int().describe('Always 0 — no subscription clubs yet'),
|
||||
subscriberCount: z.int().describe('Always 0'),
|
||||
clubId: z
|
||||
.int()
|
||||
.describe(
|
||||
'The account’s subscription club, provisioned by this read or a subscribe; 0 only for a non-account'
|
||||
),
|
||||
subscriberCount: z.int().describe('Its subscribers, the creator not counted'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /subscription/:accountId` — the reference's envelope, which differs from the club
|
||||
* one: `error` (and `errorId`) are NULL on success rather than `""`. `value` is the
|
||||
* subscription's id.
|
||||
*/
|
||||
export const SubscriptionIdEnvelope = z.object({
|
||||
value: z
|
||||
.int()
|
||||
.describe(
|
||||
'The subscription’s id — the club membership row (the existing one when already subscribed)'
|
||||
),
|
||||
success: z.boolean(),
|
||||
errorId: z.null(),
|
||||
error: z.null(),
|
||||
})
|
||||
|
||||
/** A rejected subscribe: the same envelope, carrying the message. */
|
||||
export const SubscriptionErrorEnvelope = z.object({
|
||||
value: z.null(),
|
||||
success: z.boolean().describe('Always false'),
|
||||
errorId: z.null(),
|
||||
error: z.string().describe('The message shown to the player'),
|
||||
})
|
||||
|
||||
/** The set of category tags a club can be filed under — a fixed list. */
|
||||
@@ -258,7 +299,9 @@ export const CategoryTags = z.array(z.string())
|
||||
/** `GET /subscription/subscriberCount/:accountId` — a bare JSON integer. */
|
||||
export const SubscriberCountResponse = z
|
||||
.int()
|
||||
.describe('Always 0 — there are no club subscriptions yet')
|
||||
.describe(
|
||||
'The account’s subscription club’s subscribers, its creator not counted; 0 without a club'
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /club/:clubId/hasDisabledClubChat` — a bare JSON boolean, like the other
|
||||
@@ -317,6 +360,14 @@ export const HomeClubRequest = z.object({
|
||||
clubId: z.string().describe('The club to make home; the caller must be a member of it'),
|
||||
})
|
||||
|
||||
/** `POST /subscription/:accountId` — the room the player is subscribing from. */
|
||||
export const SubscribeRequest = z.object({
|
||||
roomId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The room the subscribe was posted from. Accepted and unused — nothing stores it'),
|
||||
})
|
||||
|
||||
/** `PUT /club/:clubId/minlevel` form body. */
|
||||
export const MinLevelRequest = z.object({
|
||||
minLevel: z.string().describe('The minimum player level to join; negative/NaN is 400'),
|
||||
|
||||
@@ -84,29 +84,256 @@ describe('clubs endpoints', () => {
|
||||
expect(res.status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /subscription/mine/member returns an empty array without a token', async () => {
|
||||
// The client calls this on the clubs host with no /club prefix and no auth.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/subscription/mine/member`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /subscription/details/:subscription returns an empty object', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/subscription/details/rrplus`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({})
|
||||
})
|
||||
|
||||
test('GET /subscription/details/:accountId returns simulated details', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/subscription/details/2`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ accountId: 2, clubId: 0, subscriberCount: 0 })
|
||||
})
|
||||
describe('subscriptions', () => {
|
||||
// A creator's subscribers are the members of their SUBSCRIPTION CLUB (ClubType 1).
|
||||
// Nothing provisions those yet, so the tests seed them the way the eventual
|
||||
// provisioner will: a ClubType 1 club with the creator as its Creator-tier member.
|
||||
let clubId = 0
|
||||
const CREATOR = 8800
|
||||
beforeAll(async () => {
|
||||
const idRow = await env.DB.prepare(
|
||||
'SELECT COALESCE(MAX(club_id), 0) + 1 AS next FROM club'
|
||||
).first<{
|
||||
next: number
|
||||
}>()
|
||||
clubId = idRow?.next ?? 1
|
||||
await env.DB.prepare('INSERT INTO club (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
ClubId: clubId,
|
||||
Name: 'Player8800 subscribers',
|
||||
Description: '',
|
||||
Category: '',
|
||||
Visibility: 1,
|
||||
Joinability: 0,
|
||||
AllowJuniors: true,
|
||||
MainImageName: '',
|
||||
ClubType: 1,
|
||||
ClubhouseRoomId: null,
|
||||
CreatorAccountId: CREATOR,
|
||||
IsRRO: false,
|
||||
MinLevel: 0,
|
||||
State: 0,
|
||||
MemberCount: 1,
|
||||
CreatedAt: '2026-07-01T00:00:00Z',
|
||||
})
|
||||
)
|
||||
.run()
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO club_member (club_id, account_id, membership_type, created_at) VALUES (?1, ?2, ?3, ?4)'
|
||||
)
|
||||
.bind(clubId, CREATOR, 100, '2026-07-01T00:00:00Z')
|
||||
.run()
|
||||
})
|
||||
|
||||
test('GET /subscription/subscriberCount/:id returns 0', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/subscription/subscriberCount/2`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(0)
|
||||
type Envelope = { value: number | null; success: boolean; errorId: null; error: string | null }
|
||||
type Membership = { AccountId: number; ClubId: number; SubscriberCount: number }
|
||||
type Details = { accountId: number; clubId: number; subscriberCount: number }
|
||||
|
||||
const subscribe = async (sub: string, id: number | string = CREATOR, body = 'roomId=78') =>
|
||||
exports.default.fetch(`${ORIGIN}/subscription/${id}`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
})
|
||||
const details = async (accountId: number): Promise<Details> => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/subscription/details/${accountId}`)
|
||||
expect(res.status).toBe(200)
|
||||
return (await res.json()) as Details
|
||||
}
|
||||
const count = async (accountId: number): Promise<number> => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/subscription/subscriberCount/${accountId}`)
|
||||
expect(res.status).toBe(200)
|
||||
return (await res.json()) as number
|
||||
}
|
||||
const mine = async (headers: Record<string, string> = {}): Promise<Membership[]> => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/subscription/mine/member`, { headers })
|
||||
expect(res.status).toBe(200)
|
||||
return (await res.json()) as Membership[]
|
||||
}
|
||||
|
||||
test('an id that isn’t an account reads as club 0 and never gets one', async () => {
|
||||
expect(await details(2)).toEqual({ accountId: 2, clubId: 0, subscriberCount: 0 })
|
||||
expect(await details(2)).toEqual({ accountId: 2, clubId: 0, subscriberCount: 0 })
|
||||
expect(await count(2)).toBe(0)
|
||||
const { results } = await env.DB.prepare(
|
||||
'SELECT club_id FROM club WHERE creator_account_id = 2'
|
||||
).all()
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
test('the first details read for an account provisions its subscription club', async () => {
|
||||
// Account 9101 exists (seeded) but has no subscription club: the count read
|
||||
// doesn't make one …
|
||||
expect(await count(9101)).toBe(0)
|
||||
expect(
|
||||
await env.DB.prepare('SELECT club_id FROM club WHERE creator_account_id = 9101').first()
|
||||
).toBeNull()
|
||||
|
||||
// … the details read does, and every read after agrees on it.
|
||||
const first = await details(9101)
|
||||
expect(first.clubId).toBeGreaterThan(0)
|
||||
expect(first).toEqual({ accountId: 9101, clubId: first.clubId, subscriberCount: 0 })
|
||||
expect(await details(9101)).toEqual(first)
|
||||
expect(await count(9101)).toBe(0)
|
||||
|
||||
// A ClubType 1 club named after the creator, Private, Open, with the creator as its
|
||||
// Creator-tier (and only) member; out of the creator's own club lists and search.
|
||||
const club = (await env.DB.prepare('SELECT data FROM club WHERE club_id = ?1')
|
||||
.bind(first.clubId)
|
||||
.first<{ data: string }>())!
|
||||
expect(JSON.parse(club.data)).toMatchObject({
|
||||
ClubId: first.clubId,
|
||||
Name: 'Player9101',
|
||||
ClubType: 1,
|
||||
Visibility: 0,
|
||||
Joinability: 0,
|
||||
CreatorAccountId: 9101,
|
||||
MemberCount: 1,
|
||||
})
|
||||
const { results: members } = await env.DB.prepare(
|
||||
'SELECT account_id, membership_type FROM club_member WHERE club_id = ?1'
|
||||
)
|
||||
.bind(first.clubId)
|
||||
.all()
|
||||
expect(members).toEqual([{ account_id: 9101, membership_type: 100 }])
|
||||
expect(await mine(await bearer('9101'))).toEqual([
|
||||
{ AccountId: 9101, ClubId: first.clubId, SubscriberCount: 0 },
|
||||
])
|
||||
const created = await exports.default.fetch(`${ORIGIN}/club/mine/created`, {
|
||||
headers: await bearer('9101'),
|
||||
})
|
||||
expect(await created.json()).toEqual([])
|
||||
const search = await exports.default.fetch(`${ORIGIN}/club/search?query=Player9101`)
|
||||
expect(((await search.json()) as { Clubs: unknown[] }).Clubs).toEqual([])
|
||||
|
||||
// Subscribing to the creator lands in the provisioned club.
|
||||
expect((await subscribe('9100', 9101)).status).toBe(200)
|
||||
expect(await details(9101)).toEqual({
|
||||
accountId: 9101,
|
||||
clubId: first.clubId,
|
||||
subscriberCount: 1,
|
||||
})
|
||||
})
|
||||
|
||||
test('a fresh subscription club has its creator as a member but no subscribers', async () => {
|
||||
expect(await details(CREATOR)).toEqual({ accountId: CREATOR, clubId, subscriberCount: 0 })
|
||||
expect(await count(CREATOR)).toBe(0)
|
||||
// The creator is a Creator-tier member of their own club, so it's listed for them.
|
||||
expect(await mine(await bearer(String(CREATOR)))).toEqual([
|
||||
{ AccountId: CREATOR, ClubId: clubId, SubscriberCount: 0 },
|
||||
])
|
||||
})
|
||||
|
||||
test('GET /subscription/mine/member answers [] without a token', async () => {
|
||||
// The client has been seen calling this with no auth header: `[]`, not 401 (it
|
||||
// chokes on null). A garbage token is the same; so is a player with none.
|
||||
expect(await mine()).toEqual([])
|
||||
expect(await mine({ Authorization: 'Bearer nope' })).toEqual([])
|
||||
expect(await mine(await bearer('8801'))).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /subscription/:accountId joins the creator’s subscription club and the reads follow', async () => {
|
||||
// Auth-gated.
|
||||
const anon = await exports.default.fetch(`${ORIGIN}/subscription/${CREATOR}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'roomId=78',
|
||||
})
|
||||
expect(anon.status).toBe(401)
|
||||
|
||||
// The reference envelope: `error`/`errorId` null on success, `value` an id.
|
||||
const first = await subscribe('8801')
|
||||
expect(first.status).toBe(200)
|
||||
const created = (await first.json()) as Envelope
|
||||
expect(created).toMatchObject({ success: true, errorId: null, error: null })
|
||||
expect(created.value).toBeGreaterThan(0)
|
||||
|
||||
// The subscriber is a plain Member of the club; the club's count moved.
|
||||
const row = await env.DB.prepare(
|
||||
'SELECT club_member_id, membership_type FROM club_member WHERE club_id = ?1 AND account_id = 8801'
|
||||
)
|
||||
.bind(clubId)
|
||||
.first<{ club_member_id: number; membership_type: number }>()
|
||||
expect(row).toEqual({ club_member_id: created.value, membership_type: 10 })
|
||||
|
||||
// Idempotent: subscribing again (from another room, in the other casing) answers
|
||||
// the same id and nothing doubles.
|
||||
const again = await subscribe('8801', CREATOR, 'RoomId=99')
|
||||
expect(again.status).toBe(200)
|
||||
expect(((await again.json()) as Envelope).value).toBe(created.value)
|
||||
|
||||
// A second subscriber; every read agrees, and the creator isn't counted.
|
||||
expect((await subscribe('8802', CREATOR, '')).status).toBe(200)
|
||||
expect(await count(CREATOR)).toBe(2)
|
||||
expect(await details(CREATOR)).toEqual({ accountId: CREATOR, clubId, subscriberCount: 2 })
|
||||
expect(await mine(await bearer('8801'))).toEqual([
|
||||
{ AccountId: CREATOR, ClubId: clubId, SubscriberCount: 2 },
|
||||
])
|
||||
expect(await mine(await bearer('8802'))).toEqual([
|
||||
{ AccountId: CREATOR, ClubId: clubId, SubscriberCount: 2 },
|
||||
])
|
||||
|
||||
// The club's MemberCount counts the creator too, and the subscription club stays
|
||||
// out of the ordinary my-clubs lists.
|
||||
const club = await env.DB.prepare('SELECT data FROM club WHERE club_id = ?1')
|
||||
.bind(clubId)
|
||||
.first<{ data: string }>()
|
||||
expect((JSON.parse(club?.data ?? '{}') as { MemberCount: number }).MemberCount).toBe(3)
|
||||
const listed = await exports.default.fetch(`${ORIGIN}/club/mine/member`, {
|
||||
headers: await bearer('8801'),
|
||||
})
|
||||
expect(await listed.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('POST /subscription/:accountId refuses the creator and the banned, 404s a non-account', async () => {
|
||||
const refused = async (res: Response, error: string): Promise<void> => {
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({ value: null, success: false, errorId: null, error })
|
||||
}
|
||||
await refused(await subscribe(String(CREATOR)), 'You cannot subscribe to yourself.')
|
||||
|
||||
// A ban sticks.
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO club_member (club_id, account_id, membership_type, created_at) VALUES (?1, 8804, -1, ?2)'
|
||||
)
|
||||
.bind(clubId, '2026-07-01T00:00:00Z')
|
||||
.run()
|
||||
await refused(await subscribe('8804'), 'You cannot subscribe to this creator.')
|
||||
|
||||
// An id that isn't an account is a 404 and mints no club; a non-numeric id
|
||||
// doesn't match the route.
|
||||
expect((await subscribe('8801', 999999)).status).toBe(404)
|
||||
expect(
|
||||
await env.DB.prepare('SELECT club_id FROM club WHERE creator_account_id = 999999').first()
|
||||
).toBeNull()
|
||||
expect((await subscribe('8801', 'abc')).status).toBe(404)
|
||||
})
|
||||
|
||||
test('subscribing to an account with no club yet provisions it', async () => {
|
||||
// Account 9100 exists (seeded) and nobody has read its details.
|
||||
expect(
|
||||
await env.DB.prepare('SELECT club_id FROM club WHERE creator_account_id = 9100').first()
|
||||
).toBeNull()
|
||||
const res = await subscribe('8801', 9100)
|
||||
expect(res.status).toBe(200)
|
||||
expect(((await res.json()) as Envelope).success).toBe(true)
|
||||
|
||||
const d = await details(9100)
|
||||
expect(d.clubId).toBeGreaterThan(0)
|
||||
expect(d).toEqual({ accountId: 9100, clubId: d.clubId, subscriberCount: 1 })
|
||||
expect(await mine(await bearer('8801'))).toContainEqual({
|
||||
AccountId: 9100,
|
||||
ClubId: d.clubId,
|
||||
SubscriberCount: 1,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('GET /announcements/v2/mine/unread returns []', async () => {
|
||||
@@ -142,6 +369,64 @@ describe('clubs endpoints', () => {
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /account/:id/clubs lists only the PUBLIC clubs the account is a member of', async () => {
|
||||
const club = (ClubId: number, Visibility: number, ClubType = 0) => ({
|
||||
ClubId,
|
||||
Name: `Club ${ClubId}`,
|
||||
Description: '',
|
||||
Category: 'Social',
|
||||
Visibility,
|
||||
Joinability: 0,
|
||||
AllowJuniors: true,
|
||||
MainImageName: '',
|
||||
ClubType,
|
||||
ClubhouseRoomId: null,
|
||||
CreatorAccountId: 4500,
|
||||
IsRRO: false,
|
||||
MinLevel: 0,
|
||||
State: 0,
|
||||
MemberCount: 2,
|
||||
CreatedAt: `2026-07-0${ClubId - 9100}T00:00:00Z`,
|
||||
})
|
||||
// 9101 public, 9102 private, 9103 public but only a pending request, 9104 a
|
||||
// public subscription club, 9105 public.
|
||||
const clubs = [club(9101, 1), club(9102, 0), club(9103, 1), club(9104, 1, 1), club(9105, 1)]
|
||||
const insertClub = env.DB.prepare('INSERT INTO club (data) VALUES (?1)')
|
||||
const insertMember = env.DB.prepare(
|
||||
'INSERT INTO club_member (club_id, account_id, membership_type, created_at) VALUES (?1, 4501, ?2, ?3)'
|
||||
)
|
||||
await env.DB.batch([
|
||||
...clubs.map((c) => insertClub.bind(JSON.stringify(c))),
|
||||
...clubs.map((c) =>
|
||||
insertMember.bind(c.ClubId, c.ClubId === 9103 ? 1 : 10, '2026-07-10T00:00:00Z')
|
||||
),
|
||||
])
|
||||
const pending = await env.DB.prepare(
|
||||
'SELECT membership_type FROM club_member WHERE club_id = 9103 AND account_id = 4501'
|
||||
).first<{ membership_type: number }>()
|
||||
expect(pending?.membership_type).toBeLessThan(10)
|
||||
|
||||
// No auth needed; the private, pending and subscription clubs are left out.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/4501/clubs`)
|
||||
expect(res.status).toBe(200)
|
||||
const listed = (await res.json()) as Array<{ ClubId: number; Visibility: number }>
|
||||
expect(listed.map((c) => c.ClubId)).toEqual([9101, 9105])
|
||||
expect(listed[0]).toMatchObject({ Name: 'Club 9101', Visibility: 1, MemberCount: 2 })
|
||||
|
||||
// The member themself gets the same public-only list here.
|
||||
const own = await exports.default.fetch(`${ORIGIN}/account/4501/clubs`, {
|
||||
headers: await bearer('4501'),
|
||||
})
|
||||
expect(((await own.json()) as Array<{ ClubId: number }>).map((c) => c.ClubId)).toEqual([
|
||||
9101, 9105,
|
||||
])
|
||||
|
||||
// An account with no memberships has no clubs; a non-numeric id doesn't match.
|
||||
const none = await exports.default.fetch(`${ORIGIN}/account/4599/clubs`)
|
||||
expect(await none.json()).toEqual([])
|
||||
expect((await exports.default.fetch(`${ORIGIN}/account/abc/clubs`)).status).toBe(404)
|
||||
})
|
||||
|
||||
test('the my-clubs lists exclude subscription clubs (ClubType 1)', async () => {
|
||||
// A subscription club the player both created and is a member of. It's reached
|
||||
// through /subscription/*, so it must not show up among their clubs.
|
||||
@@ -1419,6 +1704,7 @@ describe('clubs endpoints', () => {
|
||||
'DELETE /club/{clubId}',
|
||||
'DELETE /club/{clubId}/additionalimage/{index}',
|
||||
'DELETE /club/{clubId}/clubhouse',
|
||||
'GET /account/{accountId}/clubs',
|
||||
'GET /announcements/club/{clubId}',
|
||||
'GET /announcements/v2/mine/unread',
|
||||
'GET /announcements/v2/subscription/mine/unread',
|
||||
@@ -1441,6 +1727,7 @@ describe('clubs endpoints', () => {
|
||||
'POST /club/{clubId}/join',
|
||||
'POST /club/{clubId}/leave',
|
||||
'POST /club/{clubId}/members/leave',
|
||||
'POST /subscription/{accountId}',
|
||||
'PUT /club/home/me',
|
||||
'PUT /club/{clubId}/additionalimage/{index}',
|
||||
'PUT /club/{clubId}/clubhouse',
|
||||
|
||||
@@ -30,10 +30,7 @@
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 1 // 100%
|
||||
}
|
||||
"enabled": false
|
||||
},
|
||||
// The per-account cap (MAX_CLUBS_PER_ACCOUNT) is deliberately NOT set here. It's
|
||||
// injected at deploy time from the gitignored .env (RECFLARE_MAX_CLUBS_PER_ACCOUNT, see
|
||||
|
||||
@@ -9,10 +9,7 @@
|
||||
"enabled": true
|
||||
},
|
||||
"observability": {
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 1 // 100%
|
||||
}
|
||||
"enabled": false
|
||||
},
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
|
||||
@@ -16,10 +16,7 @@
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 1 // 100%
|
||||
}
|
||||
"enabled": false
|
||||
},
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Headwear",
|
||||
"sectionType": 4,
|
||||
"sectionSubType": "Items_Headwear",
|
||||
"source": "AlgorithmicList",
|
||||
"source": "UnifiedAlgorithmicList",
|
||||
"sourceMetadata": "headwearitems",
|
||||
"displayMetadata": "{\r\n \"DisplayTitle\":\"Headwear\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 7,\r\n \"categoryUriNames\": \"HatsItems\",\r\n \"itemCount\": 5\r\n}"
|
||||
},
|
||||
@@ -11,7 +11,7 @@
|
||||
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Tops",
|
||||
"sectionType": 4,
|
||||
"sectionSubType": "Items_Tops",
|
||||
"source": "AlgorithmicList",
|
||||
"source": "UnifiedAlgorithmicList",
|
||||
"sourceMetadata": "topsitems",
|
||||
"displayMetadata": "{\r\n \"DisplayTitle\":\"Tops\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 8,\r\n \"categoryUriNames\": \"TorsoItems\",\r\n \"itemCount\": 5\r\n}"
|
||||
},
|
||||
@@ -19,7 +19,7 @@
|
||||
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Bottoms",
|
||||
"sectionType": 4,
|
||||
"sectionSubType": "Items_Bottoms",
|
||||
"source": "AlgorithmicList",
|
||||
"source": "UnifiedAlgorithmicList",
|
||||
"sourceMetadata": "bottomsitems",
|
||||
"displayMetadata": "{\r\n \"DisplayTitle\":\"Bottoms\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 17,\r\n \"categoryUriNames\": \"BottomsItems\",\r\n \"itemCount\": 5\r\n}"
|
||||
},
|
||||
@@ -27,7 +27,7 @@
|
||||
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Footwear",
|
||||
"sectionType": 4,
|
||||
"sectionSubType": "Items_Footwear",
|
||||
"source": "AlgorithmicList",
|
||||
"source": "UnifiedAlgorithmicList",
|
||||
"sourceMetadata": "footwearitems",
|
||||
"displayMetadata": "{\r\n \"DisplayTitle\":\"Footwear\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 18,\r\n \"categoryUriNames\": \"ShoesItems\",\r\n \"itemCount\": 5\r\n}"
|
||||
},
|
||||
@@ -35,7 +35,7 @@
|
||||
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Waist",
|
||||
"sectionType": 4,
|
||||
"sectionSubType": "Items_Waist",
|
||||
"source": "AlgorithmicList",
|
||||
"source": "UnifiedAlgorithmicList",
|
||||
"sourceMetadata": "waistitems",
|
||||
"displayMetadata": "{\r\n \"DisplayTitle\":\"Waist\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 23,\r\n \"categoryUriNames\": \"WaistItems\",\r\n \"itemCount\": 5\r\n}"
|
||||
},
|
||||
@@ -43,7 +43,7 @@
|
||||
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Hands",
|
||||
"sectionType": 4,
|
||||
"sectionSubType": "Items_Hands",
|
||||
"source": "AlgorithmicList",
|
||||
"source": "UnifiedAlgorithmicList",
|
||||
"sourceMetadata": "handsitems",
|
||||
"displayMetadata": "{\r\n \"DisplayTitle\":\"Hands\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 10,\r\n \"categoryUriNames\": \"GlovesItems\",\r\n \"itemCount\": 5\r\n}"
|
||||
},
|
||||
@@ -51,7 +51,7 @@
|
||||
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Shoulder",
|
||||
"sectionType": 4,
|
||||
"sectionSubType": "Items_Shoulder",
|
||||
"source": "AlgorithmicList",
|
||||
"source": "UnifiedAlgorithmicList",
|
||||
"sourceMetadata": "shoulderitems",
|
||||
"displayMetadata": "{\r\n \"DisplayTitle\":\"Shoulders & Back\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 22,\r\n \"categoryUriNames\": \"ShoulderItems\",\r\n \"itemCount\": 5\r\n}"
|
||||
},
|
||||
@@ -59,7 +59,7 @@
|
||||
"id": "StoreItemCarousel_UnifiedAlgorithmicList_Hairstyles",
|
||||
"sectionType": 4,
|
||||
"sectionSubType": "Items_Hairstyles",
|
||||
"source": "AlgorithmicList",
|
||||
"source": "UnifiedAlgorithmicList",
|
||||
"sourceMetadata": "hairitems",
|
||||
"displayMetadata": "{\r\n \"DisplayTitle\":\"Hairstyles\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 24,\r\n \"categoryUriNames\": \"HeadHairItems\",\r\n \"itemCount\": 5\r\n}"
|
||||
},
|
||||
@@ -67,7 +67,7 @@
|
||||
"id": "StoreItemCarousel_UnifiedAlgorithmicList_FacialHair",
|
||||
"sectionType": 4,
|
||||
"sectionSubType": "Items_FacialHair",
|
||||
"source": "AlgorithmicList",
|
||||
"source": "UnifiedAlgorithmicList",
|
||||
"sourceMetadata": "facialhairitems",
|
||||
"displayMetadata": "{\r\n \"DisplayTitle\":\"Facial Hair\",\r\n \"categoryDepth\":1,\r\n \"categoryCarouselType\": 25,\r\n \"categoryUriNames\": \"FacialHairItems\",\r\n \"itemCount\": 5\r\n}"
|
||||
}
|
||||
|
||||
@@ -26,10 +26,7 @@
|
||||
},
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 1 // 100%
|
||||
}
|
||||
"enabled": false
|
||||
},
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
|
||||
+69
-4
@@ -25,7 +25,11 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
|
||||
| GET | `/api/avatar/v2` | ✓ | The player's own avatar |
|
||||
| POST | `/api/avatar/v2/set` | ✓ | Save the player's avatar |
|
||||
| GET | `/api/checklist/v1/current` | ✓ | NUX checklist (stub `[]`) |
|
||||
| GET | `/api/itemWishlists/v1/wishlist/me` | ✓ | Item wishlist (stub `[]`) |
|
||||
| GET | `/api/itemWishlists/v1/wishlist/me` | ✓ | The caller's item wishlist |
|
||||
| GET | `/api/itemWishlists/v1/wishlist/:accountId` | ✓ | Another player's item wishlist |
|
||||
| POST | `/api/itemWishlists/v1/wishlist/add` | ✓ | Wish for an item (form, enveloped) |
|
||||
| POST | `/api/itemWishlists/v1/wishlist/remove` | ✓ | Un-wish an item (a POST, not DELETE) |
|
||||
| POST | `/api/itemWishlists/v1/isonwishlist/bulk` | ✓ | Per-player "has it wished for" booleans |
|
||||
| GET | `/api/avatar/v3/saved` | ✓ | Saved outfits |
|
||||
| POST | `/api/avatar/v3/saved/set` | ✓ | Save an outfit into a slot |
|
||||
| GET | `/api/avatar/v2/gifts` | ✓ | Pending (unopened) gift boxes |
|
||||
@@ -39,6 +43,7 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
|
||||
| POST | `/api/settings/v2/set` | ✓ | Persist settings (accept-and-ack) |
|
||||
| GET | `/api/consumables/v2/getUnlocked` | ✓ | Unlocked consumables |
|
||||
| POST | `/api/consumables/v1/consume` | ✓ | Consume an owned consumable |
|
||||
| POST | `/api/consumables/v1/updateActive` | ✓ | Mark a consumable active (stub, empty) |
|
||||
| GET | `/api/storefronts/v4/balance/:currencyType` | ✓ | Currency balance |
|
||||
| GET | `/api/storefronts/v3/giftdropstore/:id` | | Gift-drop storefront catalog |
|
||||
| POST | `/api/storefronts/v2/buyItem` | ✓ | Buy a storefront item |
|
||||
@@ -49,7 +54,12 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
|
||||
| GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) |
|
||||
| POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 5 XP + gift box |
|
||||
| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) |
|
||||
| GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) |
|
||||
| GET | `/api/roomkeys/v1/room` | | The keys a room has listed |
|
||||
| POST | `/api/roomkeys/v1/create` | ✓ | List a key for a room (owner/co-owner) |
|
||||
| PUT | `/api/roomkeys/v1/updateAll` | ✓ | Edit one key (owner/co-owner) |
|
||||
| GET | `/api/storefronts/v1/buyRoomKey` | ✓ | Buy a room key; pays the room owner |
|
||||
| POST | `/api/storefronts/v1/PurchaseRoomKeyWithCurrency` | ✓ | Buy a room key with its room currency |
|
||||
| POST | `/api/roomCurrencies/v2/purchase` | ✓ | Buy room currency with tokens (offer) |
|
||||
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | ~ | Gold year for `developer`s, else `{}` |
|
||||
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
|
||||
|
||||
@@ -487,6 +497,16 @@ failure can't leave a box promising XP nobody was credited.
|
||||
- **Deliberately smaller than a level.** The first level costs 10 XP, so a single action
|
||||
can't be a level-up — it takes two rewards to reach level 2, and the early levels are paced
|
||||
by the hourly cooldown rather than cleared in one match.
|
||||
- **A NEW activity pays 250 tokens on top** (`NEW_ACTIVITY_BONUS_TOKENS`): the first claim
|
||||
ever in a `giftContext`, whichever reward type asked for it — `isNewActivity` counts the
|
||||
player's rows in that context right after the claim and is true when the claim's own row
|
||||
is the only one. Counting after the write is what stops two asks landing together
|
||||
(`FirstActivityOfDay` and `PostGameActivity` for the same match) from both paying: a race
|
||||
can skip the bonus, never double it. The tokens are credited to the RecCenterTokens
|
||||
balance (`StorefrontBalanceUpdate` with the resulting total) and arrive in a **second box**
|
||||
of their own — `GiftContext` 51 (`GameRewardsTokens`), `Currency` 250, no XP — after the
|
||||
reward's, so the player sees what they were for. The contextless `''` bucket is not an
|
||||
activity and never pays it.
|
||||
- **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.
|
||||
@@ -559,6 +579,50 @@ is our cap.
|
||||
`GET /api/gamerewards/v1/pending` stays `[]`: with rewards claimed on request, nothing sits
|
||||
waiting to be collected.
|
||||
|
||||
## Discord supporter gift (`src/discord-role-gift.ts`)
|
||||
|
||||
A cron (`triggers.crons` in `wrangler.jsonc`) hands every account holding a mapped Discord
|
||||
role that role's RecCenterTokens in a gift box, every time it fires. The map is the
|
||||
`DISCORD_ROLE_TOKENS` var — `RECFLARE_DISCORD_ROLE_TOKENS` in the root `.env`, see
|
||||
`.env.example` — as `<roleId>=<tokens>` pairs separated by commas:
|
||||
|
||||
```
|
||||
928457923857943795=2500,2938479238479234=10000
|
||||
```
|
||||
|
||||
- **The schedule is the cadence.** It ships weekly (Monday 05:00 UTC); make it daily by
|
||||
changing the cron line. There is no ledger and no notion of a period in the code — every
|
||||
run pays everyone it finds, so running it twice pays twice, and a hand run is an extra
|
||||
gift on purpose.
|
||||
- **One box per account per run, the highest amount wins.** An account holding both roles
|
||||
above gets one box of 10,000, not two boxes or 12,500, so the map reads as tiers however
|
||||
Discord stacks the roles. A role named twice keeps its last amount; an entry that isn't
|
||||
`digits=positive integer` is logged and skipped, not fatal. No roles at all — unset, empty,
|
||||
all rejected — leaves the cron a logged no-op.
|
||||
- **Roles come from `platform_account.role`**, the snapshot `www`'s benefits claim writes and
|
||||
its daily role sweep (04:30 UTC, with a bot token) refreshes. This cron never asks Discord
|
||||
anything, which is why the default fires half an hour after that sweep. A player who has
|
||||
never claimed on the website has no link and is never paid, whatever they hold in the guild.
|
||||
- **The grant is econ's usual three moves**: seed the signup grant (`STARTING_TOKENS`), credit
|
||||
the balance, store a token box from the Coach (context 0, `AvatarItemType` null), then
|
||||
push `StorefrontBalanceUpdate` with the resulting total and `GiftPackageReceivedImmediate`
|
||||
for the box. An offline player finds the box on their next `GET /api/avatar/v2/gifts`.
|
||||
- A grant that fails partway is logged with the account, role and amount and is not retried —
|
||||
a retry that also failed partway is how a balance gets credited twice.
|
||||
- **The first link is paid on demand.** `www`'s benefits claim calls `grantDiscordRoleGift`
|
||||
— one account, the roles Discord just served, the same map and box — the first time an
|
||||
account links its Discord, so a new supporter gets one box now rather than after the next
|
||||
run. `www` decides "first" from the link table; this module keeps no ledger either way, and
|
||||
the cron pays that account again on its next run like everyone else.
|
||||
- **A whole run can be paid by hand.** `www`'s staff panel ("Bonus reward!", developer-only,
|
||||
`POST /api/staff/discord-roles/supporter-gift`) calls `grantDiscordRoleGifts` with its own
|
||||
bindings — the same map, audience and box as the cron — and answers with the run's summary.
|
||||
No ledger here either: it pays everyone again on top of the schedule, which is the point.
|
||||
|
||||
One D1 read for the links, then a handful of writes and two hub calls per box, sequentially.
|
||||
A cron invocation has a fixed subrequest budget (50 free, 1000 paid); a community whose
|
||||
supporters outgrow that is the point to batch the run.
|
||||
|
||||
## Bindings
|
||||
|
||||
| Binding | Type | Notes |
|
||||
@@ -568,6 +632,7 @@ waiting to be collected.
|
||||
| `ASSETS` | static assets | Serves `sf{N}.json` storefront catalogs |
|
||||
| `RECFLARE_NOTIFICATIONS_HUB` | Durable Object | Cross-worker RPC to the `notify` worker's hub |
|
||||
| `STARTING_TOKENS` | var | Optional; new-player token grant (default in balance-db) |
|
||||
| `DISCORD_ROLE_TOKENS` | var | Optional; Discord role → token gift per cron run (above) |
|
||||
|
||||
Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no code change.
|
||||
|
||||
@@ -582,8 +647,8 @@ Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no cod
|
||||
publish; per-item weighting and a multi-catalog pool would both need a manifest of the
|
||||
storefronts, which the ASSETS binding can't enumerate.
|
||||
- Consumables are granted and listed but never spent by gameplay, so `Count` only grows.
|
||||
- Several routes (room keys, wishlist, equipment, room consumables/currencies) are
|
||||
empty-list stubs pending their own stores.
|
||||
- Several routes (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
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
-- Room currencies — a room's own money.
|
||||
--
|
||||
-- A creator mints one for their room ("MySpecialTokens"), hands it out for doing things in
|
||||
-- there, and sells things for it: finish the obstacle course, earn 50 tokens, spend them on a
|
||||
-- custom shirt at the shop by the door. Play money for one room, meaning nothing outside it.
|
||||
--
|
||||
-- A row here is the CURRENCY — its name, its coin art, how fast the room may hand it out.
|
||||
-- What a player has actually earned is `room_balance` (migration 0019). Owned by the `econ`
|
||||
-- worker; generated from src/room-currency-db.ts (ROOM_CURRENCY_SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- These are NOT balances and must never reach the `balance` table, which is keyed by
|
||||
-- (account_id, currency_type) alone. That key cannot say WHICH room's currency a row is, so
|
||||
-- one room's tokens would spend in every other room — see CurrencyType in src/balance-db.ts,
|
||||
-- where RoomCurrency is left out of the spendable set for exactly this reason.
|
||||
--
|
||||
-- Every currency here is CurrencyType 300 (RoomCurrency) — that is what the type MEANS — and
|
||||
-- is told apart from the others by its own `currency_id`, so the type is not a column.
|
||||
--
|
||||
-- `currency_id` is a GUID rather than an autoincrement: it is how the client names this
|
||||
-- currency everywhere afterwards, and a room's currencies are minted independently of every
|
||||
-- other room's, so an id that says nothing about ordering is the honest one.
|
||||
--
|
||||
-- `limit_amount`, not `limit` — LIMIT is a SQL keyword, and a column that has to be quoted at
|
||||
-- every use is a column that will eventually not be. It holds the most of this currency the
|
||||
-- room may award PER DAY: a faucet rate, not a cap on what a player may hold. Nothing enforces
|
||||
-- it yet — no day's awards are tracked anywhere — so it is stored and served and otherwise
|
||||
-- inert.
|
||||
--
|
||||
-- `shape` and `color` are the client's own indices into its coin art (the create body sends
|
||||
-- `Shape=0&Color=19`); nothing interprets them, they are stored and served back. `image_name`
|
||||
-- is separate and stays empty until a room can upload custom coin art — the client's model
|
||||
-- carries both, so the column is here rather than in a later migration.
|
||||
--
|
||||
-- NOTE: `image_name` was NOT NULL DEFAULT '' here, and 0018 makes it nullable — the client
|
||||
-- serves null, not an empty string, for a currency with no custom coin art. This file is left
|
||||
-- as it shipped; the current shape of the table is 0017 + 0018.
|
||||
CREATE TABLE IF NOT EXISTS room_currency (
|
||||
currency_id TEXT PRIMARY KEY,
|
||||
room_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
limit_amount INTEGER NOT NULL,
|
||||
shape INTEGER NOT NULL,
|
||||
color INTEGER NOT NULL,
|
||||
image_name TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
modified_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Every read is "this room's currencies" — the whole access pattern.
|
||||
CREATE INDEX IF NOT EXISTS idx_room_currency_room ON room_currency (room_id);
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Make `room_currency.image_name` nullable, and turn the empty strings already stored into
|
||||
-- nulls. Owned by the `econ` worker; 0017 created the table with `image_name TEXT NOT NULL
|
||||
-- DEFAULT ''`. Generated from src/room-currency-db.ts (ROOM_CURRENCY_SCHEMA_DDL, which mirrors
|
||||
-- the table as it stands AFTER this migration) — keep in sync.
|
||||
--
|
||||
-- The client's own model has `ImageName` as a nullable string and serves NULL for a currency
|
||||
-- with no custom coin art; an empty string is a different value to its decoder, so a currency
|
||||
-- created before this read back as having art named "". Nothing can upload coin art yet, so
|
||||
-- every row is affected and every one of them should be null.
|
||||
--
|
||||
-- A rebuild rather than an ALTER: SQLite cannot drop a NOT NULL constraint in place. This is
|
||||
-- the standard four-step swap — build the new shape, copy across, drop the old, rename — and
|
||||
-- the index goes with the dropped table, so it is recreated at the end rather than left to be
|
||||
-- noticed later by a slow query. `NULLIF(image_name, '')` is the conversion: it maps '' to
|
||||
-- NULL and leaves a real name alone, so a row that somehow already carries art keeps it.
|
||||
--
|
||||
-- Safe to re-run in spirit but not in fact — it is a migration, applied once, and the rename
|
||||
-- would fail on a second pass because `room_currency_new` no longer exists.
|
||||
CREATE TABLE room_currency_new (
|
||||
currency_id TEXT PRIMARY KEY,
|
||||
room_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
limit_amount INTEGER NOT NULL,
|
||||
shape INTEGER NOT NULL,
|
||||
color INTEGER NOT NULL,
|
||||
image_name TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
modified_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO room_currency_new (
|
||||
currency_id, room_id, name, description, limit_amount, shape, color, image_name, created_at, modified_at
|
||||
)
|
||||
SELECT
|
||||
currency_id, room_id, name, description, limit_amount, shape, color, NULLIF(image_name, ''), created_at, modified_at
|
||||
FROM room_currency;
|
||||
|
||||
DROP TABLE room_currency;
|
||||
|
||||
ALTER TABLE room_currency_new RENAME TO room_currency;
|
||||
|
||||
-- Recreated because dropping the old table took its index with it. Same index 0017 defined:
|
||||
-- every read is "this room's currencies".
|
||||
CREATE INDEX IF NOT EXISTS idx_room_currency_room ON room_currency (room_id);
|
||||
@@ -0,0 +1,28 @@
|
||||
-- How much of a room's own money each player has earned — the other half of room currencies
|
||||
-- (the currencies themselves are `room_currency`, migration 0017). Written by
|
||||
-- `POST /api/roomcurrencies/v1/awardCurrency/bulk`, which is how a room pays a player for
|
||||
-- finishing its obstacle course. Owned by the `econ` worker; generated from
|
||||
-- src/room-currency-db.ts (ROOM_BALANCE_SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- Keyed by CURRENCY, not by currency type, and that difference is the whole point: the
|
||||
-- account-scoped `balance` table's (account_id, currency_type) key cannot say WHICH room's
|
||||
-- currency a row is, so one room's tokens would spend in every other room. Room currencies
|
||||
-- are left out of that table for exactly this reason (see CurrencyType in src/balance-db.ts)
|
||||
-- and live here instead, keyed by the `currency_id` that names one room's currency uniquely.
|
||||
--
|
||||
-- The room is not a column: `room_currency` already says which room a currency belongs to,
|
||||
-- and repeating it here would be a second place for that to be wrong.
|
||||
--
|
||||
-- A missing row is a balance of zero. Nothing is inserted until a player is first awarded
|
||||
-- some, so a room's currency costs a row per player who has actually earned it rather than
|
||||
-- one per player who has walked in.
|
||||
CREATE TABLE IF NOT EXISTS room_balance (
|
||||
currency_id TEXT NOT NULL,
|
||||
player_id INTEGER NOT NULL,
|
||||
amount INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (currency_id, player_id)
|
||||
);
|
||||
|
||||
-- "What does this player hold across the room?" walks the room's currencies, so the reverse
|
||||
-- lookup earns its own index rather than a scan of every holder of every currency.
|
||||
CREATE INDEX IF NOT EXISTS idx_room_balance_player ON room_balance (player_id);
|
||||
@@ -0,0 +1,19 @@
|
||||
-- The purchase offers a room sells its own currency through — "5 SuperTokens for 500 Rec
|
||||
-- Center Tokens" — served by `GET /api/roomcurrencies/v1/getPurchaseOffersBatch?ids=`. Owned
|
||||
-- by the `econ` worker; 0017 created the table. Generated from src/room-currency-db.ts
|
||||
-- (ROOM_CURRENCY_SCHEMA_DDL, which mirrors the table as it stands after 0017 + 0018 + this)
|
||||
-- — keep in sync.
|
||||
--
|
||||
-- A JSON column rather than a table of its own. Offers are a small, ordered list that is only
|
||||
-- ever read, written and replaced AS A WHOLE, for one currency at a time — the client shows
|
||||
-- them as that currency's shop and reorders them together. A table would buy per-offer
|
||||
-- queries nothing asks for, and cost a join on the one read that matters.
|
||||
--
|
||||
-- Each offer holds `CurrencyPurchaseOfferId`, `Order`, `Name`, `CurrencyAmount`, `Price` and
|
||||
-- `ModifiedAt`. It does NOT hold `CurrencyId`: the row it lives on already says which currency
|
||||
-- these are offers for, and the read projects it back in. Storing it would be a second place
|
||||
-- for that to be wrong.
|
||||
--
|
||||
-- NULL, not '[]', for a currency with no offers — a currency nobody has built a shop for has
|
||||
-- no list, and the read treats null and an unparseable value alike as "no offers".
|
||||
ALTER TABLE room_currency ADD COLUMN purchase_offers TEXT;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Room consumables — the things a room sells.
|
||||
--
|
||||
-- The other half of a room's economy: `room_currency` (migration 0017) is the money a room
|
||||
-- mints and hands out, and this is the shop that takes it back — a Health Potion for 25
|
||||
-- tokens, a custom shirt for 500. A row here is the LISTING: its name, its picture, what it
|
||||
-- costs and in which of the room's currencies. Written by
|
||||
-- `PUT /api/roomconsumables/v1/roomConsumable` and read by
|
||||
-- `GET /api/roomconsumables/v1/roomConsumable/room/{roomId}`. Owned by the `econ` worker;
|
||||
-- generated from src/room-consumable-db.ts (ROOM_CONSUMABLE_SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- `room_consumable_id` is a GUID rather than an autoincrement, like a room currency's: it is
|
||||
-- how the client names this listing afterwards, and a room's consumables are created
|
||||
-- independently of every other room's.
|
||||
--
|
||||
-- The write body's `PriceAndCurrency` object is COLLAPSED here into `price` and
|
||||
-- `purchase_currency_id`. It is one price in one currency, and a nested object would buy
|
||||
-- nothing but a JSON blob to query through. Note the client READS them flat, so the write
|
||||
-- shape and the read shape genuinely differ — do not unify them.
|
||||
--
|
||||
-- `purchase_currency_id` points at a `room_currency` and is nullable (`Guid?` in the client's
|
||||
-- model), for a listing that names no currency. Nothing resolves or validates it yet: a
|
||||
-- consumable priced in a currency that has since been deleted still lists, at a price nobody
|
||||
-- can pay. `image_name` is nullable for the same reason — a listing with no picture. `price`
|
||||
-- is not: a listing always has one, even if it is zero.
|
||||
--
|
||||
-- Nothing BUYS a consumable yet. There is no purchase endpoint, so
|
||||
-- `maximum_count_per_purchase` is stored and served and read by nothing.
|
||||
CREATE TABLE IF NOT EXISTS room_consumable (
|
||||
room_consumable_id TEXT PRIMARY KEY,
|
||||
room_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
image_name TEXT,
|
||||
price INTEGER NOT NULL,
|
||||
purchase_currency_id TEXT,
|
||||
modified_at TEXT NOT NULL,
|
||||
maximum_count_per_purchase INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Every read is "this room's shop" — the whole access pattern.
|
||||
CREATE INDEX IF NOT EXISTS idx_room_consumable_room ON room_consumable (room_id);
|
||||
@@ -0,0 +1,26 @@
|
||||
-- What players own of a room's consumables — the other side of a room's shop. `room_consumable`
|
||||
-- (migration 0021) is what a room sells; this is who has one. Written by
|
||||
-- `POST /api/roomconsumables/v1/roomConsumable/awardBulk`. Owned by the `econ` worker;
|
||||
-- generated from src/room-consumable-db.ts (ROOM_INVENTORY_SCHEMA_DDL) — keep in sync.
|
||||
--
|
||||
-- Keyed by CONSUMABLE, not by room: `room_consumable` already says which room a listing
|
||||
-- belongs to, and repeating it here would be a second place for that to be wrong. It is the
|
||||
-- same reasoning as `room_balance` (0019), which holds a currency id rather than a room id.
|
||||
--
|
||||
-- A missing row is a quantity of zero. Nothing is inserted until a player is first given one,
|
||||
-- so a room's shop costs a row per player who actually owns something rather than one per
|
||||
-- player who has looked at it.
|
||||
--
|
||||
-- `consumable_id` is not a foreign key — nothing in this database is — but the award route
|
||||
-- checks the listing exists before writing: an inventory row naming nothing would be a thing a
|
||||
-- player owns that cannot be described.
|
||||
CREATE TABLE IF NOT EXISTS room_inventory (
|
||||
player_id INTEGER NOT NULL,
|
||||
consumable_id TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (player_id, consumable_id)
|
||||
);
|
||||
|
||||
-- "What does this player own?" is the read the client makes on entering a room, so the player
|
||||
-- half of the key earns its own index for the lookups that start there.
|
||||
CREATE INDEX IF NOT EXISTS idx_room_inventory_player ON room_inventory (player_id);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Owned CUSTOM avatar items, owned by the `econ` worker. One row per (account, item): the
|
||||
-- custom avatar items a player has bought through `POST /api/items/bulkpurchase` (a line whose
|
||||
-- `ItemPurchaseMethodId` is a `Guid`, the item's `CustomAvatarItemId`) and read back by
|
||||
-- `GET /econ/customAvatarItems/v1/owned`, which serves each owned item's full record.
|
||||
--
|
||||
-- Only the id is stored: the item itself lives in `custom_avatar_item`, whose schema the `api`
|
||||
-- worker owns (apps/api/migrations/0022_custom_avatar_item_json.sql) on this same database,
|
||||
-- and copying its record here would leave two rows to keep in step — the same split as
|
||||
-- `inventory_invention` (0008). Ownership is boolean, so the pair is the primary key and a
|
||||
-- second purchase is refused rather than charged twice. The creator is NOT listed here: they
|
||||
-- own their item through `CreatorAccountId`, and the buy path refuses to sell them their own.
|
||||
--
|
||||
-- Kept in sync with INVENTORY_CUSTOM_SCHEMA_DDL in src/inventory-custom-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS inventory_custom (
|
||||
account_id INTEGER NOT NULL,
|
||||
custom_avatar_item_id TEXT NOT NULL,
|
||||
acquired_at TEXT NOT NULL,
|
||||
PRIMARY KEY (account_id, custom_avatar_item_id)
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
-- Room keys — a room's own admission tickets.
|
||||
--
|
||||
-- A creator lists a key for their room (`POST /api/roomkeys/v1/create`, form-encoded
|
||||
-- `Type=Key&RoomId=1162&Name=my%20key&Description=…&Price=50`): a named, priced thing a
|
||||
-- player can hold to get through a door the room locks. This is the LISTING — its name, its
|
||||
-- price, which room it opens. Nothing SELLS a key yet, so there is no table of who holds one.
|
||||
-- Owned by the `econ` worker; generated from src/room-key-db.ts (ROOM_KEY_SCHEMA_DDL) — keep
|
||||
-- in sync.
|
||||
--
|
||||
-- `room_key_id` is an AUTOINCREMENT rather than a GUID, unlike a room currency's or a
|
||||
-- consumable's: the client's own model (`LocalRoomKeyCreated`'s payload, which names a key by
|
||||
-- a numeric `RoomKeyId`) says so. `replication_id` is the GUID beside it that the same
|
||||
-- payload carries; it is minted here.
|
||||
--
|
||||
-- `key_type` is the body's `Type`, stored as the enum NAME it arrives as (`Key`) and served
|
||||
-- back as its ordinal (`Type: 0`); only `Key` has been observed.
|
||||
--
|
||||
-- `price` is the body's `Price`, and `purchase_currency_id` the `room_currency` it is charged
|
||||
-- in — nullable, `Guid?` in the client's model, and the create body names none, so it is null
|
||||
-- until an edit can set it. `image_name` is '' in the row and served as a null `ImageName`
|
||||
-- (what the client reads) until a key can carry art.
|
||||
CREATE TABLE IF NOT EXISTS room_key (
|
||||
room_key_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
replication_id TEXT NOT NULL,
|
||||
room_id INTEGER NOT NULL,
|
||||
key_type TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
price INTEGER NOT NULL,
|
||||
purchase_currency_id TEXT,
|
||||
image_name TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Every read is "this room's keys" — the whole access pattern.
|
||||
CREATE INDEX IF NOT EXISTS idx_room_key_room ON room_key (room_id);
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Who HOLDS a room key — one row per (key, player).
|
||||
--
|
||||
-- Written by `POST /api/roomkeys/v1/awardbulk`, which awards the CALLER the keys its body
|
||||
-- names. A key is held or it isn't — there is no quantity — so the pair is the primary key
|
||||
-- and awarding a key a player already holds changes nothing (`awarded_at` keeps the first
|
||||
-- award). `room_key_id` is `room_key.room_key_id`. Owned by the `econ` worker; generated
|
||||
-- from src/room-key-db.ts (ROOM_KEY_SCHEMA_DDL) — keep in sync.
|
||||
CREATE TABLE IF NOT EXISTS room_key_player (
|
||||
room_key_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
awarded_at TEXT NOT NULL,
|
||||
PRIMARY KEY (room_key_id, account_id)
|
||||
);
|
||||
|
||||
-- "This player's keys" — the primary key only serves lookups that lead with the key.
|
||||
CREATE INDEX IF NOT EXISTS idx_room_key_player_account ON room_key_player (account_id);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- An invention's `NumDownloads` is the number of players who have it: the rows in
|
||||
-- `inventory_invention` that name it. `grantInvention` now re-derives it on every grant
|
||||
-- (packages/domain/src/inventory-invention-db.ts); every purchase before that left it at
|
||||
-- the 0 a save mints, so this backfills the inventions already bought.
|
||||
--
|
||||
-- The `invention` table is the `api` worker's (same database). `ModifiedAt` is left alone:
|
||||
-- being bought is not an edit of the invention.
|
||||
|
||||
-- Backs that per-invention count; the primary key leads with account_id.
|
||||
-- Generated from INVENTORY_INVENTION_SCHEMA_DDL — keep in sync.
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_invention_invention
|
||||
ON inventory_invention (invention_id);
|
||||
|
||||
UPDATE invention
|
||||
SET data = json_set(
|
||||
data,
|
||||
'$.NumDownloads',
|
||||
(SELECT COUNT(*) FROM inventory_invention WHERE invention_id = invention.id)
|
||||
)
|
||||
WHERE id IN (SELECT invention_id FROM inventory_invention);
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Item wishlists, owned by the `econ` worker. One row per item a player has wished for —
|
||||
-- what the store's heart toggle writes (`POST /api/itemWishlists/v1/wishlist/add|remove`,
|
||||
-- form-encoded `purchasableItemId=1534&customAvatarItemId=`) and the wishlist page reads
|
||||
-- (`GET /api/itemWishlists/v1/wishlist/me|{accountId}`, a bare array of entries).
|
||||
--
|
||||
-- `wishlist_item_id` is a GUID: the client's `WishlistItemId` is a `Guid`, not an int, so the
|
||||
-- row's key has to be one. `player_id` is the account the list belongs to.
|
||||
--
|
||||
-- A row names ONE item in one of two ways — a catalog item by `purchasable_item_id` (the
|
||||
-- storefront's `PurchasableItemId`) or a player-made/first-party custom avatar item by
|
||||
-- `custom_avatar_item_id` (its GUID, stored lowercase) — so exactly one of the two is set.
|
||||
-- On the wire the client's `PurchasableItemId` is a plain int, so a custom-item row is served
|
||||
-- as `PurchasableItemId: 0` with the GUID beside it; the column is still NULL here so the two
|
||||
-- kinds of row can't be confused.
|
||||
--
|
||||
-- The two partial unique indexes make a wish boolean: a second `add` of the same item finds
|
||||
-- the existing row rather than making a twin. `idx_wishlist_player` is the read's access
|
||||
-- pattern (one player's whole list).
|
||||
--
|
||||
-- Kept in sync with WISHLIST_SCHEMA_DDL in src/wishlist-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wishlist (
|
||||
wishlist_item_id TEXT PRIMARY KEY,
|
||||
player_id INTEGER NOT NULL,
|
||||
purchasable_item_id INTEGER,
|
||||
custom_avatar_item_id TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wishlist_player ON wishlist (player_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_wishlist_player_purchasable
|
||||
ON wishlist (player_id, purchasable_item_id) WHERE purchasable_item_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_wishlist_player_custom
|
||||
ON wishlist (player_id, custom_avatar_item_id) WHERE custom_avatar_item_id IS NOT NULL;
|
||||
@@ -15,9 +15,13 @@ export const SCHEMA_DDL: string[] = [
|
||||
data TEXT NOT NULL,
|
||||
avatar TEXT,
|
||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
|
||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL
|
||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL,
|
||||
has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL,
|
||||
is_influencer INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInfluencer')) VIRTUAL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON account (account_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_account_has_plus ON account (has_plus) WHERE has_plus = 1`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_account_is_influencer ON account (is_influencer) WHERE is_influencer = 1`,
|
||||
]
|
||||
|
||||
/** The stored avatar payload — opaque JSON the client sets and reads back. */
|
||||
|
||||
+142
-47
@@ -1,4 +1,13 @@
|
||||
import { BalancePlatform } from '../../notify/src/notification-payloads'
|
||||
import { CurrencyType } from './currency'
|
||||
|
||||
import type { GiftContent } from '@repo/domain'
|
||||
|
||||
// The currency vocabulary and the Plus reload SQL live in the import-free `currency.ts` so
|
||||
// the Node CLI in @repo/tools can share them; re-exported here so this stays the module
|
||||
// everything else reads balances from.
|
||||
export { CurrencyType, DEFAULT_STARTING_TOKENS, PLUS_MEMBERS_SQL, plusReloadSql } from './currency'
|
||||
export type { CurrencyTypeValue } from './currency'
|
||||
|
||||
/**
|
||||
* Currency balances on the shared `recflare` D1 database.
|
||||
@@ -14,46 +23,6 @@ import { BalancePlatform } from '../../notify/src/notification-payloads'
|
||||
* it doesn't clash with the auth/rooms migration histories on the same database.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* What each one is:
|
||||
* - `Invalid` (0): the enum's zero value. Never a real balance; a request for it is
|
||||
* a client bug or a probe, and `isSpendable` rejects it.
|
||||
* - `LaserTagTickets` (1): earned in the Laser Tag activity, spent in its own store.
|
||||
* - `RecCenterTokens` (2): THE general-purpose currency — what players mean by
|
||||
* "tokens", earned everywhere and spent in the avatar/gift-drop storefronts. This
|
||||
* is the only one the client fetches on load, and the only one we grant at signup.
|
||||
* - `LostSkullsGold` (100) / `DraculaSilver` (101): per-activity currencies for the
|
||||
* Isle of Lost Skulls and Rise of Jumbo quests. Earned and spent inside those
|
||||
* activities only.
|
||||
* - `RecRoyaleSeason1` (200): a season currency for Rec Royale; legacy, no live faucet.
|
||||
* - `RoomCurrency` (300) / `RoomInventoryItem` (301): NOT global balances. These are
|
||||
* scoped to a specific room and served by the `/api/roomcurrencies/*` and
|
||||
* `/api/roomconsumables/*` endpoints, whose rows are keyed by room as well as by
|
||||
* account. They must never be stored in this (account, currency) table — a single
|
||||
* row here couldn't say WHICH room's currency it is, so a player's coins in one
|
||||
* room would spend in every other. `isSpendable` rejects them for that reason.
|
||||
* - `ProgressionEvent` (400): an XP/progression counter the client models as a
|
||||
* currency. Not spendable.
|
||||
*/
|
||||
export const CurrencyType = {
|
||||
Invalid: 0,
|
||||
LaserTagTickets: 1,
|
||||
RecCenterTokens: 2,
|
||||
LostSkullsGold: 100,
|
||||
DraculaSilver: 101,
|
||||
RecRoyaleSeason1: 200,
|
||||
RoomCurrency: 300,
|
||||
RoomInventoryItem: 301,
|
||||
ProgressionEvent: 400,
|
||||
} as const
|
||||
|
||||
export type CurrencyTypeValue = (typeof CurrencyType)[keyof typeof CurrencyType]
|
||||
|
||||
/**
|
||||
* The account-scoped currencies this table stores. Everything else in `CurrencyType`
|
||||
* is either not a balance (Invalid, ProgressionEvent) or is room-scoped and belongs to
|
||||
@@ -70,13 +39,6 @@ const SPENDABLE: readonly number[] = [
|
||||
/** Whether a currency is an account-scoped balance this table may hold. */
|
||||
export const isSpendable = (currencyType: number): boolean => SPENDABLE.includes(currencyType)
|
||||
|
||||
/**
|
||||
* The signup grant, in RecCenterTokens, when the `STARTING_TOKENS` var is unset.
|
||||
* An operator overrides it in wrangler.jsonc `vars`; 0 is a valid setting and means
|
||||
* players start broke.
|
||||
*/
|
||||
export const DEFAULT_STARTING_TOKENS = 10_000
|
||||
|
||||
/**
|
||||
* What a player starts with, granted lazily the first time their balances are touched
|
||||
* (see `ensureStartingBalances`). Currencies absent here start at 0.
|
||||
@@ -239,3 +201,136 @@ export async function spendCurrency(
|
||||
.run()
|
||||
return meta.changes > 0
|
||||
}
|
||||
|
||||
/** One token box to hand over: whose, how many, and the stored box. See {@link payTokenGifts}. */
|
||||
export interface TokenGift {
|
||||
accountId: number
|
||||
/** Signed: positive credits, negative debits (guarded — see below), zero only boxes. */
|
||||
amount: number
|
||||
/** The box as stored in `received_gift`, serialized as-is. */
|
||||
content: GiftContent
|
||||
}
|
||||
|
||||
/** One box handed over by {@link payTokenGifts}: the resulting balance and the box's id. */
|
||||
export interface PaidTokenGift extends TokenGift {
|
||||
balance: number
|
||||
giftId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* How many accounts one round of {@link payTokenGifts} writes at once. Every statement
|
||||
* binds three values per account, and D1 allows 100 bound parameters a statement.
|
||||
*/
|
||||
export const TOKEN_GIFT_CHUNK = 30
|
||||
|
||||
/**
|
||||
* Hand RecCenterTokens in a gift box to MANY accounts at once — the staff drops, the room
|
||||
* gift and the Discord supporter cron, which all pay a crowd. Paid one account at a time
|
||||
* (seed, credit, re-read, box: five D1 round trips each) a full server was ~1,250 queries
|
||||
* in series, past D1's 1,000-queries-an-invocation cap somewhere around 200 players, and
|
||||
* died partway with the first players paid and the rest not. This pays
|
||||
* {@link TOKEN_GIFT_CHUNK} accounts a round in two batches of multi-row statements.
|
||||
*
|
||||
* Per account it is exactly the one-player grant: the signup grant seeded first (a
|
||||
* never-touched balance must not start from the gift alone), the balance moved, then a box
|
||||
* stored. A credit is an upsert; a debit is `amount >= ?` guarded in the UPDATE itself, so
|
||||
* nobody is overdrawn, and an account that can't cover it is SKIPPED — no box — rather than
|
||||
* failing the rest. `paid` keeps the order of `gifts`; `skipped` is the rest. A duplicate
|
||||
* account among `gifts` is paid once, the first entry's amount: a multi-row upsert may not
|
||||
* name one key twice.
|
||||
*
|
||||
* Sends NOTHING: the caller builds the balance frame (the RESULTING total, `balance`, into
|
||||
* the -2 bucket) and the box's announcement from the rows returned, and should push them in
|
||||
* one hub call. Nothing here is transactional across rounds: a round that throws leaves the
|
||||
* earlier ones paid, which the caller reports.
|
||||
*/
|
||||
export async function payTokenGifts(
|
||||
db: D1Database,
|
||||
gifts: readonly TokenGift[],
|
||||
startingTokens: number
|
||||
): Promise<{ paid: PaidTokenGift[]; skipped: number[] }> {
|
||||
const grants = startingBalances(startingTokens)
|
||||
const paid: PaidTokenGift[] = []
|
||||
const skipped: number[] = []
|
||||
|
||||
const seen = new Set<number>()
|
||||
const unique = gifts.filter((g) => !seen.has(g.accountId) && seen.add(g.accountId))
|
||||
for (let i = 0; i < unique.length; i += TOKEN_GIFT_CHUNK) {
|
||||
const chunk = unique.slice(i, i + TOKEN_GIFT_CHUNK)
|
||||
const credits = chunk.filter((g) => g.amount > 0)
|
||||
const debits = chunk.filter((g) => g.amount < 0)
|
||||
const holds = chunk.filter((g) => g.amount === 0)
|
||||
|
||||
// Seed the signup grant, then move the money: one batch, one transaction. A debit's
|
||||
// guard is per row, so a chunk of debits is one UPDATE per distinct amount.
|
||||
const statements: D1PreparedStatement[] = [
|
||||
db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO balance (account_id, currency_type, amount) VALUES ${chunk
|
||||
.flatMap(() => grants.map(() => '(?, ?, ?)'))
|
||||
.join(', ')}`
|
||||
)
|
||||
.bind(
|
||||
...chunk.flatMap((g) => grants.flatMap((b) => [g.accountId, b.currencyType, b.amount]))
|
||||
),
|
||||
]
|
||||
if (credits.length > 0) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO balance (account_id, currency_type, amount) VALUES ${credits
|
||||
.map(() => '(?, ?, ?)')
|
||||
.join(', ')}
|
||||
ON CONFLICT (account_id, currency_type) DO UPDATE SET amount = amount + excluded.amount
|
||||
RETURNING account_id, amount`
|
||||
)
|
||||
.bind(...credits.flatMap((g) => [g.accountId, CurrencyType.RecCenterTokens, g.amount]))
|
||||
)
|
||||
}
|
||||
for (const amount of new Set(debits.map((g) => g.amount))) {
|
||||
const ids = debits.filter((g) => g.amount === amount).map((g) => g.accountId)
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE balance SET amount = amount - ?1
|
||||
WHERE currency_type = ?2 AND amount >= ?1
|
||||
AND account_id IN (${ids.map(() => '?').join(', ')})
|
||||
RETURNING account_id, amount`
|
||||
)
|
||||
.bind(-amount, CurrencyType.RecCenterTokens, ...ids)
|
||||
)
|
||||
}
|
||||
if (holds.length > 0) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`SELECT account_id, amount FROM balance
|
||||
WHERE currency_type = ?1 AND account_id IN (${holds.map(() => '?').join(', ')})`
|
||||
)
|
||||
.bind(CurrencyType.RecCenterTokens, ...holds.map((g) => g.accountId))
|
||||
)
|
||||
}
|
||||
const [, ...moved] = await db.batch<{ account_id: number; amount: number }>(statements)
|
||||
// RETURNING comes back in no promised order; key it, then walk the chunk in its order.
|
||||
const balances = new Map(moved.flatMap((r) => r.results).map((r) => [r.account_id, r.amount]))
|
||||
const debited = chunk.filter((g) => balances.has(g.accountId))
|
||||
for (const g of chunk) if (!balances.has(g.accountId)) skipped.push(g.accountId)
|
||||
if (debited.length === 0) continue
|
||||
|
||||
const createdAt = new Date().toISOString()
|
||||
const { results: boxes } = await db
|
||||
.prepare(
|
||||
`INSERT INTO received_gift (account_id, data, created_at) VALUES ${debited
|
||||
.map(() => '(?, ?, ?)')
|
||||
.join(', ')} RETURNING id, account_id`
|
||||
)
|
||||
.bind(...debited.flatMap((g) => [g.accountId, JSON.stringify(g.content), createdAt]))
|
||||
.all<{ id: number; account_id: number }>()
|
||||
const giftIds = new Map(boxes.map((r) => [r.account_id, r.id]))
|
||||
|
||||
for (const g of debited) {
|
||||
paid.push({ ...g, balance: balances.get(g.accountId)!, giftId: giftIds.get(g.accountId)! })
|
||||
}
|
||||
}
|
||||
return { paid, skipped }
|
||||
}
|
||||
|
||||
+39
-27
@@ -3,9 +3,9 @@ import { CatalogKind } from './catalog-load'
|
||||
import type { CatalogKindValue } from './catalog-load'
|
||||
|
||||
/**
|
||||
* The item CATALOG on the shared `recflare` D1 database — every avatar item and every
|
||||
* equipment skin the game knows about, one row each, loaded once by migration rather than
|
||||
* written at runtime. Nothing here is per-player: ownership lives in `inventory` and
|
||||
* The item CATALOG on the shared `recflare` D1 database — every avatar item, consumable and
|
||||
* equipment skin the game knows about, one row each, loaded by a CLI rather than written at
|
||||
* runtime. Nothing here is per-player: ownership lives in `inventory` and
|
||||
* `equipment`, and this table only says what a thing IS.
|
||||
*
|
||||
* It exists to be QUERIED. The catalogs were previously reachable only by parsing a whole
|
||||
@@ -14,15 +14,16 @@ import type { CatalogKindValue } from './catalog-load'
|
||||
* nothing can be searched by name or filtered by tag at all. A row with indexes answers those
|
||||
* in one statement.
|
||||
*
|
||||
* BOTH kinds share one table because they share most of a record — a display name, a tooltip, a
|
||||
* ALL kinds share one table because they share most of a record — a display name, a tooltip, a
|
||||
* rarity, a platform mask, a thumbnail — and because the interesting queries ("what is called
|
||||
* X", "what is this thing the player owns") run across both. `kind` discriminates, and the
|
||||
* columns each kind alone carries are nullable and empty on the other. Read a row through
|
||||
* {@link toCatalogAvatarItem}/{@link toCatalogSkin} rather than serving it raw: the client's
|
||||
* two DTOs share no key order and differ in what they omit.
|
||||
*
|
||||
* Where the rows come from: `static/db/avatar-items.json` and `static/db/skins.json`, both
|
||||
* captured from the reference and loaded by `runx catalog load`.
|
||||
* Where the rows come from: the served 2025 general store (`static/storefronts/sf3-2025.json`)
|
||||
* and `static/db/skins.json`, loaded by `runx catalog load`. A listed row's `catalog_id` is
|
||||
* its store `PurchasableItemId`.
|
||||
*
|
||||
* The migration builds the TABLE ONLY — it holds no rows. The catalog changes as the game's
|
||||
* item list changes, and that is not a schema change: a migration per refresh would mean a
|
||||
@@ -42,12 +43,15 @@ export {
|
||||
buildCatalogLoad,
|
||||
CATALOG_INSERT_COLUMNS,
|
||||
CatalogKind,
|
||||
type AvatarItemCapture,
|
||||
listingKind,
|
||||
type CatalogCollision,
|
||||
type CatalogKindValue,
|
||||
type CatalogLoad,
|
||||
type CatalogLoadRow,
|
||||
type CatalogValue,
|
||||
type SkinCapture,
|
||||
type StoreListing,
|
||||
type StorefrontDump,
|
||||
} from './catalog-load'
|
||||
|
||||
/**
|
||||
@@ -66,12 +70,11 @@ export {
|
||||
* avatar items and repeated on 9 more (id 9503 alone covers five unrelated developer items), so
|
||||
* it can neither key nor reliably find anything.
|
||||
*
|
||||
* `catalog_id` is the small NUMERIC handle the site uses where a key would be unwieldy, and is
|
||||
* what a generated storefront lists a row under as its `PurchasableItemId`. It is assigned by
|
||||
* the loader from `CATALOG_ID_BASE` (10000, clear of every captured storefront's own ids) and
|
||||
* renumbered by every load, so it identifies a row only within one load — see the field's own
|
||||
* note. It is unique where set, and the migration that adds it is 0016, since 0015 was already
|
||||
* applied.
|
||||
* `catalog_id` is the small NUMERIC handle the site uses where a key would be unwieldy. For a
|
||||
* row the store lists it is the listing's own `PurchasableItemId` — the number `sf3-2025.json`
|
||||
* sells it under — and for a skin the store never listed it is handed out by the loader from
|
||||
* `CATALOG_ID_BASE`, well above any real one. It is unique where set, and the migration that
|
||||
* adds it is 0016, since 0015 was already applied.
|
||||
*
|
||||
* Nullability is load-bearing rather than incidental: `tooltip` is genuinely NULL on some rows
|
||||
* of BOTH kinds and `""` on others, and the client's DTOs serve the distinction through, so the
|
||||
@@ -113,13 +116,14 @@ export interface CatalogRow {
|
||||
item_key: string
|
||||
/**
|
||||
* A small numeric handle for the row — for the surfaces that need to name an item as a
|
||||
* number rather than as a comma-laden `AvatarItemDesc` or a guid, and the
|
||||
* `PurchasableItemId` a generated storefront lists it under.
|
||||
* number rather than as a comma-laden `AvatarItemDesc` or a guid. For a listed item it is
|
||||
* the store's `PurchasableItemId`, so the number resolves a row here and a listing in
|
||||
* `sf3-2025.json` alike.
|
||||
*
|
||||
* A LOAD-ORDER SURROGATE, not an identity: `runx catalog load` assigns it, so it is stable
|
||||
* only until the next load. Never store it, never reference it across a load, never treat it
|
||||
* as what an item IS — `item_key` is that, and it is what the inventory holds. NULL only
|
||||
* between a row existing and a load numbering it.
|
||||
* Still not an identity: an unlisted skin's number is a load-order surrogate, and a store
|
||||
* could renumber. Never store it, never treat it as what an item IS — `item_key` is that,
|
||||
* and it is what the inventory holds. NULL only between a row existing and a load numbering
|
||||
* it.
|
||||
*/
|
||||
catalog_id: number | null
|
||||
kind: string
|
||||
@@ -145,6 +149,14 @@ export interface CatalogRow {
|
||||
*
|
||||
* `AvatarItemId` is nullable and `Tooltip` may be null; both are true of the captured data and
|
||||
* the client reads them that way. Do not tighten either to keep a projection simple.
|
||||
*
|
||||
* `CreatedAt` and `ThumbnailImage` are OPTIONAL, never null: the capture this record mirrors
|
||||
* carried a date on most rows and simply had no key on the rest — the permanent hair dyes
|
||||
* among them — and never `null`. A row loaded from a store listing has no date at all, and
|
||||
* serving it as `"CreatedAt": null` on every record (as `lockeditems/bulk` did once the
|
||||
* loader moved to the store) broke the dorm mirror's hair dyes: the client resolves locked
|
||||
* hair colours through that route, and a null where it expects a date costs it the whole
|
||||
* response, not the field. Leave the key out, as the capture did.
|
||||
*/
|
||||
export interface CatalogAvatarItem {
|
||||
AvatarItemDesc: string
|
||||
@@ -156,8 +168,8 @@ export interface CatalogAvatarItem {
|
||||
TagList: string | null
|
||||
AvatarItemId: number | null
|
||||
IsBaseAvatarItem: boolean
|
||||
CreatedAt: string | null
|
||||
ThumbnailImage: string | null
|
||||
CreatedAt?: string
|
||||
ThumbnailImage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -197,8 +209,9 @@ export function toCatalogAvatarItem(row: CatalogRow): CatalogAvatarItem {
|
||||
TagList: row.tag_list,
|
||||
AvatarItemId: row.avatar_item_id,
|
||||
IsBaseAvatarItem: row.is_base_avatar_item === 1,
|
||||
CreatedAt: row.created_at,
|
||||
ThumbnailImage: row.thumbnail_image,
|
||||
// Absent rather than null when the row has none — see the record's note.
|
||||
...(row.created_at !== null ? { CreatedAt: row.created_at } : {}),
|
||||
...(row.thumbnail_image !== null ? { ThumbnailImage: row.thumbnail_image } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,11 +257,10 @@ export async function getCatalogItem(db: D1Database, itemKey: string): Promise<C
|
||||
}
|
||||
|
||||
/**
|
||||
* One catalog row by its numeric handle.
|
||||
* One catalog row by its numeric handle — a store `PurchasableItemId` for a listed item.
|
||||
*
|
||||
* Only meaningful WITHIN a load: the number is reassigned every time the catalog is loaded, so
|
||||
* a caller holding one from before a reload will get a different item or nothing at all. Fine
|
||||
* for a request that looked the number up moments ago; never for anything stored.
|
||||
* Fine for a request that looked the number up moments ago; never for anything stored, since
|
||||
* an unlisted skin's number is reassigned by every load.
|
||||
*/
|
||||
export async function getCatalogItemById(
|
||||
db: D1Database,
|
||||
|
||||
+358
-136
@@ -10,6 +10,15 @@
|
||||
* The mapping lives beside the schema it fills (rather than in the CLI) so that a column added
|
||||
* to the table and a column added to the loader cannot drift apart — a test pins that they
|
||||
* agree, and the loader renders values POSITIONALLY, so a mismatch is a silent mis-load.
|
||||
*
|
||||
* WHERE THE ROWS COME FROM. The 2025 general store as this worker serves it, and nothing else:
|
||||
* `static/storefronts/sf3-2025.json`, the live game's storefront 3 dumped
|
||||
* (`static/db/Watch_EnumValue_3.json`) and written out by `runx storefront build` with its
|
||||
* captured sale stripped and the skins the store never listed (`static/db/skins.json`)
|
||||
* appended as listings of their own. Loading the served file is what makes a row here and a
|
||||
* listing there one record: same asset id, same number, always. An earlier `avatar-items.json`
|
||||
* capture used to be the source and a storefront was generated FROM the table with invented
|
||||
* ids; the dump made both unnecessary.
|
||||
*/
|
||||
|
||||
/** What a catalog row IS — the discriminator, and what says which id `item_key` holds. */
|
||||
@@ -18,33 +27,78 @@ export const CatalogKind = {
|
||||
AvatarItem: 'avatar_item',
|
||||
/** An equipment skin: a re-skin of a held prefab. Its `item_key` is the `ModificationGuid`. */
|
||||
Skin: 'skin',
|
||||
/** A consumable: food, a potion, a KO icon. Its `item_key` is the `ConsumableItemDesc`. */
|
||||
Consumable: 'consumable',
|
||||
} as const
|
||||
|
||||
export type CatalogKindValue = (typeof CatalogKind)[keyof typeof CatalogKind]
|
||||
|
||||
/**
|
||||
* The capture's avatar-item record (`static/db/avatar-items.json`).
|
||||
*
|
||||
* Everything from `TagList` down is absent on some rows — the 22 permanent hair dyes carry
|
||||
* only the first six fields — so those are optional rather than nullable. The distinction
|
||||
* matters: a missing key and a null value both land as NULL, but only one of them is a field
|
||||
* the capture actually recorded.
|
||||
*/
|
||||
export interface AvatarItemCapture {
|
||||
AvatarItemDesc: string
|
||||
AvatarItemType: number
|
||||
PlatformMask: number
|
||||
FriendlyName: string
|
||||
Tooltip: string | null
|
||||
Rarity: number
|
||||
TagList?: string | null
|
||||
AvatarItemId?: number
|
||||
IsBaseAvatarItem?: boolean
|
||||
CreatedAt?: string
|
||||
ThumbnailImage?: string | null
|
||||
/** One price on a store listing. `StorefrontSaleData` is a discount the dump captured live. */
|
||||
export interface StoreListingPrice {
|
||||
CurrencyType: number
|
||||
Price: number
|
||||
StorefrontSaleData: {
|
||||
SalePercent: number
|
||||
SaleStartDate: string | null
|
||||
SaleEndDate: string | null
|
||||
} | null
|
||||
}
|
||||
|
||||
/** The capture's skin record (`static/db/skins.json`). Every field is present on every row. */
|
||||
/**
|
||||
* A store listing's `GiftDrop` as the 2025 dump records it — the client's own `GiftDrop` class.
|
||||
* Only the members the loader reads are typed; the rest ride along untouched.
|
||||
*
|
||||
* Exactly ONE of the four item ids is set on a listing that carries an item: `AvatarItemDesc`,
|
||||
* `ConsumableItemDesc`, `EquipmentModificationGuid` or `CustomAvatarItemId`. A query box
|
||||
* (`IsQuery`) and a token bundle (`Currency` > 0) carry none.
|
||||
*/
|
||||
export interface StoreListingGiftDrop {
|
||||
GiftDropId: number
|
||||
FriendlyName: string
|
||||
Tooltip: string | null
|
||||
TagList: string | null
|
||||
ConsumableItemDesc: string
|
||||
AvatarItemDesc: string
|
||||
CustomAvatarItemId: string | null
|
||||
AvatarItemType: number | null
|
||||
EquipmentPrefabName: string
|
||||
EquipmentModificationGuid: string
|
||||
IsQuery: boolean
|
||||
Rarity: number
|
||||
Currency: number
|
||||
CurrencyType: number
|
||||
AvatarItemId: number
|
||||
ThumbnailImageName: string | null
|
||||
[extra: string]: unknown
|
||||
}
|
||||
|
||||
/** One listing of the store dump — a `StoreItems[]` entry, as the client reads it. */
|
||||
export interface StoreListing {
|
||||
PurchasableItemId: number
|
||||
GiftDrop: StoreListingGiftDrop
|
||||
Prices: StoreListingPrice[]
|
||||
SubscriberPrices: StoreListingPrice[] | null
|
||||
[extra: string]: unknown
|
||||
}
|
||||
|
||||
/** One storefront page — the dump (`static/db/Watch_EnumValue_3.json`) or the served file. */
|
||||
export interface StorefrontDump {
|
||||
StoreItems: StoreListing[]
|
||||
NextUpdate: string
|
||||
StorefrontType: number
|
||||
SubscriberDiscountPercent: number
|
||||
[extra: string]: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* The capture's skin record (`static/db/skins.json`). Every field is present on every row.
|
||||
*
|
||||
* A thin capture: every `Rarity` is 0 or 1, every `UnlockedLevel` 0, every `ThumbnailImage`
|
||||
* empty, and where a guid is also in the store the store's name and rarity are the real ones
|
||||
* and disagree with these on every row. So it is read by `runx storefront build` ALONE, for
|
||||
* the skins the store does not list — see {@link unlistedSkinListing} — and the loader never
|
||||
* sees it.
|
||||
*/
|
||||
export interface SkinCapture {
|
||||
PrefabName: string
|
||||
ModificationGuid: string
|
||||
@@ -58,16 +112,146 @@ export interface SkinCapture {
|
||||
}
|
||||
|
||||
/**
|
||||
* The first `catalog_id` a load hands out.
|
||||
* What a store listing SELLS — the catalog kind it loads as, or why it loads as nothing.
|
||||
*
|
||||
* The catalog needs ids that cannot be confused with any captured storefront's, because a
|
||||
* generated storefront lists a row under its `catalog_id` DIRECTLY — one number, no second
|
||||
* numbering and no arithmetic between them. Every real captured `PurchasableItemId` is 2764 or
|
||||
* below (one sf3 outlier at 20756767 aside), so numbering from 1 would have collided with sf3's
|
||||
* own head-on and the same id would mean two different items depending on which storefront the
|
||||
* client read it from. Starting at 10000 puts the whole catalog somewhere nothing else uses.
|
||||
* `custom_avatar_item` listings are first-party UGC: they live in the `custom_avatar_item`
|
||||
* table (`just cai-load`), not here. `query` is a loot box and `currency` a token bundle —
|
||||
* neither is an item. `unknown` carries no id at all (two expo items the dump prices at
|
||||
* nothing). `consumable_pack` is assigned by the load rather than by {@link listingKind}: a
|
||||
* consumable the store sells singly AND as a pack ("Disco Dance Break", "… 3-Pack") is one
|
||||
* consumable listed twice, and the catalog is of things, not of listings.
|
||||
*/
|
||||
export const CATALOG_ID_BASE = 10_000
|
||||
export type ListingKind =
|
||||
CatalogKindValue | 'custom_avatar_item' | 'query' | 'currency' | 'unknown' | 'consumable_pack'
|
||||
|
||||
/** Classify one listing by which of its item ids is set. */
|
||||
export function listingKind(listing: StoreListing): ListingKind {
|
||||
const drop = listing.GiftDrop
|
||||
if (drop.AvatarItemDesc !== '') return CatalogKind.AvatarItem
|
||||
if (drop.ConsumableItemDesc !== '') return CatalogKind.Consumable
|
||||
if (drop.EquipmentModificationGuid !== '') return CatalogKind.Skin
|
||||
if (drop.CustomAvatarItemId !== null && drop.CustomAvatarItemId !== '') {
|
||||
return 'custom_avatar_item'
|
||||
}
|
||||
if (drop.IsQuery) return 'query'
|
||||
if (drop.Currency > 0) return 'currency'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
/**
|
||||
* The dump's listings with its verbatim repeats collapsed — twenty listings appear twice, byte
|
||||
* for byte. One copy is kept, in first position; a repeated id whose copies DIFFER is refused,
|
||||
* since order would then decide what the id means. The storefront file is written from this,
|
||||
* and the catalog load reads that file, so neither sees a repeat as a duplicate item.
|
||||
*/
|
||||
export function collapseRepeatedListings(listings: StoreListing[]): {
|
||||
listings: StoreListing[]
|
||||
repeats: number
|
||||
} {
|
||||
const byId = new Map<number, string>()
|
||||
const kept: StoreListing[] = []
|
||||
let repeats = 0
|
||||
for (const listing of listings) {
|
||||
const json = JSON.stringify(listing)
|
||||
const first = byId.get(listing.PurchasableItemId)
|
||||
if (first === undefined) {
|
||||
byId.set(listing.PurchasableItemId, json)
|
||||
kept.push(listing)
|
||||
} else if (first === json) {
|
||||
repeats++
|
||||
} else {
|
||||
throw new Error(
|
||||
`PurchasableItemId ${listing.PurchasableItemId} is listed twice with different contents`
|
||||
)
|
||||
}
|
||||
}
|
||||
return { listings: kept, repeats }
|
||||
}
|
||||
|
||||
/**
|
||||
* The first `PurchasableItemId` given to a skin the store does NOT list.
|
||||
*
|
||||
* Every other number in `sf3-2025.json` is the game's own. The skins the 2025 store never sold
|
||||
* still have to be listed — the weekly gift pool and the discovery rows draw skins from the
|
||||
* store, and a `catalog` row IS a listing — so they are appended with numbers of their own,
|
||||
* from here, in `skins.json` order. The store's ids top out below 40 000; a million is clear of
|
||||
* anything a fresher dump could bring.
|
||||
*/
|
||||
export const UNLISTED_SKIN_ID_BASE = 1_000_000
|
||||
|
||||
/** The rarity an unlisted skin is sold at: the top tier, where the store puts most of its own. */
|
||||
export const UNLISTED_SKIN_RARITY = 50
|
||||
|
||||
/**
|
||||
* What a GOLD skin costs — any skin whose name carries `(Gold)`, thirteen of them, all from the
|
||||
* capture. A prestige price, and the same for a subscriber: no discount, no sale.
|
||||
*/
|
||||
export const GOLD_SKIN_PRICE = 5_000_000
|
||||
|
||||
/** Whether a listing is a gold skin — a skin whose name says `(Gold)`. */
|
||||
export const isGoldSkin = (listing: StoreListing): boolean =>
|
||||
listing.GiftDrop.EquipmentModificationGuid !== '' &&
|
||||
listing.GiftDrop.FriendlyName.includes('(Gold)')
|
||||
|
||||
/** RecCenterTokens — the currency the store sells in. */
|
||||
const CURRENCY_TYPE_TOKENS = 2
|
||||
|
||||
/**
|
||||
* A skin the store does not list, AS a listing — the dump's own skin shape, so the client reads
|
||||
* it like the rest. Rarity {@link UNLISTED_SKIN_RARITY} and priced from it: the store's own
|
||||
* rarity-50 skins run 1000-6000 tokens, and {@link PRICE_BY_RARITY} puts this in the middle.
|
||||
* The capture's name and tooltip. `ThumbnailImageName` is `""` — as it is on EVERY skin the
|
||||
* served store lists, the game's own included (see `runx storefront build`).
|
||||
*/
|
||||
export function unlistedSkinListing(skin: SkinCapture, id: number): StoreListing {
|
||||
const price = priceForRarity(UNLISTED_SKIN_RARITY)
|
||||
const priced = (p: number): StoreListingPrice => ({
|
||||
CurrencyType: CURRENCY_TYPE_TOKENS,
|
||||
Price: p,
|
||||
StorefrontSaleData: null,
|
||||
})
|
||||
return {
|
||||
GiftDrop: {
|
||||
GiftDropId: id,
|
||||
FriendlyName: skin.FriendlyName,
|
||||
Tooltip: skin.Tooltip ?? '',
|
||||
TagList: '',
|
||||
ConsumableItemDesc: '',
|
||||
AvatarItemDesc: '',
|
||||
CustomAvatarItemId: null,
|
||||
AvatarItemType: null,
|
||||
EquipmentPrefabName: skin.PrefabName,
|
||||
EquipmentModificationGuid: skin.ModificationGuid,
|
||||
IsQuery: false,
|
||||
QueryRedirectContext: null,
|
||||
QueryRedirectTag: null,
|
||||
QueryRedirectRarity: null,
|
||||
Unique: false,
|
||||
SubscribersOnly: false,
|
||||
Rarity: UNLISTED_SKIN_RARITY,
|
||||
Context: 1003,
|
||||
Currency: 0,
|
||||
CurrencyType: 0,
|
||||
ItemCount: 1,
|
||||
ItemSetId: null,
|
||||
ItemSetFriendlyName: '',
|
||||
AvatarItemId: 0,
|
||||
EquipmentItemId: 0,
|
||||
ThumbnailImageName: '',
|
||||
AvatarItemInfo: null,
|
||||
},
|
||||
PurchasableItemId: id,
|
||||
Type: 0,
|
||||
Prices: [priced(price)],
|
||||
SubscriberPrices: [priced(subscriberPriceFor(price))],
|
||||
IsFeatured: false,
|
||||
NewUntil: null,
|
||||
AvailableAt: null,
|
||||
AvailableUntil: null,
|
||||
CanBeGifted: true,
|
||||
OnlyAvailableThroughCv2: false,
|
||||
}
|
||||
}
|
||||
|
||||
/** The columns a load writes, in the order {@link toCatalogInsertRow} returns values. */
|
||||
export const CATALOG_INSERT_COLUMNS = [
|
||||
@@ -94,7 +278,7 @@ export type CatalogValue = string | number | boolean | null | undefined
|
||||
/** One row of a load, plus enough to name it in a collision report. */
|
||||
export interface CatalogLoadRow {
|
||||
key: string
|
||||
/** Its `catalog_id`: 1-based position in this load, also present inside `values`. */
|
||||
/** Its `catalog_id`: the listing's `PurchasableItemId`. */
|
||||
id: number
|
||||
label: string
|
||||
values: CatalogValue[]
|
||||
@@ -107,126 +291,185 @@ export interface CatalogCollision {
|
||||
dropped: string
|
||||
}
|
||||
|
||||
function avatarItemRow(i: AvatarItemCapture): Omit<CatalogLoadRow, 'id'> {
|
||||
/** What {@link buildCatalogLoad} produced, and what it left out. */
|
||||
export interface CatalogLoad {
|
||||
rows: CatalogLoadRow[]
|
||||
collisions: CatalogCollision[]
|
||||
/** Listings that load as nothing, counted by {@link ListingKind}. */
|
||||
skipped: Partial<Record<ListingKind, number>>
|
||||
}
|
||||
|
||||
/**
|
||||
* The store records no platform mask — every listing is for every platform, which is what the
|
||||
* `platform_mask` column's `-1` default means.
|
||||
*/
|
||||
const ALL_PLATFORMS = -1
|
||||
|
||||
/**
|
||||
* A listed avatar item. `is_base_avatar_item` and `created_at` are NULL because a store listing
|
||||
* records neither; a NULL there is "not recorded", which is true.
|
||||
*/
|
||||
function avatarItemRow(listing: StoreListing): CatalogLoadRow {
|
||||
const drop = listing.GiftDrop
|
||||
return {
|
||||
key: i.AvatarItemDesc,
|
||||
label: `${i.FriendlyName} (avatar item)`,
|
||||
key: drop.AvatarItemDesc,
|
||||
id: listing.PurchasableItemId,
|
||||
label: `${drop.FriendlyName} (avatar item)`,
|
||||
values: [
|
||||
i.AvatarItemDesc,
|
||||
// Filled in by buildCatalogLoad once the de-duplicated order is known.
|
||||
null,
|
||||
drop.AvatarItemDesc,
|
||||
listing.PurchasableItemId,
|
||||
CatalogKind.AvatarItem,
|
||||
i.FriendlyName,
|
||||
i.Tooltip,
|
||||
i.Rarity,
|
||||
i.PlatformMask,
|
||||
i.ThumbnailImage,
|
||||
i.AvatarItemType,
|
||||
i.AvatarItemId,
|
||||
i.IsBaseAvatarItem ?? false,
|
||||
i.TagList,
|
||||
i.CreatedAt,
|
||||
drop.FriendlyName,
|
||||
drop.Tooltip,
|
||||
drop.Rarity,
|
||||
ALL_PLATFORMS,
|
||||
drop.ThumbnailImageName,
|
||||
drop.AvatarItemType ?? 0,
|
||||
// 0 is the dump's "none"; the column's is NULL.
|
||||
drop.AvatarItemId || null,
|
||||
null,
|
||||
drop.TagList,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function skinRow(s: SkinCapture): Omit<CatalogLoadRow, 'id'> {
|
||||
/** A listed consumable: the shared columns only, keyed by its `ConsumableItemDesc`. */
|
||||
function consumableRow(listing: StoreListing): CatalogLoadRow {
|
||||
const drop = listing.GiftDrop
|
||||
return {
|
||||
key: s.ModificationGuid,
|
||||
label: `${s.FriendlyName} (skin, ${s.PrefabName})`,
|
||||
key: drop.ConsumableItemDesc,
|
||||
id: listing.PurchasableItemId,
|
||||
label: `${drop.FriendlyName} (consumable)`,
|
||||
values: [
|
||||
s.ModificationGuid,
|
||||
// Filled in by buildCatalogLoad once the de-duplicated order is known.
|
||||
drop.ConsumableItemDesc,
|
||||
listing.PurchasableItemId,
|
||||
CatalogKind.Consumable,
|
||||
drop.FriendlyName,
|
||||
drop.Tooltip,
|
||||
drop.Rarity,
|
||||
ALL_PLATFORMS,
|
||||
drop.ThumbnailImageName,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
drop.TagList,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** A listed skin: what the listing carries. */
|
||||
function listedSkinRow(listing: StoreListing): CatalogLoadRow {
|
||||
const drop = listing.GiftDrop
|
||||
return {
|
||||
key: drop.EquipmentModificationGuid,
|
||||
id: listing.PurchasableItemId,
|
||||
label: `${drop.FriendlyName} (skin, ${drop.EquipmentPrefabName})`,
|
||||
values: [
|
||||
drop.EquipmentModificationGuid,
|
||||
listing.PurchasableItemId,
|
||||
CatalogKind.Skin,
|
||||
s.FriendlyName,
|
||||
s.Tooltip,
|
||||
s.Rarity,
|
||||
s.PlatformMask,
|
||||
s.ThumbnailImage,
|
||||
drop.FriendlyName,
|
||||
drop.Tooltip,
|
||||
drop.Rarity,
|
||||
ALL_PLATFORMS,
|
||||
drop.ThumbnailImageName,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
s.PrefabName,
|
||||
s.UnlockedLevel,
|
||||
drop.EquipmentPrefabName,
|
||||
null,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn both captures into the rows a load writes, de-duplicated on `item_key`.
|
||||
* Turn the served store into the rows a load writes, de-duplicated on `item_key`.
|
||||
*
|
||||
* `item_key` is unique across BOTH kinds, so a repeat is a defect in the capture rather than
|
||||
* `item_key` is unique across ALL kinds, so a repeat is a defect in the source rather than
|
||||
* something the table should model. First occurrence wins and the rest are RETURNED rather
|
||||
* than dropped on the floor: the caller has to report them, because a collision that vanishes
|
||||
* quietly is the exact failure the single key exists to prevent.
|
||||
* quietly is the exact failure the single key exists to prevent. (The dump's verbatim repeats
|
||||
* are collapsed by {@link collapseRepeatedListings} before the served file is written, so they
|
||||
* never get here; a consumable's PACK is counted, not reported — see {@link ListingKind}.)
|
||||
*
|
||||
* Each surviving row is also numbered — `catalog_id`, from {@link CATALOG_ID_BASE} upward in
|
||||
* capture order, the small numeric handle the site uses in place of a comma-laden desc or a
|
||||
* guid, and the `PurchasableItemId` a generated storefront lists it under. It is assigned here
|
||||
* rather than by the database so the caller can render it into the same INSERT, and it is a
|
||||
* LOAD-ORDER surrogate: the next load renumbers, and nothing may store it.
|
||||
* Rows come out in STORE ORDER, one per listing that sells a catalog item, and `catalog_id` is
|
||||
* the listing's `PurchasableItemId` — a real, stable number, the one the client buys by. A
|
||||
* store id naming two different keys is refused outright: it cannot be resolved by order, and
|
||||
* nothing in the dump does it.
|
||||
*/
|
||||
export function buildCatalogLoad(
|
||||
avatarItems: AvatarItemCapture[],
|
||||
skins: SkinCapture[]
|
||||
): { rows: CatalogLoadRow[]; collisions: CatalogCollision[] } {
|
||||
export function buildCatalogLoad(listings: StoreListing[]): CatalogLoad {
|
||||
const seen = new Map<string, string>()
|
||||
const idOwner = new Map<number, string>()
|
||||
const rows: CatalogLoadRow[] = []
|
||||
const collisions: CatalogCollision[] = []
|
||||
const idAt = CATALOG_INSERT_COLUMNS.indexOf('catalog_id')
|
||||
for (const row of [...avatarItems.map(avatarItemRow), ...skins.map(skinRow)]) {
|
||||
const skipped: Partial<Record<ListingKind, number>> = {}
|
||||
|
||||
/** Keep the row, or report it as a repeat of one already kept. */
|
||||
const push = (row: CatalogLoadRow): void => {
|
||||
const kept = seen.get(row.key)
|
||||
if (kept !== undefined) {
|
||||
collisions.push({ key: row.key, kept, dropped: row.label })
|
||||
continue
|
||||
return
|
||||
}
|
||||
const owner = idOwner.get(row.id)
|
||||
if (owner !== undefined) {
|
||||
throw new Error(`catalog_id ${row.id} names two items: ${owner} and ${row.label}`)
|
||||
}
|
||||
seen.set(row.key, row.label)
|
||||
// Numbered AFTER de-duplication and from {@link CATALOG_ID_BASE}, so a load's ids are
|
||||
// exactly BASE..BASE+rows.length-1 with no gaps — a dropped duplicate must not burn a
|
||||
// number.
|
||||
const id = CATALOG_ID_BASE + rows.length
|
||||
row.values[idAt] = id
|
||||
rows.push({ ...row, id })
|
||||
idOwner.set(row.id, row.label)
|
||||
rows.push(row)
|
||||
}
|
||||
return { rows, collisions }
|
||||
|
||||
for (const listing of listings) {
|
||||
const kind = listingKind(listing)
|
||||
switch (kind) {
|
||||
case CatalogKind.AvatarItem:
|
||||
push(avatarItemRow(listing))
|
||||
break
|
||||
case CatalogKind.Consumable:
|
||||
// The pack of a consumable already listed singly: one thing, one row.
|
||||
if (seen.has(listing.GiftDrop.ConsumableItemDesc)) {
|
||||
skipped.consumable_pack = (skipped.consumable_pack ?? 0) + 1
|
||||
break
|
||||
}
|
||||
push(consumableRow(listing))
|
||||
break
|
||||
case CatalogKind.Skin:
|
||||
push(listedSkinRow(listing))
|
||||
break
|
||||
default:
|
||||
skipped[kind] = (skipped[kind] ?? 0) + 1
|
||||
}
|
||||
}
|
||||
|
||||
return { rows, collisions, skipped }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rarities that are NOT sold, and so never appear in a generated storefront.
|
||||
* Rarities that mark the developer/unreleased tier.
|
||||
*
|
||||
* `-1` is the developer/unreleased tier. The items carrying it stay in the `catalog` table —
|
||||
* that is a record of what EXISTS — but a storefront is a record of what is for SALE, and an
|
||||
* item listed in one can be bought: `findStoreItem` resolves a purchase against the catalog
|
||||
* file itself, so listing them at any price would put them on sale.
|
||||
*
|
||||
* Lives here rather than in the storefront generator because two things need the same answer:
|
||||
* the generator, which omits them, and anything that hands the client a PurchasableItem id,
|
||||
* which must not name one the storefront never listed. A client asked to resolve an id no
|
||||
* storefront sells renders nothing, indistinguishably from an id it failed to parse.
|
||||
* `-1`. The 2025 store lists a handful of such items and this server serves that store
|
||||
* verbatim, so they CAN be bought; the tier is kept out of the discovery rows the `lists`
|
||||
* worker draws from the catalog, which is the one place this is still read.
|
||||
*/
|
||||
export const UNSELLABLE_RARITIES: readonly number[] = [-1]
|
||||
|
||||
/** Whether an item of this rarity may appear in a storefront. */
|
||||
/** Whether an item of this rarity is outside the developer tier. */
|
||||
export const isSellableRarity = (rarity: number): boolean => !UNSELLABLE_RARITIES.includes(rarity)
|
||||
|
||||
/**
|
||||
* What a catalog item costs, by rarity — the pricing every surface that sells a catalog row
|
||||
* must agree on.
|
||||
*
|
||||
* Shared rather than living in the storefront generator alone because a purchase is CHECKED
|
||||
* against the price the client was shown: `priceCheck` compares the posted `RequestedPrice`
|
||||
* with the catalog's, and a server that priced a buy differently from the file it listed would
|
||||
* refuse every purchase as "Price has changed". One table, both sides.
|
||||
*
|
||||
* The tiers 0/10/30/50 were specified; 20 sits between its neighbours at 700 (sf3's own
|
||||
* rarity-20 items cluster at 400-600, but 600 is taken by rarity 10 here, and two tiers sharing
|
||||
* a price makes the rarity invisible). {@link UNSELLABLE_RARITIES} is priced by nothing — those
|
||||
* items are not sold at all.
|
||||
* What an item costs by rarity, in RecCenterTokens — for the things the store dump does not
|
||||
* price: the skins it never listed ({@link unlistedSkinListing}) and a first-party custom
|
||||
* avatar item it does not list (`apps/api/scripts/price-custom-avatar-items.ts`). Everything
|
||||
* the dump lists carries its real price, verbatim.
|
||||
*/
|
||||
export const PRICE_BY_RARITY: Record<number, number> = {
|
||||
0: 150,
|
||||
@@ -237,52 +480,31 @@ export const PRICE_BY_RARITY: Record<number, number> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* What a rarity absent from {@link PRICE_BY_RARITY} costs — the bottom tier, never free.
|
||||
*
|
||||
* Only reachable if a future capture introduces a rarity nobody has priced. A floor rather than
|
||||
* a skip because an unpriced item silently vanishing from the store is harder to notice than
|
||||
* one that turns up cheap; a rarity meant to be unsellable belongs in
|
||||
* {@link UNSELLABLE_RARITIES}, where a load reports it.
|
||||
* What a rarity absent from {@link PRICE_BY_RARITY} costs — the bottom tier, never free. A
|
||||
* floor rather than a skip because an unpriced item silently vanishing is harder to notice
|
||||
* than one that turns up cheap.
|
||||
*/
|
||||
export const DEFAULT_PRICE = 150
|
||||
|
||||
/** What one catalog row costs, in RecCenterTokens. */
|
||||
/** What one item of this rarity costs, in RecCenterTokens. */
|
||||
export const priceForRarity = (rarity: number): number => PRICE_BY_RARITY[rarity] ?? DEFAULT_PRICE
|
||||
|
||||
/**
|
||||
* What Rec Room Plus takes off, in percent. The same number the server's own `subscriberFloor`
|
||||
* allows, which is what makes a subscriber's discounted `RequestedPrice` land inside the band
|
||||
* rather than through the floor.
|
||||
* What Rec Room Plus takes off, in percent — the same number the econ worker's own
|
||||
* `subscriberFloor` allows, which is what makes a subscriber's discounted `RequestedPrice` land
|
||||
* inside the band rather than through the floor.
|
||||
*/
|
||||
export const SUBSCRIBER_DISCOUNT_PERCENT = 10
|
||||
|
||||
/** The subscriber price for a regular one. Floored, matching the server's `subscriberFloor`. */
|
||||
/** The subscriber price for a regular one. Floored, matching the worker's `subscriberFloor`. */
|
||||
export const subscriberPriceFor = (regular: number): number =>
|
||||
Math.floor((regular * (100 - SUBSCRIBER_DISCOUNT_PERCENT)) / 100)
|
||||
|
||||
/**
|
||||
* The last client build treated as the 2023-era one, as a build number and as the ISO date the
|
||||
* item catalogue records `CreatedAt` in.
|
||||
* The last client build served the 2023 general store.
|
||||
*
|
||||
* One constant in two forms because two things key off the same moment: the econ worker decides
|
||||
* which storefront FILE a caller is served by comparing their token's `rn.ver` to the number,
|
||||
* and the storefront generator decides which ITEMS go in the 2023 file by comparing each item's
|
||||
* `CreatedAt` to the date. They must not drift — a build served the old store but a store built
|
||||
* to a different date would sell items that build has never heard of.
|
||||
* The econ worker compares a caller's token `rn.ver` to this to pick which storefront FILE
|
||||
* they get for storefront 3: `sf3.json` (the 2023 store, frozen) up to and including this
|
||||
* build, `sf3-2025.json` (the 2025 store dump) after it.
|
||||
*/
|
||||
export const LEGACY_CLIENT_BUILD = 20_230_414
|
||||
|
||||
/** {@link LEGACY_CLIENT_BUILD} as `YYYY-MM-DD`, for comparing against a `CreatedAt`. */
|
||||
export const LEGACY_CLIENT_BUILD_DATE = '2023-04-14'
|
||||
|
||||
/**
|
||||
* Whether an item existed by the time of {@link LEGACY_CLIENT_BUILD} — i.e. whether the 2023
|
||||
* store should sell it.
|
||||
*
|
||||
* An item with NO `CreatedAt` is treated as too new and left out: three catalogue rows carry
|
||||
* none, and there is no way to show they predate the cutoff. Excluding is the conservative
|
||||
* direction — the 2023 store missing three items nobody noticed is better than it offering
|
||||
* something that build cannot render.
|
||||
*/
|
||||
export const existedByLegacyBuild = (createdAt: string | null | undefined): boolean =>
|
||||
typeof createdAt === 'string' && createdAt.slice(0, 10) < LEGACY_CLIENT_BUILD_DATE
|
||||
|
||||
@@ -24,6 +24,17 @@ export type Env = SharedHonoEnv & {
|
||||
* read it through `intVar`, never as a bare number.
|
||||
*/
|
||||
STARTING_TOKENS?: string | number
|
||||
/**
|
||||
* The Discord supporter gift (see discord-role-gift.ts): Discord ROLE ids mapped to the
|
||||
* RecCenterTokens a holder is boxed each time the cron fires, `<roleId>=<tokens>` pairs
|
||||
* separated by commas, e.g. `928457923857943795=2500,2938479238479234=10000`. Role ids
|
||||
* are snowflakes — all digits, never a role's name — kept as strings because one
|
||||
* exceeds 2^53. One box per account per run: a holder of several mapped roles is paid
|
||||
* the HIGHEST amount. Optional: unset, or parsing to no roles, leaves the cron off.
|
||||
* Roles are read from `platform_account.role`, the snapshot `www`'s claim and daily
|
||||
* sweep maintain, so this only ever pays players who have claimed on the website.
|
||||
*/
|
||||
DISCORD_ROLE_TOKENS?: string
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user