mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
Compare commits
234 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5f34028267 | |||
| b02a646892 | |||
| 8397997234 | |||
| 4e2cdd87eb | |||
| d1e69bfd14 | |||
| 09e40af6b7 | |||
| f9600c1836 | |||
| 3da35a8692 | |||
| 16e5f61160 | |||
| 20a947dcad | |||
| fbf7c923a6 | |||
| 1d0e71d651 | |||
| 76db89c30c | |||
| 208178494a | |||
| 2222a545ff | |||
| 605a35c36b | |||
| 3cf4b4029e | |||
| 337367da52 | |||
| 7abef376b6 | |||
| ae360e1e83 | |||
| a17183f15c | |||
| ed45f38137 | |||
| affdcb85e3 | |||
| 5f3cdc5922 | |||
| eab86ec223 | |||
| 71b0215ed3 | |||
| 38bb9cff73 | |||
| 6bf297ed98 | |||
| f04b9bd66b | |||
| 2091483cc3 | |||
| d918936795 | |||
| be1b275a0a | |||
| b1628f28e2 | |||
| 05d7fe4c32 | |||
| a3291bc9ce | |||
| 81c372135c | |||
| 02ca828a69 | |||
| 2ba2ad3a4b | |||
| 4c1b9ca9da | |||
| 33b74d02c5 | |||
| f24e35224c | |||
| 6d905e1811 | |||
| 5eac7480ae | |||
| d87e1ca5ae | |||
| c52b37037e | |||
| f8a6338eaa | |||
| 972432d4d3 | |||
| e225a8a2f1 | |||
| 2a9ff1337a | |||
| e7ee9ed444 | |||
| 6f8e1182e8 | |||
| 0f757b2c4f | |||
| eb5c0d28ff | |||
| 9902de39a4 | |||
| 94b106c12f | |||
| ee725056d2 | |||
| 4f36146912 | |||
| 5ba57210b9 | |||
| ba6c703050 | |||
| 33d235f8ad | |||
| 6d4156bd67 | |||
| bf817977ec | |||
| cf6868a753 | |||
| 810e3b8f27 | |||
| 7d509f6230 | |||
| c48cefda26 | |||
| c1444f906d | |||
| 961dae5c7a | |||
| 92c93b0a58 | |||
| 1f4c3e5fc0 | |||
| 6f9348222a | |||
| e06eba6929 | |||
| 1dd5fd409e | |||
| bd15e30bfc | |||
| dfe5071878 | |||
| a0345af765 | |||
| 4d6a88f20e | |||
| aa5e83764e | |||
| 58a9b08548 | |||
| 0e699cab2b | |||
| 2a41b93c13 | |||
| a3a5851373 | |||
| 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 |
+141
-4
@@ -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,26 @@ 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 pool players on a build under staged rollout (RECFLARE_ROLLOUT_GAME_VERSIONS below,
|
||||
# by default `20260323`; see RECFLARE_DISCORD_ROLLOUT_ROLE_IDS too) are served from, same format,
|
||||
# ids `rollout-1`, `rollout-2`, … — the new build gets its own voice servers while it rolls
|
||||
# out. A sandbox account on that build still gets the sandbox pool. Unset, rollout players
|
||||
# use the pool above.
|
||||
# RECFLARE_TACHYON_HOST_PORT_ROLLOUT=198.51.100.13:7777
|
||||
#
|
||||
# WHICH builds are served that rollout pool, when it should not simply follow the sign-in
|
||||
# gate: same format as RECFLARE_ROLLOUT_GAME_VERSIONS (builds, commas; `off` for none;
|
||||
# `20260323` and `20260323.14` name the same build). Unset, the rollout pool goes to the
|
||||
# builds under staged rollout, so ending the rollout (ROLLOUT_GAME_VERSIONS=off) also moves
|
||||
# those players onto the live pool. Set this to keep the 2026 build on its own voice
|
||||
# servers after it opens to everyone — the 2025 and 2023 builds stay on the pool above.
|
||||
# RECFLARE_TACHYON_ROLLOUT_GAME_VERSIONS=20260323
|
||||
|
||||
# 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 +182,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.
|
||||
@@ -185,6 +227,101 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# RECFLARE_DISCORD_GUILD_ID=1077000000000000000
|
||||
# RECFLARE_DISCORD_BENEFITS_ROLE_IDS=1077000000000000001,1077000000000000002
|
||||
#
|
||||
# Also add https://<your domain>/claim to the app's Redirects in the Discord developer
|
||||
# portal, or the exchange is refused. Granting Plus takes effect on the player's NEXT
|
||||
# sign-in. `runx admin grant-plus` sets it directly, with no Discord involved.
|
||||
# Also add https://<your domain>/claim AND https://<your domain>/login/discord to the app's
|
||||
# Redirects in the Discord developer portal, or the exchange is refused. The second is the
|
||||
# login page's "Sign in with Discord" (same switch): it signs into the account the Discord is
|
||||
# linked to, or CREATES one — the player then sets a password on the website to sign in to
|
||||
# the game, including on a Quest with no Meta account. 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.
|
||||
|
||||
# Which client BUILDS this server serves (`auth` and `api`, with `match` reading the rollout
|
||||
# list). Both are comma-separated lists of builds — the `YYYYMMDD` of the client version;
|
||||
# a copied `20250718.01` is read as `20250718` — or `off` for an empty list. Unset means
|
||||
# the defaults in packages/domain/src/presence-db.ts (SUPPORTED_GAME_VERSIONS: the 2023
|
||||
# and 2025 builds plus the alphas; ROLLOUT_GAME_VERSIONS: `20260323`).
|
||||
#
|
||||
# A build outside SUPPORTED is told to update by `api`'s version check AND refused a token
|
||||
# by `auth` (every grant, staff included), so dropping a build here is how its players are
|
||||
# made to upgrade. A build in ROLLOUT is served too, whether or not SUPPORTED names it, but
|
||||
# only to staff and the Discord roles below. The lifecycle these exist for:
|
||||
# 1. Rollout (both unset): the 2026 build is for staff and supporters only.
|
||||
# 2. General availability: ROLLOUT_GAME_VERSIONS=off — the 2026 build opens to everyone
|
||||
# (and joins the live voice pool unless TACHYON_ROLLOUT_GAME_VERSIONS above keeps it).
|
||||
# 3. Forced upgrade: SUPPORTED_GAME_VERSIONS=20230414,20260323 (with `off` above) — the
|
||||
# 2025 build is refused and has to update.
|
||||
# To pull a rollout back instead, list neither: SUPPORTED_GAME_VERSIONS=20230414,20250718
|
||||
# with ROLLOUT_GAME_VERSIONS=off.
|
||||
# RECFLARE_SUPPORTED_GAME_VERSIONS=20230414,20250718,20260323
|
||||
# RECFLARE_ROLLOUT_GAME_VERSIONS=20260323
|
||||
|
||||
# OPTIONAL: a staged rollout of a new client build to Discord supporters (`auth`). The
|
||||
# Discord ROLE ids whose holders may sign in on a ROLLOUT-GATED build — by default
|
||||
# `20260323`, see RECFLARE_ROLLOUT_GAME_VERSIONS above. A login
|
||||
# posting that build as `ver` is refused ("this build is limited to Discord supporters")
|
||||
# unless the account is staff (developer or moderator — always let in) or its Discord link
|
||||
# holds one of these roles; every other build is never touched. Same rules as ROLE_IDS above: snowflakes, not names; commas, no spaces; ANY one
|
||||
# qualifies. Roles come from the snapshot the benefits claim and the daily sweep keep, so a
|
||||
# player has to have claimed on the website, and a role granted today opens the gate once
|
||||
# the sweep runs (or they re-claim). UNSET — or parsing to no roles — CLOSES the gate: nobody
|
||||
# but staff signs in on the gated build until a role is named here. Accounts can't be CREATED on a
|
||||
# gated build (a new account has no Discord link); players make one on a released build,
|
||||
# link Discord at /claim, and come back.
|
||||
# RECFLARE_DISCORD_ROLLOUT_ROLE_IDS=1077000000000000001,1077000000000000002
|
||||
|
||||
# OPTIONAL: a staged rollout of Rec Room Studio to Discord roles (`auth`, `studio`, `www`).
|
||||
# The Discord ROLE ids whose holders get Studio upload access — `auth` stamps the
|
||||
# `betastudio` role into their token exactly as it does for an account staff flagged
|
||||
# `hasStudio` on the website, and `studio`'s access lookups and the website's Studio
|
||||
# settings page say "granted" for them too. The flag keeps working beside this, so staff
|
||||
# can still add individuals. Same rules as ROLE_IDS above: snowflakes, not names; commas, no
|
||||
# spaces; ANY one qualifies; roles come from the snapshot the benefits claim and the daily
|
||||
# sweep keep, so a newly granted role counts once the sweep runs (or the player re-claims),
|
||||
# and the player's Studio token picks it up on their next sign-in or refresh. UNSET — or
|
||||
# parsing to no roles — grants nobody: the flag alone decides.
|
||||
# RECFLARE_STUDIO_ROLLOUT=1077000000000000001,1077000000000000002
|
||||
|
||||
# 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
|
||||
|
||||
# Rec Room Studio (`studio`). Cloud builds are configured OUTSIDE this file: the credential
|
||||
# the build host (recflare/cloud-building, its STUDIO_WORKER_KEY) presents to claim jobs
|
||||
# lives in the shared Secrets Store, like JWT_SECRET above — not as a var, since every var
|
||||
# here reaches every worker in plaintext. The binding has to resolve for `studio` to
|
||||
# deploy, so create it even if you run no build host: the literal `off` disables cloud
|
||||
# builds (the editor is told "Cloud building is not configured").
|
||||
#
|
||||
# wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY \
|
||||
# --scopes workers --remote
|
||||
#
|
||||
# The knobs ARE vars. TARGETS is the Unity build targets a job is built for (0 Windows,
|
||||
# 2 Android), commas, no spaces; the build host needs the matching editor modules.
|
||||
# TEMPLATE_VERSION is the Studio template a job is built against, and the build host
|
||||
# claims ONLY jobs for its own configured version (171 by default there, 169 here), so
|
||||
# a mismatch leaves jobs queued forever with no error on either side — set this to the
|
||||
# template the host has installed. DISTRIBUTION_MANIFEST is the R2 key of the template
|
||||
# catalog the editor downloads from; unset disables downloads (see apps/studio/README.md).
|
||||
# RECFLARE_CLOUD_BUILD_TARGETS=0,2
|
||||
# RECFLARE_CLOUD_BUILD_TEMPLATE_VERSION=169
|
||||
# RECFLARE_STUDIO_DISTRIBUTION_MANIFEST=roomunityasset/studio-distribution/catalog-v171.json
|
||||
|
||||
@@ -47,15 +47,30 @@
|
||||
<game-clients>
|
||||
Supported client builds live in `SUPPORTED_GAME_VERSIONS`
|
||||
(`packages/domain/src/presence-db.ts`); `GAME_VERSION` is the default the stack targets.
|
||||
Those lists are DEFAULTS: the `SUPPORTED_GAME_VERSIONS` and `ROLLOUT_GAME_VERSIONS` worker
|
||||
vars (`RECFLARE_*` in `.env`, reaching every worker) override them — read through
|
||||
`supportedGameBuilds(env)` / `rolloutGameBuilds(env)`, never the constants, in anything
|
||||
that gates on "is this build served / under rollout" (`auth` tokens, `api` versioncheck,
|
||||
`match` rollout voice pool). A build outside the supported set is told to update by `api`
|
||||
AND refused a token by `auth`; a rollout build is always served, but gated to staff and
|
||||
Discord roles until `ROLLOUT_GAME_VERSIONS=off`. The rollout VOICE pool
|
||||
(`TACHYON_HOST_PORT_ROLLOUT`) follows that list by default but `TACHYON_ROLLOUT_GAME_VERSIONS`
|
||||
overrides it (`tachyonRolloutGameBuilds` in `match`), so a build can stay on its own Tachyon
|
||||
servers after its rollout ends.
|
||||
|
||||
- `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.
|
||||
- `20250718` — beta, official (the `20250718.01` client). Manifest `1151455856673601091`;
|
||||
reaches this server via the [patch-2025](https://github.com/recflare/patch-2025) patch.
|
||||
- `20260323` (`20260323.14`), `20250424` (`20250424.01`), `20231207`, `20230616` — alpha.
|
||||
|
||||
Builds are date-stamped (`YYYYMMDD[.NN]`) so they order as plain strings; several
|
||||
surfaces gate on "newer than `20230414`" (econ storefront catalog, `api` event tags,
|
||||
`rooms` featured rooms) rather than on an explicit list.
|
||||
A client reports `YYYYMMDD[.NN]`; the `.NN` is a same-day point release of the same build
|
||||
and decides nothing. `gameBuild` strips it, the lists above hold bare dates, every gate
|
||||
compares builds (`validateAndGetBuild` in `@repo/jwt` is the token form), and the
|
||||
`room_instance` key is stored as the build so `20250718.01` and `20250718.2` share a
|
||||
session. The token's `rn.ver` and presence's `appVersion` still echo the client's full
|
||||
version. Builds order as plain strings; several surfaces gate on "newer than `20230414`"
|
||||
(econ storefront catalog, `api` event tags, `rooms` featured rooms) rather than on an
|
||||
explicit list.
|
||||
</game-clients>
|
||||
|
||||
<code-style>
|
||||
@@ -100,8 +115,8 @@ inconsistency here without checking the client first.
|
||||
- The room save (`rooms`: `POST /subrooms/:sid/data`) is the ONE exception to that shape:
|
||||
`value` is `{ room, subRoomDataSave }`, and `error` is NULL rather than `""`. The
|
||||
`subRoomDataSave` is camelCase with a different field set from the PascalCase
|
||||
`CurrentSave` embedded in the room (no persistence/OM/UGC versions, no moderation state,
|
||||
no asset arrays; but `unityAsset`/`unityAssetHash`). Don't unify the two projections.
|
||||
`CurrentSave` embedded in the room (no moderation state or asset arrays;
|
||||
version fields are camelCase, alongside `unityAsset`/`unityAssetHash`). Keep the projections distinct.
|
||||
- A subroom's saved scene loads from `CurrentSave.DataBlob` (`rooms`: `GET /rooms/:id`),
|
||||
NOT the flat `DataBlob` on the subroom — a subroom with no `CurrentSave` silently loads
|
||||
nothing. The key must be present (null before the first publish); read it via
|
||||
@@ -115,15 +130,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: dependency entries in `UnitySubAssets`
|
||||
are `{ UnityAssetId, UnityAsset, UnityAssetHash }`, the main bundle appears only at 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 +211,107 @@ 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.
|
||||
- `unity_asset` holds two kinds of bundle under one shape: Rec Room Studio builds (UGC, the
|
||||
`studio` worker writes them, filenames `studio/<roomId>/…`) and FIRST-PARTY bundles captured
|
||||
from the official game and loaded by hand (`unity/<roomId>/…`). Only the Studio ones may be
|
||||
folded onto `CurrentSave.UnitySubAssets` (`rooms`: `GET /rooms/:id`, the save reads) —
|
||||
`attachStudioUnityAssets` filters on `isStudioBundleFilename`. The 2025 client resolves a
|
||||
first-party bundle through `POST /unity_assets/baked/bulk` (which serves both kinds); handed
|
||||
it inline in the five-key `{ Target, Version, Filename, Hash }` shape it takes that instead,
|
||||
cannot name the file (its decoder wants `UnityAsset`/`UnityAssetHash`), and the room loads
|
||||
nothing. Every imported first-party room broke when the splice moved from the Studio-only
|
||||
table to `unity_asset`, with every response looking correct.
|
||||
- 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,113 @@ 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. To open
|
||||
Studio to a whole Discord role instead, name it in `RECFLARE_STUDIO_ROLLOUT` (`.env`).
|
||||
|
||||
```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.
|
||||
|
||||
### `catalog load` — reload the item catalog
|
||||
|
||||
```sh
|
||||
just catalog-load # sf3-2025.json → local (merge)
|
||||
just catalog-load --remote # production
|
||||
just catalog-load --replace # also drop rows the file no longer lists
|
||||
just catalog-load --dry-run # build and validate, write nothing
|
||||
bun runx catalog check # validate the store file without touching a database
|
||||
```
|
||||
|
||||
Fills econ's `catalog` table from `apps/econ/static/storefronts/sf3-2025.json`, the 2025
|
||||
store as the game is served it, and nothing else. Re-run it whenever that file changes (after
|
||||
`runx storefront build`). Each row's `catalog_id` is its listing's `PurchasableItemId` and its
|
||||
`listing` column the listing verbatim, which is what
|
||||
`POST /api/storefronts/v1/purchasableitems` serves. Avatar items, consumables, skins and
|
||||
first-party custom avatar items all load; loot boxes, token bundles and consumable packs do
|
||||
not. A merge leaves rows the file no longer lists in place with no `catalog_id` or `listing`;
|
||||
`--replace` deletes them. The inventory does not read this table, so either is safe.
|
||||
|
||||
### `cai-load` — load the first-party custom avatar items
|
||||
|
||||
```sh
|
||||
bun runx admin cai-load # 2025-1-cai.json + studio87-extras.json → local
|
||||
bun runx admin cai-load --remote
|
||||
bun runx admin cai-load --file path/to/export.json --dry-run # --file takes one or more
|
||||
just cai-load --remote # the same, as a recipe
|
||||
```
|
||||
|
||||
Loads exports of `CustomAvatarItem` records (each a JSON array, a `{ Results }` page, or one
|
||||
record) into the `custom_avatar_item` table. With no `--file` it reads
|
||||
`apps/econ/static/db/2025-1-cai.json`, then `studio87-extras.json` — the official-store items
|
||||
the 2025 export lacks, fetched from another server's `customAvatarItems/v1/bulk` and priced
|
||||
from the storefront dump (an item it lists with no price is free). A later export only ADDS:
|
||||
an id an earlier one already holds is skipped, so the extras never overwrite a 2025 item. A
|
||||
duplicate within one export is refused. Each record is 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 +172,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)
|
||||
|
||||
+22
-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`
|
||||
@@ -133,6 +140,17 @@ wrangler secrets-store secret create <store-id> --name META_APP_SECRET --scopes
|
||||
> with `wrangler secrets-store secret update` — no redeploy needed, the worker reads
|
||||
> the secret per request.
|
||||
|
||||
The `studio` worker binds one more, `CLOUD_BUILD_KEY`: the credential the Rec Room
|
||||
Studio cloud-building host ([recflare/cloud-building](https://github.com/recflare/cloud-building),
|
||||
its `STUDIO_WORKER_KEY`) presents to claim queued builds. Same rule — it must exist for
|
||||
`studio` to deploy. Create it as the literal `off` to run without cloud builds; put a
|
||||
long random string in later when you set up a build host, and give that host the same
|
||||
string.
|
||||
|
||||
```bash
|
||||
wrangler secrets-store secret create <store-id> --name CLOUD_BUILD_KEY --scopes workers --remote
|
||||
```
|
||||
|
||||
Then apply the schema. `just migrate` will set up the database and populate it with data. This runs non-interactively, so be careful!
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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,37 @@ 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, then
|
||||
# studio87-extras.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 "$@"
|
||||
|
||||
# Merges by default; --replace drops rows the file no longer lists. Defaults to --local; pass
|
||||
# --remote for production, --dry-run to only validate. Run it whenever sf3-2025.json changes.
|
||||
# Reload the item catalog (econ `catalog` table) from apps/econ/static/storefronts/sf3-2025.json
|
||||
[group('4. utility')]
|
||||
[positional-arguments]
|
||||
[no-cd]
|
||||
catalog-load *args:
|
||||
bun runx catalog load "$@"
|
||||
|
||||
@@ -40,18 +40,30 @@ See [RecFlare Client](https://github.com/djdevin/recflare-client) for the offici
|
||||
|
||||
These game builds are supported:
|
||||
|
||||
| Build | Manifest | Support |
|
||||
| ------------- | --------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `20230414` | `7859140924515540835` | **Default**, official — the 2023 build the rest of the stack targets |
|
||||
| `20250718.01` | `1151455856673601091` | **Beta**, official — use the [patch-2025](https://github.com/recflare/patch-2025) patch |
|
||||
| `20250424.01` | | Alpha |
|
||||
| `20231207` | | Alpha |
|
||||
| `20230616` | | Alpha |
|
||||
| Build | Client version | Manifest | Support |
|
||||
| ---------- | -------------- | --------------------- | --------------------------------------------------------------------------------------- |
|
||||
| `20230414` | `20230414` | `7859140924515540835` | **Default**, official — the 2023 build the rest of the stack targets |
|
||||
| `20250718` | `20250718.01` | `1151455856673601091` | **Beta**, official — use the [patch-2025](https://github.com/recflare/patch-2025) patch |
|
||||
| `20260323` | `20260323.14` | | Alpha |
|
||||
| `20250424` | `20250424.01` | | Alpha |
|
||||
| `20231207` | `20231207` | | Alpha |
|
||||
| `20230616` | `20230616` | | Alpha |
|
||||
|
||||
A build is the `YYYYMMDD` date of the client version; the `.NN` after it is a same-day
|
||||
point release and is ignored everywhere, so `20250718.01`, `20250718.2` and `20250718`
|
||||
are all the beta build and share game sessions.
|
||||
|
||||
Alpha builds get past the version check and largely work, but nothing else in the
|
||||
stack targets them, so expect protocol differences. Other client or game versions
|
||||
may expect different endpoints and response shapes and are not supported.
|
||||
|
||||
The table is the default. An operator narrows it with `RECFLARE_SUPPORTED_GAME_VERSIONS`
|
||||
in `.env` (a build left out is told to update and refused a sign-in, which is how players
|
||||
are moved to a newer build), and `RECFLARE_ROLLOUT_GAME_VERSIONS` names the builds under
|
||||
staged rollout to Discord supporters — `20260323` by default, `off` to open it to
|
||||
everyone (`RECFLARE_TACHYON_ROLLOUT_GAME_VERSIONS` keeps a build on the rollout voice
|
||||
servers past that). See `.env.example`.
|
||||
|
||||
Generally speaking any client that effectively rewrites the nameserver with the
|
||||
right mods (see [RecNet Plugin](https://github.com/djdevin/recnet-plugin), [2025 patch](https://github.com/recflare/patch-2025)) can be used with this server.
|
||||
|
||||
|
||||
+5
-5
@@ -21,14 +21,14 @@ 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) |
|
||||
| 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 (R2) |
|
||||
| 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 |
|
||||
@@ -54,7 +54,7 @@ apex/`ns` host and isn't listed within it. Each implemented worker has its own
|
||||
| Storage | `storage` | — | Room uploader |
|
||||
| Strings | `strings` | — | Not yet implemented |
|
||||
| StringsCDN | `strings-cdn` | — | Not yet implemented |
|
||||
| Studio | `studio` | — | 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` | — | Website/Panel |
|
||||
| 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).
|
||||
@@ -284,43 +347,49 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// ---- Bulk / single lookup ------------------------------------------------
|
||||
// Register the static `bulk` path before the `/account/:id` param route.
|
||||
// Exact lookup by username — what the site's `/u/<name>` profile pages and the staff
|
||||
// `@username` fields resolve a player through. An indexed equality on `username_lower`;
|
||||
// before this existed they went through the prefix search above and filtered the
|
||||
// exact hit client-side, which cost a 20-row index walk per profile view. Same path
|
||||
// as the official API's `GET /account/username/{username}`.
|
||||
.get(
|
||||
'/account/bulk',
|
||||
'/account/username/:name',
|
||||
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(' '),
|
||||
summary: 'A single public account, by exact username',
|
||||
description:
|
||||
'Case-insensitive exact match; a leading `@` is ignored. 404 when no account has the name.',
|
||||
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' } },
|
||||
name: 'name',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The username, exactly (case-insensitive)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(AccountDto.array(), 'One public account per requested id') },
|
||||
responses: {
|
||||
200: json(AccountDto, 'The public account'),
|
||||
404: { description: 'No account has that username' },
|
||||
},
|
||||
}),
|
||||
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))))
|
||||
const name = c.req.param('name').trim().replace(/^@/, '')
|
||||
const account = name === '' ? null : await getAccountByUsername(c.env.DB, name)
|
||||
if (!account) return c.json({ error: `There's no player called @${name}.` }, 404)
|
||||
return c.json(toAccountDto(account))
|
||||
}
|
||||
)
|
||||
|
||||
// ---- Bulk / single lookup ------------------------------------------------
|
||||
// Register the static `bulk` path before the `/account/:id` param route.
|
||||
.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',
|
||||
describeRoute({
|
||||
@@ -470,7 +539,8 @@ const app = new Hono<App>()
|
||||
responses: {
|
||||
200: json(SuccessResponse, 'Updated'),
|
||||
400: {
|
||||
description: 'Empty, over 15 characters, non-alphanumeric, or profane (empty body)',
|
||||
description:
|
||||
'Empty, over 15 characters, outside the display name charset, or profane (empty body)',
|
||||
},
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { resolver } from 'hono-openapi'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
displayNameRejection,
|
||||
isValidBio,
|
||||
isValidEmail,
|
||||
MAX_DISPLAY_NAME_LENGTH,
|
||||
@@ -181,12 +182,12 @@ export const CreateAccountRequest = z.object({
|
||||
* sentence that explains it rather than two, and the swear check never sees the
|
||||
* punctuation the charset rule has already refused.
|
||||
*/
|
||||
const nameCheck = (label: string, max: number) =>
|
||||
const nameCheck = (label: string, rejectionFor: (value: string) => string | null) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.superRefine((value, ctx) => {
|
||||
const rejection = nameRejection(value, label, max)
|
||||
const rejection = rejectionFor(value)
|
||||
if (rejection !== null) {
|
||||
ctx.addIssue({ code: 'custom', message: rejection })
|
||||
} else if (nameContainsSwears(value)) {
|
||||
@@ -197,21 +198,31 @@ const nameCheck = (label: string, max: number) =>
|
||||
})
|
||||
|
||||
export const DisplayNameRequest = z.object({
|
||||
displayName: nameCheck('display name', MAX_DISPLAY_NAME_LENGTH)
|
||||
displayName: nameCheck('display name', displayNameRejection)
|
||||
.min(1)
|
||||
.describe(
|
||||
'Trimmed; letters and digits only, max 15, no profanity. Empty or invalid is rejected (400)'
|
||||
`Trimmed; Latin letters, digits, spaces and .,'!?_&()#@:+- only, max ${MAX_DISPLAY_NAME_LENGTH}, no profanity. Empty or invalid is rejected (400)`
|
||||
),
|
||||
})
|
||||
|
||||
export const UsernameRequest = z.object({
|
||||
username: nameCheck('username', MAX_USERNAME_LENGTH)
|
||||
username: nameCheck('username', (value) => nameRejection(value, 'username', MAX_USERNAME_LENGTH))
|
||||
.min(1, 'You must enter a username.')
|
||||
.describe(
|
||||
'Trimmed; letters and digits only, max 50, no profanity. Must be unique and changes must remain'
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* `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)
|
||||
@@ -111,6 +153,24 @@ describe('public endpoints', () => {
|
||||
expect(await empty.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /account/username/:name is an exact, case-insensitive lookup', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/username/coach`)
|
||||
expect(res.status).toBe(200)
|
||||
const account = (await res.json()) as { accountId: number; username: string }
|
||||
expect(account).toMatchObject({ accountId: 1, username: 'Coach' })
|
||||
// A leading `@` is tolerated — staff fields take names in that form.
|
||||
const at = await exports.default.fetch(
|
||||
`${ORIGIN}/account/username/${encodeURIComponent('@Coach')}`
|
||||
)
|
||||
expect(((await at.json()) as { accountId: number }).accountId).toBe(1)
|
||||
// A prefix is NOT a match: that's what `/account/search` is for.
|
||||
const prefix = await exports.default.fetch(`${ORIGIN}/account/username/coa`)
|
||||
expect(prefix.status).toBe(404)
|
||||
const missing = await exports.default.fetch(`${ORIGIN}/account/username/zzzznope`)
|
||||
expect(missing.status).toBe(404)
|
||||
expect(await missing.json()).toEqual({ error: "There's no player called @zzzznope." })
|
||||
})
|
||||
|
||||
test('GET /account/:id/bio returns an empty bio', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/7/bio`)
|
||||
expect(await res.json()).toEqual({ accountId: 7, bio: '' })
|
||||
@@ -230,7 +290,7 @@ describe('auth-gated endpoints', () => {
|
||||
...(await bearer('895')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
}
|
||||
// A name carries no spaces, so the filter has to find the swear at the seam the
|
||||
// These names carry no spaces, so the filter has to find the swear at the seam the
|
||||
// player typed instead of one.
|
||||
for (const displayName of ['fuck', 'ShitLord', 'Fucker123']) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
||||
@@ -647,11 +707,13 @@ describe('auth-gated endpoints', () => {
|
||||
'GET /account/bulk',
|
||||
'GET /account/me',
|
||||
'GET /account/search',
|
||||
'GET /account/username/{name}',
|
||||
'GET /account/{id}',
|
||||
'GET /account/{id}/bio',
|
||||
'GET /accountprivacysettings/{id}',
|
||||
'GET /emojiConfig/whitelistedEmojis',
|
||||
'GET /parentalcontrol/me',
|
||||
'POST /account/bulk',
|
||||
'POST /account/create',
|
||||
'POST /account/me/email',
|
||||
'POST /account/me/phone',
|
||||
@@ -698,8 +760,9 @@ describe('auth-gated endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// The names a player chooses are alphanumeric and length-capped, by the same rule the
|
||||
// `rooms` worker applies (see `nameRejection` in @repo/domain). The three limits come
|
||||
// The names a player chooses are charset- and length-capped by the rules in @repo/domain:
|
||||
// a username is alphanumeric (`nameRejection`), a display name takes the wider charset
|
||||
// the client's own box does (`displayNameRejection`). The limits come
|
||||
// from the client's own input boxes rather than a round number, so anything stored is
|
||||
// something the game can render and re-edit.
|
||||
//
|
||||
@@ -744,25 +807,44 @@ describe('name, email and bio validation', () => {
|
||||
expect(((await ok.json()) as { success: boolean }).success).toBe(true)
|
||||
})
|
||||
|
||||
test('PUT /account/me/displayname refuses anything but letters and digits, max 15', async () => {
|
||||
test('PUT /account/me/displayname holds a name to the client charset, max 15', async () => {
|
||||
const headers = await authed('8802')
|
||||
for (const displayName of ['has space', 'punct!', 'a'.repeat(16)]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
||||
const put = (displayName: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
||||
...form({ displayName }),
|
||||
headers,
|
||||
})
|
||||
|
||||
// The Rec Room build's own rule: ^[\p{Latin}0-9 .,'!?_&()#@:+-]+$
|
||||
for (const displayName of ['has space', 'punct!', 'café', "O'Neil_Jr.", '(a)&b#1@c:+-,?']) {
|
||||
expect((await put(displayName)).status, displayName).toBe(200)
|
||||
}
|
||||
|
||||
for (const displayName of [
|
||||
'semi;colon',
|
||||
'star*',
|
||||
'sla/sh',
|
||||
'Привет',
|
||||
'名前',
|
||||
'smile😀',
|
||||
'a'.repeat(16),
|
||||
]) {
|
||||
// An empty 400, matching what this route already answers for an empty name —
|
||||
// it acks with a bare `{ success: true }` and has never sent the client a body
|
||||
// on failure.
|
||||
expect(res.status, displayName).toBe(400)
|
||||
expect((await put(displayName)).status, displayName).toBe(400)
|
||||
}
|
||||
|
||||
// Trimmed before it is checked, so a name of nothing but spaces is an empty one.
|
||||
expect((await put(' ')).status).toBe(400)
|
||||
|
||||
// A swear set off by the punctuation the charset now allows is still a swear.
|
||||
for (const displayName of ['shit lord', 'Shit_Lord', 'fuck.you', 'fuck!']) {
|
||||
expect((await put(displayName)).status, displayName).toBe(400)
|
||||
}
|
||||
|
||||
// 15 is the client's box, so it must fit.
|
||||
const ok = await exports.default.fetch(`${ORIGIN}/account/me/displayname`, {
|
||||
...form({ displayName: 'a'.repeat(15) }),
|
||||
headers,
|
||||
})
|
||||
expect(ok.status).toBe(200)
|
||||
expect((await put('a'.repeat(15))).status).toBe(200)
|
||||
})
|
||||
|
||||
// Syntax comes from the `isemail` package rather than a pattern written here — this is
|
||||
|
||||
@@ -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
|
||||
|
||||
+28
-12
@@ -3,7 +3,7 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
import { validateAndGetAccountId, validateAndGetBuild } from '@repo/jwt'
|
||||
|
||||
import {
|
||||
AUTHED,
|
||||
@@ -34,8 +34,8 @@ import type { App } from './context'
|
||||
*
|
||||
* - Game AI is a SERVER-side feature. This server cannot provide it, so both its reads are
|
||||
* refused and the client hides the feature.
|
||||
* - Roomie runs on the CLIENT and only asks this service what it may spend, so the budget
|
||||
* reads are granted in full. The session that would actually reach a model
|
||||
* - Roomie's native 2026 companion gets zero energy and no generated output audio;
|
||||
* older clients keep the full budget. The session that would actually reach a model
|
||||
* (`/realtime-session/create`) is where it stops.
|
||||
* - Maker AI meters model usage in dollars. Nothing here bills, so every figure is zero.
|
||||
*/
|
||||
@@ -163,26 +163,27 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// Roomie AI's energy budget. Granted, unlike Game AI above: Roomie runs on the client
|
||||
// and only asks this service how much energy it has, so the honest answer for a server
|
||||
// that meters nothing is "as much as you can count".
|
||||
// The 20260323 build (20260323.14) has native low-energy and no-energy companion support. Advertising
|
||||
// full energy selects a conversation backend we do not provide. Grant access with
|
||||
// zero energy and the client's recognized no-credit result instead. A successful
|
||||
// access response still opens a realtime session even when its energy is zero.
|
||||
// Preserve the older client's budget and recharge-field contract.
|
||||
.get(
|
||||
'/roomieai/user/access',
|
||||
describeRoute({
|
||||
tags: ['Roomie AI', '2025'],
|
||||
summary: 'The caller’s Roomie AI energy budget',
|
||||
description: [
|
||||
'What Roomie may spend: an energy ceiling, what is left of it, and when it next',
|
||||
'refills. Nothing here meters energy, so the budget is `int.MaxValue` and never',
|
||||
'depletes — which is why `NextSubscriptionEnergyRechargeAt` is null, there being no',
|
||||
'spend to recharge from.',
|
||||
'The 20260323 build receives the recognized no-credit result and zero energy for its native',
|
||||
'companion mode. Its DTO has no recharge field. Older clients retain the full',
|
||||
'int32-max budget and null recharge time. No model session is created.',
|
||||
'',
|
||||
'The envelope is `{ success, error_id, error, value }`, NOT the flat body the Game AI',
|
||||
'check answers with. The two are different shapes on purpose — don’t unify them.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(RoomieAiAccess, 'The energy budget — always granted, always full'),
|
||||
200: json(RoomieAiAccess, 'Build-specific conversation access and companion fallback'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
@@ -190,6 +191,21 @@ const app = new Hono<App>()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
// The build, not the version: every point release of 20260323 has the companion mode.
|
||||
const build = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
if (build === '20260323') {
|
||||
return c.json({
|
||||
success: false,
|
||||
error_id: 'AI.InsufficientRoomieAICreditNoSub',
|
||||
error: 'AI conversation is disabled. Roomie companion mode is available.',
|
||||
value: {
|
||||
MaxEnergyFromSubscriptions: 0,
|
||||
EnergyLeft: 0,
|
||||
OutputAudioEnabled: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
error_id: null,
|
||||
@@ -389,7 +405,7 @@ app.get(
|
||||
'refusals, because the features fail at different points. Game AI is a server-side',
|
||||
'feature this server cannot provide, so both its reads refuse. Roomie and Maker AI',
|
||||
'only ask what the caller may SPEND, which nothing here meters, so those reads are',
|
||||
'granted in full; the refusal lands instead on `POST /realtime-session/create`, the',
|
||||
'granted with a build-specific budget; conversation refuses on `POST /realtime-session/create`, the',
|
||||
'one call whose real answer is a working credential rather than a description of one.',
|
||||
'',
|
||||
'The refusals are 200s carrying `success: false`, which is the shape the client',
|
||||
|
||||
@@ -71,22 +71,23 @@ export const HealthResponse = z.object({
|
||||
|
||||
/**
|
||||
* The Roomie AI access envelope — the `{ success, error_id, error, value }` shape, unlike
|
||||
* the flat Game AI refusal above. Roomie is granted here, with its energy budget pinned at
|
||||
* the maximum a signed 32-bit int holds (see INT32_MAX).
|
||||
* the flat Game AI refusal above. 2026 uses the no-credit fallback with zero energy;
|
||||
* older builds retain their full signed-32-bit budget and recharge field.
|
||||
*/
|
||||
export const RoomieAiAccess = z.object({
|
||||
success: z.literal(true),
|
||||
error_id: z.null(),
|
||||
error: z.null(),
|
||||
success: z.boolean(),
|
||||
error_id: z.string().nullable(),
|
||||
error: z.string().nullable(),
|
||||
value: z.object({
|
||||
MaxEnergyFromSubscriptions: z
|
||||
.int()
|
||||
.describe('The energy ceiling a subscription buys — pinned to int32 max'),
|
||||
EnergyLeft: z.int().describe('Energy remaining. Never spent here, so also int32 max'),
|
||||
.describe('Zero for the 2026 companion; int32 max for older builds'),
|
||||
EnergyLeft: z.int().describe('Zero for the 2026 companion; int32 max for older builds'),
|
||||
NextSubscriptionEnergyRechargeAt: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('When the budget refills. Null — nothing depletes, so nothing recharges'),
|
||||
.optional()
|
||||
.describe('Null on older builds; omitted for the 2026 DTO'),
|
||||
OutputAudioEnabled: z.boolean().describe('Whether Roomie may speak its replies'),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -27,10 +27,10 @@ function b64url(input: ArrayBuffer | string): string {
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
async function bearer(sub = '42'): Promise<Record<string, string>> {
|
||||
async function bearer(sub = '42', version?: string): Promise<Record<string, string>> {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const signingInput = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(
|
||||
JSON.stringify({ sub, exp: now + 3600 })
|
||||
JSON.stringify({ sub, exp: now + 3600, 'rn.ver': version })
|
||||
)}`
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
@@ -96,6 +96,35 @@ describe('GET /gameai/user/access', () => {
|
||||
})
|
||||
|
||||
describe('GET /roomieai/user/access', () => {
|
||||
it('selects the 2026 no-credit companion fallback without model credentials', async () => {
|
||||
const headers = await bearer('42', '20260323.14')
|
||||
const res = await SELF.fetch(`${ORIGIN}/roomieai/user/access`, { headers })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
success: false,
|
||||
error_id: 'AI.InsufficientRoomieAICreditNoSub',
|
||||
error: 'AI conversation is disabled. Roomie companion mode is available.',
|
||||
value: { MaxEnergyFromSubscriptions: 0, EnergyLeft: 0, OutputAudioEnabled: false },
|
||||
})
|
||||
// Entitlement to local activities must never mint a model credential.
|
||||
const session = await SELF.fetch(`${ORIGIN}/realtime-session/create`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ AIType: 'Roomie' }),
|
||||
})
|
||||
expect(session.status).toBe(200)
|
||||
expect(await session.json()).toMatchObject({ success: false, value: null })
|
||||
})
|
||||
|
||||
it('preserves the older build budget', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/roomieai/user/access`, {
|
||||
headers: await bearer('42', '20250718.01'),
|
||||
})
|
||||
expect(await res.json()).toMatchObject({
|
||||
value: { EnergyLeft: 2147483647, OutputAudioEnabled: true },
|
||||
})
|
||||
})
|
||||
|
||||
// Granted, unlike the Game AI check: Roomie runs on the client and only asks for its
|
||||
// energy budget, which nothing here meters.
|
||||
it('grants an int32-max energy budget', async () => {
|
||||
|
||||
@@ -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,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,18 @@
|
||||
-- Index the room-badge lookup that hydrates every room list (`attachBadges` in
|
||||
-- packages/domain/src/rooms-db.ts). Every room page — feeds, search, the owned list, a single
|
||||
-- room read — asks this table for the published badges of the rooms on the page, and without an
|
||||
-- index that was a full scan of every custom avatar item (thousands of first-party rows) per
|
||||
-- page. At the time of writing it was half of the database's row reads.
|
||||
--
|
||||
-- PARTIAL, over only the badge rows (`OutfitType` 1000), keyed by the badge's room. SQLite uses
|
||||
-- a partial index only when the query's WHERE contains its term as written, and an expression
|
||||
-- index only when the query names the same expression, so the query must keep BOTH
|
||||
-- `outfit_type = 1000` and `json_extract(data, '$.CustomBadgeMetadata.RoomId')` exactly —
|
||||
-- change either and the index silently stops being used. The key is an expression rather than
|
||||
-- a new generated column so the query reads the same whether or not this migration has run yet
|
||||
-- (the `rooms` worker can deploy ahead of `api`'s migrations).
|
||||
-- Generated from src/custom-avatar-items-db.ts (SCHEMA_DDL) — keep in sync.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_badge_room
|
||||
ON custom_avatar_item (json_extract(data, '$.CustomBadgeMetadata.RoomId'))
|
||||
WHERE outfit_type = 1000;
|
||||
@@ -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(', ')}`
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo
|
||||
|
||||
import { accountRoutes } from './routes/account'
|
||||
import { avatarRoutes } from './routes/avatar'
|
||||
import { avatarStudioRoutes } from './routes/avatar-studio'
|
||||
import { configRoutes } from './routes/config'
|
||||
import { eventRoutes } from './routes/events'
|
||||
import { gameplayRoutes } from './routes/gameplay'
|
||||
@@ -56,6 +57,7 @@ const app = new Hono<App>({ strict: false })
|
||||
.route('/', configRoutes)
|
||||
.route('/', socialRoutes)
|
||||
.route('/', progressionRoutes)
|
||||
.route('/', avatarStudioRoutes)
|
||||
.route('/', avatarRoutes)
|
||||
.route('/', gameplayRoutes)
|
||||
.route('/', eventRoutes)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -17,6 +17,22 @@ export type Env = SharedHonoEnv & {
|
||||
DOMAIN: string
|
||||
/** Maximum accepted size of each API-owned image upload, in bytes. */
|
||||
RECFLARE_MAX_API_UPLOAD_BYTES?: string
|
||||
/**
|
||||
* The client builds `/api/versioncheck/v4` answers "current" for, as a comma-separated
|
||||
* list of builds (`20230414,20250718`), or `off` for none. Unset means the default
|
||||
* `SUPPORTED_GAME_VERSIONS` in @repo/domain. Set for every worker from the root `.env`
|
||||
* (`RECFLARE_SUPPORTED_GAME_VERSIONS`), so `auth` refuses a token to exactly the builds
|
||||
* this flags for an update. Read through `supportedGameBuilds`, which also folds in
|
||||
* every build under rollout (below).
|
||||
*/
|
||||
SUPPORTED_GAME_VERSIONS?: string
|
||||
/**
|
||||
* The builds under staged rollout (`RECFLARE_ROLLOUT_GAME_VERSIONS`), same format;
|
||||
* `off` ends the rollout. Unset means the default `ROLLOUT_GAME_VERSIONS` in
|
||||
* @repo/domain. A rollout build is always "current" here — it is `auth` that decides who
|
||||
* may sign in on it — so the version check never sends a supporter to update.
|
||||
*/
|
||||
ROLLOUT_GAME_VERSIONS?: string
|
||||
// Shared rooms database (schema/migrations owned by the `rooms` worker). Used
|
||||
// read-only here to resolve room roles for `/api/rooms/v1/verifyRole`.
|
||||
DB: D1Database
|
||||
|
||||
@@ -1,42 +1,96 @@
|
||||
/**
|
||||
* 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 and 0035_custom_avatar_item_badge_room.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 room-badge lookup (`attachBadges`, packages/domain/src/rooms-db.ts) runs on every room
|
||||
// page; partial over the badge rows, keyed by the badge's room (migrations/0035).
|
||||
`CREATE INDEX IF NOT EXISTS idx_custom_avatar_item_badge_room ON custom_avatar_item (json_extract(data, '$.CustomBadgeMetadata.RoomId')) WHERE outfit_type = 1000`,
|
||||
]
|
||||
|
||||
/** 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
|
||||
/** Studio builds persist separate target hashes; legacy imports omit these. */
|
||||
QuestUnityAssetHash?: string | null
|
||||
QuestUnityAsset2Hash?: 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 +101,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 +160,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 +279,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 +288,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,
|
||||
@@ -200,15 +303,71 @@ export async function getCustomAvatarItems(
|
||||
ids: string[]
|
||||
): Promise<CustomAvatarItem[]> {
|
||||
if (ids.length === 0) return []
|
||||
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
|
||||
const { results } = await db
|
||||
.prepare(`SELECT * FROM custom_avatar_item WHERE custom_avatar_item_id IN (${placeholders})`)
|
||||
.bind(...ids)
|
||||
.prepare(
|
||||
`SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id IN (SELECT value FROM json_each(?1))`
|
||||
)
|
||||
.bind(JSON.stringify(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),
|
||||
UnityAssetHash: save.QuestUnityAssetHash ?? save.UnityAssetHash,
|
||||
UnityAsset2Hash: save.QuestUnityAsset2Hash ?? save.UnityAsset2Hash,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +380,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 +400,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 +410,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 +424,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 +459,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 +499,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 +513,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 +528,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 +551,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 +570,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 +583,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 +610,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 +622,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
|
||||
}
|
||||
+197
-49
@@ -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.
|
||||
@@ -869,10 +926,9 @@ export async function getEventById(db: D1Database, eventId: number): Promise<Pla
|
||||
*/
|
||||
export async function getEventsByIds(db: D1Database, ids: number[]): Promise<PlayerEvent[]> {
|
||||
if (ids.length === 0) return []
|
||||
const placeholders = ids.map((_, i) => `?${i + 1}`).join(', ')
|
||||
const { results } = await db
|
||||
.prepare(`SELECT data FROM event WHERE id IN (${placeholders})`)
|
||||
.bind(...ids)
|
||||
.prepare(`SELECT data FROM event WHERE id IN (SELECT value FROM json_each(?1))`)
|
||||
.bind(JSON.stringify(ids))
|
||||
.all<EventRow>()
|
||||
const byId = new Map<number, PlayerEvent>()
|
||||
for (const r of results) {
|
||||
@@ -897,6 +953,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
|
||||
@@ -904,10 +1026,9 @@ export async function getEventsByCreator(
|
||||
*/
|
||||
export async function getEventsByClubs(db: D1Database, clubIds: number[]): Promise<PlayerEvent[]> {
|
||||
if (clubIds.length === 0) return []
|
||||
const placeholders = clubIds.map((_, i) => `?${i + 1}`).join(', ')
|
||||
const { results } = await db
|
||||
.prepare(`SELECT data FROM event WHERE club_id IN (${placeholders})`)
|
||||
.bind(...clubIds)
|
||||
.prepare(`SELECT data FROM event WHERE club_id IN (SELECT value FROM json_each(?1))`)
|
||||
.bind(JSON.stringify(clubIds))
|
||||
.all<EventRow>()
|
||||
return results.map((r) => JSON.parse(r.data) as PlayerEvent).sort(bySoonest)
|
||||
}
|
||||
@@ -956,9 +1077,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 +1126,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 +1159,5 @@ export async function searchEvents(
|
||||
)
|
||||
}
|
||||
|
||||
return events.sort(bySoonest).slice(skip, skip + take)
|
||||
return events.sort(sort === 'Attendance' ? byAttendance : bySoonest).slice(skip, skip + take)
|
||||
}
|
||||
|
||||
+158
-27
@@ -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,
|
||||
@@ -1148,10 +1278,11 @@ export async function getInventionsByIds(
|
||||
inventionIds: number[]
|
||||
): Promise<SavedInvention[]> {
|
||||
if (inventionIds.length === 0) return []
|
||||
const placeholders = inventionIds.map((_, i) => `?${i + 1}`).join(', ')
|
||||
// One bound JSON array rather than a placeholder per id: D1 caps a statement at 100
|
||||
// bound variables, and the top-today window or a big collection easily passes that.
|
||||
const { results } = await db
|
||||
.prepare(`SELECT data FROM invention WHERE id IN (${placeholders})`)
|
||||
.bind(...inventionIds)
|
||||
.prepare(`SELECT data FROM invention WHERE id IN (SELECT value FROM json_each(?1))`)
|
||||
.bind(JSON.stringify(inventionIds))
|
||||
.all<InventionRow>()
|
||||
|
||||
const byId = new Map<number, SavedInvention>()
|
||||
|
||||
+238
-51
@@ -143,7 +143,7 @@ export const BacktraceConfig = z.object({
|
||||
SampleRate: z.int(),
|
||||
LogLineCount: z.int(),
|
||||
CaptureNativeCrashes: z.int(),
|
||||
AMRThresholdMS: z.int(),
|
||||
ANRThresholdMs: z.int(),
|
||||
MessageCount: z.int(),
|
||||
MessageRegex: z.string(),
|
||||
VersionRegex: z.string(),
|
||||
@@ -310,6 +310,40 @@ export const ProgressionDto = z.object({
|
||||
XP: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The caller's progress through one progression event (`/api/progressionEvents/record/:id`).
|
||||
* A stub for now: every counter is 0 and nothing is collected.
|
||||
*/
|
||||
export const ProgressionEventRecordDto = z.object({
|
||||
AccountId: z.int().describe('The caller'),
|
||||
ProgressionEventId: z.int().describe('The event requested'),
|
||||
Xp: z.int(),
|
||||
GameMinutesToday: z.int(),
|
||||
RewardsCollected: z.int(),
|
||||
BonusRewardsCollected: z.int(),
|
||||
XpBoostLastPurchasedAt: z.string().nullable().describe('ISO-8601 UTC; null when never bought'),
|
||||
PremiumRewardsCollected: z.int(),
|
||||
LimitedRewardCollectionState: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* One XP boost a player bought and has running, as `GET /api/purchasableXpBoosts/activations`
|
||||
* lists it. Documented from the reference shape; nothing here sells or stores boosts yet.
|
||||
*/
|
||||
export const XpBoostActivationDto = z.object({
|
||||
PurchasableXpBoostLedgerEntryId: z.int().describe('The purchase this activation came from'),
|
||||
PurchasableXpBoostId: z.string().describe('GUID of the boost product bought'),
|
||||
XpMultiplier: z.number().describe('XP is multiplied by this while the boost runs, e.g. 2.0'),
|
||||
StartTime: z.string().describe('ISO-8601 UTC'),
|
||||
EndTime: z.string().describe('ISO-8601 UTC'),
|
||||
})
|
||||
|
||||
/** `GET /api/purchasableXpBoosts/activations` — a paged list of the caller's active boosts. */
|
||||
export const XpBoostActivationsResponse = z.object({
|
||||
Results: XpBoostActivationDto.array(),
|
||||
TotalResults: z.int().describe('The number of entries in `Results`'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The form body of `POST /api/PlayerCheer/v1/create`. Nothing here is stored beyond the
|
||||
* counter the cheer increments: `Anonymous` is spent on the notification it triggers and
|
||||
@@ -361,7 +395,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(),
|
||||
@@ -374,7 +412,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(),
|
||||
@@ -385,16 +452,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(),
|
||||
})
|
||||
|
||||
@@ -402,7 +472,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(),
|
||||
})
|
||||
|
||||
@@ -531,7 +601,9 @@ 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(),
|
||||
@@ -668,6 +740,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.
|
||||
@@ -687,12 +796,22 @@ 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. */
|
||||
@@ -716,7 +835,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. */
|
||||
@@ -779,7 +898,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(),
|
||||
@@ -810,6 +929,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. */
|
||||
@@ -985,6 +1111,14 @@ export const SanitizeRequest = z.object({
|
||||
/** `POST /api/sanitize/v1/isPure` — whether the text is free of profanity. */
|
||||
export const IsPureResponse = z.object({ IsPure: z.boolean() })
|
||||
|
||||
/** Studio sends repeated Values form fields and consumes this result envelope. */
|
||||
export const BulkAllowedRequest = z.object({ Values: z.array(z.string()) })
|
||||
export const BulkAllowedResponse = z.object({
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable(),
|
||||
Value: z.object({ ArePure: z.array(z.boolean()) }).nullable(),
|
||||
})
|
||||
|
||||
/** `GET /api/keepsakes/globalconfig` — the keepsake feature switches. */
|
||||
export const KeepsakeConfig = z.object({
|
||||
KeepsakeFeatureEnabled: z.boolean(),
|
||||
@@ -1074,15 +1208,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'
|
||||
),
|
||||
})
|
||||
@@ -1100,7 +1235,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'),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -1188,7 +1323,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. */
|
||||
@@ -1261,6 +1398,19 @@ 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'),
|
||||
@@ -1284,8 +1434,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`
|
||||
@@ -1305,7 +1456,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'),
|
||||
@@ -1325,7 +1476,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'),
|
||||
@@ -1363,6 +1514,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
|
||||
@@ -1388,12 +1560,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'),
|
||||
})
|
||||
|
||||
@@ -1408,6 +1586,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'),
|
||||
@@ -1454,29 +1644,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
|
||||
@@ -1497,6 +1664,14 @@ export const ImagesPlayerDto = z.object({
|
||||
RoomId: z.int().nullable(),
|
||||
})
|
||||
|
||||
/**
|
||||
* The bulk lookup's projection (`GET /api/images/v5/bulk`): {@link ImagesPlayerDto} plus a
|
||||
* `ClubId`, always null.
|
||||
*/
|
||||
export const ImagesBulkDto = ImagesPlayerDto.extend({
|
||||
ClubId: z.int().nullable().describe('Always null — images are not filed under clubs'),
|
||||
})
|
||||
|
||||
/** One entry in the anonymous slideshow feed, joined to its creator and room. */
|
||||
export const SlideshowImageDto = z.object({
|
||||
SavedImageId: z.int(),
|
||||
@@ -1532,9 +1707,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
|
||||
// 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 (${placeholders})`
|
||||
WHERE created > ?1 AND player_id IN (${bindPlaceholders(chunk, 1)})`
|
||||
)
|
||||
.bind(windowCutoff(now), ...playerIds)
|
||||
.bind(windowCutoff(now), ...chunk)
|
||||
.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([
|
||||
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 (${placeholders})`)
|
||||
.bind(...accountIds)
|
||||
.all<ReputationRow>(),
|
||||
.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)
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import {
|
||||
AVATAR_STUDIO_SLOTS,
|
||||
avatarItemAccessBit,
|
||||
avatarSaveDto,
|
||||
avatarStudioFlags,
|
||||
avatarStudioItem,
|
||||
avatarStudioSave,
|
||||
canManageRoomById,
|
||||
publishAvatarSave,
|
||||
} from '@repo/domain'
|
||||
|
||||
import { parseMultipart } from '../../../studio/src/multipart'
|
||||
import { getCustomAvatarItem, toQuestCustomAvatarItem } from '../custom-avatar-items-db'
|
||||
import { authedId, authedRoles } from '../http'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { AvatarStudioSave } from '@repo/domain'
|
||||
import type { App } from '../context'
|
||||
|
||||
const uuid = '[0-9a-fA-F-]{36}'
|
||||
const ok = (value: unknown) => ({ success: true, error: null, value })
|
||||
const fail = (error: string) => ({ success: false, error, value: null })
|
||||
const slots = new Set(AVATAR_STUDIO_SLOTS)
|
||||
function validTags(value: unknown): boolean {
|
||||
return (
|
||||
value == null ||
|
||||
(Array.isArray(value) &&
|
||||
value.length <= 64 &&
|
||||
value.every(
|
||||
(t) =>
|
||||
t &&
|
||||
Number.isInteger(t.TagType) &&
|
||||
t.TagType >= 0 &&
|
||||
t.TagType <= 255 &&
|
||||
typeof t.Value === 'string' &&
|
||||
t.Value.length > 0 &&
|
||||
t.Value.length <= 64
|
||||
))
|
||||
)
|
||||
}
|
||||
async function allowed(c: Context<App>, accountId: number, slot: number) {
|
||||
const roles = await authedRoles(c)
|
||||
return (
|
||||
roles?.includes('betastudio') &&
|
||||
(![104, 203].includes(slot) || roles.includes('developer')) &&
|
||||
((await avatarStudioFlags(c.env.DB, accountId)) & avatarItemAccessBit(slot)) !== 0
|
||||
)
|
||||
}
|
||||
const safeName = (value: unknown): value is string =>
|
||||
typeof value === 'string' &&
|
||||
/^[a-zA-Z0-9_./-]+$/.test(value) &&
|
||||
!value.split('/').some((p) => p === '..' || p === '.' || p === '')
|
||||
|
||||
export const avatarStudioRoutes = new Hono<App>()
|
||||
.get('/api/customAvatarItems/v1/me', async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const rows = await c.env.DB.prepare(
|
||||
'SELECT data FROM custom_avatar_item WHERE creator_account_id=? ORDER BY created_at DESC,custom_avatar_item_id'
|
||||
)
|
||||
.bind(id)
|
||||
.all<{ data: string }>()
|
||||
const all = rows.results.map((r) => JSON.parse(r.data))
|
||||
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
|
||||
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
|
||||
return c.json({ Results: all.slice(skip, skip + take), TotalResults: all.length })
|
||||
})
|
||||
.get(`/api/customAvatarItems/v1/:id{${uuid}}`, async (c) => {
|
||||
const item = await getCustomAvatarItem(c.env.DB, c.req.param('id'))
|
||||
if (!item) return c.notFound()
|
||||
if (item.Accessibility === 0 && item.CreatorAccountId !== (await authedId(c)))
|
||||
return c.body(null, 403)
|
||||
return c.json(c.req.query('unityAssetTarget') === '2' ? toQuestCustomAvatarItem(item) : item)
|
||||
})
|
||||
.post('/api/customAvatarItems/v1', async (c, next) => {
|
||||
// Preserve the existing in-game custom-shirt contract, including its errors.
|
||||
const form = await parseMultipart(c.req.raw.clone())
|
||||
let body: Record<string, unknown>
|
||||
try {
|
||||
body = JSON.parse(String(form.metadata))
|
||||
} catch {
|
||||
return next()
|
||||
}
|
||||
if (
|
||||
!body ||
|
||||
typeof body !== 'object' ||
|
||||
body.BaseAvatarItemId !== undefined ||
|
||||
form.design ||
|
||||
form.thumbnailImage
|
||||
)
|
||||
return next()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const slot = Number(body.OutfitType)
|
||||
if (!Number.isInteger(slot) || !slots.has(slot))
|
||||
return c.json(fail('Unsupported avatar item type.'), 400)
|
||||
if (!(await allowed(c, id, slot)))
|
||||
return c.json(fail('Avatar Studio authorization is required.'), 403)
|
||||
const badgeRoomId = Number(body.BadgeRoomId ?? 0)
|
||||
if (
|
||||
slot === 1000 &&
|
||||
(!Number.isSafeInteger(badgeRoomId) ||
|
||||
badgeRoomId < 0 ||
|
||||
(badgeRoomId > 0 && !(await canManageRoomById(c.env.DB, badgeRoomId, id))))
|
||||
)
|
||||
return c.json(fail('Badge room is not managed by this account.'), 403)
|
||||
if (
|
||||
typeof body.Name !== 'string' ||
|
||||
!body.Name.trim() ||
|
||||
body.Name.length > 128 ||
|
||||
(body.Description != null &&
|
||||
(typeof body.Description !== 'string' || body.Description.length > 4000))
|
||||
)
|
||||
return c.json(fail('Invalid item name or description.'), 400)
|
||||
if (
|
||||
body.Price !== undefined &&
|
||||
body.Price !== null &&
|
||||
(!Number.isInteger(body.Price) || Number(body.Price) < 0 || Number(body.Price) > 1000)
|
||||
)
|
||||
return c.json(fail('Invalid item price.'), 400)
|
||||
if (body.Accessibility !== undefined && ![0, 1, 2].includes(Number(body.Accessibility)))
|
||||
return c.json(fail('Invalid accessibility.'), 400)
|
||||
if (body.Accessibility && body.Accessibility !== 0)
|
||||
return c.json(fail('Create the item privately and publish a built save first.'), 400)
|
||||
if (!validTags(body.Tags)) return c.json(fail('Invalid avatar tags.'), 400)
|
||||
const now = new Date().toISOString(),
|
||||
itemId = crypto.randomUUID()
|
||||
const item = {
|
||||
CustomAvatarItemId: itemId,
|
||||
CreatorAccountId: id,
|
||||
Name: body.Name.trim(),
|
||||
Description: body.Description ?? '',
|
||||
Price: body.Price ?? 0,
|
||||
Accessibility: body.Accessibility ?? 0,
|
||||
ForceCannotPublish: false,
|
||||
IsFeatured: false,
|
||||
IsRecRoomApproved: false,
|
||||
BaseAvatarItemId: null,
|
||||
BaseAvatarItemColor: null,
|
||||
DesignFilename: null,
|
||||
ThumbnailImageFilename: null,
|
||||
CreatedAt: now,
|
||||
ModifiedAt: now,
|
||||
PreviewOrientation: body.PreviewOrientation ?? 0,
|
||||
RankingContext: null,
|
||||
OutfitType: slot,
|
||||
CurrentSaves: [],
|
||||
Tags: body.Tags ?? [],
|
||||
IsStudioAuthored: true,
|
||||
CustomBadgeMetadata: slot === 1000 ? { RoomId: badgeRoomId } : null,
|
||||
RankedEntityId: itemId,
|
||||
PurchaseInfo: null,
|
||||
}
|
||||
await c.env.DB.prepare('INSERT INTO custom_avatar_item(data) VALUES (?)')
|
||||
.bind(JSON.stringify(item))
|
||||
.run()
|
||||
return c.json(ok(item))
|
||||
})
|
||||
.put(`/api/customAvatarItems/v1/:id{${uuid}}`, async (c, next) => {
|
||||
const itemId = c.req.param('id')
|
||||
const existing = await avatarStudioItem(c.env.DB, itemId)
|
||||
if (!existing?.IsStudioAuthored) return next()
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
if (existing.CreatorAccountId !== id || !(await allowed(c, id, existing.OutfitType)))
|
||||
return c.body(null, 403)
|
||||
const body = await c.req.json<Record<string, unknown>>().catch(() => null)
|
||||
if (
|
||||
!body ||
|
||||
(body.Name != null &&
|
||||
(typeof body.Name !== 'string' || !body.Name.trim() || body.Name.length > 128)) ||
|
||||
(body.Description != null &&
|
||||
(typeof body.Description !== 'string' || body.Description.length > 4000)) ||
|
||||
(body.Price != null &&
|
||||
(!Number.isInteger(body.Price) || Number(body.Price) < 0 || Number(body.Price) > 1000)) ||
|
||||
(body.Accessibility != null && ![0, 1, 2].includes(Number(body.Accessibility))) ||
|
||||
!validTags(body.Tags)
|
||||
)
|
||||
return c.json(fail('Invalid avatar metadata.'), 400)
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const item = await avatarStudioItem(c.env.DB, itemId)
|
||||
if (!item) return c.notFound()
|
||||
if (body.Accessibility && !item.CurrentSaves?.length)
|
||||
return c.json(fail('Publish a built save before making the item public.'), 400)
|
||||
const prior = JSON.stringify(item)
|
||||
for (const key of ['Name', 'Description', 'Price', 'Accessibility', 'Tags'])
|
||||
if (body[key] != null) item[key] = key === 'Name' ? String(body[key]).trim() : body[key]
|
||||
item.ModifiedAt = new Date().toISOString()
|
||||
const result = await c.env.DB.prepare(
|
||||
'UPDATE custom_avatar_item SET data=? WHERE custom_avatar_item_id=? AND json(data)=json(?)'
|
||||
)
|
||||
.bind(JSON.stringify(item), itemId, prior)
|
||||
.run()
|
||||
if (result.meta.changes) return c.json(ok(item))
|
||||
}
|
||||
return c.json(fail('The item changed concurrently. Retry the edit.'), 409)
|
||||
})
|
||||
.get(`/api/customAvatarItems/:id{${uuid}}/saves`, async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const itemId = c.req.param('id'),
|
||||
item = await avatarStudioItem(c.env.DB, itemId)
|
||||
if (!item) return c.notFound()
|
||||
if (item.CreatorAccountId !== id) return c.body(null, 403)
|
||||
const rows = await c.env.DB.prepare(
|
||||
'SELECT * FROM avatar_studio_save WHERE item_id=? ORDER BY id DESC'
|
||||
)
|
||||
.bind(itemId)
|
||||
.all<AvatarStudioSave>()
|
||||
const skip = Math.max(0, Number(c.req.query('skip')) || 0),
|
||||
take = Math.min(10000, Math.max(1, Number(c.req.query('take')) || 100))
|
||||
return c.json(
|
||||
ok({
|
||||
Results: await Promise.all(
|
||||
rows.results.slice(skip, skip + take).map((r) => avatarSaveDto(c.env.DB, r))
|
||||
),
|
||||
TotalResults: rows.results.length,
|
||||
})
|
||||
)
|
||||
})
|
||||
.post(`/api/customAvatarItems/:id{${uuid}}/saves`, async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const itemId = c.req.param('id'),
|
||||
item = await avatarStudioItem(c.env.DB, itemId)
|
||||
if (!item) return c.notFound()
|
||||
if (item.CreatorAccountId !== id || !(await allowed(c, id, item.OutfitType)))
|
||||
return c.body(null, 403)
|
||||
const body = await c.req.json<Record<string, unknown>>().catch(() => null)
|
||||
if (
|
||||
!body ||
|
||||
!Number.isInteger(body.BodyType) ||
|
||||
![0, 1, 2, 3, 4].includes(Number(body.BodyType)) ||
|
||||
!safeName(body.ThumbnailFileName) ||
|
||||
typeof body.AdditionalConfiguration !== 'string' ||
|
||||
body.AdditionalConfiguration.length > 1024 * 1024 ||
|
||||
(body.SaveDescription != null &&
|
||||
(typeof body.SaveDescription !== 'string' || body.SaveDescription.length > 4000))
|
||||
)
|
||||
return c.json(fail('Invalid avatar save.'), 400)
|
||||
try {
|
||||
JSON.parse(body.AdditionalConfiguration)
|
||||
} catch {
|
||||
return c.json(fail('Invalid additional configuration JSON.'), 400)
|
||||
}
|
||||
let filename: string, hash: string, unityId: string
|
||||
if (body.NewUnityAssetFile && typeof body.NewUnityAssetFile === 'object') {
|
||||
const file = body.NewUnityAssetFile as Record<string, unknown>
|
||||
if (!safeName(file.Filename)) return c.json(fail('Invalid source filename.'), 400)
|
||||
const object = await c.env.CDN_ASSETS.head('avatarunityasset/' + file.Filename)
|
||||
if (!object || object.customMetadata?.ownerId !== String(id))
|
||||
return c.json(fail('Source upload does not belong to this account.'), 403)
|
||||
const checksum = object.checksums.sha256
|
||||
hash = checksum ? btoa(String.fromCharCode(...new Uint8Array(checksum))) : ''
|
||||
if (!hash || file.Hash !== hash)
|
||||
return c.json(fail('Source hash does not match uploaded bytes.'), 400)
|
||||
filename = file.Filename
|
||||
unityId = crypto.randomUUID()
|
||||
} else if (typeof body.ExistingUnityAssetId === 'string') {
|
||||
const prior = await c.env.DB.prepare(
|
||||
'SELECT * FROM avatar_studio_save WHERE item_id=? AND unity_asset_id=? ORDER BY id DESC LIMIT 1'
|
||||
)
|
||||
.bind(itemId, body.ExistingUnityAssetId)
|
||||
.first<AvatarStudioSave>()
|
||||
if (!prior) return c.json(fail('No matching source revision on this item.'), 400)
|
||||
filename = prior.source_filename
|
||||
hash = prior.source_hash
|
||||
// A source can be reused across builds, but compiled outputs belong to
|
||||
// one body/configuration. Only metadata-only revisions share their output.
|
||||
unityId =
|
||||
prior.body_type === body.BodyType && prior.configuration === body.AdditionalConfiguration
|
||||
? prior.unity_asset_id
|
||||
: crypto.randomUUID()
|
||||
} else return c.json(fail('A new or existing source asset is required.'), 400)
|
||||
if (item.OutfitType === 1000) {
|
||||
const config = JSON.parse(body.AdditionalConfiguration as string)
|
||||
const badgeRoom = config?.d?.d?.r ?? 0
|
||||
if (
|
||||
config?.v !== 1 ||
|
||||
config?.d?.o !== 1000 ||
|
||||
!Number.isSafeInteger(badgeRoom) ||
|
||||
badgeRoom < 0
|
||||
)
|
||||
return c.json(fail('Invalid badge configuration.'), 400)
|
||||
if (badgeRoom > 0 && !(await canManageRoomById(c.env.DB, badgeRoom, id)))
|
||||
return c.json(fail('Badge room is not managed by this account.'), 403)
|
||||
if (body.BodyType !== 4)
|
||||
return c.json(fail('Badge images require the body-agnostic body type.'), 400)
|
||||
const source = await c.env.CDN_ASSETS.get('avatarunityasset/' + filename)
|
||||
if (!source || source.size > 8 * 1024 * 1024)
|
||||
return c.json(fail('Missing or oversized badge source.'), 400)
|
||||
const manifest = await source
|
||||
.json<{
|
||||
Version: number
|
||||
MainAssetProjectRelativePath: string
|
||||
Files: Array<{ p: string; n: string }>
|
||||
}>()
|
||||
.catch(() => null)
|
||||
if (
|
||||
!manifest ||
|
||||
manifest.Version !== 3 ||
|
||||
!Array.isArray(manifest.Files) ||
|
||||
!manifest.Files.length ||
|
||||
manifest.Files.length > 100000 ||
|
||||
!safeName(manifest.MainAssetProjectRelativePath) ||
|
||||
!manifest.MainAssetProjectRelativePath.startsWith('Assets/') ||
|
||||
!/\.(png|jpe?g)$/i.test(manifest.MainAssetProjectRelativePath) ||
|
||||
manifest.Files.some(
|
||||
(f) =>
|
||||
!f ||
|
||||
!safeName(f.p) ||
|
||||
!/^(Assets|Packages)\//.test(f.p) ||
|
||||
/\.(cs|dll|exe|so|dylib|rsp|asmdef|asmref)$/i.test(f.p) ||
|
||||
!/^[a-z0-9]{40}$/.test(f.n)
|
||||
) ||
|
||||
new Set(manifest.Files.map((f) => f.p.toLowerCase())).size !== manifest.Files.length
|
||||
)
|
||||
return c.json(fail('Invalid badge image source manifest.'), 400)
|
||||
const main = manifest.Files.find((f) => f.p === manifest.MainAssetProjectRelativePath)
|
||||
const image = main ? await c.env.CDN_ASSETS.get('unityasset/' + main.n) : null
|
||||
if (!image || image.size > 16 * 1024 * 1024)
|
||||
return c.json(fail('Badge source image is missing or oversized.'), 400)
|
||||
const signature = new Uint8Array(await image.arrayBuffer()).subarray(0, 8)
|
||||
if (
|
||||
!(
|
||||
signature[0] === 137 &&
|
||||
signature[1] === 80 &&
|
||||
signature[2] === 78 &&
|
||||
signature[3] === 71
|
||||
) &&
|
||||
!(signature[0] === 255 && signature[1] === 216 && signature[2] === 255)
|
||||
)
|
||||
return c.json(fail('Badge source must be a PNG or JPEG image.'), 400)
|
||||
}
|
||||
for (const name of [body.ThumbnailFileName, body.ModerationImageFileName].filter(
|
||||
(v) => v !== undefined && v !== null
|
||||
)) {
|
||||
if (!safeName(name)) return c.json(fail('Invalid image filename.'), 400)
|
||||
const image = await c.env.CDN_ASSETS.get('image/' + name)
|
||||
if (!image || image.customMetadata?.ownerId !== String(id))
|
||||
return c.json(fail('Image upload does not belong to this account.'), 403)
|
||||
if (image.size > 16 * 1024 * 1024) return c.json(fail('Avatar image exceeds limit.'), 413)
|
||||
// Storage's image upload and the Images service use different buckets.
|
||||
// Keep the SDK filename unchanged when publishing creator-owned thumbnails.
|
||||
await c.env.IMAGES.put(name, image.body, { httpMetadata: image.httpMetadata })
|
||||
}
|
||||
const row = await c.env.DB.prepare(
|
||||
'INSERT INTO avatar_studio_save(item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,moderation_image,configuration,description,update_current,created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?) RETURNING id'
|
||||
)
|
||||
.bind(
|
||||
itemId,
|
||||
body.BodyType,
|
||||
unityId,
|
||||
filename,
|
||||
hash,
|
||||
body.ThumbnailFileName,
|
||||
typeof body.ModerationImageFileName === 'string' ? body.ModerationImageFileName : null,
|
||||
body.AdditionalConfiguration,
|
||||
body.SaveDescription ?? null,
|
||||
body.UpdateCurrentSaveIfPrivate === true ? 1 : 0,
|
||||
new Date().toISOString()
|
||||
)
|
||||
.first<{ id: number }>()
|
||||
if (body.UpdateCurrentSaveIfPrivate === true && item.Accessibility === 0)
|
||||
await publishAvatarSave(c.env.DB, itemId, row!.id, true)
|
||||
return c.json(ok(row!.id))
|
||||
})
|
||||
.post(`/api/customAvatarItems/:id{${uuid}}/saves/:saveId{[1-9][0-9]*}/publish`, async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
const item = await avatarStudioItem(c.env.DB, c.req.param('id'))
|
||||
if (!item) return c.notFound()
|
||||
if (item.CreatorAccountId !== id || !(await allowed(c, id, item.OutfitType)))
|
||||
return c.body(null, 403)
|
||||
const save = await avatarStudioSave(
|
||||
c.env.DB,
|
||||
item.CustomAvatarItemId,
|
||||
Number(c.req.param('saveId'))
|
||||
)
|
||||
if (item.OutfitType === 1000 && save) {
|
||||
const badgeRoom = JSON.parse(save.configuration)?.d?.d?.r ?? 0
|
||||
if (badgeRoom > 0 && !(await canManageRoomById(c.env.DB, badgeRoom, id)))
|
||||
return c.body(null, 403)
|
||||
}
|
||||
if (!save || !(await publishAvatarSave(c.env.DB, item.CustomAvatarItemId, save.id)))
|
||||
return c.json(fail('Save has not completed a build.'), 400)
|
||||
return c.json(ok(null))
|
||||
})
|
||||
+394
-66
@@ -13,16 +13,20 @@ import {
|
||||
MAX_BULK_OUTFIT_ACCOUNTS,
|
||||
setOutfit,
|
||||
} from '@repo/domain'
|
||||
import { SNIFFED_IMAGE_FORMATS, sniffImage } from '@repo/hono-helpers'
|
||||
|
||||
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 +35,7 @@ import {
|
||||
deleteInvention,
|
||||
getFeaturedInventions,
|
||||
getInventionById,
|
||||
getInventionsByCreators,
|
||||
getInventionsByIds,
|
||||
getInventionsByRoom,
|
||||
getInventionTagFilters,
|
||||
@@ -42,6 +47,7 @@ import {
|
||||
inventionDeleteResult,
|
||||
inventionSaveV9Failure,
|
||||
isInventionCheered,
|
||||
MAX_INVENTION_PRICE,
|
||||
normalizeInventionTags,
|
||||
ownsAllInventions,
|
||||
parsePermissionLevel,
|
||||
@@ -52,6 +58,7 @@ import {
|
||||
setInventionTags,
|
||||
toSaveResult,
|
||||
toSaveResultV9,
|
||||
unpublishInvention,
|
||||
updateInvention,
|
||||
} from '../inventions-db'
|
||||
import {
|
||||
@@ -79,6 +86,7 @@ import {
|
||||
InventionReportRequest,
|
||||
InventionSaveResult,
|
||||
InventionSaveV9Result,
|
||||
InventionV9Dto,
|
||||
InventionVersionDto,
|
||||
json,
|
||||
JsonArray,
|
||||
@@ -102,8 +110,10 @@ import {
|
||||
SuccessValueEnvelope,
|
||||
TagFilters,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
UnpublishInventionRequest,
|
||||
UpdateCustomAvatarItemRequest,
|
||||
UpdateInventionMetadataRequest,
|
||||
UpdateInventionRequest,
|
||||
UpdatePriceRequest,
|
||||
} from '../openapi'
|
||||
import { createReport } from '../reports-db'
|
||||
@@ -149,6 +159,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 +185,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 +241,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 +254,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 +379,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,
|
||||
@@ -447,17 +474,27 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
summary: 'Create a custom avatar item',
|
||||
description:
|
||||
'Multipart: a `metadata` JSON text field plus two file parts, `thumbnailImage` ' +
|
||||
'(PNG) and `design` (the design blob). Inserts a `custom_avatar_item` row owned ' +
|
||||
'(PNG) and `design` (the design PNG). 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. Each file is ' +
|
||||
'identified by its bytes — the client posts them under `.bin` names — and must be a ' +
|
||||
'PNG or JPEG; the key’s extension and the stored content-type come ' +
|
||||
'from that signature, and anything else is refused with a 400 before either file is ' +
|
||||
'written.\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, a file that is not an image, or a `Price` outside 0–1000'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
413: json(CustomAvatarItemResponse, 'Either file exceeds the configured per-file limit'),
|
||||
},
|
||||
@@ -483,6 +520,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)
|
||||
@@ -511,19 +555,30 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
)
|
||||
}
|
||||
|
||||
// The parts' filenames and content-types are the uploader's claims (the client
|
||||
// posts both as `.bin`); the `img` worker serves each back under whatever type is
|
||||
// stored, so the extension and the stored type come from the bytes. Both are
|
||||
// checked before either is written, so a refusal leaves nothing in R2.
|
||||
const thumbnailBytes = new Uint8Array(await body.thumbnailImage.arrayBuffer())
|
||||
const thumbnail = sniffImage(thumbnailBytes)
|
||||
if (!thumbnail) return fail(`thumbnailImage is not a ${SNIFFED_IMAGE_FORMATS} image`)
|
||||
const designBytes = new Uint8Array(await body.design.arrayBuffer())
|
||||
const design = sniffImage(designBytes)
|
||||
if (!design) return fail(`design is not a ${SNIFFED_IMAGE_FORMATS} image`)
|
||||
|
||||
// Both files go to the shared image bucket, foldered by upload date and keyed by
|
||||
// the item's id (chosen here so the keys can carry it). The `img` worker serves
|
||||
// them back by key.
|
||||
const customAvatarItemId = crypto.randomUUID()
|
||||
const prefix = `avatar-item/${new Date().toISOString().slice(0, 10)}/${customAvatarItemId}`
|
||||
const thumbnailImageFilename = `${prefix}-thumb.png`
|
||||
const designFilename = `${prefix}-design.png`
|
||||
const thumbnailImageFilename = `${prefix}-thumb${thumbnail.extension}`
|
||||
const designFilename = `${prefix}-design${design.extension}`
|
||||
await Promise.all([
|
||||
c.env.IMAGES.put(thumbnailImageFilename, await body.thumbnailImage.arrayBuffer(), {
|
||||
httpMetadata: { contentType: body.thumbnailImage.type || 'image/png' },
|
||||
c.env.IMAGES.put(thumbnailImageFilename, thumbnailBytes, {
|
||||
httpMetadata: { contentType: thumbnail.contentType },
|
||||
}),
|
||||
c.env.IMAGES.put(designFilename, await body.design.arrayBuffer(), {
|
||||
httpMetadata: { contentType: body.design.type || 'image/png' },
|
||||
c.env.IMAGES.put(designFilename, designBytes, {
|
||||
httpMetadata: { contentType: design.contentType },
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -551,13 +606,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 +656,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 +696,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 +745,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 +810,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 +842,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 +855,22 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
||||
return Number.isInteger(n) ? n : undefined
|
||||
}
|
||||
|
||||
return c.json(
|
||||
await searchCustomAvatarItems(c.env.DB, {
|
||||
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(
|
||||
isQuestAssetTarget(c.req.query('unityAssetTarget'))
|
||||
? items.map(toQuestCustomAvatarItem)
|
||||
: items
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -828,6 +928,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 +957,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
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -1385,15 +1496,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 +1519,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 +1540,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 +1561,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 +1722,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 +1763,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 +1801,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 +1920,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 +2196,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 +2223,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 +2341,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 +2386,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 +2400,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.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { GAME_VERSION, isSupportedGameVersion } from '@repo/domain'
|
||||
import { validateAndGetVersion } from '@repo/jwt'
|
||||
import { GAME_VERSION, isSupportedGameVersion, supportedGameBuilds } from '@repo/domain'
|
||||
import { validateAndGetBuild } from '@repo/jwt'
|
||||
|
||||
import apiConfigV2 from '../../static/api-config-v2.json'
|
||||
import gameConfigsV1All2025 from '../../static/gameconfigs-v1-all-2025.json'
|
||||
@@ -73,15 +73,15 @@ export const configRoutes = new Hono<App>({ strict: false })
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
ReportBudget: 125,
|
||||
ReportBudget: 0,
|
||||
FilterType: 0,
|
||||
SampleRate: 1,
|
||||
LogLineCount: 50,
|
||||
CaptureNativeCrashes: 1,
|
||||
AMRThresholdMS: 0,
|
||||
MessageCount: 1000,
|
||||
MessageRegex: '^.*$',
|
||||
VersionRegex: '.*',
|
||||
SampleRate: 0,
|
||||
LogLineCount: 0,
|
||||
CaptureNativeCrashes: 0,
|
||||
ANRThresholdMs: 0,
|
||||
MessageCount: 0,
|
||||
MessageRegex: '',
|
||||
VersionRegex: '',
|
||||
})
|
||||
)
|
||||
// ShareBaseUrl is derived from the deploy-time base domain; the rest of the
|
||||
@@ -106,13 +106,17 @@ export const configRoutes = new Hono<App>({ strict: false })
|
||||
summary: 'Client version check',
|
||||
description:
|
||||
'Whether the client build is current. Compares the client’s `?v=` build against ' +
|
||||
'the builds we serve (`SUPPORTED_GAME_VERSIONS`): `VersionStatus` is 0 when the ' +
|
||||
'client is on one of them, 1 when it is on some other build.',
|
||||
'the builds we serve — the operator’s `SUPPORTED_GAME_VERSIONS` var (or the ' +
|
||||
'default list in `@repo/domain`) plus every build under rollout ' +
|
||||
'(`ROLLOUT_GAME_VERSIONS`): `VersionStatus` is 0 when the client is on one of ' +
|
||||
'them, 1 when it is on some other build. The same lists decide which builds ' +
|
||||
'`auth` hands a token to, so a build dropped from them is told to update here ' +
|
||||
'and refused there.',
|
||||
responses: { 200: json(VersionCheck, 'Version status') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
VersionStatus: isSupportedGameVersion(c.req.query('v')) ? 0 : 1,
|
||||
VersionStatus: isSupportedGameVersion(c.req.query('v'), supportedGameBuilds(c.env)) ? 0 : 1,
|
||||
UpdateNotificationStage: 0,
|
||||
IsVersionIslanded: false,
|
||||
IsCrossPlayDisabled: false,
|
||||
@@ -140,8 +144,9 @@ export const configRoutes = new Hono<App>({ strict: false })
|
||||
// Which one a caller gets is decided by the token's `rn.ver` claim — the build the
|
||||
// client posted at login — since the request itself carries no version. Anything NEWER
|
||||
// than `GAME_VERSION` (20230414) gets the 2025 catalog; that build and anything older
|
||||
// get the 2023 one. Builds are date-stamped (`20230414`, `20250718.01`), so they order
|
||||
// as strings, the same comparison `match` makes for cross-build joins.
|
||||
// get the 2023 one. Builds are date-stamped (`20230414`, `20250718`), so they order as
|
||||
// strings, the same comparison `match` makes for cross-build joins — compared as BUILDS,
|
||||
// so a same-day point release of the target (`20230414.01`) stays on the 2023 catalog.
|
||||
//
|
||||
// A request with no readable token version gets the 2023 catalog, the same body this
|
||||
// route has always served: unauthenticated is not evidence of a newer client, and this
|
||||
@@ -163,8 +168,8 @@ export const configRoutes = new Hono<App>({ strict: false })
|
||||
responses: { 200: json(JsonObject, 'The game config catalog for the caller’s build') },
|
||||
}),
|
||||
async (c) => {
|
||||
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
const newerThanTarget = version !== null && version > GAME_VERSION
|
||||
const build = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
const newerThanTarget = build !== null && build > GAME_VERSION
|
||||
return c.json(newerThanTarget ? gameConfigsV1All2025 : gameConfigsV1All)
|
||||
}
|
||||
)
|
||||
|
||||
+260
-44
@@ -1,9 +1,9 @@
|
||||
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'
|
||||
import { validateAndGetBuild } from '@repo/jwt'
|
||||
|
||||
// The notification-type ids the hub carries (owned by the `notify` worker). Imported
|
||||
// as a value — the enum has no runtime dependencies.
|
||||
@@ -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,51 +200,123 @@ 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 notifyInvited(
|
||||
async function notifyResponseChanged(
|
||||
c: Context<App>,
|
||||
event: PlayerEvent,
|
||||
added: EventAttendeeRow[]
|
||||
response: 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) },
|
||||
PlayerEvent: { ...toEventNotification(event) },
|
||||
PlayerEventResponse: { ...toEventResponse(response) },
|
||||
} satisfies PlayerEventResponsePayload
|
||||
try {
|
||||
await hub.notifyPlayer(row.player_id, NotificationType.PlayerEventResponseChanged, payload)
|
||||
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: row.player_id,
|
||||
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> {
|
||||
for (const row of added) {
|
||||
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 — compared as BUILDS, with the client's point release dropped, so a
|
||||
* same-day rebuild of the target stays on its side. `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.
|
||||
*/
|
||||
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)
|
||||
const build = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
const isModernBuild = build !== null && build > GAME_VERSION
|
||||
return toEventResult(event, tags, isModernBuild)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,6 +332,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 +352,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 +450,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 +629,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 +646,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 +735,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 +764,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 +835,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 +867,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 +907,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 +986,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 +1012,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 +1068,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 +1105,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))
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute } from 'hono-openapi'
|
||||
|
||||
import { validateStudioBuildValidationToken } from '@repo/jwt'
|
||||
|
||||
import charadesAprilWords from '../../static/charades-april.json'
|
||||
import charadesWords from '../../static/charades.json'
|
||||
import communityBoard from '../../static/community-board.json'
|
||||
@@ -8,6 +10,9 @@ import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
BareString,
|
||||
BulkAllowedRequest,
|
||||
BulkAllowedResponse,
|
||||
form,
|
||||
idParam,
|
||||
IsPureResponse,
|
||||
json,
|
||||
@@ -144,6 +149,42 @@ export const gameplayRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Studio checks all component text in one request, preserving duplicates and order.
|
||||
.post(
|
||||
'/api/sanitize/v2/isAllowed/bulk',
|
||||
describeRoute({
|
||||
tags: ['Gameplay'],
|
||||
summary: 'Check Studio component text in bulk',
|
||||
description:
|
||||
'Checks repeated Values form fields using the existing profanity policy. ArePure matches the input order, including duplicate and empty strings. Accepts an account token or a dedicated Studio build-validation credential with no account identity.',
|
||||
security: AUTHED,
|
||||
requestBody: form(BulkAllowedRequest, 'Repeated Values fields'),
|
||||
responses: {
|
||||
200: json(BulkAllowedResponse, 'Ordered results in a Studio result envelope'),
|
||||
400: json(BulkAllowedResponse, 'Invalid form values'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
if (
|
||||
(await authedId(c)) === null &&
|
||||
!(await validateStudioBuildValidationToken(c.req.raw, await c.env.JWT_SECRET.get()))
|
||||
)
|
||||
return unauthorized(c)
|
||||
const body = await c.req.parseBody({ all: true }).catch(() => null)
|
||||
const field = body?.Values
|
||||
const values = field === undefined ? [] : Array.isArray(field) ? field : [field]
|
||||
if (body === null || values.some((value) => typeof value !== 'string')) {
|
||||
return c.json({ Success: false, Error: 'Values must be strings.', Value: null }, 400)
|
||||
}
|
||||
return c.json({
|
||||
Success: true,
|
||||
Error: null,
|
||||
Value: { ArePure: values.map((value) => !containsSwears(value as string)) },
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// ---- Activities -----------------------------------------------------------
|
||||
// Word bank for the Charades activity. The client requests the list by
|
||||
// activity name (`.../words/Charades`); other activities have no data yet.
|
||||
|
||||
+264
-54
@@ -5,19 +5,25 @@ import {
|
||||
createImage,
|
||||
deleteImage,
|
||||
getCheeredImageIds,
|
||||
getImageById,
|
||||
getImageByName,
|
||||
getImagesByIds,
|
||||
getImagesByPlayer,
|
||||
getImagesByRoom,
|
||||
getPlayerFeed,
|
||||
getSlideshowImages,
|
||||
mergePlayerSettings,
|
||||
readPlayerSettings,
|
||||
SavedImageType,
|
||||
setImageAccessibility,
|
||||
setImageCheer,
|
||||
setImageDescription,
|
||||
SLIDESHOW_LIMIT,
|
||||
SLIDESHOW_MAX_LIMIT,
|
||||
toImageMetadata,
|
||||
toImagesBulk,
|
||||
toImagesPlayer,
|
||||
} from '@repo/domain'
|
||||
import { SNIFFED_IMAGE_FORMATS, sniffImage } from '@repo/hono-helpers'
|
||||
|
||||
import { authedId, unauthorized } from '../http'
|
||||
import {
|
||||
@@ -29,23 +35,27 @@ import {
|
||||
ErrorResponse,
|
||||
form,
|
||||
idParam,
|
||||
ImageMetadataDto,
|
||||
ImagesBulkDto,
|
||||
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 +83,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 +106,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 +122,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),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -204,12 +220,16 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
'The `imgMeta` multipart field is a JSON `SavedImageMetaDTO` describing the upload; ' +
|
||||
'malformed JSON is tolerated and the image is still stored, just untyped. A ' +
|
||||
'`savedImageType` of 4 (profile thumbnail) additionally becomes the account’s ' +
|
||||
'avatar, persisted on the account row.',
|
||||
'avatar, persisted on the account row.\n\n' +
|
||||
'The file is identified by its bytes, not by the part’s filename or content-type: ' +
|
||||
'it must be a PNG or JPEG, and the stored key’s extension and ' +
|
||||
'content-type come from that signature. Anything else is refused with a 400 — the ' +
|
||||
'`img` worker serves only those two formats, so it would never display.',
|
||||
security: AUTHED,
|
||||
requestBody: form(UploadImageRequest, 'The image file plus its metadata'),
|
||||
responses: {
|
||||
200: json(UploadImageResponse, 'The stored bucket key'),
|
||||
400: json(ErrorResponse, 'No file in the request'),
|
||||
400: json(ErrorResponse, 'No file in the request, or the file is not an image'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
413: json(ErrorResponse, 'The image exceeds the configured per-file limit'),
|
||||
},
|
||||
@@ -249,10 +269,12 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
const roomId = num(meta.roomId)
|
||||
const playerEventId = num(meta.playerEventId)
|
||||
|
||||
const valid = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']
|
||||
const dot = file.name.lastIndexOf('.')
|
||||
const ext = dot >= 0 ? file.name.slice(dot).toLowerCase() : ''
|
||||
const extension = valid.includes(ext) ? ext : '.jpg'
|
||||
// The part's filename and content-type are the uploader's claims; the `img` worker
|
||||
// serves the object back under whatever type is stored, so both the extension and
|
||||
// the stored type come from the bytes. A body that is not an image is refused.
|
||||
const bytes = new Uint8Array(await file.arrayBuffer())
|
||||
const image = sniffImage(bytes)
|
||||
if (!image) return c.json({ error: `image is not a ${SNIFFED_IMAGE_FORMATS} image` }, 400)
|
||||
|
||||
// Store the upload in the shared image bucket under a random key, foldered by
|
||||
// the image type and then the upload date (e.g. `sharecamera/2026-06-15/`) so
|
||||
@@ -260,10 +282,8 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
// key (slashes and all), which is the returned ImageName.
|
||||
const typePrefix = (typeFolder[savedImageType] ?? typeFolder[SavedImageType.None]) + '/'
|
||||
const datePrefix = new Date().toISOString().slice(0, 10) + '/'
|
||||
const name = typePrefix + datePrefix + crypto.randomUUID() + extension
|
||||
await c.env.IMAGES.put(name, await file.arrayBuffer(), {
|
||||
httpMetadata: { contentType: file.type || 'image/jpeg' },
|
||||
})
|
||||
const name = typePrefix + datePrefix + crypto.randomUUID() + image.extension
|
||||
await c.env.IMAGES.put(name, bytes, { httpMetadata: { contentType: image.contentType } })
|
||||
|
||||
// A profile thumbnail becomes the account's avatar — persist it on the
|
||||
// account row (a JSON blob in the shared accounts table) so it sticks.
|
||||
@@ -297,8 +317,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 +359,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 +487,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 +510,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 +523,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 +538,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 +553,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 +565,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
|
||||
@@ -487,9 +617,9 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
// with what it asked for; an id with no record (or one that isn't public) is simply
|
||||
// absent, which is why this answers 200 with a short list rather than 404ing the lot.
|
||||
//
|
||||
// Serves the RAW `SavedImage`, like `v6` (metadata by filename) and the room feed — NOT
|
||||
// the `ImagesPlayer` projection the player photo LISTS use. Those are a rendered grid,
|
||||
// where the raw record comes up blank; this is a metadata lookup.
|
||||
// Serves the `ImagesBulk` projection: the player lists' `ImagesPlayer` shape
|
||||
// (`SavedImageId`/`SavedImageType`, no `TaggedPlayerIds`) plus a `ClubId`, always null.
|
||||
// NOT the raw `SavedImage` that `v6` and the room feed serve.
|
||||
//
|
||||
// Public-only, as every image read here is: ids are sequential, so honouring whatever
|
||||
// id is named would hand out private photos to anyone who counts.
|
||||
@@ -502,12 +632,13 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
'The stored `SavedImage` records for the given ids (`?ids=207&ids=106`), as a bare ' +
|
||||
'array in request order. An id with no record, or one that is not public, is absent ' +
|
||||
'from the answer rather than an error — the list can be shorter than the request. ' +
|
||||
'Serves the raw `SavedImage` (as `v6` does), not the `ImagesPlayer` projection the ' +
|
||||
'player photo lists use.',
|
||||
'Serves the `ImagesPlayer` projection (`SavedImageId`/`SavedImageType`, no ' +
|
||||
'`TaggedPlayerIds`) plus a `ClubId` that is always null — not the raw `SavedImage` ' +
|
||||
'that `v6` serves.',
|
||||
parameters: [
|
||||
intQuery('ids', 'Repeatable; each value may also be a comma-separated list of image ids'),
|
||||
],
|
||||
responses: { 200: json(SavedImageDto.array(), 'The matching records, in request order') },
|
||||
responses: { 200: json(ImagesBulkDto.array(), 'The matching records, in request order') },
|
||||
}),
|
||||
async (c) => {
|
||||
const ids =
|
||||
@@ -516,27 +647,28 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
?.flatMap((raw) => raw.split(','))
|
||||
.map((raw) => Number.parseInt(raw.trim(), 10))
|
||||
.filter((imageId) => !Number.isNaN(imageId)) ?? []
|
||||
return c.json(await getImagesByIds(c.env.DB, ids))
|
||||
return c.json((await getImagesByIds(c.env.DB, ids)).map(toImagesBulk))
|
||||
}
|
||||
)
|
||||
|
||||
// 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}` serves, 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 +677,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. 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 `v6?name=` ' +
|
||||
'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: '' })
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+422
-107
@@ -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.
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
const PERMANENT_BAN_DURATION = 2_147_483_647
|
||||
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)
|
||||
|
||||
/**
|
||||
* 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,
|
||||
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: 0,
|
||||
GameSessionId: gameSessionId,
|
||||
IsHostKick: false,
|
||||
Message: null,
|
||||
PlayerIdReporter: null,
|
||||
Message: `You have been kicked from ${roomName}.`,
|
||||
PlayerIdReporter: carriedByAccountId,
|
||||
IsBan: false,
|
||||
IsVoiceModAutoban: false,
|
||||
IsDeviceBan: false,
|
||||
IsWarning: false,
|
||||
VoteKickReason: null,
|
||||
VoteKickReason: reason,
|
||||
TimeoutStartedAt: null,
|
||||
AssociatedAccountUsername: null,
|
||||
ShowCreatorCodeOfConduct: false,
|
||||
TopMessageOverride: null,
|
||||
BottomMessageOverride: null,
|
||||
}
|
||||
|
||||
/**
|
||||
* The block details for a ban in force — the `report` row a moderator set `banned` on.
|
||||
*
|
||||
* `Duration` and `TimeoutStartedAt` are a PAIR in the client: the block runs from the
|
||||
* start for the duration. The start is the report's `created_at` — nothing records when
|
||||
* the ban itself was handed down, and the report is the record the ban rests on — and the
|
||||
* duration is the seconds from there to `ban_expires`, so the two sum to the expiry; or
|
||||
* `PERMANENT_BAN_DURATION` when there is none. The category is the one the report was
|
||||
* filed under, so the client's ban screen names the reason. `Message` is a fixed "Rule
|
||||
* violation" rather than the report's `details` — those are the REPORTER's words, and the
|
||||
* banned player isn't shown them, for the same reason `PlayerIdReporter` stays null: the
|
||||
* reporter is not a host who kicked them, and naming them would tell the banned player who
|
||||
* reported them. Everything else keeps its `NOT_BLOCKED` value: the other block kinds and
|
||||
* screen dressings, none of which this server hands out.
|
||||
*/
|
||||
function banBlockDetails(ban: ReportRow) {
|
||||
const startedAt = Date.parse(ban.created_at)
|
||||
const duration =
|
||||
ban.ban_expires === null
|
||||
? PERMANENT_BAN_DURATION
|
||||
: Math.max(1, Math.ceil((Date.parse(ban.ban_expires) - startedAt) / 1000))
|
||||
return {
|
||||
...NOT_BLOCKED,
|
||||
ReportCategory: ban.report_category,
|
||||
Duration: duration,
|
||||
IsBan: true,
|
||||
Message: 'Rule violation',
|
||||
TimeoutStartedAt: ban.created_at,
|
||||
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(
|
||||
|
||||
@@ -13,21 +13,39 @@ import { logger } from '@repo/hono-helpers'
|
||||
// The notification-type ids the hub carries (owned by the `notify` worker). Imported as a
|
||||
// value — the enum has no runtime dependencies.
|
||||
import { NotificationType } from '../../../notify/src/notification-types'
|
||||
import progressionEvent13 from '../../static/progression-events/Anime_Season_EventId_13.json'
|
||||
import progressionEvent8 from '../../static/progression-events/Base_Event_EventId_8.json'
|
||||
import progressionEvent2 from '../../static/progression-events/Coming_in_Hot_Progression_Event_Alpha_EventId_2.json'
|
||||
import progressionEvent9 from '../../static/progression-events/Invasion_2_EventId_9.json'
|
||||
import progressionEvent6 from '../../static/progression-events/Invasion_EventId_6.json'
|
||||
import progressionEvent15 from '../../static/progression-events/Medieval_Season_EventId_15.json'
|
||||
import progressionEvent5 from '../../static/progression-events/Progression_Event_Alpha_Repeat_EventId_5.json'
|
||||
import progressionEvent3 from '../../static/progression-events/Rec_Room_EventId_3.json'
|
||||
import progressionEvent12 from '../../static/progression-events/Science_Experiment_Restoration_EventId_12.json'
|
||||
import progressionEvent7 from '../../static/progression-events/Science_Party_EventId_7.json'
|
||||
import progressionEvent14 from '../../static/progression-events/Summer_Party_EventId_14.json'
|
||||
import progressionEvent16 from '../../static/progression-events/Treat_Collection_Challenge_EventId_16.json'
|
||||
import progressionEvent17 from '../../static/progression-events/Tricks_Challenge_EventId_17.json'
|
||||
import progressionEvent18 from '../../static/progression-events/Yetis_Challenge_EventId_18.json'
|
||||
import { authedId, parseFormIds, queryIds, unauthorized } from '../http'
|
||||
import {
|
||||
AUTHED,
|
||||
BareInteger,
|
||||
BulkIdsRequest,
|
||||
CheerPlayerRequest,
|
||||
CheerPlayerResponse,
|
||||
SetSelectedCheerRequest,
|
||||
form,
|
||||
idParam,
|
||||
intQuery,
|
||||
json,
|
||||
JsonArray,
|
||||
JsonObject,
|
||||
ProgressionDto,
|
||||
ProgressionEventRecordDto,
|
||||
ReputationDto,
|
||||
SetSelectedCheerRequest,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
XpBoostActivationsResponse,
|
||||
} from '../openapi'
|
||||
import {
|
||||
addCheer,
|
||||
@@ -46,6 +64,32 @@ import type { ReputationPayload } from '../../../notify/src/notification-payload
|
||||
import type { App } from '../context'
|
||||
import type { Reputation } from '../reputation-db'
|
||||
|
||||
/**
|
||||
* The archived official progression events, by `ProgressionEventId` — each the event record
|
||||
* exactly as the official API served it. They are held as TEXT and served byte for byte, not
|
||||
* imported as JSON: a keepsake room's `RoomId` is a 64-bit number (`8364812036745705361`)
|
||||
* that a JS parse rounds to a different room. The archive has gaps (no 1, 4, 10, 11).
|
||||
*
|
||||
* The text import is a `rules` entry in wrangler.jsonc, which TypeScript cannot see — it
|
||||
* types a `.json` import as the parsed object — hence the cast.
|
||||
*/
|
||||
const PROGRESSION_EVENTS = new Map([
|
||||
[2, progressionEvent2],
|
||||
[3, progressionEvent3],
|
||||
[5, progressionEvent5],
|
||||
[6, progressionEvent6],
|
||||
[7, progressionEvent7],
|
||||
[8, progressionEvent8],
|
||||
[9, progressionEvent9],
|
||||
[12, progressionEvent12],
|
||||
[13, progressionEvent13],
|
||||
[14, progressionEvent14],
|
||||
[15, progressionEvent15],
|
||||
[16, progressionEvent16],
|
||||
[17, progressionEvent17],
|
||||
[18, progressionEvent18],
|
||||
] as Array<[number, unknown]> as Array<[number, string]>)
|
||||
|
||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||
const HUB_INSTANCE = 'global'
|
||||
|
||||
@@ -255,6 +299,28 @@ export const progressionRoutes = new Hono<App>({ strict: false })
|
||||
}),
|
||||
async (c) => c.json(await getReputation(c.env.DB, Number.parseInt(c.req.param('id'), 10)))
|
||||
)
|
||||
// How much XP the caller has earned today — what the client measures against its daily
|
||||
// XP cap. A BARE INTEGER (the client decodes a plain `int`), not a progression record.
|
||||
//
|
||||
// Registered BEFORE `/progression/:id` below, which otherwise takes `xpEarnedToday` as
|
||||
// an account id and answers `{ PlayerId: null, Level: 1, XP: 0 }` — the client's decoder
|
||||
// threw on that (`expected:'Number Token', actual:'{'`).
|
||||
//
|
||||
// STUB: always 0. Nothing here records when XP was earned, only the running total. No
|
||||
// auth while the answer is the same for everybody; it will need the caller once it is
|
||||
// real, since the path names no player.
|
||||
.get(
|
||||
'/api/players/v1/progression/xpEarnedToday',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'XP the caller earned today (stub)',
|
||||
description:
|
||||
'The XP earned so far today, as a bare integer. Always `0` — XP is banked as a ' +
|
||||
'running total and nothing records when it was earned.',
|
||||
responses: { 200: json(BareInteger, 'A bare `0`') },
|
||||
}),
|
||||
(c) => c.json(0)
|
||||
)
|
||||
.get(
|
||||
'/api/players/v1/progression/:id',
|
||||
describeRoute({
|
||||
@@ -520,23 +586,114 @@ 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 `18` — the id of the active event, or 0 when there is none (this once
|
||||
// served -1 for the same meaning). 18 is hardcoded: it names an event the archive above
|
||||
// holds, so the client can fetch its definition from `/event/18`, but nothing here
|
||||
// tracks progress through it yet. 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 (`0` would mean “no ' +
|
||||
'active event”). Always `18` for now — a hardcoded archived event; nothing on this ' +
|
||||
'server tracks progress through it yet. A 404 here would stall the load. No auth: ' +
|
||||
'it is the same answer for every player.',
|
||||
responses: { 200: json(BareInteger, 'A bare `18` — the hardcoded active event') },
|
||||
}),
|
||||
(c) => c.json([])
|
||||
(c) => c.json(18)
|
||||
)
|
||||
|
||||
// One progression event's definition — its reward track, keepsake rooms, dates and boost
|
||||
// settings — served from the archive of official events above. Reading a definition says
|
||||
// nothing about whether the event is running (`/active` above is a hardcoded 18), and no
|
||||
// auth for the same reason as there: it is the same record for everybody.
|
||||
.get(
|
||||
'/api/progressionEvents/event/:id{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'A progression event’s definition',
|
||||
description:
|
||||
'The archived official event with this `ProgressionEventId`, verbatim: rewards, ' +
|
||||
'keepsake room lists, start/end/collection times and boost settings. 404 for an ' +
|
||||
'id the archive does not hold. No auth.',
|
||||
parameters: [idParam('id', 'The `ProgressionEventId`')],
|
||||
responses: {
|
||||
200: json(JsonObject, 'The progression event'),
|
||||
404: { description: 'No such event (empty body)' },
|
||||
},
|
||||
}),
|
||||
(c) => {
|
||||
const event = PROGRESSION_EVENTS.get(Number(c.req.param('id')))
|
||||
if (event === undefined) return c.body(null, 404)
|
||||
return c.body(event, 200, { 'Content-Type': 'application/json' })
|
||||
}
|
||||
)
|
||||
|
||||
// The caller's progress through one progression event — the XP, minutes and rewards the
|
||||
// client draws its event track from.
|
||||
//
|
||||
// STUB: every counter 0, for any event id, echoing the id requested. Nothing tracks
|
||||
// progress through an event yet (see `/active` above). Auth-gated because the record is
|
||||
// the CALLER's and the path names no player.
|
||||
.get(
|
||||
'/api/progressionEvents/record/:id{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'The caller’s progress through a progression event (stub)',
|
||||
description:
|
||||
'The caller’s record for the event with this `ProgressionEventId`. Always empty ' +
|
||||
'for now — 0 XP, nothing collected, `XpBoostLastPurchasedAt` null — for any id: ' +
|
||||
'nothing on this server tracks progress through an event yet.',
|
||||
security: AUTHED,
|
||||
parameters: [idParam('id', 'The `ProgressionEventId`')],
|
||||
responses: {
|
||||
200: json(ProgressionEventRecordDto, 'An empty record for the event requested'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({
|
||||
AccountId: id,
|
||||
ProgressionEventId: Number(c.req.param('id')),
|
||||
Xp: 0,
|
||||
GameMinutesToday: 0,
|
||||
RewardsCollected: 0,
|
||||
BonusRewardsCollected: 0,
|
||||
XpBoostLastPurchasedAt: null,
|
||||
PremiumRewardsCollected: 0,
|
||||
LimitedRewardCollectionState: 0,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// The XP boosts the caller has bought and has running — the client multiplies the XP it
|
||||
// shows by them and draws a timer for each.
|
||||
//
|
||||
// STUB: always the empty page. Nothing sells boosts yet, so nobody has one running. No
|
||||
// auth while the answer is the same for everybody, like `xpEarnedToday` above; it will
|
||||
// need the caller once it is real, since the path names no player.
|
||||
.get(
|
||||
'/api/purchasableXpBoosts/activations',
|
||||
describeRoute({
|
||||
tags: ['Progression'],
|
||||
summary: 'The caller’s active XP boosts (stub)',
|
||||
description:
|
||||
'The XP boosts the caller has running, as `{ Results, TotalResults }`. Always the ' +
|
||||
'empty page (`{ "Results": [], "TotalResults": 0 }`) — nothing on this server sells ' +
|
||||
'boosts yet. Each entry, once there are any, names the purchase ' +
|
||||
'(`PurchasableXpBoostLedgerEntryId`), the boost bought (`PurchasableXpBoostId`), its ' +
|
||||
'`XpMultiplier`, and when it runs (`StartTime`/`EndTime`, ISO-8601 UTC).',
|
||||
responses: {
|
||||
200: json(XpBoostActivationsResponse, 'Always `{ "Results": [], "TotalResults": 0 }`'),
|
||||
},
|
||||
}),
|
||||
(c) => c.json({ Results: [], TotalResults: 0 })
|
||||
)
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
getMutualFriendIds,
|
||||
getNotificationsForPlayer,
|
||||
getRelationshipsForPlayer,
|
||||
MessageType,
|
||||
MUTUAL_FRIENDS_LIMIT,
|
||||
RelationshipType,
|
||||
removeFriend,
|
||||
sendFriendRequest,
|
||||
setRelationshipFlag,
|
||||
@@ -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
|
||||
@@ -454,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)
|
||||
@@ -464,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)
|
||||
}
|
||||
)
|
||||
@@ -474,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)
|
||||
@@ -484,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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -75,11 +75,13 @@ const NAME_WORD_BOUNDARIES: Array<[RegExp, string]> = [
|
||||
/**
|
||||
* Whether `value`, read as a NAME, contains a swear.
|
||||
*
|
||||
* A username or display name is letters and digits only (`nameRejection`), so it carries
|
||||
* no spaces — and the filter matches whole words. Handing one to {@link containsSwears}
|
||||
* as-is therefore only refuses a name that IS a swear and nothing else: `Fucker123` and
|
||||
* `ShitLord` sail through. So the name is split at the boundaries a player types instead
|
||||
* of a space, and the pieces are checked as words.
|
||||
* A username is letters and digits only (`nameRejection`), so it carries no spaces — and
|
||||
* the filter matches whole words. Handing one to {@link containsSwears} as-is therefore
|
||||
* only refuses a name that IS a swear and nothing else: `Fucker123` and `ShitLord` sail
|
||||
* through. So the name is split at the boundaries a player types instead of a space, and
|
||||
* the pieces are checked as words. A display name may carry real spaces and punctuation
|
||||
* (`displayNameRejection`), which the filter already breaks words at; the split still
|
||||
* applies to the runs in between.
|
||||
*
|
||||
* That keeps the library's trade-off rather than reaching for substring matching, which
|
||||
* is the tempting fix and the wrong one: `Scunthorpe`, `assassin`, `Classic`,
|
||||
|
||||
@@ -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,
|
||||
@@ -31,25 +31,42 @@ 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 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,
|
||||
@@ -60,6 +77,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'
|
||||
@@ -77,6 +95,9 @@ declare module 'cloudflare:test' {
|
||||
|
||||
const ORIGIN = 'https://example.com'
|
||||
|
||||
/** The PNG signature — the least an upload route accepts as an image. */
|
||||
const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
|
||||
// `/api/rooms/v1/verifyRole` reads room roles from the shared recflare D1. Set
|
||||
// up the schema (matching the rooms worker's migration) + a couple of rooms.
|
||||
const TEST_ROOMS = [
|
||||
@@ -98,7 +119,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,
|
||||
@@ -107,6 +129,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 },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -163,6 +186,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()
|
||||
@@ -182,6 +207,11 @@ beforeAll(async () => {
|
||||
// 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
|
||||
@@ -259,7 +289,7 @@ describe('public endpoints', () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/backtrace`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { ReportBudget: number; VersionRegex: string }
|
||||
expect(body).toMatchObject({ ReportBudget: 125, VersionRegex: '.*' })
|
||||
expect(body).toMatchObject({ ReportBudget: 0, ANRThresholdMs: 0, VersionRegex: '' })
|
||||
})
|
||||
|
||||
test('GET /api/versioncheck/v4 reports current for the matching build', async () => {
|
||||
@@ -274,6 +304,18 @@ describe('public endpoints', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('GET /api/versioncheck/v4 ignores the point release: every .NN of a supported build is current', async () => {
|
||||
// The list holds bare dates; the client reports `YYYYMMDD.NN`, and the `.NN` is a
|
||||
// same-day rebuild of the same build. All of these are the beta build.
|
||||
for (const version of ['20250718.01', '20250718.2', '20250718', '20250718.99']) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=${version}`)
|
||||
expect(await res.json(), version).toMatchObject({ VersionStatus: 0 })
|
||||
}
|
||||
// ...and a point release of a build that ISN'T supported is still not.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101.01`)
|
||||
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
|
||||
})
|
||||
|
||||
test('GET /api/versioncheck/v4 flags a mismatched build', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=19990101`)
|
||||
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
|
||||
@@ -284,6 +326,56 @@ describe('public endpoints', () => {
|
||||
expect(await res.json()).toMatchObject({ VersionStatus: 1 })
|
||||
})
|
||||
|
||||
// The list is the operator's SUPPORTED_GAME_VERSIONS var when set, plus every build
|
||||
// under rollout (ROLLOUT_GAME_VERSIONS) — the same lists `auth` gates tokens on, so a
|
||||
// build told to update here is refused there too.
|
||||
test('GET /api/versioncheck/v4 reads the lists from vars', async () => {
|
||||
const status = async (version: string) => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4?v=${version}`)
|
||||
return ((await res.json()) as { VersionStatus: number }).VersionStatus
|
||||
}
|
||||
const originals = {
|
||||
SUPPORTED_GAME_VERSIONS: env.SUPPORTED_GAME_VERSIONS,
|
||||
ROLLOUT_GAME_VERSIONS: env.ROLLOUT_GAME_VERSIONS,
|
||||
}
|
||||
try {
|
||||
// Forced upgrade: the 2025 build drops out, every point release of it.
|
||||
env.SUPPORTED_GAME_VERSIONS = '20230414,20260323'
|
||||
expect(await status('20250718.01')).toBe(1)
|
||||
expect(await status('20250718')).toBe(1)
|
||||
expect(await status(GAME_VERSION)).toBe(0)
|
||||
expect(await status('20260323.14')).toBe(0)
|
||||
// Alphas not named in the var are gone too: the var replaces the default list.
|
||||
expect(await status('20231207')).toBe(1)
|
||||
|
||||
// A build under rollout is current even when the var omits it: who may sign in on
|
||||
// it is `auth`'s call, and the version check must not send a supporter to update.
|
||||
env.SUPPORTED_GAME_VERSIONS = '20230414'
|
||||
expect(await status('20260323.14')).toBe(0)
|
||||
env.ROLLOUT_GAME_VERSIONS = 'off'
|
||||
expect(await status('20260323.14')).toBe(1)
|
||||
env.ROLLOUT_GAME_VERSIONS = '20250718'
|
||||
expect(await status('20250718.01')).toBe(0)
|
||||
|
||||
// Entries are builds: a copied point release matches, and `off` empties the list.
|
||||
env.ROLLOUT_GAME_VERSIONS = undefined
|
||||
env.SUPPORTED_GAME_VERSIONS = '20250718.01'
|
||||
expect(await status('20250718.2')).toBe(0)
|
||||
expect(await status(GAME_VERSION)).toBe(1)
|
||||
env.SUPPORTED_GAME_VERSIONS = 'off'
|
||||
env.ROLLOUT_GAME_VERSIONS = 'off'
|
||||
for (const version of SUPPORTED_GAME_VERSIONS) expect(await status(version), version).toBe(1)
|
||||
|
||||
// Blank is unset: the default list.
|
||||
env.SUPPORTED_GAME_VERSIONS = ''
|
||||
env.ROLLOUT_GAME_VERSIONS = ''
|
||||
for (const version of SUPPORTED_GAME_VERSIONS) expect(await status(version), version).toBe(0)
|
||||
} finally {
|
||||
env.SUPPORTED_GAME_VERSIONS = originals.SUPPORTED_GAME_VERSIONS
|
||||
env.ROLLOUT_GAME_VERSIONS = originals.ROLLOUT_GAME_VERSIONS
|
||||
}
|
||||
})
|
||||
|
||||
// Two catalogs, picked off the token's `rn.ver`: a build newer than GAME_VERSION gets
|
||||
// the 2025 one. `Avatars.AdvancedFaceCustomizationEnabled` is a key only that catalog
|
||||
// has, so its presence identifies which body was served.
|
||||
@@ -304,6 +396,9 @@ describe('public endpoints', () => {
|
||||
|
||||
test('GET /api/gameconfigs/v1/all serves the 2023 catalog to the target build', async () => {
|
||||
expect(await gameConfigKeys(GAME_VERSION)).not.toContain(KEY_2025_ONLY)
|
||||
// A same-day point release of the target is the target build: compared as a build,
|
||||
// not as a string that happens to sort after `20230414`.
|
||||
expect(await gameConfigKeys(`${GAME_VERSION}.01`)).not.toContain(KEY_2025_ONLY)
|
||||
})
|
||||
|
||||
test('GET /api/gameconfigs/v1/all serves the 2023 catalog to an older build', async () => {
|
||||
@@ -844,6 +939,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
|
||||
@@ -890,6 +1007,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,
|
||||
@@ -973,7 +1097,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()
|
||||
@@ -1123,9 +1248,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()
|
||||
@@ -1173,20 +1301,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)
|
||||
@@ -1259,17 +1434,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' +
|
||||
@@ -1279,6 +1455,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 = {
|
||||
@@ -1376,8 +1791,8 @@ describe('public endpoints', () => {
|
||||
...metadata,
|
||||
})
|
||||
)
|
||||
form.set('thumbnailImage', new File([new Uint8Array([1])], 'f.bin', { type: 'image/png' }))
|
||||
form.set('design', new File([new Uint8Array([2])], 'f.bin', { type: 'image/png' }))
|
||||
form.set('thumbnailImage', new File([PNG_BYTES], 'f.bin', { type: 'image/png' }))
|
||||
form.set('design', new File([PNG_BYTES], 'f.bin', { type: 'image/png' }))
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(creator),
|
||||
@@ -1719,12 +2134,73 @@ 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/players/v1/progression/xpEarnedToday is a bare 0', async () => {
|
||||
// A plain int on the client. It must not fall through to `/progression/:id`, which
|
||||
// would read the path segment as an account id and answer a progression OBJECT.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/players/v1/progression/xpEarnedToday`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(0)
|
||||
})
|
||||
|
||||
test('GET /api/progressionEvents/active is a bare 18 (no auth)', async () => {
|
||||
// A hardcoded stub: the id of an archived event (0 would mean "no active event").
|
||||
// A 404 would stall the client's 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(18)
|
||||
})
|
||||
|
||||
test('GET /api/progressionEvents/event/:id serves the archived event verbatim', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/progressionEvents/event/18`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.headers.get('content-type')).toContain('application/json')
|
||||
expect(await res.json()).toMatchObject({
|
||||
ProgressionEventId: 18,
|
||||
Name: 'Yetis Challenge',
|
||||
Rewards: [],
|
||||
KeepsakeRoomLists: [],
|
||||
EventLeaderboardId: 18,
|
||||
})
|
||||
|
||||
// A keepsake room's id is 64-bit: it has to reach the client as written, not as the
|
||||
// nearest double (…705361 parses to …705000), so the body is checked as text.
|
||||
const base = await exports.default.fetch(`${ORIGIN}/api/progressionEvents/event/8`)
|
||||
expect(await base.text()).toContain('"RoomId": 8364812036745705361')
|
||||
|
||||
// The archive has gaps, and an id it doesn't hold is a 404.
|
||||
const missing = await exports.default.fetch(`${ORIGIN}/api/progressionEvents/event/4`)
|
||||
expect(missing.status).toBe(404)
|
||||
})
|
||||
|
||||
test('GET /api/progressionEvents/record/:id is an empty record for the caller', async () => {
|
||||
const url = `${ORIGIN}/api/progressionEvents/record/18`
|
||||
expect((await exports.default.fetch(url)).status).toBe(401)
|
||||
|
||||
const res = await exports.default.fetch(url, { headers: await bearer('2') })
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toStrictEqual({
|
||||
AccountId: 2,
|
||||
ProgressionEventId: 18,
|
||||
Xp: 0,
|
||||
GameMinutesToday: 0,
|
||||
RewardsCollected: 0,
|
||||
BonusRewardsCollected: 0,
|
||||
XpBoostLastPurchasedAt: null,
|
||||
PremiumRewardsCollected: 0,
|
||||
LimitedRewardCollectionState: 0,
|
||||
})
|
||||
|
||||
// The id is echoed, whichever event is asked for.
|
||||
const other = await exports.default.fetch(`${ORIGIN}/api/progressionEvents/record/7`, {
|
||||
headers: await bearer('2'),
|
||||
})
|
||||
expect(await other.json()).toMatchObject({ ProgressionEventId: 7 })
|
||||
})
|
||||
|
||||
test('GET /api/purchasableXpBoosts/activations is the empty page', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/purchasableXpBoosts/activations`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toStrictEqual({ Results: [], TotalResults: 0 })
|
||||
})
|
||||
|
||||
test('GET /api/rooms/v1/filters returns an object with filter arrays', async () => {
|
||||
@@ -1776,6 +2252,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',
|
||||
@@ -1845,6 +2350,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(
|
||||
@@ -1964,7 +2482,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.
|
||||
@@ -2159,6 +2677,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',
|
||||
@@ -2296,7 +2966,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' },
|
||||
@@ -2316,8 +2986,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}`
|
||||
)
|
||||
@@ -2331,6 +3001,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 () => {
|
||||
@@ -2365,6 +3068,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()
|
||||
})
|
||||
@@ -2734,6 +3451,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',
|
||||
@@ -2773,9 +3587,9 @@ describe('public endpoints', () => {
|
||||
body: JSON.stringify({ inventionDataFilename: 'a.inv', ...fields }),
|
||||
})
|
||||
|
||||
// A name is 3–24 characters of letters, digits, spaces, dashes and colons.
|
||||
// A name is 3–40 characters of letters, digits, spaces, dashes and colons.
|
||||
expect((await save({ name: 'ab' })).status).toBe(400)
|
||||
expect((await save({ name: 'a'.repeat(25) })).status).toBe(400)
|
||||
expect((await save({ name: 'a'.repeat(41) })).status).toBe(400)
|
||||
expect((await save({ name: 'Rocket!' })).status).toBe(400)
|
||||
expect((await save({ name: 'Café Lamp' })).status).toBe(400)
|
||||
const ok = await save({ name: 'Rocket Sofa-Bed 2' })
|
||||
@@ -2986,6 +3800,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[]) =>
|
||||
({
|
||||
@@ -3229,6 +4092,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',
|
||||
@@ -3545,6 +4452,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',
|
||||
@@ -3597,26 +4519,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`
|
||||
// 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`
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
.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 () => {
|
||||
@@ -3771,6 +4809,59 @@ describe('public endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/sanitize/v2/isAllowed/bulk', () => {
|
||||
const bulk = async (body: URLSearchParams | FormData, authed = true) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/sanitize/v2/isAllowed/bulk`, {
|
||||
method: 'POST',
|
||||
headers: authed ? await bearer('42') : {},
|
||||
body,
|
||||
})
|
||||
|
||||
test('401s without a token', async () => {
|
||||
expect((await bulk(new URLSearchParams('Values=hello'), false)).status).toBe(401)
|
||||
})
|
||||
|
||||
test('preserves repeated values, order, empty text and duplicates in the Studio envelope', async () => {
|
||||
const body = new URLSearchParams()
|
||||
for (const text of ['MakerRoom', 'fuck this', '', 'Scunthorpe', 'MakerRoom', 'sh1t'])
|
||||
body.append('Values', text)
|
||||
const res = await bulk(body)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({
|
||||
Success: true,
|
||||
Error: null,
|
||||
Value: { ArePure: [true, false, true, true, true, false] },
|
||||
})
|
||||
})
|
||||
|
||||
test('accepts a single multipart value and an empty batch', async () => {
|
||||
const body = new FormData()
|
||||
body.append('Values', 'a$$hole')
|
||||
expect(await (await bulk(body)).json()).toEqual({
|
||||
Success: true,
|
||||
Error: null,
|
||||
Value: { ArePure: [false] },
|
||||
})
|
||||
expect(await (await bulk(new URLSearchParams())).json()).toEqual({
|
||||
Success: true,
|
||||
Error: null,
|
||||
Value: { ArePure: [] },
|
||||
})
|
||||
})
|
||||
|
||||
test('rejects file values rather than marking them clean', async () => {
|
||||
const body = new FormData()
|
||||
body.append('Values', new Blob(['hello']), 'text.txt')
|
||||
const res = await bulk(body)
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({
|
||||
Success: false,
|
||||
Error: 'Values must be strings.',
|
||||
Value: null,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/sanitize/v1/isPure', () => {
|
||||
const isPure = async (Value?: string, authed = true) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/sanitize/v1/isPure`, {
|
||||
@@ -3878,11 +4969,8 @@ describe('custom avatar items', () => {
|
||||
Accessibility: 0,
|
||||
})
|
||||
)
|
||||
form.set(
|
||||
'thumbnailImage',
|
||||
new File([new Uint8Array([1, 2, 3])], 'file.bin', { type: 'image/png' })
|
||||
)
|
||||
form.set('design', new File([new Uint8Array([4, 5, 6])], 'file.bin', { type: 'image/png' }))
|
||||
form.set('thumbnailImage', new File([PNG_BYTES], 'file.bin', { type: 'image/png' }))
|
||||
form.set('design', new File([PNG_BYTES], 'file.bin', { type: 'image/png' }))
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('205'),
|
||||
@@ -3911,11 +4999,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`)
|
||||
@@ -3924,17 +5017,15 @@ describe('custom avatar items', () => {
|
||||
|
||||
// Both uploads landed in the image bucket under those keys.
|
||||
const thumb = await env.IMAGES.get(body.Value.ThumbnailImageFilename as string)
|
||||
expect(new Uint8Array((await thumb!.arrayBuffer()) as ArrayBuffer)).toEqual(
|
||||
new Uint8Array([1, 2, 3])
|
||||
)
|
||||
expect(new Uint8Array((await thumb!.arrayBuffer()) as ArrayBuffer)).toEqual(PNG_BYTES)
|
||||
expect(thumb!.httpMetadata?.contentType).toBe('image/png')
|
||||
const design = await env.IMAGES.get(body.Value.DesignFilename as string)
|
||||
expect(new Uint8Array((await design!.arrayBuffer()) as ArrayBuffer)).toEqual(
|
||||
new Uint8Array([4, 5, 6])
|
||||
)
|
||||
expect(new Uint8Array((await design!.arrayBuffer()) as ArrayBuffer)).toEqual(PNG_BYTES)
|
||||
|
||||
// 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()
|
||||
@@ -4007,6 +5098,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([PNG_BYTES], 't.png', { type: 'image/png' }))
|
||||
form.set('design', new File([PNG_BYTES], '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()
|
||||
@@ -4014,8 +5249,8 @@ describe('custom avatar items', () => {
|
||||
'metadata',
|
||||
JSON.stringify({ Name: 'Gone', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
|
||||
)
|
||||
form.set('thumbnailImage', new File([new Uint8Array([1])], 'file.bin', { type: 'image/png' }))
|
||||
form.set('design', new File([new Uint8Array([2])], 'file.bin', { type: 'image/png' }))
|
||||
form.set('thumbnailImage', new File([PNG_BYTES], 'file.bin', { type: 'image/png' }))
|
||||
form.set('design', new File([PNG_BYTES], 'file.bin', { type: 'image/png' }))
|
||||
const created = (await (
|
||||
await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
@@ -4109,7 +5344,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(
|
||||
@@ -4120,6 +5355,48 @@ describe('custom avatar items', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Both files are identified by their bytes — the client posts them as `.bin` under
|
||||
// `image/png` — and the `img` worker serves each under its stored type, so a non-image
|
||||
// is refused before either file or the row is written.
|
||||
test('POST refuses a file whose bytes are not an image, writing nothing', async () => {
|
||||
const objectsBefore = (await env.IMAGES.list({ prefix: 'avatar-item/' })).objects.length
|
||||
const upload = async (thumbnail: string | Uint8Array, design: string | Uint8Array) => {
|
||||
const form = new FormData()
|
||||
form.set(
|
||||
'metadata',
|
||||
JSON.stringify({ Name: 'not-image', BaseAvatarItemId: 1, BaseAvatarItemColor: '#fff' })
|
||||
)
|
||||
form.set('thumbnailImage', new File([thumbnail], 'f.bin', { type: 'image/png' }))
|
||||
form.set('design', new File([design], 'f.bin', { type: 'image/png' }))
|
||||
return exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('205'),
|
||||
body: form,
|
||||
})
|
||||
}
|
||||
const html = '<script>alert(1)</script>'
|
||||
|
||||
const badThumbnail = await upload(html, PNG_BYTES)
|
||||
expect(badThumbnail.status).toBe(400)
|
||||
expect(await badThumbnail.json()).toMatchObject({
|
||||
Success: false,
|
||||
Error: 'thumbnailImage is not a PNG or JPEG image',
|
||||
})
|
||||
|
||||
const badDesign = await upload(PNG_BYTES, html)
|
||||
expect(badDesign.status).toBe(400)
|
||||
expect(await badDesign.json()).toMatchObject({
|
||||
Success: false,
|
||||
Error: 'design is not a PNG or JPEG image',
|
||||
})
|
||||
|
||||
const row = await env.DB.prepare(
|
||||
"SELECT COUNT(*) AS n FROM custom_avatar_item WHERE json_extract(data, '$.Name') = 'not-image'"
|
||||
).first<{ n: number }>()
|
||||
expect(row?.n).toBe(0)
|
||||
expect((await env.IMAGES.list({ prefix: 'avatar-item/' })).objects).toHaveLength(objectsBefore)
|
||||
})
|
||||
|
||||
test('POST 401s without a token', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1`, {
|
||||
method: 'POST',
|
||||
@@ -4190,6 +5467,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)
|
||||
@@ -4221,9 +5516,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)
|
||||
|
||||
@@ -4273,7 +5578,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.
|
||||
@@ -4282,7 +5587,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',
|
||||
@@ -4302,8 +5607,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([])
|
||||
})
|
||||
|
||||
@@ -4339,11 +5644,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')
|
||||
|
||||
@@ -4363,6 +5907,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`, {
|
||||
@@ -4400,17 +5978,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()
|
||||
@@ -4427,6 +6008,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)
|
||||
@@ -4494,6 +6178,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', () => {
|
||||
@@ -4655,6 +6413,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', () => {
|
||||
@@ -4706,10 +6693,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' },
|
||||
@@ -4727,26 +6713,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
|
||||
@@ -4884,7 +6922,7 @@ describe('rooms', () => {
|
||||
|
||||
describe('images', () => {
|
||||
test('POST /api/images/v4/uploadsaved stores the file in R2 and returns its name', async () => {
|
||||
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3, 4])
|
||||
const bytes = new Uint8Array([...PNG_BYTES, 1, 2, 3, 4])
|
||||
const fd = new FormData()
|
||||
fd.append('imgMeta', JSON.stringify({ savedImageType: 1 })) // ShareCamera
|
||||
fd.append('image', new File([bytes], 'avatar.png', { type: 'image/png' }))
|
||||
@@ -4909,14 +6947,50 @@ 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)
|
||||
})
|
||||
|
||||
// The part's filename and content-type are the uploader's claims, and the `img`
|
||||
// worker serves the object under whatever type is stored — so an HTML body under an
|
||||
// image name would be a page on the image origin. The bytes decide, and only images
|
||||
// get in.
|
||||
test('POST /api/images/v4/uploadsaved refuses a file whose bytes are not an image', async () => {
|
||||
const objectsBefore = (await env.IMAGES.list()).objects.length
|
||||
const fd = new FormData()
|
||||
fd.append('imgMeta', JSON.stringify({ savedImageType: 1 }))
|
||||
fd.append('image', new File(['<script>alert(1)</script>'], 'photo.jpg', { type: 'image/jpeg' }))
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(),
|
||||
body: fd,
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
expect(await res.json()).toEqual({ error: 'image is not a PNG or JPEG image' })
|
||||
expect((await env.IMAGES.list()).objects).toHaveLength(objectsBefore)
|
||||
})
|
||||
|
||||
test('POST /api/images/v4/uploadsaved stores the type and extension the bytes say, not the claim', async () => {
|
||||
const fd = new FormData()
|
||||
fd.append('imgMeta', JSON.stringify({ savedImageType: 1 }))
|
||||
// A real PNG posted as a JPEG under an HTML name.
|
||||
fd.append('image', new File([PNG_BYTES], 'photo.html', { type: 'text/html' }))
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
|
||||
method: 'POST',
|
||||
headers: await bearer(),
|
||||
body: fd,
|
||||
})
|
||||
expect(res.status).toBe(200)
|
||||
const { ImageName } = (await res.json()) as { ImageName: string }
|
||||
expect(ImageName).toMatch(/\.png$/)
|
||||
const stored = await env.IMAGES.get(ImageName)
|
||||
expect(stored?.httpMetadata?.contentType).toBe('image/png')
|
||||
})
|
||||
|
||||
test('GET /api/images/v1/slideshow is public and joins username + room name', async () => {
|
||||
// Seed a public image (Accessibility 1) taken in RecCenter (room 2) by account 42.
|
||||
await env.DB.prepare('INSERT INTO image (data) VALUES (?1)')
|
||||
@@ -4960,6 +7034,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.
|
||||
@@ -5001,20 +7106,37 @@ describe('images', () => {
|
||||
|
||||
// Request order, not id order — the client lines the answers up with what it asked for.
|
||||
const both = await bulk(`?ids=${two.Id}&ids=${one.Id}`)
|
||||
expect(both.map((i) => i.Id)).toEqual([two.Id, one.Id])
|
||||
// The RAW SavedImage: `Id`/`Type`, and TaggedPlayerIds present — not the
|
||||
// SavedImageId/SavedImageType projection the player photo lists serve.
|
||||
expect(both.map((i) => i.SavedImageId)).toEqual([two.Id, one.Id])
|
||||
// The ImagesPlayer projection plus ClubId — not the raw SavedImage (`Id`/`Type`,
|
||||
// `TaggedPlayerIds`).
|
||||
expect(Object.keys(both[0]).sort()).toEqual(
|
||||
[
|
||||
'SavedImageId',
|
||||
'ImageName',
|
||||
'PlayerId',
|
||||
'RoomId',
|
||||
'PlayerEventId',
|
||||
'ClubId',
|
||||
'Description',
|
||||
'Accessibility',
|
||||
'AccessibilityLocked',
|
||||
'SavedImageType',
|
||||
'CreatedAt',
|
||||
'CheerCount',
|
||||
'CommentCount',
|
||||
].sort()
|
||||
)
|
||||
expect(both[0]).toMatchObject({
|
||||
Id: two.Id,
|
||||
Type: two.Type,
|
||||
SavedImageId: two.Id,
|
||||
SavedImageType: two.Type,
|
||||
ImageName: 'bulktwo.jpg',
|
||||
PlayerId: 7102,
|
||||
TaggedPlayerIds: [],
|
||||
ClubId: null,
|
||||
})
|
||||
|
||||
// An unknown id and a non-public one are absent rather than errors or holes, so the
|
||||
// answer can be shorter than the request.
|
||||
expect((await bulk(`?ids=${one.Id}&ids=999999&ids=${hidden.Id}`)).map((i) => i.Id)).toEqual([
|
||||
expect((await bulk(`?ids=${one.Id}&ids=999999&ids=${hidden.Id}`)).map((i) => i.SavedImageId)).toEqual([
|
||||
one.Id,
|
||||
])
|
||||
|
||||
@@ -5023,7 +7145,7 @@ describe('images', () => {
|
||||
|
||||
// More ids than D1 will bind in one query (100) — the lookup has to split.
|
||||
const many = [...Array.from({ length: 130 }, (_, i) => 800000 + i), one.Id, two.Id]
|
||||
expect((await bulk(`?${many.map((id) => `ids=${id}`).join('&')}`)).map((i) => i.Id)).toEqual([
|
||||
expect((await bulk(`?${many.map((id) => `ids=${id}`).join('&')}`)).map((i) => i.SavedImageId)).toEqual([
|
||||
one.Id,
|
||||
two.Id,
|
||||
])
|
||||
@@ -5121,6 +7243,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')
|
||||
@@ -5220,7 +7349,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,
|
||||
@@ -5228,24 +7357,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(
|
||||
@@ -5266,7 +7469,7 @@ describe('images', () => {
|
||||
accessibility: 2,
|
||||
})
|
||||
)
|
||||
fd.append('image', new File([new Uint8Array([1, 2, 3])], 'pic.png', { type: 'image/png' }))
|
||||
fd.append('image', new File([PNG_BYTES], 'pic.png', { type: 'image/png' }))
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
|
||||
method: 'POST',
|
||||
headers: await bearer('42'),
|
||||
@@ -5277,23 +7480,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')
|
||||
@@ -5304,7 +7504,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()
|
||||
})
|
||||
|
||||
@@ -5334,7 +7534,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)')
|
||||
@@ -5362,7 +7562,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 }),
|
||||
})
|
||||
@@ -5374,7 +7574,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' }),
|
||||
})
|
||||
@@ -5390,6 +7590,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' }))
|
||||
@@ -5520,6 +7827,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
|
||||
@@ -5571,6 +7882,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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5834,8 +8183,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,
|
||||
@@ -5847,6 +8199,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 () => {
|
||||
@@ -5854,8 +8234,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([
|
||||
@@ -5902,8 +8287,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 } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6594,10 +8981,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')
|
||||
@@ -6606,7 +8993,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.
|
||||
@@ -6626,16 +9013,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.
|
||||
@@ -6648,8 +9037,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 () => {
|
||||
@@ -6867,6 +9256,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)
|
||||
@@ -7087,6 +9539,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)
|
||||
@@ -7242,7 +9765,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
|
||||
|
||||
@@ -7258,8 +9781,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
|
||||
@@ -7268,10 +9792,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.
|
||||
@@ -7280,14 +9804,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 () => {
|
||||
@@ -7305,17 +9838,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({
|
||||
@@ -7325,6 +9898,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 () => {
|
||||
@@ -7591,11 +10264,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'] })
|
||||
@@ -7651,6 +10322,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
|
||||
@@ -7671,7 +10344,7 @@ describe('player events', () => {
|
||||
|
||||
// Nothing moved.
|
||||
expect(await (await get(`/api/playerevents/v1/${id}`)).json()).toEqual(asRecord(event, null))
|
||||
})
|
||||
}, 20_000)
|
||||
})
|
||||
|
||||
describe('openapi', () => {
|
||||
@@ -7699,7 +10372,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',
|
||||
@@ -7731,6 +10403,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',
|
||||
@@ -7756,6 +10429,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',
|
||||
@@ -7767,9 +10441,13 @@ describe('openapi', () => {
|
||||
'GET /api/playerevents/v1/{eventId}/responses',
|
||||
'GET /api/playerevents/v2/{eventId}',
|
||||
'GET /api/players/v1/playerPhotoTaggingSetting',
|
||||
'GET /api/players/v1/progression/xpEarnedToday',
|
||||
'GET /api/players/v1/progression/{id}',
|
||||
'GET /api/players/v2/progression/bulk',
|
||||
'GET /api/progressionEvents/active',
|
||||
'GET /api/progressionEvents/event/{id}',
|
||||
'GET /api/progressionEvents/record/{id}',
|
||||
'GET /api/purchasableXpBoosts/activations',
|
||||
'GET /api/quickPlay/v1/getandclear',
|
||||
'GET /api/referee/files',
|
||||
'GET /api/relationships/mutualfriends',
|
||||
@@ -7800,16 +10478,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',
|
||||
@@ -7817,7 +10501,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',
|
||||
@@ -7850,6 +10537,7 @@ describe('openapi', () => {
|
||||
'POST /api/rooms/v1/verifyRole',
|
||||
'POST /api/sanitize/v1',
|
||||
'POST /api/sanitize/v1/isPure',
|
||||
'POST /api/sanitize/v2/isAllowed/bulk',
|
||||
'POST /api/v1/progression/bulk',
|
||||
'POST /outfits/bulk',
|
||||
'POST /statsigUserProperties',
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||
import { beforeAll, beforeEach, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
AVATAR_STUDIO_SCHEMA,
|
||||
avatarSaveDto,
|
||||
avatarStudioSave,
|
||||
publishAvatarSave,
|
||||
ROOM_SCHEMA_DDL,
|
||||
UNITY_ASSET_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
import { generateToken } from '@repo/jwt'
|
||||
|
||||
import { SCHEMA_DDL } from '../../custom-avatar-items-db'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
}
|
||||
const base = 'https://example.com/api/customAvatarItems'
|
||||
async function headers(id = 42, roles = ['betastudio']) {
|
||||
return {
|
||||
Authorization: 'Bearer ' + (await generateToken(String(id), 'fixture', 0, 'test-key', roles)),
|
||||
}
|
||||
}
|
||||
beforeAll(async () => {
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-key')
|
||||
for (const sql of [
|
||||
...SCHEMA_DDL,
|
||||
...AVATAR_STUDIO_SCHEMA,
|
||||
...UNITY_ASSET_SCHEMA_DDL,
|
||||
...ROOM_SCHEMA_DDL,
|
||||
])
|
||||
await env.DB.prepare(sql).run()
|
||||
await env.DB.prepare("INSERT INTO avatar_studio_access VALUES (42,1,'now','now')").run()
|
||||
})
|
||||
beforeEach(async () => {
|
||||
await env.DB.prepare('DELETE FROM avatar_studio_save').run()
|
||||
await env.DB.prepare('DELETE FROM custom_avatar_item').run()
|
||||
await env.DB.prepare('UPDATE avatar_studio_access SET flags=1 WHERE account_id=42').run()
|
||||
})
|
||||
it('saves and publishes image badges directly, while enforcing badge grants and room ownership', async () => {
|
||||
expect((await create(42, ['betastudio'], 1000)).status).toBe(403)
|
||||
await env.DB.prepare('UPDATE avatar_studio_access SET flags=5 WHERE account_id=42').run()
|
||||
const foreign = new FormData()
|
||||
foreign.set(
|
||||
'metadata',
|
||||
JSON.stringify({ Name: 'Foreign badge', OutfitType: 1000, BadgeRoomId: 98765 })
|
||||
)
|
||||
expect(
|
||||
(await SELF.fetch(base + '/v1', { method: 'POST', headers: await headers(), body: foreign }))
|
||||
.status
|
||||
).toBe(403)
|
||||
const { value: item } = await (
|
||||
await create(42, ['betastudio'], 1000)
|
||||
).json<{ value: { CustomAvatarItemId: string } }>()
|
||||
const png = Uint8Array.from(
|
||||
atob(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLbtAAAAABJRU5ErkJggg=='
|
||||
),
|
||||
(c) => c.charCodeAt(0)
|
||||
)
|
||||
const main = 'b'.repeat(40)
|
||||
const bytes = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
Version: 3,
|
||||
MainAssetProjectRelativePath: 'Assets/Badge.png',
|
||||
Files: [{ n: main, p: 'Assets/Badge.png' }],
|
||||
})
|
||||
)
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes)
|
||||
await env.CDN_ASSETS.put('avatarunityasset/badge-source', bytes, {
|
||||
sha256: digest,
|
||||
customMetadata: { ownerId: '42' },
|
||||
})
|
||||
await env.CDN_ASSETS.put('unityasset/' + main, png)
|
||||
await env.CDN_ASSETS.put('image/badge.png', png, {
|
||||
customMetadata: { ownerId: '42' },
|
||||
httpMetadata: { contentType: 'image/png' },
|
||||
})
|
||||
const url = base + '/' + item.CustomAvatarItemId + '/saves'
|
||||
const save = await SELF.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...(await headers()), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
NewUnityAssetFile: {
|
||||
Filename: 'badge-source',
|
||||
Hash: btoa(String.fromCharCode(...new Uint8Array(digest))),
|
||||
},
|
||||
BodyType: 4,
|
||||
ThumbnailFileName: 'badge.png',
|
||||
AdditionalConfiguration: JSON.stringify({
|
||||
v: 1,
|
||||
d: { n: 'Badge', o: 1000, d: { t: '000000' } },
|
||||
}),
|
||||
UpdateCurrentSaveIfPrivate: true,
|
||||
}),
|
||||
})
|
||||
expect(save.status).toBe(200)
|
||||
const { value: saveId } = await save.json<{ value: number }>()
|
||||
const detail = await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
|
||||
headers: await headers(),
|
||||
})
|
||||
expect(await detail.json()).toMatchObject({
|
||||
ThumbnailImageFilename: 'badge.png',
|
||||
CurrentSaves: [{ CustomAvatarItemSaveId: saveId, BodyType: 4, UnityAsset: null }],
|
||||
})
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(url + '/' + saveId + '/publish', {
|
||||
method: 'POST',
|
||||
headers: await headers(),
|
||||
})
|
||||
).status
|
||||
).toBe(200)
|
||||
expect(await (await env.IMAGES.get('badge.png'))!.arrayBuffer()).toEqual(png.buffer)
|
||||
const published = await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await headers()), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ Accessibility: 1 }),
|
||||
})
|
||||
expect(published.status).toBe(200)
|
||||
expect((await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId)).status).toBe(200)
|
||||
})
|
||||
async function create(id = 42, roles = ['betastudio'], outfitType = 0) {
|
||||
const form = new FormData()
|
||||
form.set(
|
||||
'metadata',
|
||||
JSON.stringify({ Name: 'Studio Hat', OutfitType: outfitType, Accessibility: 0, Price: 0 })
|
||||
)
|
||||
return SELF.fetch(base + '/v1', { method: 'POST', headers: await headers(id, roles), body: form })
|
||||
}
|
||||
it('accepts the SDK create request with nullable metadata fields', async () => {
|
||||
const form = new FormData()
|
||||
form.set(
|
||||
'metadata',
|
||||
JSON.stringify({
|
||||
Name: 'SDK item',
|
||||
OutfitType: 101,
|
||||
BaseAvatarItemColor: '#101',
|
||||
BadgeRoomId: null,
|
||||
PreviewOrientation: null,
|
||||
Description: null,
|
||||
Tags: null,
|
||||
Price: null,
|
||||
Accessibility: null,
|
||||
})
|
||||
)
|
||||
const response = await SELF.fetch(base + '/v1', {
|
||||
method: 'POST',
|
||||
headers: await headers(),
|
||||
body: form,
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
value: { Description: '', Tags: [], Price: 0, Accessibility: 0, CurrentSaves: [] },
|
||||
})
|
||||
})
|
||||
it('keeps grants per-account, rejects forbidden item categories, and creates metadata-only Studio items', async () => {
|
||||
expect((await create(43)).status).toBe(403)
|
||||
expect((await create(42, [])).status).toBe(403)
|
||||
expect((await create(42, ['betastudio'], 500)).status).toBe(403)
|
||||
const response = await create()
|
||||
expect(response.status).toBe(200)
|
||||
const { value: item } = await response.json<{
|
||||
value: { CustomAvatarItemId: string; OutfitType: number; CurrentSaves: unknown[] }
|
||||
}>()
|
||||
expect(item.OutfitType).toBe(0)
|
||||
expect(item.CurrentSaves).toEqual([])
|
||||
expect((await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId)).status).toBe(403)
|
||||
expect(
|
||||
(await SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, { headers: await headers() })).status
|
||||
).toBe(200)
|
||||
const page = await SELF.fetch(base + '/v1/me', { headers: await headers() })
|
||||
expect(await page.json()).toMatchObject({
|
||||
TotalResults: 1,
|
||||
Results: [{ CustomAvatarItemId: item.CustomAvatarItemId }],
|
||||
})
|
||||
})
|
||||
it('protects source history, verifies upload ownership and hash, and never publishes unbuilt revisions', async () => {
|
||||
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
|
||||
const url = base + '/' + item.CustomAvatarItemId + '/saves'
|
||||
const bytes = new TextEncoder().encode('{"Version":3}'),
|
||||
digest = await crypto.subtle.digest('SHA-256', bytes)
|
||||
await env.CDN_ASSETS.put('avatarunityasset/2026-10-18/source', bytes, {
|
||||
sha256: digest,
|
||||
customMetadata: { ownerId: '42' },
|
||||
})
|
||||
await env.CDN_ASSETS.put('image/thumb.png', 'thumbnail', {
|
||||
customMetadata: { ownerId: '42' },
|
||||
httpMetadata: { contentType: 'image/png' },
|
||||
})
|
||||
const body = {
|
||||
NewUnityAssetFile: {
|
||||
Filename: '2026-10-18/source',
|
||||
Hash: btoa(String.fromCharCode(...new Uint8Array(digest))),
|
||||
},
|
||||
BodyType: 1,
|
||||
ThumbnailFileName: 'thumb.png',
|
||||
AdditionalConfiguration: '{}',
|
||||
UpdateCurrentSaveIfPrivate: true,
|
||||
}
|
||||
const post = async (value: unknown, id = 42) =>
|
||||
SELF.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...(await headers(id)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(value),
|
||||
})
|
||||
expect((await post(body, 43)).status).toBe(403)
|
||||
expect(
|
||||
(await post({ ...body, NewUnityAssetFile: { ...body.NewUnityAssetFile, Hash: 'wrong' } }))
|
||||
.status
|
||||
).toBe(400)
|
||||
const response = await post(body)
|
||||
expect(response.status).toBe(200)
|
||||
const { value: saveId } = await response.json<{ value: number }>()
|
||||
expect(await (await env.IMAGES.get('thumb.png'))?.text()).toBe('thumbnail')
|
||||
expect((await SELF.fetch(url, { headers: await headers(43) })).status).toBe(403)
|
||||
const history = await SELF.fetch(url, { headers: await headers() })
|
||||
expect(await history.json()).toMatchObject({
|
||||
success: true,
|
||||
value: {
|
||||
TotalResults: 1,
|
||||
Results: [
|
||||
{
|
||||
CustomAvatarItemSaveId: saveId,
|
||||
SourceUnityAssetFilename: body.NewUnityAssetFile.Filename,
|
||||
UnityAsset: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch(url + '/' + saveId + '/publish', {
|
||||
method: 'POST',
|
||||
headers: await headers(),
|
||||
})
|
||||
).status
|
||||
).toBe(400)
|
||||
expect(
|
||||
(await post({ ...body, NewUnityAssetFile: null, ExistingUnityAssetId: crypto.randomUUID() }))
|
||||
.status
|
||||
).toBe(400)
|
||||
})
|
||||
it('allows creator metadata edits without exposing unbuilt items or changing ownership', async () => {
|
||||
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
|
||||
const edit = async (body: unknown, id = 42) =>
|
||||
SELF.fetch(base + '/v1/' + item.CustomAvatarItemId, {
|
||||
method: 'PUT',
|
||||
headers: { ...(await headers(id)), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
expect((await edit({ Name: 'Other owner' }, 43)).status).toBe(403)
|
||||
expect((await edit({ Accessibility: 1 })).status).toBe(400)
|
||||
expect((await edit({ Tags: [{ TagType: 1, Value: '' }] })).status).toBe(400)
|
||||
const response = await edit({
|
||||
Name: ' Updated hat ',
|
||||
Description: 'Edited in Studio',
|
||||
Tags: [{ TagType: 1, Value: 'hat' }],
|
||||
CreatorAccountId: 43,
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toMatchObject({
|
||||
value: {
|
||||
Name: 'Updated hat',
|
||||
Description: 'Edited in Studio',
|
||||
CreatorAccountId: 42,
|
||||
Accessibility: 0,
|
||||
Tags: [{ TagType: 1, Value: 'hat' }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('isolates outputs across reused-source bodies/configurations and preserves original restore', async () => {
|
||||
const { value: item } = await (await create()).json<{ value: { CustomAvatarItemId: string } }>()
|
||||
const asset = crypto.randomUUID()
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO avatar_studio_save(id,item_id,body_type,unity_asset_id,source_filename,source_hash,thumbnail,configuration,created_at) VALUES (100,?,1,?,'same-source','same-hash','thumb.png','{}','now')"
|
||||
)
|
||||
.bind(item.CustomAvatarItemId, asset)
|
||||
.run()
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO unity_asset VALUES (?,0,5,'main','original.bundle','original-hash')"
|
||||
)
|
||||
.bind(asset)
|
||||
.run()
|
||||
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, 100)).toBe(true)
|
||||
await env.CDN_ASSETS.put('image/thumb.png', 'fixture', { customMetadata: { ownerId: '42' } })
|
||||
const url = base + '/' + item.CustomAvatarItemId + '/saves'
|
||||
const save = async (bodyType: number, config: string) => {
|
||||
const response = await SELF.fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { ...(await headers()), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
ExistingUnityAssetId: asset,
|
||||
BodyType: bodyType,
|
||||
AdditionalConfiguration: config,
|
||||
ThumbnailFileName: 'thumb.png',
|
||||
UpdateCurrentSaveIfPrivate: true,
|
||||
}),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const { value: id } = await response.json<{ value: number }>()
|
||||
return (await avatarStudioSave(env.DB, item.CustomAvatarItemId, id))!
|
||||
}
|
||||
const metadata = await save(1, '{}')
|
||||
expect(metadata.unity_asset_id).toBe(asset)
|
||||
const otherBody = await save(2, '{}'),
|
||||
otherConfig = await save(1, '{"variant":2}')
|
||||
for (const revision of [otherBody, otherConfig]) {
|
||||
expect(revision.unity_asset_id).not.toBe(asset)
|
||||
expect(revision.source_filename).toBe('same-source')
|
||||
expect(revision.source_hash).toBe('same-hash')
|
||||
expect((await avatarSaveDto(env.DB, revision)).UnityAsset).toBeNull()
|
||||
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, revision.id)).toBe(false)
|
||||
}
|
||||
expect(otherBody.unity_asset_id).not.toBe(otherConfig.unity_asset_id)
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO unity_asset VALUES (?,0,5,'main','other-body.bundle','other-hash')"
|
||||
)
|
||||
.bind(otherBody.unity_asset_id)
|
||||
.run()
|
||||
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, otherBody.id)).toBe(true)
|
||||
expect(await publishAvatarSave(env.DB, item.CustomAvatarItemId, 100)).toBe(true)
|
||||
const stored = JSON.parse(
|
||||
(await env.DB.prepare('SELECT data FROM custom_avatar_item WHERE custom_avatar_item_id=?')
|
||||
.bind(item.CustomAvatarItemId)
|
||||
.first<{ data: string }>())!.data
|
||||
)
|
||||
expect(stored.CurrentSaves.find((s: { BodyType: number }) => s.BodyType === 1).UnityAsset).toBe(
|
||||
'original.bundle'
|
||||
)
|
||||
expect(stored.CurrentSaves.find((s: { BodyType: number }) => s.BodyType === 2).UnityAsset).toBe(
|
||||
'other-body.bundle'
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||
import { beforeAll, expect, it } from 'vitest'
|
||||
|
||||
import { generateStudioBuildValidationToken, validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
}
|
||||
beforeAll(async () => {
|
||||
await adminSecretsStore(env.JWT_SECRET).create('test-key')
|
||||
})
|
||||
it('allows build validation credentials to check text without granting an account identity', async () => {
|
||||
const token = await generateStudioBuildValidationToken('123456', 'test-key')
|
||||
const headers = { Authorization: 'Bearer ' + token }
|
||||
expect(
|
||||
await validateAndGetAccountId(new Request('https://example.com', { headers }), 'test-key')
|
||||
).toBeNull()
|
||||
const response = await SELF.fetch('https://example.com/api/sanitize/v2/isAllowed/bulk', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: new URLSearchParams('Values=hello&Values=fuck'),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual({
|
||||
Success: true,
|
||||
Error: null,
|
||||
Value: { ArePure: [true, false] },
|
||||
})
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch('https://example.com/api/PlayerCheer/v1/create', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: new URLSearchParams(),
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
})
|
||||
it('rejects an invalid build validation signature', async () => {
|
||||
const token = await generateStudioBuildValidationToken('123456', 'wrong-key')
|
||||
expect(
|
||||
(
|
||||
await SELF.fetch('https://example.com/api/sanitize/v2/isAllowed/bulk', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + token },
|
||||
body: new URLSearchParams('Values=hello'),
|
||||
})
|
||||
).status
|
||||
).toBe(401)
|
||||
})
|
||||
@@ -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": ""
|
||||
}
|
||||
]
|
||||
@@ -4782,8 +4782,8 @@
|
||||
},
|
||||
{
|
||||
"Key": "ShareCam.VideoEnabled",
|
||||
"Value": "false",
|
||||
"ActiveExperiments": "video_recording_2025q2",
|
||||
"Value": "true",
|
||||
"ActiveExperiments": "",
|
||||
"StartTime": null,
|
||||
"EndTime": null
|
||||
},
|
||||
|
||||
@@ -0,0 +1,981 @@
|
||||
{
|
||||
"ProgressionEventId": 13,
|
||||
"Name": "Anime Season",
|
||||
"IsEnabled": true,
|
||||
"Rewards": [
|
||||
{
|
||||
"ProgressionEventRewardId": 83,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15422,
|
||||
"GiftDropIds": [
|
||||
15422
|
||||
],
|
||||
"ImageName": "asec16t4zlrlpn7ddct1tpjbn.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 10,
|
||||
"RewardIndex": 0,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 84,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15642,
|
||||
"GiftDropIds": [
|
||||
15642
|
||||
],
|
||||
"ImageName": "bhnowifgzj7yqn8yenud48wvk.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 10,
|
||||
"RewardIndex": 1,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 85,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14338,
|
||||
"GiftDropIds": [
|
||||
14338
|
||||
],
|
||||
"ImageName": "804hhdj7eugd0ebefgi7i450k.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 20,
|
||||
"RewardIndex": 2,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 86,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14341,
|
||||
"GiftDropIds": [
|
||||
14341
|
||||
],
|
||||
"ImageName": "24m5t3glureyoj6qk0csg3gfa.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 20,
|
||||
"RewardIndex": 3,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 87,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14338,
|
||||
"GiftDropIds": [
|
||||
14338
|
||||
],
|
||||
"ImageName": "6t3bh0cysaaf8cq1985uykyqm.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 40,
|
||||
"RewardIndex": 4,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 88,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15579,
|
||||
"GiftDropIds": [
|
||||
15579
|
||||
],
|
||||
"ImageName": "9afiojqywq28a9hvw9k6z96u0.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 40,
|
||||
"RewardIndex": 5,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 89,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15422,
|
||||
"GiftDropIds": [
|
||||
15422
|
||||
],
|
||||
"ImageName": "3hhyelwld7gr7xcydmtake5ka.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 70,
|
||||
"RewardIndex": 6,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 90,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14341,
|
||||
"GiftDropIds": [
|
||||
14341
|
||||
],
|
||||
"ImageName": "dz9pqfvgkt8w3pruvqd5hws8j.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 70,
|
||||
"RewardIndex": 7,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 91,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14339,
|
||||
"GiftDropIds": [
|
||||
14339
|
||||
],
|
||||
"ImageName": "dkwv56rpw7df98mm5zgklj02m.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 100,
|
||||
"RewardIndex": 8,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 92,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15582,
|
||||
"GiftDropIds": [
|
||||
15582
|
||||
],
|
||||
"ImageName": "0xwod7lzm0zbqogrpdrvxwgxh.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 100,
|
||||
"RewardIndex": 9,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 93,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14338,
|
||||
"GiftDropIds": [
|
||||
14338
|
||||
],
|
||||
"ImageName": "49il0ry6hd75o26079qvysrw8.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 130,
|
||||
"RewardIndex": 10,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 94,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14341,
|
||||
"GiftDropIds": [
|
||||
14341
|
||||
],
|
||||
"ImageName": "ckykgyrgxwf6guz83q4x25f1x.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 130,
|
||||
"RewardIndex": 11,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 95,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15422,
|
||||
"GiftDropIds": [
|
||||
15422
|
||||
],
|
||||
"ImageName": "2mvonbbn7igpdcbkm9lmbrex6.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 160,
|
||||
"RewardIndex": 12,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 96,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15422,
|
||||
"GiftDropIds": [
|
||||
15422
|
||||
],
|
||||
"ImageName": "3fmhzm3f8j8iwmikiq4tt8xq6.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 160,
|
||||
"RewardIndex": 13,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 97,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14338,
|
||||
"GiftDropIds": [
|
||||
14338
|
||||
],
|
||||
"ImageName": "ckahnsok8kfarvtqdhzbo42d6.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 200,
|
||||
"RewardIndex": 14,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 98,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15581,
|
||||
"GiftDropIds": [
|
||||
15581
|
||||
],
|
||||
"ImageName": "9hfyowdb6lrsayrv6rgfy45bh.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 200,
|
||||
"RewardIndex": 15,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 99,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14340,
|
||||
"GiftDropIds": [
|
||||
14340
|
||||
],
|
||||
"ImageName": "1o03eyr37gmp1zm4o7ofc936b.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 230,
|
||||
"RewardIndex": 16,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 100,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14341,
|
||||
"GiftDropIds": [
|
||||
14341
|
||||
],
|
||||
"ImageName": "9mtv2qroauql1o57efaodyz0x.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 230,
|
||||
"RewardIndex": 17,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 101,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15422,
|
||||
"GiftDropIds": [
|
||||
15422
|
||||
],
|
||||
"ImageName": "611lb9ddygir3fc96vjbc6lys.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 260,
|
||||
"RewardIndex": 18,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 102,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15422,
|
||||
"GiftDropIds": [
|
||||
15422
|
||||
],
|
||||
"ImageName": "27x3n35qf9nlhha4pa9ut73m2.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 260,
|
||||
"RewardIndex": 19,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 103,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14339,
|
||||
"GiftDropIds": [
|
||||
14339
|
||||
],
|
||||
"ImageName": "4ko0och2lu6tz55r23czwxy3n.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 290,
|
||||
"RewardIndex": 20,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 104,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15578,
|
||||
"GiftDropIds": [
|
||||
15578
|
||||
],
|
||||
"ImageName": "777clrrjfvadn3a3p9j3r8lq4.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 290,
|
||||
"RewardIndex": 21,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 105,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14339,
|
||||
"GiftDropIds": [
|
||||
14339
|
||||
],
|
||||
"ImageName": "1a10x4p9w0wncea4v6hs2kz56.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 320,
|
||||
"RewardIndex": 22,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 106,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14341,
|
||||
"GiftDropIds": [
|
||||
14341
|
||||
],
|
||||
"ImageName": "3fd6vliml6xffrztxgygnsx28.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 320,
|
||||
"RewardIndex": 23,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 107,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14338,
|
||||
"GiftDropIds": [
|
||||
14338
|
||||
],
|
||||
"ImageName": "5xtlbxccbhh366g5mbw30y1y0.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 350,
|
||||
"RewardIndex": 24,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 108,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15585,
|
||||
"GiftDropIds": [
|
||||
15585
|
||||
],
|
||||
"ImageName": "c7nboei2r02423p2tm32qh9kc.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 350,
|
||||
"RewardIndex": 25,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 109,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 3880,
|
||||
"GiftDropIds": [
|
||||
3880
|
||||
],
|
||||
"ImageName": "1d5nimrm9oie7ig4wwr9f6fan.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 410,
|
||||
"RewardIndex": 26,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 110,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15422,
|
||||
"GiftDropIds": [
|
||||
15422
|
||||
],
|
||||
"ImageName": "bj7usomic48jawtned9hjucyn.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 410,
|
||||
"RewardIndex": 27,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 111,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15422,
|
||||
"GiftDropIds": [
|
||||
15422
|
||||
],
|
||||
"ImageName": "c185oly3ylat030qkw24aphhc.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 470,
|
||||
"RewardIndex": 28,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 112,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14341,
|
||||
"GiftDropIds": [
|
||||
14341
|
||||
],
|
||||
"ImageName": "b732uynmakcfgcmbr8g1an6lu.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 470,
|
||||
"RewardIndex": 29,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 113,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14339,
|
||||
"GiftDropIds": [
|
||||
14339
|
||||
],
|
||||
"ImageName": "15aomg6mkmxf4cjqjhgs82mlg.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 530,
|
||||
"RewardIndex": 30,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 114,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15584,
|
||||
"GiftDropIds": [
|
||||
15584
|
||||
],
|
||||
"ImageName": "2o20kavqm9o2catpp2qx8kaqk.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 530,
|
||||
"RewardIndex": 31,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 115,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14339,
|
||||
"GiftDropIds": [
|
||||
14339
|
||||
],
|
||||
"ImageName": "1gs1u99c9bxme1azznn8ngvqq.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 590,
|
||||
"RewardIndex": 32,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 116,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14341,
|
||||
"GiftDropIds": [
|
||||
14341
|
||||
],
|
||||
"ImageName": "1z5wp5lwqlffvt7h3fg1y378b.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 590,
|
||||
"RewardIndex": 33,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 117,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14340,
|
||||
"GiftDropIds": [
|
||||
14340
|
||||
],
|
||||
"ImageName": "05r0rmf9y4d67h6ye8jpite75.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 650,
|
||||
"RewardIndex": 34,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 118,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15580,
|
||||
"GiftDropIds": [
|
||||
15580
|
||||
],
|
||||
"ImageName": "0npm6i03bs8osho7686vjtzad.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 650,
|
||||
"RewardIndex": 35,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 119,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14338,
|
||||
"GiftDropIds": [
|
||||
14338
|
||||
],
|
||||
"ImageName": "1gxungkle9bvp0qdprldcnft1.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 710,
|
||||
"RewardIndex": 36,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 125,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14341,
|
||||
"GiftDropIds": [
|
||||
14341
|
||||
],
|
||||
"ImageName": "5zavnpdapdem2xc0aq7axt5b5.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 710,
|
||||
"RewardIndex": 37,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 124,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15422,
|
||||
"GiftDropIds": [
|
||||
15422
|
||||
],
|
||||
"ImageName": "8sszy7p5dlune762vn8lm1oy7.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 770,
|
||||
"RewardIndex": 38,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 123,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15583,
|
||||
"GiftDropIds": [
|
||||
15583
|
||||
],
|
||||
"ImageName": "e2ncor6ccwj5t6cqugmjug4c7.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 770,
|
||||
"RewardIndex": 39,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 129,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14339,
|
||||
"GiftDropIds": [
|
||||
14339
|
||||
],
|
||||
"ImageName": "bl1t78iofsjb1qtgdbklilbb0.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 850,
|
||||
"RewardIndex": 40,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 128,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14341,
|
||||
"GiftDropIds": [
|
||||
14341
|
||||
],
|
||||
"ImageName": "10pqse99purq5xjws537orysb.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 850,
|
||||
"RewardIndex": 41,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 127,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14340,
|
||||
"GiftDropIds": [
|
||||
14340
|
||||
],
|
||||
"ImageName": "3knjal5w2lrict10ygu6xaumw.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 930,
|
||||
"RewardIndex": 42,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 126,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14341,
|
||||
"GiftDropIds": [
|
||||
14341
|
||||
],
|
||||
"ImageName": "alk0chwlwxme9oxd1fytlamxa.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 930,
|
||||
"RewardIndex": 43,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 135,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14338,
|
||||
"GiftDropIds": [
|
||||
14338
|
||||
],
|
||||
"ImageName": "0t880a28a8uy40b3p1jyhri26.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1030,
|
||||
"RewardIndex": 44,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 134,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15422,
|
||||
"GiftDropIds": [
|
||||
15422
|
||||
],
|
||||
"ImageName": "cus32m9rew5niammslay2x2eo.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1030,
|
||||
"RewardIndex": 45,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 133,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15422,
|
||||
"GiftDropIds": [
|
||||
15422
|
||||
],
|
||||
"ImageName": "6l4df8qa6i77gs1ooq2k8uj1s.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1130,
|
||||
"RewardIndex": 46,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 132,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14341,
|
||||
"GiftDropIds": [
|
||||
14341
|
||||
],
|
||||
"ImageName": "ee896zb9514fljxf4xzn5ez0a.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1130,
|
||||
"RewardIndex": 47,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 131,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14339,
|
||||
"GiftDropIds": [
|
||||
14339
|
||||
],
|
||||
"ImageName": "8b8n5mqgbmmbkmae81ssldy5y.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1250,
|
||||
"RewardIndex": 48,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 130,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14341,
|
||||
"GiftDropIds": [
|
||||
14341
|
||||
],
|
||||
"ImageName": "c9ho9mdvnbks3r1iqga1ulon1.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1250,
|
||||
"RewardIndex": 49,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 121,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14340,
|
||||
"GiftDropIds": [
|
||||
14340
|
||||
],
|
||||
"ImageName": "e4es0hla8k9rmyuccuzjvxbrh.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1370,
|
||||
"RewardIndex": 50,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 120,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14341,
|
||||
"GiftDropIds": [
|
||||
14341
|
||||
],
|
||||
"ImageName": "56j8qyv68hd1o8zsqp03vnuxl.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1370,
|
||||
"RewardIndex": 51,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 141,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14338,
|
||||
"GiftDropIds": [
|
||||
14338
|
||||
],
|
||||
"ImageName": "criqbrh93fdcug6o04za19zvs.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1510,
|
||||
"RewardIndex": 52,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 140,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15422,
|
||||
"GiftDropIds": [
|
||||
15422
|
||||
],
|
||||
"ImageName": "92uz0mh3vq4vv3ok672zcx0qy.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1510,
|
||||
"RewardIndex": 53,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 139,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14340,
|
||||
"GiftDropIds": [
|
||||
14340
|
||||
],
|
||||
"ImageName": "4fpa5no0vdm60jul2yu002nec.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1650,
|
||||
"RewardIndex": 54,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 138,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14341,
|
||||
"GiftDropIds": [
|
||||
14341
|
||||
],
|
||||
"ImageName": "97ucjiycstjyholpsddtaq7er.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1650,
|
||||
"RewardIndex": 55,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 137,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14338,
|
||||
"GiftDropIds": [
|
||||
14338
|
||||
],
|
||||
"ImageName": "2tboxk0avvq9aozt1q5an6vmz.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1810,
|
||||
"RewardIndex": 56,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 136,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 14341,
|
||||
"GiftDropIds": [
|
||||
14341
|
||||
],
|
||||
"ImageName": "0a70iz2k5gr2lbpi7x4izqr3l.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1810,
|
||||
"RewardIndex": 57,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 142,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15698,
|
||||
"GiftDropIds": [
|
||||
15698
|
||||
],
|
||||
"ImageName": "0eenxm5lf4eqh3po9qcdfllxk.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1970,
|
||||
"RewardIndex": 58,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 143,
|
||||
"ProgressionEventId": 13,
|
||||
"GiftDropId": 15697,
|
||||
"GiftDropIds": [
|
||||
15697
|
||||
],
|
||||
"ImageName": "40nrm63jjeuxy5amf4xw0nr9n.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1970,
|
||||
"RewardIndex": 59,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
}
|
||||
],
|
||||
"KeepsakeRoomLists": [],
|
||||
"StartTime": "2025-05-30T14:00:00Z",
|
||||
"EndTime": "2025-06-27T14:00:00Z",
|
||||
"CollectionEndTime": "2025-06-28T14:00:00Z",
|
||||
"UsesBoost": false,
|
||||
"BoostDailyGameplayMinutesLimit": 0,
|
||||
"BoostXpMultiplier": 0,
|
||||
"PurchasableXpBoostId": null,
|
||||
"ActiveExperiment": null,
|
||||
"ChallengesIconImageName": null,
|
||||
"RewardsPipImageName": null,
|
||||
"EventInfoImageName": "6e7cosfz20dokv5179njfqc6m.png",
|
||||
"EventLeaderboardId": null,
|
||||
"InitialCurrencyGrant": 0
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"ProgressionEventId": 8,
|
||||
"Name": "Base Event",
|
||||
"IsEnabled": false,
|
||||
"Rewards": [],
|
||||
"KeepsakeRoomLists": [
|
||||
{
|
||||
"KeepsakeRoomListId": 30,
|
||||
"ProgressionEventId": 8,
|
||||
"UnlockItemAvatarItemId": null,
|
||||
"UnlockItemGiftDropId": null,
|
||||
"UnlockItemLockDurationTicks": null,
|
||||
"KeepsakeRooms": [
|
||||
{
|
||||
"KeepsakeRoomId": 270,
|
||||
"RoomId": 41854064,
|
||||
"KeepsakeRoomListId": 30,
|
||||
"Type": 0,
|
||||
"Order": 0
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 265,
|
||||
"RoomId": 8364812036745705361,
|
||||
"KeepsakeRoomListId": 30,
|
||||
"Type": 0,
|
||||
"Order": 1
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 271,
|
||||
"RoomId": 4678168151807803565,
|
||||
"KeepsakeRoomListId": 30,
|
||||
"Type": 0,
|
||||
"Order": 2
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 267,
|
||||
"RoomId": 8786672571011050982,
|
||||
"KeepsakeRoomListId": 30,
|
||||
"Type": 0,
|
||||
"Order": 3
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 273,
|
||||
"RoomId": 59058753,
|
||||
"KeepsakeRoomListId": 30,
|
||||
"Type": 0,
|
||||
"Order": 4
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 266,
|
||||
"RoomId": 41922506,
|
||||
"KeepsakeRoomListId": 30,
|
||||
"Type": 0,
|
||||
"Order": 5
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 272,
|
||||
"RoomId": 70225004,
|
||||
"KeepsakeRoomListId": 30,
|
||||
"Type": 0,
|
||||
"Order": 6
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 274,
|
||||
"RoomId": 50107153,
|
||||
"KeepsakeRoomListId": 30,
|
||||
"Type": 0,
|
||||
"Order": 7
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 260,
|
||||
"RoomId": 944261190986353689,
|
||||
"KeepsakeRoomListId": 30,
|
||||
"Type": 0,
|
||||
"Order": 8
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 262,
|
||||
"RoomId": 41854320,
|
||||
"KeepsakeRoomListId": 30,
|
||||
"Type": 0,
|
||||
"Order": 9
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 264,
|
||||
"RoomId": 1063552974137030672,
|
||||
"KeepsakeRoomListId": 30,
|
||||
"Type": 0,
|
||||
"Order": 10
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 275,
|
||||
"RoomId": 3662988090243921994,
|
||||
"KeepsakeRoomListId": 30,
|
||||
"Type": 0,
|
||||
"Order": 11
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 269,
|
||||
"RoomId": 31382618,
|
||||
"KeepsakeRoomListId": 30,
|
||||
"Type": 0,
|
||||
"Order": 12
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 261,
|
||||
"RoomId": 1752507419075074724,
|
||||
"KeepsakeRoomListId": 30,
|
||||
"Type": 0,
|
||||
"Order": 13
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 263,
|
||||
"RoomId": 74897901,
|
||||
"KeepsakeRoomListId": 30,
|
||||
"Type": 0,
|
||||
"Order": 14
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 268,
|
||||
"RoomId": 42201785,
|
||||
"KeepsakeRoomListId": 30,
|
||||
"Type": 0,
|
||||
"Order": 15
|
||||
}
|
||||
],
|
||||
"RoomUnlockStartOffsetTicks": 600000000,
|
||||
"RoomUnlockIntervalTicks": 600000000,
|
||||
"RoomUnlockBatchSize": 1,
|
||||
"RoomType": 0,
|
||||
"UnlockItemLockDuration": null,
|
||||
"RoomUnlockStartOffset": "00:01:00",
|
||||
"RoomUnlockInterval": "00:01:00"
|
||||
}
|
||||
],
|
||||
"StartTime": "5555-02-06T00:00:00Z",
|
||||
"EndTime": "5555-02-07T00:00:00Z",
|
||||
"CollectionEndTime": "5555-02-08T00:00:00Z",
|
||||
"UsesBoost": false,
|
||||
"BoostDailyGameplayMinutesLimit": 0,
|
||||
"BoostXpMultiplier": 0,
|
||||
"PurchasableXpBoostId": null,
|
||||
"ActiveExperiment": null,
|
||||
"ChallengesIconImageName": null,
|
||||
"RewardsPipImageName": null,
|
||||
"EventInfoImageName": null,
|
||||
"EventLeaderboardId": null,
|
||||
"InitialCurrencyGrant": 0
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"ProgressionEventId": 2,
|
||||
"Name": "Coming in Hot! Progression Event Alpha",
|
||||
"IsEnabled": true,
|
||||
"Rewards": [
|
||||
{
|
||||
"ProgressionEventRewardId": 1,
|
||||
"ProgressionEventId": 2,
|
||||
"GiftDropId": 3798,
|
||||
"GiftDropIds": [
|
||||
3798
|
||||
],
|
||||
"ImageName": "7w7xumgr5rtaouze5e5lwspea.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 30,
|
||||
"RewardIndex": 0,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 2,
|
||||
"ProgressionEventId": 2,
|
||||
"GiftDropId": 3779,
|
||||
"GiftDropIds": [
|
||||
3779
|
||||
],
|
||||
"ImageName": "eynegc0nils797wgjqz96a2ds.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 60,
|
||||
"RewardIndex": 1,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 3,
|
||||
"ProgressionEventId": 2,
|
||||
"GiftDropId": 3025,
|
||||
"GiftDropIds": [
|
||||
3025
|
||||
],
|
||||
"ImageName": "2ikt2bx6xwiyyr6s8qducxjr1.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 90,
|
||||
"RewardIndex": 2,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 4,
|
||||
"ProgressionEventId": 2,
|
||||
"GiftDropId": 4079,
|
||||
"GiftDropIds": [
|
||||
4079
|
||||
],
|
||||
"ImageName": "dziv7z4fwbejheoy7yvrjlby1.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 130,
|
||||
"RewardIndex": 3,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 6,
|
||||
"ProgressionEventId": 2,
|
||||
"GiftDropId": 4078,
|
||||
"GiftDropIds": [
|
||||
4078
|
||||
],
|
||||
"ImageName": "6fepaaug17ai3guqpazy3v86o.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 180,
|
||||
"RewardIndex": 4,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 7,
|
||||
"ProgressionEventId": 2,
|
||||
"GiftDropId": 3974,
|
||||
"GiftDropIds": [
|
||||
3974
|
||||
],
|
||||
"ImageName": "7958ooqtj2o04rkcdqtyb3rko.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 250,
|
||||
"RewardIndex": 5,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
}
|
||||
],
|
||||
"KeepsakeRoomLists": [],
|
||||
"StartTime": "2022-09-13T23:00:00Z",
|
||||
"EndTime": "2022-09-16T23:00:00Z",
|
||||
"CollectionEndTime": "2022-09-17T23:00:00Z",
|
||||
"UsesBoost": true,
|
||||
"BoostDailyGameplayMinutesLimit": 20,
|
||||
"BoostXpMultiplier": 3,
|
||||
"PurchasableXpBoostId": null,
|
||||
"ActiveExperiment": "growth_progression_events",
|
||||
"ChallengesIconImageName": null,
|
||||
"RewardsPipImageName": null,
|
||||
"EventInfoImageName": null,
|
||||
"EventLeaderboardId": null,
|
||||
"InitialCurrencyGrant": 0
|
||||
}
|
||||
@@ -0,0 +1,703 @@
|
||||
{
|
||||
"ProgressionEventId": 9,
|
||||
"Name": "Invasion 2!",
|
||||
"IsEnabled": true,
|
||||
"Rewards": [
|
||||
{
|
||||
"ProgressionEventRewardId": 48,
|
||||
"ProgressionEventId": 9,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "03msdpjjdn4rye4a5rzlb0iy4.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 60,
|
||||
"RewardIndex": 0,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 49,
|
||||
"ProgressionEventId": 9,
|
||||
"GiftDropId": 5060,
|
||||
"GiftDropIds": [
|
||||
5060
|
||||
],
|
||||
"ImageName": "eg02namtz6220on6tswdh1a7m.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 170,
|
||||
"RewardIndex": 1,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 50,
|
||||
"ProgressionEventId": 9,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "dhtb7pja4o4wau6a4nb9i6hmm.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 330,
|
||||
"RewardIndex": 2,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 51,
|
||||
"ProgressionEventId": 9,
|
||||
"GiftDropId": 5000,
|
||||
"GiftDropIds": [
|
||||
5000
|
||||
],
|
||||
"ImageName": "8th0lp6vzerjmvesnk9rayofk.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 540,
|
||||
"RewardIndex": 3,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 52,
|
||||
"ProgressionEventId": 9,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "5173t89qwmeqyo5z1m6w87g59.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 750,
|
||||
"RewardIndex": 4,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 53,
|
||||
"ProgressionEventId": 9,
|
||||
"GiftDropId": 4433,
|
||||
"GiftDropIds": [
|
||||
4433
|
||||
],
|
||||
"ImageName": "1q562e2xf83t8mk71tq6ivttb.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1070,
|
||||
"RewardIndex": 5,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 54,
|
||||
"ProgressionEventId": 9,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "bsyfz1t3uzuaorgvzosp47ryc.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1280,
|
||||
"RewardIndex": 6,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 55,
|
||||
"ProgressionEventId": 9,
|
||||
"GiftDropId": 5047,
|
||||
"GiftDropIds": [
|
||||
5047
|
||||
],
|
||||
"ImageName": "1oysf2o2iqrvvkbzgxj2nt5ls.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1810,
|
||||
"RewardIndex": 7,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 56,
|
||||
"ProgressionEventId": 9,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "6efp1yda2ld5508dty6r7r2li.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 2020,
|
||||
"RewardIndex": 8,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 57,
|
||||
"ProgressionEventId": 9,
|
||||
"GiftDropId": 5060,
|
||||
"GiftDropIds": [
|
||||
5060
|
||||
],
|
||||
"ImageName": "9qtg9rduxkg1vwt10ta18qxoj.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 2870,
|
||||
"RewardIndex": 9,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 58,
|
||||
"ProgressionEventId": 9,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "7ylm9s3ipm79nb97tbidj9sfw.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 3080,
|
||||
"RewardIndex": 10,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 59,
|
||||
"ProgressionEventId": 9,
|
||||
"GiftDropId": 4954,
|
||||
"GiftDropIds": [
|
||||
4954
|
||||
],
|
||||
"ImageName": "7cnvplzo7iqzvgwwyg13x9yul.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 3930,
|
||||
"RewardIndex": 11,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 60,
|
||||
"ProgressionEventId": 9,
|
||||
"GiftDropId": 5060,
|
||||
"GiftDropIds": [
|
||||
5060
|
||||
],
|
||||
"ImageName": "b6jdkpp062231ywmjppkc0skm.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 4140,
|
||||
"RewardIndex": 12,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 61,
|
||||
"ProgressionEventId": 9,
|
||||
"GiftDropId": 5272,
|
||||
"GiftDropIds": [
|
||||
5272
|
||||
],
|
||||
"ImageName": "24624vyh7d3pkjzmpabdo884n.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 4990,
|
||||
"RewardIndex": 13,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 62,
|
||||
"ProgressionEventId": 9,
|
||||
"GiftDropId": 5060,
|
||||
"GiftDropIds": [
|
||||
5060
|
||||
],
|
||||
"ImageName": "5wyzjcnty2ejkmdhwrj9a179p.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 5200,
|
||||
"RewardIndex": 14,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 63,
|
||||
"ProgressionEventId": 9,
|
||||
"GiftDropId": 5302,
|
||||
"GiftDropIds": [
|
||||
5302
|
||||
],
|
||||
"ImageName": "d3vuktasdn3tfvdjjzkcbwjd8.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 6050,
|
||||
"RewardIndex": 15,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 64,
|
||||
"ProgressionEventId": 9,
|
||||
"GiftDropId": 5060,
|
||||
"GiftDropIds": [
|
||||
5060
|
||||
],
|
||||
"ImageName": "0vhtp1bz55aj6cch5z922vhiy.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 300,
|
||||
"RewardIndex": 16,
|
||||
"IsBonus": true,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
}
|
||||
],
|
||||
"KeepsakeRoomLists": [
|
||||
{
|
||||
"KeepsakeRoomListId": 24,
|
||||
"ProgressionEventId": 9,
|
||||
"UnlockItemAvatarItemId": null,
|
||||
"UnlockItemGiftDropId": null,
|
||||
"UnlockItemLockDurationTicks": null,
|
||||
"KeepsakeRooms": [
|
||||
{
|
||||
"KeepsakeRoomId": 202,
|
||||
"RoomId": 79637721166461380,
|
||||
"KeepsakeRoomListId": 24,
|
||||
"Type": 0,
|
||||
"Order": 0
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 205,
|
||||
"RoomId": 7755621295025968191,
|
||||
"KeepsakeRoomListId": 24,
|
||||
"Type": 0,
|
||||
"Order": 1
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 242,
|
||||
"RoomId": 76890764,
|
||||
"KeepsakeRoomListId": 24,
|
||||
"Type": 0,
|
||||
"Order": 2
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 200,
|
||||
"RoomId": 8483543681332714280,
|
||||
"KeepsakeRoomListId": 24,
|
||||
"Type": 0,
|
||||
"Order": 3
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 204,
|
||||
"RoomId": 75208136,
|
||||
"KeepsakeRoomListId": 24,
|
||||
"Type": 0,
|
||||
"Order": 4
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 201,
|
||||
"RoomId": 7036488310533039471,
|
||||
"KeepsakeRoomListId": 24,
|
||||
"Type": 0,
|
||||
"Order": 5
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 203,
|
||||
"RoomId": 49268683,
|
||||
"KeepsakeRoomListId": 24,
|
||||
"Type": 0,
|
||||
"Order": 6
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 207,
|
||||
"RoomId": 58246484,
|
||||
"KeepsakeRoomListId": 24,
|
||||
"Type": 0,
|
||||
"Order": 7
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 197,
|
||||
"RoomId": 887680104196187339,
|
||||
"KeepsakeRoomListId": 24,
|
||||
"Type": 0,
|
||||
"Order": 8
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 249,
|
||||
"RoomId": 40613764,
|
||||
"KeepsakeRoomListId": 24,
|
||||
"Type": 0,
|
||||
"Order": 9
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 251,
|
||||
"RoomId": 72079035,
|
||||
"KeepsakeRoomListId": 24,
|
||||
"Type": 0,
|
||||
"Order": 10
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 198,
|
||||
"RoomId": 73692017,
|
||||
"KeepsakeRoomListId": 24,
|
||||
"Type": 0,
|
||||
"Order": 11
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 206,
|
||||
"RoomId": 4678168151807803565,
|
||||
"KeepsakeRoomListId": 24,
|
||||
"Type": 0,
|
||||
"Order": 12
|
||||
}
|
||||
],
|
||||
"RoomUnlockStartOffsetTicks": 0,
|
||||
"RoomUnlockIntervalTicks": 2592000000000,
|
||||
"RoomUnlockBatchSize": 3,
|
||||
"RoomType": 0,
|
||||
"UnlockItemLockDuration": null,
|
||||
"RoomUnlockStartOffset": "00:00:00",
|
||||
"RoomUnlockInterval": "3.00:00:00"
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomListId": 25,
|
||||
"ProgressionEventId": 9,
|
||||
"UnlockItemAvatarItemId": 2713,
|
||||
"UnlockItemGiftDropId": 5281,
|
||||
"UnlockItemLockDurationTicks": null,
|
||||
"KeepsakeRooms": [
|
||||
{
|
||||
"KeepsakeRoomId": 212,
|
||||
"RoomId": 1629507034485430449,
|
||||
"KeepsakeRoomListId": 25,
|
||||
"Type": 1,
|
||||
"Order": 0
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 208,
|
||||
"RoomId": 5786874011574248004,
|
||||
"KeepsakeRoomListId": 25,
|
||||
"Type": 1,
|
||||
"Order": 1
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 210,
|
||||
"RoomId": 3207992100513208840,
|
||||
"KeepsakeRoomListId": 25,
|
||||
"Type": 1,
|
||||
"Order": 2
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 209,
|
||||
"RoomId": 3200554354111267322,
|
||||
"KeepsakeRoomListId": 25,
|
||||
"Type": 1,
|
||||
"Order": 3
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 213,
|
||||
"RoomId": 77570566,
|
||||
"KeepsakeRoomListId": 25,
|
||||
"Type": 1,
|
||||
"Order": 4
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 211,
|
||||
"RoomId": 57707466,
|
||||
"KeepsakeRoomListId": 25,
|
||||
"Type": 1,
|
||||
"Order": 5
|
||||
}
|
||||
],
|
||||
"RoomUnlockStartOffsetTicks": 0,
|
||||
"RoomUnlockIntervalTicks": 1728000000000,
|
||||
"RoomUnlockBatchSize": 2,
|
||||
"RoomType": 1,
|
||||
"UnlockItemLockDuration": null,
|
||||
"RoomUnlockStartOffset": "00:00:00",
|
||||
"RoomUnlockInterval": "2.00:00:00"
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomListId": 26,
|
||||
"ProgressionEventId": 9,
|
||||
"UnlockItemAvatarItemId": 2701,
|
||||
"UnlockItemGiftDropId": 5269,
|
||||
"UnlockItemLockDurationTicks": null,
|
||||
"KeepsakeRooms": [
|
||||
{
|
||||
"KeepsakeRoomId": 245,
|
||||
"RoomId": 26723336,
|
||||
"KeepsakeRoomListId": 26,
|
||||
"Type": 1,
|
||||
"Order": 0
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 243,
|
||||
"RoomId": 4496117626402779973,
|
||||
"KeepsakeRoomListId": 26,
|
||||
"Type": 1,
|
||||
"Order": 1
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 219,
|
||||
"RoomId": 64885245733710823,
|
||||
"KeepsakeRoomListId": 26,
|
||||
"Type": 1,
|
||||
"Order": 2
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 217,
|
||||
"RoomId": 66460043,
|
||||
"KeepsakeRoomListId": 26,
|
||||
"Type": 1,
|
||||
"Order": 3
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 221,
|
||||
"RoomId": 9004916221398793266,
|
||||
"KeepsakeRoomListId": 26,
|
||||
"Type": 1,
|
||||
"Order": 4
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 215,
|
||||
"RoomId": 75570087,
|
||||
"KeepsakeRoomListId": 26,
|
||||
"Type": 1,
|
||||
"Order": 5
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 214,
|
||||
"RoomId": 43401202,
|
||||
"KeepsakeRoomListId": 26,
|
||||
"Type": 1,
|
||||
"Order": 6
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 216,
|
||||
"RoomId": 1751471322540793226,
|
||||
"KeepsakeRoomListId": 26,
|
||||
"Type": 1,
|
||||
"Order": 7
|
||||
}
|
||||
],
|
||||
"RoomUnlockStartOffsetTicks": 1728000000000,
|
||||
"RoomUnlockIntervalTicks": 864000000000,
|
||||
"RoomUnlockBatchSize": 1,
|
||||
"RoomType": 1,
|
||||
"UnlockItemLockDuration": null,
|
||||
"RoomUnlockStartOffset": "2.00:00:00",
|
||||
"RoomUnlockInterval": "1.00:00:00"
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomListId": 27,
|
||||
"ProgressionEventId": 9,
|
||||
"UnlockItemAvatarItemId": 2703,
|
||||
"UnlockItemGiftDropId": 5271,
|
||||
"UnlockItemLockDurationTicks": null,
|
||||
"KeepsakeRooms": [
|
||||
{
|
||||
"KeepsakeRoomId": 226,
|
||||
"RoomId": 7576729427803564326,
|
||||
"KeepsakeRoomListId": 27,
|
||||
"Type": 1,
|
||||
"Order": 0
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 258,
|
||||
"RoomId": 31369470,
|
||||
"KeepsakeRoomListId": 27,
|
||||
"Type": 1,
|
||||
"Order": 1
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 229,
|
||||
"RoomId": 70225004,
|
||||
"KeepsakeRoomListId": 27,
|
||||
"Type": 1,
|
||||
"Order": 2
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 230,
|
||||
"RoomId": 8837447252376723733,
|
||||
"KeepsakeRoomListId": 27,
|
||||
"Type": 1,
|
||||
"Order": 3
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 256,
|
||||
"RoomId": 73821809,
|
||||
"KeepsakeRoomListId": 27,
|
||||
"Type": 1,
|
||||
"Order": 4
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 222,
|
||||
"RoomId": 3101788489080959468,
|
||||
"KeepsakeRoomListId": 27,
|
||||
"Type": 1,
|
||||
"Order": 5
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 227,
|
||||
"RoomId": 10266603,
|
||||
"KeepsakeRoomListId": 27,
|
||||
"Type": 1,
|
||||
"Order": 6
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 228,
|
||||
"RoomId": 5792488805990360638,
|
||||
"KeepsakeRoomListId": 27,
|
||||
"Type": 1,
|
||||
"Order": 7
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 257,
|
||||
"RoomId": 8364883655950427728,
|
||||
"KeepsakeRoomListId": 27,
|
||||
"Type": 1,
|
||||
"Order": 8
|
||||
}
|
||||
],
|
||||
"RoomUnlockStartOffsetTicks": 2592000000000,
|
||||
"RoomUnlockIntervalTicks": 2592000000000,
|
||||
"RoomUnlockBatchSize": 3,
|
||||
"RoomType": 1,
|
||||
"UnlockItemLockDuration": null,
|
||||
"RoomUnlockStartOffset": "3.00:00:00",
|
||||
"RoomUnlockInterval": "3.00:00:00"
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomListId": 28,
|
||||
"ProgressionEventId": 9,
|
||||
"UnlockItemAvatarItemId": 2699,
|
||||
"UnlockItemGiftDropId": 5267,
|
||||
"UnlockItemLockDurationTicks": null,
|
||||
"KeepsakeRooms": [
|
||||
{
|
||||
"KeepsakeRoomId": 250,
|
||||
"RoomId": 7021872,
|
||||
"KeepsakeRoomListId": 28,
|
||||
"Type": 1,
|
||||
"Order": 0
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 254,
|
||||
"RoomId": 74897901,
|
||||
"KeepsakeRoomListId": 28,
|
||||
"Type": 1,
|
||||
"Order": 1
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 241,
|
||||
"RoomId": 64936037,
|
||||
"KeepsakeRoomListId": 28,
|
||||
"Type": 1,
|
||||
"Order": 2
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 231,
|
||||
"RoomId": 31518506,
|
||||
"KeepsakeRoomListId": 28,
|
||||
"Type": 1,
|
||||
"Order": 3
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 239,
|
||||
"RoomId": 189633977796607371,
|
||||
"KeepsakeRoomListId": 28,
|
||||
"Type": 1,
|
||||
"Order": 4
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 255,
|
||||
"RoomId": 4708745686835418998,
|
||||
"KeepsakeRoomListId": 28,
|
||||
"Type": 1,
|
||||
"Order": 5
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 237,
|
||||
"RoomId": 5628775642092102621,
|
||||
"KeepsakeRoomListId": 28,
|
||||
"Type": 1,
|
||||
"Order": 6
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 235,
|
||||
"RoomId": 351964245102334138,
|
||||
"KeepsakeRoomListId": 28,
|
||||
"Type": 1,
|
||||
"Order": 7
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 240,
|
||||
"RoomId": 1752507419075074724,
|
||||
"KeepsakeRoomListId": 28,
|
||||
"Type": 1,
|
||||
"Order": 8
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 253,
|
||||
"RoomId": 4747460318795739950,
|
||||
"KeepsakeRoomListId": 28,
|
||||
"Type": 1,
|
||||
"Order": 9
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 238,
|
||||
"RoomId": 47579443,
|
||||
"KeepsakeRoomListId": 28,
|
||||
"Type": 1,
|
||||
"Order": 10
|
||||
}
|
||||
],
|
||||
"RoomUnlockStartOffsetTicks": 0,
|
||||
"RoomUnlockIntervalTicks": 1728000000000,
|
||||
"RoomUnlockBatchSize": 2,
|
||||
"RoomType": 1,
|
||||
"UnlockItemLockDuration": null,
|
||||
"RoomUnlockStartOffset": "00:00:00",
|
||||
"RoomUnlockInterval": "2.00:00:00"
|
||||
}
|
||||
],
|
||||
"StartTime": "2023-03-02T20:33:00Z",
|
||||
"EndTime": "2023-03-15T20:35:00Z",
|
||||
"CollectionEndTime": "2023-03-16T20:35:00Z",
|
||||
"UsesBoost": false,
|
||||
"BoostDailyGameplayMinutesLimit": 0,
|
||||
"BoostXpMultiplier": 0,
|
||||
"PurchasableXpBoostId": "58e9a05b-6106-4d8b-9fce-de4fec6c4d5b",
|
||||
"ActiveExperiment": null,
|
||||
"ChallengesIconImageName": null,
|
||||
"RewardsPipImageName": null,
|
||||
"EventInfoImageName": "1u0mceq0gypn478mqymsmc6es.png",
|
||||
"EventLeaderboardId": null,
|
||||
"InitialCurrencyGrant": 0
|
||||
}
|
||||
+557
@@ -0,0 +1,557 @@
|
||||
{
|
||||
"ProgressionEventId": 6,
|
||||
"Name": "Invasion",
|
||||
"IsEnabled": false,
|
||||
"Rewards": [
|
||||
{
|
||||
"ProgressionEventRewardId": 14,
|
||||
"ProgressionEventId": 6,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "8p6bvkicn08fxm736v7vc6px8.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 60,
|
||||
"RewardIndex": 0,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 15,
|
||||
"ProgressionEventId": 6,
|
||||
"GiftDropId": 4423,
|
||||
"GiftDropIds": [
|
||||
4423
|
||||
],
|
||||
"ImageName": "29d6vkhsoavxvy2fbub3p8623.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 90,
|
||||
"RewardIndex": 1,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 16,
|
||||
"ProgressionEventId": 6,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "9etpy89ul022jlnixh2pz1t81.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 120,
|
||||
"RewardIndex": 2,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 17,
|
||||
"ProgressionEventId": 6,
|
||||
"GiftDropId": 377,
|
||||
"GiftDropIds": [
|
||||
377
|
||||
],
|
||||
"ImageName": "d6y2itbia3t43bcn2rlxjr9g2.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 160,
|
||||
"RewardIndex": 3,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 18,
|
||||
"ProgressionEventId": 6,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "388dhe2inzetzkupstnbfzelf.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 210,
|
||||
"RewardIndex": 4,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 19,
|
||||
"ProgressionEventId": 6,
|
||||
"GiftDropId": 4433,
|
||||
"GiftDropIds": [
|
||||
4433
|
||||
],
|
||||
"ImageName": "05u5fjm5rdakjnoknn43uxjra.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 270,
|
||||
"RewardIndex": 5,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 20,
|
||||
"ProgressionEventId": 6,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "1vgxmpgz6tavxiy389rv89xtn.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 340,
|
||||
"RewardIndex": 6,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 21,
|
||||
"ProgressionEventId": 6,
|
||||
"GiftDropId": 5039,
|
||||
"GiftDropIds": [
|
||||
5039
|
||||
],
|
||||
"ImageName": "960jtbwz6swvytlgschy82g2k.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 420,
|
||||
"RewardIndex": 7,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 22,
|
||||
"ProgressionEventId": 6,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "8gs80hrx6bp617nqc7o65g6s3.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 520,
|
||||
"RewardIndex": 8,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 23,
|
||||
"ProgressionEventId": 6,
|
||||
"GiftDropId": 4433,
|
||||
"GiftDropIds": [
|
||||
4433
|
||||
],
|
||||
"ImageName": "c5zppw23v8jovvc5u356hl8or.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 650,
|
||||
"RewardIndex": 9,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 24,
|
||||
"ProgressionEventId": 6,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "537qmmlviwslvgc5gudhc9wgq.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 810,
|
||||
"RewardIndex": 10,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 25,
|
||||
"ProgressionEventId": 6,
|
||||
"GiftDropId": 3729,
|
||||
"GiftDropIds": [
|
||||
3729
|
||||
],
|
||||
"ImageName": "d4cb6v5ck49ir3jqjhwrzxd92.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1020,
|
||||
"RewardIndex": 11,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 26,
|
||||
"ProgressionEventId": 6,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "9ihssplq503eqyybg1g0hc8n4.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1280,
|
||||
"RewardIndex": 12,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 27,
|
||||
"ProgressionEventId": 6,
|
||||
"GiftDropId": 330,
|
||||
"GiftDropIds": [
|
||||
330
|
||||
],
|
||||
"ImageName": "d1rswffe1ma4i03f88yjztwna.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1610,
|
||||
"RewardIndex": 13,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 28,
|
||||
"ProgressionEventId": 6,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "8ci3t9f4o51uuvr7u140t0ejb.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 2040,
|
||||
"RewardIndex": 14,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 29,
|
||||
"ProgressionEventId": 6,
|
||||
"GiftDropId": 5032,
|
||||
"GiftDropIds": [
|
||||
5032
|
||||
],
|
||||
"ImageName": "1cwj4ceyz3w8q0s7iz7p4euyh.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 2580,
|
||||
"RewardIndex": 15,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 30,
|
||||
"ProgressionEventId": 6,
|
||||
"GiftDropId": 5060,
|
||||
"GiftDropIds": [
|
||||
5060
|
||||
],
|
||||
"ImageName": "5r5wx9zmnpk1v6tj943nj45cs.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 230,
|
||||
"RewardIndex": 16,
|
||||
"IsBonus": true,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
}
|
||||
],
|
||||
"KeepsakeRoomLists": [
|
||||
{
|
||||
"KeepsakeRoomListId": 3,
|
||||
"ProgressionEventId": 6,
|
||||
"UnlockItemAvatarItemId": null,
|
||||
"UnlockItemGiftDropId": null,
|
||||
"UnlockItemLockDurationTicks": null,
|
||||
"KeepsakeRooms": [
|
||||
{
|
||||
"KeepsakeRoomId": 83,
|
||||
"RoomId": 26265683,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 0
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 82,
|
||||
"RoomId": 26003309,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 1
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 94,
|
||||
"RoomId": 41855338,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 2
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 88,
|
||||
"RoomId": 59046620,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 3
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 85,
|
||||
"RoomId": 59058753,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 4
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 81,
|
||||
"RoomId": 32217089,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 5
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 89,
|
||||
"RoomId": 9112061,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 6
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 80,
|
||||
"RoomId": 59100897,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 7
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 95,
|
||||
"RoomId": 32840405,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 8
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 92,
|
||||
"RoomId": 16860104,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 9
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 98,
|
||||
"RoomId": 59070317,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 10
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 91,
|
||||
"RoomId": 78042908,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 11
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 100,
|
||||
"RoomId": 31886274,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 12
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 96,
|
||||
"RoomId": 76714234,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 13
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 90,
|
||||
"RoomId": 5242677,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 14
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 93,
|
||||
"RoomId": 59058035,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 15
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 97,
|
||||
"RoomId": 25982226,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 16
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 77,
|
||||
"RoomId": 59045944,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 17
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 78,
|
||||
"RoomId": 42598725,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 18
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 86,
|
||||
"RoomId": 42146023,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 19
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 75,
|
||||
"RoomId": 54586671,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 20
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 38,
|
||||
"RoomId": 76890764,
|
||||
"KeepsakeRoomListId": 3,
|
||||
"Type": 0,
|
||||
"Order": 21
|
||||
}
|
||||
],
|
||||
"RoomUnlockStartOffsetTicks": 0,
|
||||
"RoomUnlockIntervalTicks": 504000000000,
|
||||
"RoomUnlockBatchSize": 1,
|
||||
"RoomType": 0,
|
||||
"UnlockItemLockDuration": null,
|
||||
"RoomUnlockStartOffset": "00:00:00",
|
||||
"RoomUnlockInterval": "14:00:00"
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomListId": 4,
|
||||
"ProgressionEventId": 6,
|
||||
"UnlockItemAvatarItemId": 2371,
|
||||
"UnlockItemGiftDropId": 4508,
|
||||
"UnlockItemLockDurationTicks": null,
|
||||
"KeepsakeRooms": [
|
||||
{
|
||||
"KeepsakeRoomId": 71,
|
||||
"RoomId": 67219271,
|
||||
"KeepsakeRoomListId": 4,
|
||||
"Type": 1,
|
||||
"Order": 0
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 43,
|
||||
"RoomId": 59106455,
|
||||
"KeepsakeRoomListId": 4,
|
||||
"Type": 1,
|
||||
"Order": 1
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 72,
|
||||
"RoomId": 25251760,
|
||||
"KeepsakeRoomListId": 4,
|
||||
"Type": 1,
|
||||
"Order": 2
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 84,
|
||||
"RoomId": 77993488,
|
||||
"KeepsakeRoomListId": 4,
|
||||
"Type": 1,
|
||||
"Order": 3
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 42,
|
||||
"RoomId": 41885666,
|
||||
"KeepsakeRoomListId": 4,
|
||||
"Type": 1,
|
||||
"Order": 4
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 74,
|
||||
"RoomId": 41854064,
|
||||
"KeepsakeRoomListId": 4,
|
||||
"Type": 1,
|
||||
"Order": 5
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 76,
|
||||
"RoomId": 41892811,
|
||||
"KeepsakeRoomListId": 4,
|
||||
"Type": 1,
|
||||
"Order": 6
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 41,
|
||||
"RoomId": 31382618,
|
||||
"KeepsakeRoomListId": 4,
|
||||
"Type": 1,
|
||||
"Order": 7
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 87,
|
||||
"RoomId": 31370276,
|
||||
"KeepsakeRoomListId": 4,
|
||||
"Type": 1,
|
||||
"Order": 8
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 79,
|
||||
"RoomId": 69004612,
|
||||
"KeepsakeRoomListId": 4,
|
||||
"Type": 1,
|
||||
"Order": 9
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 99,
|
||||
"RoomId": 42201785,
|
||||
"KeepsakeRoomListId": 4,
|
||||
"Type": 1,
|
||||
"Order": 10
|
||||
}
|
||||
],
|
||||
"RoomUnlockStartOffsetTicks": 0,
|
||||
"RoomUnlockIntervalTicks": 1728000000000,
|
||||
"RoomUnlockBatchSize": 2,
|
||||
"RoomType": 1,
|
||||
"UnlockItemLockDuration": null,
|
||||
"RoomUnlockStartOffset": "00:00:00",
|
||||
"RoomUnlockInterval": "2.00:00:00"
|
||||
}
|
||||
],
|
||||
"StartTime": "2023-10-18T20:45:00Z",
|
||||
"EndTime": "2023-11-01T23:00:00Z",
|
||||
"CollectionEndTime": "2023-11-02T23:00:00Z",
|
||||
"UsesBoost": false,
|
||||
"BoostDailyGameplayMinutesLimit": 0,
|
||||
"BoostXpMultiplier": 0,
|
||||
"PurchasableXpBoostId": "9833aa2e-30f8-4fe6-9ba8-54ae3eff5dd8",
|
||||
"ActiveExperiment": null,
|
||||
"ChallengesIconImageName": null,
|
||||
"RewardsPipImageName": null,
|
||||
"EventInfoImageName": "7cbeqeya66819q7jahtlldkax.png",
|
||||
"EventLeaderboardId": null,
|
||||
"InitialCurrencyGrant": 0
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
{
|
||||
"ProgressionEventId": 15,
|
||||
"Name": "Medieval Season",
|
||||
"IsEnabled": true,
|
||||
"Rewards": [
|
||||
{
|
||||
"ProgressionEventRewardId": 184,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 31965,
|
||||
"GiftDropIds": [
|
||||
31965
|
||||
],
|
||||
"ImageName": "119ujjiogkl6o8hvn0bv1e9v8.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 10,
|
||||
"RewardIndex": 0,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 185,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 31964,
|
||||
"GiftDropIds": [
|
||||
31964
|
||||
],
|
||||
"ImageName": "2ekumj2jf1uz8yw8d7gmtnunm.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 10,
|
||||
"RewardIndex": 1,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 186,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 18130,
|
||||
"GiftDropIds": [
|
||||
18130
|
||||
],
|
||||
"ImageName": "01vkyx5jx6em155gbfpdy4fxa.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 20,
|
||||
"RewardIndex": 2,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 187,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 18178,
|
||||
"GiftDropIds": [
|
||||
18178
|
||||
],
|
||||
"ImageName": "4x07bt9riqjsnr8reyyuu1hh8.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 20,
|
||||
"RewardIndex": 3,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 188,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 31966,
|
||||
"GiftDropIds": [
|
||||
31966
|
||||
],
|
||||
"ImageName": "523hupp6etxjopnc8eqrnihks.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 40,
|
||||
"RewardIndex": 4,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 189,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 31965,
|
||||
"GiftDropIds": [
|
||||
31965
|
||||
],
|
||||
"ImageName": "55el0zslgj93g4g0ob2tlaiyx.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 40,
|
||||
"RewardIndex": 5,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 190,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 2010,
|
||||
"GiftDropIds": [
|
||||
2010
|
||||
],
|
||||
"ImageName": "4z1sudgzj0fcvuzh85ygggbbs.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 60,
|
||||
"RewardIndex": 6,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 191,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 18128,
|
||||
"GiftDropIds": [
|
||||
18128
|
||||
],
|
||||
"ImageName": "atoz95u6cuuavzkxx8ahbaqvd.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 60,
|
||||
"RewardIndex": 7,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 196,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 31967,
|
||||
"GiftDropIds": [
|
||||
31967
|
||||
],
|
||||
"ImageName": "230klvvpb0fxmd9514h7ikvl9.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 90,
|
||||
"RewardIndex": 8,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 197,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 31966,
|
||||
"GiftDropIds": [
|
||||
31966
|
||||
],
|
||||
"ImageName": "di0eruhrxnqcqfu8ovjxo02c3.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 90,
|
||||
"RewardIndex": 9,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 198,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 18130,
|
||||
"GiftDropIds": [
|
||||
18130
|
||||
],
|
||||
"ImageName": "22j1uti988vvw686gzcu5ke17.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 130,
|
||||
"RewardIndex": 10,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 199,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 31968,
|
||||
"GiftDropIds": [
|
||||
31968
|
||||
],
|
||||
"ImageName": "bmt82rnj5xrry556ifjo9u29f.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 130,
|
||||
"RewardIndex": 11,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 200,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 15500,
|
||||
"GiftDropIds": [
|
||||
15500
|
||||
],
|
||||
"ImageName": "epdwizupz0rstj2bzod82kmza.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 180,
|
||||
"RewardIndex": 12,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 201,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 31967,
|
||||
"GiftDropIds": [
|
||||
31967
|
||||
],
|
||||
"ImageName": "36t3tpm79wuydbdomdu26alkv.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 180,
|
||||
"RewardIndex": 13,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 202,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 31965,
|
||||
"GiftDropIds": [
|
||||
31965
|
||||
],
|
||||
"ImageName": "6s3w0zksn3wwdbcq1ia77lyz7.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 240,
|
||||
"RewardIndex": 14,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 203,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 18128,
|
||||
"GiftDropIds": [
|
||||
18128
|
||||
],
|
||||
"ImageName": "97dm76eryr3gwtl6ue1zcmacw.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 240,
|
||||
"RewardIndex": 15,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 204,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 15572,
|
||||
"GiftDropIds": [
|
||||
15572
|
||||
],
|
||||
"ImageName": "1cwr07kjp9rvvi6pod9xhuugx.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 300,
|
||||
"RewardIndex": 16,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 205,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 31965,
|
||||
"GiftDropIds": [
|
||||
31965
|
||||
],
|
||||
"ImageName": "4jnib0tx57plpgm6le0ek7c68.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 300,
|
||||
"RewardIndex": 17,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 206,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 31965,
|
||||
"GiftDropIds": [
|
||||
31965
|
||||
],
|
||||
"ImageName": "evymtv1hf5uyibfglhp91ly5l.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 360,
|
||||
"RewardIndex": 18,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 207,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 31969,
|
||||
"GiftDropIds": [
|
||||
31969
|
||||
],
|
||||
"ImageName": "93xulm1o1e5i8hepqfg6i6dld.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 360,
|
||||
"RewardIndex": 19,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 208,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 2010,
|
||||
"GiftDropIds": [
|
||||
2010
|
||||
],
|
||||
"ImageName": "1l9oio4bq2umlhpom71jeka87.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 440,
|
||||
"RewardIndex": 20,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 209,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 31966,
|
||||
"GiftDropIds": [
|
||||
31966
|
||||
],
|
||||
"ImageName": "4qrt06kn0qxikchcsb4xf7cp6.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 440,
|
||||
"RewardIndex": 21,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 210,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 18130,
|
||||
"GiftDropIds": [
|
||||
18130
|
||||
],
|
||||
"ImageName": "5js3ybdcbnkwpaz68huelwanj.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 520,
|
||||
"RewardIndex": 22,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 211,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 18128,
|
||||
"GiftDropIds": [
|
||||
18128
|
||||
],
|
||||
"ImageName": "bok9gvl9mp81pud1xcp1owzum.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 520,
|
||||
"RewardIndex": 23,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 212,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 15558,
|
||||
"GiftDropIds": [
|
||||
15558
|
||||
],
|
||||
"ImageName": "a88y623frl8axhvp1equcqbw0.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 610,
|
||||
"RewardIndex": 24,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 213,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 31966,
|
||||
"GiftDropIds": [
|
||||
31966
|
||||
],
|
||||
"ImageName": "2trla90had5222g1roc7myvys.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 610,
|
||||
"RewardIndex": 25,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 214,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 18130,
|
||||
"GiftDropIds": [
|
||||
18130
|
||||
],
|
||||
"ImageName": "e226ung2j4vwcwhbrvqurenue.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 710,
|
||||
"RewardIndex": 26,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 215,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 17030,
|
||||
"GiftDropIds": [
|
||||
17030
|
||||
],
|
||||
"ImageName": "br79hvrvevfdxjnw30auft6ps.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 710,
|
||||
"RewardIndex": 27,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 216,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 31966,
|
||||
"GiftDropIds": [
|
||||
31966
|
||||
],
|
||||
"ImageName": "02ibg78pact35tmgo17h47r0d.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 810,
|
||||
"RewardIndex": 28,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 217,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 31965,
|
||||
"GiftDropIds": [
|
||||
31965
|
||||
],
|
||||
"ImageName": "bx673w6t4y0sus5lqavuuhw34.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 810,
|
||||
"RewardIndex": 29,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 218,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 18180,
|
||||
"GiftDropIds": [
|
||||
18180
|
||||
],
|
||||
"ImageName": "dmz15b9tuc95xxp4emqt2oemi.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 930,
|
||||
"RewardIndex": 30,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 219,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 18128,
|
||||
"GiftDropIds": [
|
||||
18128
|
||||
],
|
||||
"ImageName": "0qaxg6x1j5k5m75d9gius4mdp.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 930,
|
||||
"RewardIndex": 31,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 220,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 2010,
|
||||
"GiftDropIds": [
|
||||
2010
|
||||
],
|
||||
"ImageName": "9xjv03v8l6bme8dz3t6e3bzsr.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1050,
|
||||
"RewardIndex": 32,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 221,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 31966,
|
||||
"GiftDropIds": [
|
||||
31966
|
||||
],
|
||||
"ImageName": "b6xga15ruxpacniaqcnjnv1er.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1050,
|
||||
"RewardIndex": 33,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 222,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 18130,
|
||||
"GiftDropIds": [
|
||||
18130
|
||||
],
|
||||
"ImageName": "8c2h0r2swf6bpi9671j29drus.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1170,
|
||||
"RewardIndex": 34,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 223,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 18179,
|
||||
"GiftDropIds": [
|
||||
18179
|
||||
],
|
||||
"ImageName": "bj47w995if9qpgwzs3h9b2nzb.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1170,
|
||||
"RewardIndex": 35,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 224,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 31967,
|
||||
"GiftDropIds": [
|
||||
31967
|
||||
],
|
||||
"ImageName": "e2jryr3ypkzni6kkga234jfoi.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1300,
|
||||
"RewardIndex": 36,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 225,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 31967,
|
||||
"GiftDropIds": [
|
||||
31967
|
||||
],
|
||||
"ImageName": "8ks2xl4ce9fz3xbpsp794vmjf.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1300,
|
||||
"RewardIndex": 37,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 226,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 17065,
|
||||
"GiftDropIds": [
|
||||
17065
|
||||
],
|
||||
"ImageName": "3ldt5498un7cv6mt52ybkd7x1.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1440,
|
||||
"RewardIndex": 38,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 227,
|
||||
"ProgressionEventId": 15,
|
||||
"GiftDropId": 17064,
|
||||
"GiftDropIds": [
|
||||
17064
|
||||
],
|
||||
"ImageName": "7x6406x170ca8k6xxn49gpp67.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1440,
|
||||
"RewardIndex": 39,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
}
|
||||
],
|
||||
"KeepsakeRoomLists": [],
|
||||
"StartTime": "2025-08-08T14:00:00Z",
|
||||
"EndTime": "2025-08-25T14:00:00Z",
|
||||
"CollectionEndTime": "2025-08-26T14:00:00Z",
|
||||
"UsesBoost": true,
|
||||
"BoostDailyGameplayMinutesLimit": 1440,
|
||||
"BoostXpMultiplier": 1,
|
||||
"PurchasableXpBoostId": "b86411a0-10c5-4d2b-9228-30e94a00eb68",
|
||||
"ActiveExperiment": null,
|
||||
"ChallengesIconImageName": null,
|
||||
"RewardsPipImageName": null,
|
||||
"EventInfoImageName": "76lkj9zjul69p2jtjwwb3gtyp.png",
|
||||
"EventLeaderboardId": null,
|
||||
"InitialCurrencyGrant": 0
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"ProgressionEventId": 5,
|
||||
"Name": "Progression Event Alpha Repeat",
|
||||
"IsEnabled": false,
|
||||
"Rewards": [
|
||||
{
|
||||
"ProgressionEventRewardId": 8,
|
||||
"ProgressionEventId": 5,
|
||||
"GiftDropId": 3798,
|
||||
"GiftDropIds": [
|
||||
3798
|
||||
],
|
||||
"ImageName": "c6o7fs5n863b70ib8l72db471.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 30,
|
||||
"RewardIndex": 0,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 9,
|
||||
"ProgressionEventId": 5,
|
||||
"GiftDropId": 3779,
|
||||
"GiftDropIds": [
|
||||
3779
|
||||
],
|
||||
"ImageName": "dr989ihctyomlfrq9cvy1m53o.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 60,
|
||||
"RewardIndex": 1,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 10,
|
||||
"ProgressionEventId": 5,
|
||||
"GiftDropId": 3025,
|
||||
"GiftDropIds": [
|
||||
3025
|
||||
],
|
||||
"ImageName": "1kgv7gxn97k59qpd8ulfupg9p.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 90,
|
||||
"RewardIndex": 2,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 11,
|
||||
"ProgressionEventId": 5,
|
||||
"GiftDropId": 4079,
|
||||
"GiftDropIds": [
|
||||
4079
|
||||
],
|
||||
"ImageName": "bg5irppfjfcvspbyg0xupdzzz.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 130,
|
||||
"RewardIndex": 3,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 12,
|
||||
"ProgressionEventId": 5,
|
||||
"GiftDropId": 4078,
|
||||
"GiftDropIds": [
|
||||
4078
|
||||
],
|
||||
"ImageName": "cc5c98ttyl081t6wxadsaovh4.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 180,
|
||||
"RewardIndex": 4,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 13,
|
||||
"ProgressionEventId": 5,
|
||||
"GiftDropId": 3974,
|
||||
"GiftDropIds": [
|
||||
3974
|
||||
],
|
||||
"ImageName": "213ynroo1di0tvm9najsuxwrw.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 250,
|
||||
"RewardIndex": 5,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
}
|
||||
],
|
||||
"KeepsakeRoomLists": [],
|
||||
"StartTime": "2022-09-20T23:00:00Z",
|
||||
"EndTime": "2022-09-23T23:00:00Z",
|
||||
"CollectionEndTime": "2022-09-24T23:00:00Z",
|
||||
"UsesBoost": true,
|
||||
"BoostDailyGameplayMinutesLimit": 20,
|
||||
"BoostXpMultiplier": 3,
|
||||
"PurchasableXpBoostId": null,
|
||||
"ActiveExperiment": "growth_second_progression_event",
|
||||
"ChallengesIconImageName": null,
|
||||
"RewardsPipImageName": null,
|
||||
"EventInfoImageName": null,
|
||||
"EventLeaderboardId": null,
|
||||
"InitialCurrencyGrant": 0
|
||||
}
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
{
|
||||
"ProgressionEventId": 3,
|
||||
"Name": "Rec Room",
|
||||
"IsEnabled": false,
|
||||
"Rewards": [],
|
||||
"KeepsakeRoomLists": [
|
||||
{
|
||||
"KeepsakeRoomListId": 1,
|
||||
"ProgressionEventId": 3,
|
||||
"UnlockItemAvatarItemId": null,
|
||||
"UnlockItemGiftDropId": null,
|
||||
"UnlockItemLockDurationTicks": null,
|
||||
"KeepsakeRooms": [
|
||||
{
|
||||
"KeepsakeRoomId": 6,
|
||||
"RoomId": 41855338,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 0
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 7,
|
||||
"RoomId": 54586671,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 1
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 27,
|
||||
"RoomId": 76714234,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 2
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 13,
|
||||
"RoomId": 9112061,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 3
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 5,
|
||||
"RoomId": 59058753,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 4
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 1,
|
||||
"RoomId": 5242677,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 5
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 26,
|
||||
"RoomId": 59046620,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 6
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 23,
|
||||
"RoomId": 42598725,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 7
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 37,
|
||||
"RoomId": 59100897,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 8
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 12,
|
||||
"RoomId": 59058035,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 9
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 24,
|
||||
"RoomId": 78042908,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 10
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 15,
|
||||
"RoomId": 25982226,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 11
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 3,
|
||||
"RoomId": 59070317,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 12
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 4,
|
||||
"RoomId": 32840405,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 13
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 8,
|
||||
"RoomId": 42146023,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 14
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 14,
|
||||
"RoomId": 32217089,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 15
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 22,
|
||||
"RoomId": 76890764,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 16
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 28,
|
||||
"RoomId": 59045944,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 17
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 25,
|
||||
"RoomId": 26003309,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 18
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 11,
|
||||
"RoomId": 16860104,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 19
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 2,
|
||||
"RoomId": 26265683,
|
||||
"KeepsakeRoomListId": 1,
|
||||
"Type": 0,
|
||||
"Order": 20
|
||||
}
|
||||
],
|
||||
"RoomUnlockStartOffsetTicks": 0,
|
||||
"RoomUnlockIntervalTicks": 0,
|
||||
"RoomUnlockBatchSize": 0,
|
||||
"RoomType": 0,
|
||||
"UnlockItemLockDuration": null,
|
||||
"RoomUnlockStartOffset": "00:00:00",
|
||||
"RoomUnlockInterval": "00:00:00"
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomListId": 2,
|
||||
"ProgressionEventId": 3,
|
||||
"UnlockItemAvatarItemId": null,
|
||||
"UnlockItemGiftDropId": null,
|
||||
"UnlockItemLockDurationTicks": null,
|
||||
"KeepsakeRooms": [
|
||||
{
|
||||
"KeepsakeRoomId": 34,
|
||||
"RoomId": 67219271,
|
||||
"KeepsakeRoomListId": 2,
|
||||
"Type": 1,
|
||||
"Order": 0
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 30,
|
||||
"RoomId": 41885666,
|
||||
"KeepsakeRoomListId": 2,
|
||||
"Type": 1,
|
||||
"Order": 1
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 16,
|
||||
"RoomId": 69004612,
|
||||
"KeepsakeRoomListId": 2,
|
||||
"Type": 1,
|
||||
"Order": 2
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 31,
|
||||
"RoomId": 25251760,
|
||||
"KeepsakeRoomListId": 2,
|
||||
"Type": 1,
|
||||
"Order": 3
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 29,
|
||||
"RoomId": 41892811,
|
||||
"KeepsakeRoomListId": 2,
|
||||
"Type": 1,
|
||||
"Order": 4
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 18,
|
||||
"RoomId": 31382618,
|
||||
"KeepsakeRoomListId": 2,
|
||||
"Type": 1,
|
||||
"Order": 5
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 33,
|
||||
"RoomId": 42201785,
|
||||
"KeepsakeRoomListId": 2,
|
||||
"Type": 1,
|
||||
"Order": 6
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 36,
|
||||
"RoomId": 77993488,
|
||||
"KeepsakeRoomListId": 2,
|
||||
"Type": 1,
|
||||
"Order": 7
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 20,
|
||||
"RoomId": 59106455,
|
||||
"KeepsakeRoomListId": 2,
|
||||
"Type": 1,
|
||||
"Order": 8
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 32,
|
||||
"RoomId": 31370276,
|
||||
"KeepsakeRoomListId": 2,
|
||||
"Type": 1,
|
||||
"Order": 9
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 17,
|
||||
"RoomId": 41854064,
|
||||
"KeepsakeRoomListId": 2,
|
||||
"Type": 1,
|
||||
"Order": 10
|
||||
}
|
||||
],
|
||||
"RoomUnlockStartOffsetTicks": 0,
|
||||
"RoomUnlockIntervalTicks": 0,
|
||||
"RoomUnlockBatchSize": 0,
|
||||
"RoomType": 1,
|
||||
"UnlockItemLockDuration": null,
|
||||
"RoomUnlockStartOffset": "00:00:00",
|
||||
"RoomUnlockInterval": "00:00:00"
|
||||
}
|
||||
],
|
||||
"StartTime": "2023-12-14T00:00:00Z",
|
||||
"EndTime": "2023-12-15T00:00:00Z",
|
||||
"CollectionEndTime": "2023-12-16T00:00:00Z",
|
||||
"UsesBoost": false,
|
||||
"BoostDailyGameplayMinutesLimit": 0,
|
||||
"BoostXpMultiplier": 0,
|
||||
"PurchasableXpBoostId": null,
|
||||
"ActiveExperiment": null,
|
||||
"ChallengesIconImageName": null,
|
||||
"RewardsPipImageName": null,
|
||||
"EventInfoImageName": null,
|
||||
"EventLeaderboardId": null,
|
||||
"InitialCurrencyGrant": 0
|
||||
}
|
||||
+445
@@ -0,0 +1,445 @@
|
||||
{
|
||||
"ProgressionEventId": 12,
|
||||
"Name": "Science Experiment: Restoration",
|
||||
"IsEnabled": true,
|
||||
"Rewards": [
|
||||
{
|
||||
"ProgressionEventRewardId": 66,
|
||||
"ProgressionEventId": 12,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "djfaxfi8a6af5mvtc3qmh3jow.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 20,
|
||||
"RewardIndex": 0,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 67,
|
||||
"ProgressionEventId": 12,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "6fx5ck93714znx3eyldgfxs0j.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 110,
|
||||
"RewardIndex": 1,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 68,
|
||||
"ProgressionEventId": 12,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "2hbq188nsz4wvh8qzbz60ciz4.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 300,
|
||||
"RewardIndex": 2,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 69,
|
||||
"ProgressionEventId": 12,
|
||||
"GiftDropId": 3783,
|
||||
"GiftDropIds": [
|
||||
3783
|
||||
],
|
||||
"ImageName": "2w3tsxe7ocrn4smgkrocmiex1.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 580,
|
||||
"RewardIndex": 3,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 70,
|
||||
"ProgressionEventId": 12,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "5ox4r2qpvqwhxlpsh2fnysdpf.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 950,
|
||||
"RewardIndex": 4,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 71,
|
||||
"ProgressionEventId": 12,
|
||||
"GiftDropId": 4433,
|
||||
"GiftDropIds": [
|
||||
4433
|
||||
],
|
||||
"ImageName": "2is18pivakktvlegkrth0acpw.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1410,
|
||||
"RewardIndex": 5,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 72,
|
||||
"ProgressionEventId": 12,
|
||||
"GiftDropId": 5060,
|
||||
"GiftDropIds": [
|
||||
5060
|
||||
],
|
||||
"ImageName": "2hzczu8kudpizq3sjmmq2klyp.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1960,
|
||||
"RewardIndex": 6,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 73,
|
||||
"ProgressionEventId": 12,
|
||||
"GiftDropId": 5517,
|
||||
"GiftDropIds": [
|
||||
5517
|
||||
],
|
||||
"ImageName": "cb9y4mey6y94xd7tkfxz4znnh.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 2700,
|
||||
"RewardIndex": 7,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 74,
|
||||
"ProgressionEventId": 12,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "e9n6quz84ppvxkvszgaqhgjo1.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 3340,
|
||||
"RewardIndex": 8,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 75,
|
||||
"ProgressionEventId": 12,
|
||||
"GiftDropId": 5060,
|
||||
"GiftDropIds": [
|
||||
5060
|
||||
],
|
||||
"ImageName": "d6ika8z7hn8c245yt0hn0kgiz.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 4130,
|
||||
"RewardIndex": 9,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 76,
|
||||
"ProgressionEventId": 12,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "5ydvmfqzoiicmiuz4tvvraahh.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 4920,
|
||||
"RewardIndex": 10,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 77,
|
||||
"ProgressionEventId": 12,
|
||||
"GiftDropId": 5524,
|
||||
"GiftDropIds": [
|
||||
5524
|
||||
],
|
||||
"ImageName": "37w5i7re60wjsgn4l1yocw425.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 5760,
|
||||
"RewardIndex": 11,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 78,
|
||||
"ProgressionEventId": 12,
|
||||
"GiftDropId": 5060,
|
||||
"GiftDropIds": [
|
||||
5060
|
||||
],
|
||||
"ImageName": "3xbb886g6ylgmhjm3c4ifzpua.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 6550,
|
||||
"RewardIndex": 12,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 81,
|
||||
"ProgressionEventId": 12,
|
||||
"GiftDropId": 5528,
|
||||
"GiftDropIds": [
|
||||
5528
|
||||
],
|
||||
"ImageName": "bvhbfhwqpu5qj9pilb5i9thcl.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 7440,
|
||||
"RewardIndex": 13,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 82,
|
||||
"ProgressionEventId": 12,
|
||||
"GiftDropId": 5060,
|
||||
"GiftDropIds": [
|
||||
5060
|
||||
],
|
||||
"ImageName": "7hyz2xgjisqdimlbzshe6y5v0.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 800,
|
||||
"RewardIndex": 14,
|
||||
"IsBonus": true,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
}
|
||||
],
|
||||
"KeepsakeRoomLists": [
|
||||
{
|
||||
"KeepsakeRoomListId": 31,
|
||||
"ProgressionEventId": 12,
|
||||
"UnlockItemAvatarItemId": null,
|
||||
"UnlockItemGiftDropId": null,
|
||||
"UnlockItemLockDurationTicks": null,
|
||||
"KeepsakeRooms": [
|
||||
{
|
||||
"KeepsakeRoomId": 276,
|
||||
"RoomId": 76890764,
|
||||
"KeepsakeRoomListId": 31,
|
||||
"Type": 0,
|
||||
"Order": 0
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 279,
|
||||
"RoomId": 59058753,
|
||||
"KeepsakeRoomListId": 31,
|
||||
"Type": 0,
|
||||
"Order": 1
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 280,
|
||||
"RoomId": 70225004,
|
||||
"KeepsakeRoomListId": 31,
|
||||
"Type": 0,
|
||||
"Order": 2
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 281,
|
||||
"RoomId": 41922506,
|
||||
"KeepsakeRoomListId": 31,
|
||||
"Type": 0,
|
||||
"Order": 3
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 278,
|
||||
"RoomId": 1063552974137030672,
|
||||
"KeepsakeRoomListId": 31,
|
||||
"Type": 0,
|
||||
"Order": 4
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 277,
|
||||
"RoomId": 8786672571011050982,
|
||||
"KeepsakeRoomListId": 31,
|
||||
"Type": 0,
|
||||
"Order": 5
|
||||
}
|
||||
],
|
||||
"RoomUnlockStartOffsetTicks": 0,
|
||||
"RoomUnlockIntervalTicks": 2556000000000,
|
||||
"RoomUnlockBatchSize": 2,
|
||||
"RoomType": 0,
|
||||
"UnlockItemLockDuration": null,
|
||||
"RoomUnlockStartOffset": "00:00:00",
|
||||
"RoomUnlockInterval": "2.23:00:00"
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomListId": 32,
|
||||
"ProgressionEventId": 12,
|
||||
"UnlockItemAvatarItemId": 2942,
|
||||
"UnlockItemGiftDropId": 5531,
|
||||
"UnlockItemLockDurationTicks": null,
|
||||
"KeepsakeRooms": [
|
||||
{
|
||||
"KeepsakeRoomId": 285,
|
||||
"RoomId": 74897901,
|
||||
"KeepsakeRoomListId": 32,
|
||||
"Type": 1,
|
||||
"Order": 0
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 284,
|
||||
"RoomId": 1752507419075074724,
|
||||
"KeepsakeRoomListId": 32,
|
||||
"Type": 1,
|
||||
"Order": 1
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 282,
|
||||
"RoomId": 41854064,
|
||||
"KeepsakeRoomListId": 32,
|
||||
"Type": 1,
|
||||
"Order": 2
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 283,
|
||||
"RoomId": 42201785,
|
||||
"KeepsakeRoomListId": 32,
|
||||
"Type": 1,
|
||||
"Order": 3
|
||||
}
|
||||
],
|
||||
"RoomUnlockStartOffsetTicks": 0,
|
||||
"RoomUnlockIntervalTicks": 2556000000000,
|
||||
"RoomUnlockBatchSize": 1,
|
||||
"RoomType": 1,
|
||||
"UnlockItemLockDuration": null,
|
||||
"RoomUnlockStartOffset": "00:00:00",
|
||||
"RoomUnlockInterval": "2.23:00:00"
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomListId": 33,
|
||||
"ProgressionEventId": 12,
|
||||
"UnlockItemAvatarItemId": 2936,
|
||||
"UnlockItemGiftDropId": 5525,
|
||||
"UnlockItemLockDurationTicks": null,
|
||||
"KeepsakeRooms": [
|
||||
{
|
||||
"KeepsakeRoomId": 286,
|
||||
"RoomId": 944261190986353689,
|
||||
"KeepsakeRoomListId": 33,
|
||||
"Type": 1,
|
||||
"Order": 0
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 288,
|
||||
"RoomId": 41854320,
|
||||
"KeepsakeRoomListId": 33,
|
||||
"Type": 1,
|
||||
"Order": 1
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 287,
|
||||
"RoomId": 4678168151807803565,
|
||||
"KeepsakeRoomListId": 33,
|
||||
"Type": 1,
|
||||
"Order": 2
|
||||
}
|
||||
],
|
||||
"RoomUnlockStartOffsetTicks": 828000000000,
|
||||
"RoomUnlockIntervalTicks": 2556000000000,
|
||||
"RoomUnlockBatchSize": 3,
|
||||
"RoomType": 1,
|
||||
"UnlockItemLockDuration": null,
|
||||
"RoomUnlockStartOffset": "23:00:00",
|
||||
"RoomUnlockInterval": "2.23:00:00"
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomListId": 34,
|
||||
"ProgressionEventId": 12,
|
||||
"UnlockItemAvatarItemId": 2419,
|
||||
"UnlockItemGiftDropId": 4953,
|
||||
"UnlockItemLockDurationTicks": null,
|
||||
"KeepsakeRooms": [
|
||||
{
|
||||
"KeepsakeRoomId": 289,
|
||||
"RoomId": 8364812036745705361,
|
||||
"KeepsakeRoomListId": 34,
|
||||
"Type": 1,
|
||||
"Order": 0
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 292,
|
||||
"RoomId": 50107153,
|
||||
"KeepsakeRoomListId": 34,
|
||||
"Type": 1,
|
||||
"Order": 1
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 290,
|
||||
"RoomId": 31382618,
|
||||
"KeepsakeRoomListId": 34,
|
||||
"Type": 1,
|
||||
"Order": 2
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 291,
|
||||
"RoomId": 3662988090243921994,
|
||||
"KeepsakeRoomListId": 34,
|
||||
"Type": 1,
|
||||
"Order": 3
|
||||
}
|
||||
],
|
||||
"RoomUnlockStartOffsetTicks": 864000000000,
|
||||
"RoomUnlockIntervalTicks": 1692000000000,
|
||||
"RoomUnlockBatchSize": 1,
|
||||
"RoomType": 1,
|
||||
"UnlockItemLockDuration": null,
|
||||
"RoomUnlockStartOffset": "1.00:00:00",
|
||||
"RoomUnlockInterval": "1.23:00:00"
|
||||
}
|
||||
],
|
||||
"StartTime": "2023-05-17T20:25:00Z",
|
||||
"EndTime": "2023-05-29T20:25:00Z",
|
||||
"CollectionEndTime": "2023-05-30T20:25:00Z",
|
||||
"UsesBoost": false,
|
||||
"BoostDailyGameplayMinutesLimit": 0,
|
||||
"BoostXpMultiplier": 0,
|
||||
"PurchasableXpBoostId": "8869236d-830a-497d-a155-2f4d7ce88c13",
|
||||
"ActiveExperiment": null,
|
||||
"ChallengesIconImageName": null,
|
||||
"RewardsPipImageName": null,
|
||||
"EventInfoImageName": "8z3cjwkqoa24yyu2ha059s28y.jpg",
|
||||
"EventLeaderboardId": null,
|
||||
"InitialCurrencyGrant": 0
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
{
|
||||
"ProgressionEventId": 7,
|
||||
"Name": "Science Party!",
|
||||
"IsEnabled": true,
|
||||
"Rewards": [
|
||||
{
|
||||
"ProgressionEventRewardId": 31,
|
||||
"ProgressionEventId": 7,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "aaed5tx5tan53wnahv00j0dqp.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 60,
|
||||
"RewardIndex": 0,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 32,
|
||||
"ProgressionEventId": 7,
|
||||
"GiftDropId": 5142,
|
||||
"GiftDropIds": [
|
||||
5142
|
||||
],
|
||||
"ImageName": "50hv6aaon37ldw0l9k6ujnyzd.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 100,
|
||||
"RewardIndex": 1,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 33,
|
||||
"ProgressionEventId": 7,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "05xtgqiljov5lqpfmtl0r229h.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 150,
|
||||
"RewardIndex": 2,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 34,
|
||||
"ProgressionEventId": 7,
|
||||
"GiftDropId": 3788,
|
||||
"GiftDropIds": [
|
||||
3788
|
||||
],
|
||||
"ImageName": "07t4xzjqh8wsz34qzgau846ie.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 210,
|
||||
"RewardIndex": 3,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 35,
|
||||
"ProgressionEventId": 7,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "8unssdi33pepexzci1fa7ekzc.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 290,
|
||||
"RewardIndex": 4,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 36,
|
||||
"ProgressionEventId": 7,
|
||||
"GiftDropId": 5142,
|
||||
"GiftDropIds": [
|
||||
5142
|
||||
],
|
||||
"ImageName": "bzalktli2kkbr5o0l8hwc85bp.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 390,
|
||||
"RewardIndex": 5,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 37,
|
||||
"ProgressionEventId": 7,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "cynkckele9webz9vxtpb4ub63.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 510,
|
||||
"RewardIndex": 6,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 38,
|
||||
"ProgressionEventId": 7,
|
||||
"GiftDropId": 5034,
|
||||
"GiftDropIds": [
|
||||
5034
|
||||
],
|
||||
"ImageName": "1u4rcnhwj45z3jkia5gspofb2.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 660,
|
||||
"RewardIndex": 7,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 39,
|
||||
"ProgressionEventId": 7,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "4rofrk3908ubai7h6wl8k8mc3.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 840,
|
||||
"RewardIndex": 8,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 40,
|
||||
"ProgressionEventId": 7,
|
||||
"GiftDropId": 5142,
|
||||
"GiftDropIds": [
|
||||
5142
|
||||
],
|
||||
"ImageName": "bn4b3ydufhuqd2kqu0ejhl8d3.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1060,
|
||||
"RewardIndex": 9,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 41,
|
||||
"ProgressionEventId": 7,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "b7263qy82jdh6nkxteqy3j7wl.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1320,
|
||||
"RewardIndex": 10,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 42,
|
||||
"ProgressionEventId": 7,
|
||||
"GiftDropId": 3889,
|
||||
"GiftDropIds": [
|
||||
3889
|
||||
],
|
||||
"ImageName": "ea48ip5d566wyomm3vr76n8v4.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1620,
|
||||
"RewardIndex": 11,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 43,
|
||||
"ProgressionEventId": 7,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "6vvnks7ggj3ml5368tuxcmt3h.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1970,
|
||||
"RewardIndex": 12,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 44,
|
||||
"ProgressionEventId": 7,
|
||||
"GiftDropId": 4959,
|
||||
"GiftDropIds": [
|
||||
4959
|
||||
],
|
||||
"ImageName": "ag5yesrz6z9zom62u84272srf.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 2370,
|
||||
"RewardIndex": 13,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 45,
|
||||
"ProgressionEventId": 7,
|
||||
"GiftDropId": 4965,
|
||||
"GiftDropIds": [
|
||||
4965
|
||||
],
|
||||
"ImageName": "2bo973tl5fkp76pslpxd7myp2.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 2820,
|
||||
"RewardIndex": 14,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 46,
|
||||
"ProgressionEventId": 7,
|
||||
"GiftDropId": 5106,
|
||||
"GiftDropIds": [
|
||||
5106
|
||||
],
|
||||
"ImageName": "5g7k91zycq4tcunlmhj277162.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 3320,
|
||||
"RewardIndex": 15,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 47,
|
||||
"ProgressionEventId": 7,
|
||||
"GiftDropId": 5060,
|
||||
"GiftDropIds": [
|
||||
5060
|
||||
],
|
||||
"ImageName": "7kj6usg7wfr522j6gc3yqudej.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 240,
|
||||
"RewardIndex": 16,
|
||||
"IsBonus": true,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
}
|
||||
],
|
||||
"KeepsakeRoomLists": [
|
||||
{
|
||||
"KeepsakeRoomListId": 21,
|
||||
"ProgressionEventId": 7,
|
||||
"UnlockItemAvatarItemId": null,
|
||||
"UnlockItemGiftDropId": null,
|
||||
"UnlockItemLockDurationTicks": null,
|
||||
"KeepsakeRooms": [
|
||||
{
|
||||
"KeepsakeRoomId": 140,
|
||||
"RoomId": 31709424,
|
||||
"KeepsakeRoomListId": 21,
|
||||
"Type": 0,
|
||||
"Order": 0
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 102,
|
||||
"RoomId": 2330357920977922697,
|
||||
"KeepsakeRoomListId": 21,
|
||||
"Type": 0,
|
||||
"Order": 1
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 109,
|
||||
"RoomId": 2748754362604835048,
|
||||
"KeepsakeRoomListId": 21,
|
||||
"Type": 0,
|
||||
"Order": 2
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 136,
|
||||
"RoomId": 57228091,
|
||||
"KeepsakeRoomListId": 21,
|
||||
"Type": 0,
|
||||
"Order": 3
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 106,
|
||||
"RoomId": 77747629,
|
||||
"KeepsakeRoomListId": 21,
|
||||
"Type": 0,
|
||||
"Order": 4
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 139,
|
||||
"RoomId": 76890764,
|
||||
"KeepsakeRoomListId": 21,
|
||||
"Type": 0,
|
||||
"Order": 5
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 133,
|
||||
"RoomId": 4163663717250077171,
|
||||
"KeepsakeRoomListId": 21,
|
||||
"Type": 0,
|
||||
"Order": 6
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 112,
|
||||
"RoomId": 7638707323349236913,
|
||||
"KeepsakeRoomListId": 21,
|
||||
"Type": 0,
|
||||
"Order": 7
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 107,
|
||||
"RoomId": 25952571,
|
||||
"KeepsakeRoomListId": 21,
|
||||
"Type": 0,
|
||||
"Order": 8
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 115,
|
||||
"RoomId": 70047190,
|
||||
"KeepsakeRoomListId": 21,
|
||||
"Type": 0,
|
||||
"Order": 9
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 114,
|
||||
"RoomId": 36754794,
|
||||
"KeepsakeRoomListId": 21,
|
||||
"Type": 0,
|
||||
"Order": 10
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 111,
|
||||
"RoomId": 3981920779437373918,
|
||||
"KeepsakeRoomListId": 21,
|
||||
"Type": 0,
|
||||
"Order": 11
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 103,
|
||||
"RoomId": 41873233,
|
||||
"KeepsakeRoomListId": 21,
|
||||
"Type": 0,
|
||||
"Order": 12
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 134,
|
||||
"RoomId": 35221854,
|
||||
"KeepsakeRoomListId": 21,
|
||||
"Type": 0,
|
||||
"Order": 13
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 116,
|
||||
"RoomId": 68171853,
|
||||
"KeepsakeRoomListId": 21,
|
||||
"Type": 0,
|
||||
"Order": 14
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 104,
|
||||
"RoomId": 64386353,
|
||||
"KeepsakeRoomListId": 21,
|
||||
"Type": 0,
|
||||
"Order": 15
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 132,
|
||||
"RoomId": 37711570,
|
||||
"KeepsakeRoomListId": 21,
|
||||
"Type": 0,
|
||||
"Order": 16
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 101,
|
||||
"RoomId": 17830076,
|
||||
"KeepsakeRoomListId": 21,
|
||||
"Type": 0,
|
||||
"Order": 17
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 137,
|
||||
"RoomId": 52815862,
|
||||
"KeepsakeRoomListId": 21,
|
||||
"Type": 0,
|
||||
"Order": 18
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 113,
|
||||
"RoomId": 21478010,
|
||||
"KeepsakeRoomListId": 21,
|
||||
"Type": 0,
|
||||
"Order": 19
|
||||
}
|
||||
],
|
||||
"RoomUnlockStartOffsetTicks": 0,
|
||||
"RoomUnlockIntervalTicks": 1728000000000,
|
||||
"RoomUnlockBatchSize": 3,
|
||||
"RoomType": 0,
|
||||
"UnlockItemLockDuration": null,
|
||||
"RoomUnlockStartOffset": "00:00:00",
|
||||
"RoomUnlockInterval": "2.00:00:00"
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomListId": 22,
|
||||
"ProgressionEventId": 7,
|
||||
"UnlockItemAvatarItemId": 2554,
|
||||
"UnlockItemGiftDropId": 5108,
|
||||
"UnlockItemLockDurationTicks": null,
|
||||
"KeepsakeRooms": [
|
||||
{
|
||||
"KeepsakeRoomId": 127,
|
||||
"RoomId": 9473003,
|
||||
"KeepsakeRoomListId": 22,
|
||||
"Type": 1,
|
||||
"Order": 0
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 123,
|
||||
"RoomId": 50107153,
|
||||
"KeepsakeRoomListId": 22,
|
||||
"Type": 1,
|
||||
"Order": 1
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 128,
|
||||
"RoomId": 8971419584305936263,
|
||||
"KeepsakeRoomListId": 22,
|
||||
"Type": 1,
|
||||
"Order": 2
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 122,
|
||||
"RoomId": 2402812124088696720,
|
||||
"KeepsakeRoomListId": 22,
|
||||
"Type": 1,
|
||||
"Order": 3
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 130,
|
||||
"RoomId": 57707466,
|
||||
"KeepsakeRoomListId": 22,
|
||||
"Type": 1,
|
||||
"Order": 4
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 118,
|
||||
"RoomId": 21582210,
|
||||
"KeepsakeRoomListId": 22,
|
||||
"Type": 1,
|
||||
"Order": 5
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 117,
|
||||
"RoomId": 4708745686835418998,
|
||||
"KeepsakeRoomListId": 22,
|
||||
"Type": 1,
|
||||
"Order": 6
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 135,
|
||||
"RoomId": 789919,
|
||||
"KeepsakeRoomListId": 22,
|
||||
"Type": 1,
|
||||
"Order": 7
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 138,
|
||||
"RoomId": 31422335,
|
||||
"KeepsakeRoomListId": 22,
|
||||
"Type": 1,
|
||||
"Order": 8
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 120,
|
||||
"RoomId": 21911777,
|
||||
"KeepsakeRoomListId": 22,
|
||||
"Type": 1,
|
||||
"Order": 9
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 124,
|
||||
"RoomId": 6005761176800757444,
|
||||
"KeepsakeRoomListId": 22,
|
||||
"Type": 1,
|
||||
"Order": 10
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 126,
|
||||
"RoomId": 72145061,
|
||||
"KeepsakeRoomListId": 22,
|
||||
"Type": 1,
|
||||
"Order": 11
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 119,
|
||||
"RoomId": 42288509,
|
||||
"KeepsakeRoomListId": 22,
|
||||
"Type": 1,
|
||||
"Order": 12
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 121,
|
||||
"RoomId": 11034150,
|
||||
"KeepsakeRoomListId": 22,
|
||||
"Type": 1,
|
||||
"Order": 13
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 131,
|
||||
"RoomId": 2134942440841839507,
|
||||
"KeepsakeRoomListId": 22,
|
||||
"Type": 1,
|
||||
"Order": 14
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 125,
|
||||
"RoomId": 649924194922273060,
|
||||
"KeepsakeRoomListId": 22,
|
||||
"Type": 1,
|
||||
"Order": 15
|
||||
}
|
||||
],
|
||||
"RoomUnlockStartOffsetTicks": 0,
|
||||
"RoomUnlockIntervalTicks": 1728000000000,
|
||||
"RoomUnlockBatchSize": 3,
|
||||
"RoomType": 1,
|
||||
"UnlockItemLockDuration": null,
|
||||
"RoomUnlockStartOffset": "00:00:00",
|
||||
"RoomUnlockInterval": "2.00:00:00"
|
||||
}
|
||||
],
|
||||
"StartTime": "2022-12-21T18:35:00Z",
|
||||
"EndTime": "2023-01-03T18:35:00Z",
|
||||
"CollectionEndTime": "2023-01-04T18:35:00Z",
|
||||
"UsesBoost": false,
|
||||
"BoostDailyGameplayMinutesLimit": 0,
|
||||
"BoostXpMultiplier": 0,
|
||||
"PurchasableXpBoostId": "5c0a2aa2-f419-49cd-abf9-d4614c3e5036",
|
||||
"ActiveExperiment": null,
|
||||
"ChallengesIconImageName": null,
|
||||
"RewardsPipImageName": null,
|
||||
"EventInfoImageName": "a5cscydpx5x2i6lphnsv5xfk6.jpg",
|
||||
"EventLeaderboardId": null,
|
||||
"InitialCurrencyGrant": 0
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
{
|
||||
"ProgressionEventId": 14,
|
||||
"Name": "Summer Party",
|
||||
"IsEnabled": false,
|
||||
"Rewards": [
|
||||
{
|
||||
"ProgressionEventRewardId": 144,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 3989,
|
||||
"GiftDropIds": [
|
||||
3989
|
||||
],
|
||||
"ImageName": "8t69rx7il3qacste29a6obz5w.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 10,
|
||||
"RewardIndex": 0,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 145,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17008,
|
||||
"GiftDropIds": [
|
||||
17008
|
||||
],
|
||||
"ImageName": "a5hfoi04rs8n7vaflsb0upy0n.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 10,
|
||||
"RewardIndex": 1,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 146,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 14274,
|
||||
"GiftDropIds": [
|
||||
14274
|
||||
],
|
||||
"ImageName": "dyu1yu4zip5sae8q5gi5l72pv.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 20,
|
||||
"RewardIndex": 2,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 147,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17026,
|
||||
"GiftDropIds": [
|
||||
17026
|
||||
],
|
||||
"ImageName": "0hygq3q4gyaex5c3l7peqoj6a.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 20,
|
||||
"RewardIndex": 3,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 148,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17028,
|
||||
"GiftDropIds": [
|
||||
17028
|
||||
],
|
||||
"ImageName": "b7oxbidp1dcm03bt4pwxr8ur7.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 40,
|
||||
"RewardIndex": 4,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 149,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 14274,
|
||||
"GiftDropIds": [
|
||||
14274
|
||||
],
|
||||
"ImageName": "9650alp4fzdx3vn6lyy13mtg3.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 40,
|
||||
"RewardIndex": 5,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 150,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17083,
|
||||
"GiftDropIds": [
|
||||
17083
|
||||
],
|
||||
"ImageName": "5yhva1w958hknw1vn7pt6ms6n.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 60,
|
||||
"RewardIndex": 6,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 151,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17026,
|
||||
"GiftDropIds": [
|
||||
17026
|
||||
],
|
||||
"ImageName": "61cdhwhloej6zy5ypdmogt248.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 60,
|
||||
"RewardIndex": 7,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 152,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17024,
|
||||
"GiftDropIds": [
|
||||
17024
|
||||
],
|
||||
"ImageName": "c6u9av1tjoeufnjpov3weoe2j.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 90,
|
||||
"RewardIndex": 8,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 153,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17078,
|
||||
"GiftDropIds": [
|
||||
17078
|
||||
],
|
||||
"ImageName": "734oescsnasxod5lj0bkp262i.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 90,
|
||||
"RewardIndex": 9,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 154,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17025,
|
||||
"GiftDropIds": [
|
||||
17025
|
||||
],
|
||||
"ImageName": "8g5gzg65fpt54tlgnlpebnqb7.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 130,
|
||||
"RewardIndex": 10,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 155,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 14274,
|
||||
"GiftDropIds": [
|
||||
14274
|
||||
],
|
||||
"ImageName": "33qnr08uznr13tvua2rkybwaa.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 130,
|
||||
"RewardIndex": 11,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 156,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17058,
|
||||
"GiftDropIds": [
|
||||
17058
|
||||
],
|
||||
"ImageName": "aj7sxyq6u7kch8v4grh1ja5w9.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 180,
|
||||
"RewardIndex": 12,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 157,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17030,
|
||||
"GiftDropIds": [
|
||||
17030
|
||||
],
|
||||
"ImageName": "cjz3c14kgtz96cmckpncnifn6.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 180,
|
||||
"RewardIndex": 13,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 158,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 14274,
|
||||
"GiftDropIds": [
|
||||
14274
|
||||
],
|
||||
"ImageName": "6uu6py0qs0gax9cvachj6910j.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 240,
|
||||
"RewardIndex": 14,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 159,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17079,
|
||||
"GiftDropIds": [
|
||||
17079
|
||||
],
|
||||
"ImageName": "2gq9xiv5ojy7682mc9xzld42p.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 240,
|
||||
"RewardIndex": 15,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 160,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17028,
|
||||
"GiftDropIds": [
|
||||
17028
|
||||
],
|
||||
"ImageName": "1fmvxbij1e3z4rsax1xaiejn8.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 300,
|
||||
"RewardIndex": 16,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 161,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17026,
|
||||
"GiftDropIds": [
|
||||
17026
|
||||
],
|
||||
"ImageName": "380z3zyut971wvwrzz8pj1z5b.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 300,
|
||||
"RewardIndex": 17,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 162,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17061,
|
||||
"GiftDropIds": [
|
||||
17061
|
||||
],
|
||||
"ImageName": "2gpauvs1hseg7ptr5pcd09za3.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 360,
|
||||
"RewardIndex": 18,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 163,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17081,
|
||||
"GiftDropIds": [
|
||||
17081
|
||||
],
|
||||
"ImageName": "6obtoj80pjn1g8cizwdbvxvxd.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 360,
|
||||
"RewardIndex": 19,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 164,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 3989,
|
||||
"GiftDropIds": [
|
||||
3989
|
||||
],
|
||||
"ImageName": "c04l6nmz0z69jn6dfjfptcsi6.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 440,
|
||||
"RewardIndex": 20,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 165,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17030,
|
||||
"GiftDropIds": [
|
||||
17030
|
||||
],
|
||||
"ImageName": "035iqb84hbeo3hp0i9p9ov6ox.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 440,
|
||||
"RewardIndex": 21,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 166,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17059,
|
||||
"GiftDropIds": [
|
||||
17059
|
||||
],
|
||||
"ImageName": "3oodo9mhvy0kdac30tnzpp2wo.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 520,
|
||||
"RewardIndex": 22,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 167,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17080,
|
||||
"GiftDropIds": [
|
||||
17080
|
||||
],
|
||||
"ImageName": "f1jj5jvro9pw83kz9qrqhl3zq.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 520,
|
||||
"RewardIndex": 23,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 168,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 3989,
|
||||
"GiftDropIds": [
|
||||
3989
|
||||
],
|
||||
"ImageName": "1yti0qsf9qp1a0gok2lve1tw8.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 610,
|
||||
"RewardIndex": 24,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 169,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17026,
|
||||
"GiftDropIds": [
|
||||
17026
|
||||
],
|
||||
"ImageName": "0osnuz105rvfe3bm6ktubtvjw.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 610,
|
||||
"RewardIndex": 25,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 170,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17060,
|
||||
"GiftDropIds": [
|
||||
17060
|
||||
],
|
||||
"ImageName": "1a3ehh1lt5u15dbsp7djj68y3.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 710,
|
||||
"RewardIndex": 26,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 171,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17082,
|
||||
"GiftDropIds": [
|
||||
17082
|
||||
],
|
||||
"ImageName": "36gvdi900qbrcvvugg4hqsncm.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 710,
|
||||
"RewardIndex": 27,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 172,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17024,
|
||||
"GiftDropIds": [
|
||||
17024
|
||||
],
|
||||
"ImageName": "dl5a8wlvl46sd20ujwkfqy6vs.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 810,
|
||||
"RewardIndex": 28,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 173,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 14274,
|
||||
"GiftDropIds": [
|
||||
14274
|
||||
],
|
||||
"ImageName": "0s6pdag7186jrtozh4i8blgog.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 810,
|
||||
"RewardIndex": 29,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 174,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17028,
|
||||
"GiftDropIds": [
|
||||
17028
|
||||
],
|
||||
"ImageName": "bbtkhdvgh0akznofr9et71jn2.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 930,
|
||||
"RewardIndex": 30,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 175,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17026,
|
||||
"GiftDropIds": [
|
||||
17026
|
||||
],
|
||||
"ImageName": "9qjbcxizpxh8wv26ka1mkbfz0.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 930,
|
||||
"RewardIndex": 31,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 176,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17024,
|
||||
"GiftDropIds": [
|
||||
17024
|
||||
],
|
||||
"ImageName": "d41623xsr285d4ico556zx95f.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1050,
|
||||
"RewardIndex": 32,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 177,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 14274,
|
||||
"GiftDropIds": [
|
||||
14274
|
||||
],
|
||||
"ImageName": "bdq4siuzhyxnc0ow0elurdu77.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1050,
|
||||
"RewardIndex": 33,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 178,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17025,
|
||||
"GiftDropIds": [
|
||||
17025
|
||||
],
|
||||
"ImageName": "9if9e7u9k24sfys7ui1mqovmx.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1170,
|
||||
"RewardIndex": 34,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 179,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17028,
|
||||
"GiftDropIds": [
|
||||
17028
|
||||
],
|
||||
"ImageName": "2zja5d03bdsc6gbwc1ymgl7m3.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1170,
|
||||
"RewardIndex": 35,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 180,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17026,
|
||||
"GiftDropIds": [
|
||||
17026
|
||||
],
|
||||
"ImageName": "80nsy4tf5ysrrxklukvn2pe2i.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1300,
|
||||
"RewardIndex": 36,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 181,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17030,
|
||||
"GiftDropIds": [
|
||||
17030
|
||||
],
|
||||
"ImageName": "7j3oshdr6u0rck15coy2p0l7j.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1300,
|
||||
"RewardIndex": 37,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 182,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17042,
|
||||
"GiftDropIds": [
|
||||
17042
|
||||
],
|
||||
"ImageName": "7jtl3d7xflfyhofzstw7jg9og.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1440,
|
||||
"RewardIndex": 38,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 183,
|
||||
"ProgressionEventId": 14,
|
||||
"GiftDropId": 17041,
|
||||
"GiftDropIds": [
|
||||
17041
|
||||
],
|
||||
"ImageName": "dtnlvtn5z24lbskhx6jmzrgy7.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1440,
|
||||
"RewardIndex": 39,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": true,
|
||||
"MaxGlobalCollections": null
|
||||
}
|
||||
],
|
||||
"KeepsakeRoomLists": [],
|
||||
"StartTime": "2025-07-04T00:00:00Z",
|
||||
"EndTime": "2025-07-21T00:00:00Z",
|
||||
"CollectionEndTime": "2025-07-22T00:00:00Z",
|
||||
"UsesBoost": true,
|
||||
"BoostDailyGameplayMinutesLimit": 1440,
|
||||
"BoostXpMultiplier": 1,
|
||||
"PurchasableXpBoostId": "b86411a0-10c5-4d2b-9228-30e94a00eb68",
|
||||
"ActiveExperiment": null,
|
||||
"ChallengesIconImageName": null,
|
||||
"RewardsPipImageName": null,
|
||||
"EventInfoImageName": "drimdw4wgoyxhywh87v8ul24o.png",
|
||||
"EventLeaderboardId": null,
|
||||
"InitialCurrencyGrant": 0
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
{
|
||||
"ProgressionEventId": 16,
|
||||
"Name": "Treat Collection Challenge",
|
||||
"IsEnabled": true,
|
||||
"Rewards": [
|
||||
{
|
||||
"ProgressionEventRewardId": 241,
|
||||
"ProgressionEventId": 16,
|
||||
"GiftDropId": 33148,
|
||||
"GiftDropIds": [
|
||||
33148
|
||||
],
|
||||
"ImageName": "5u8mhj7w2qpr1f6gv1i2j9agh.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 100,
|
||||
"RewardIndex": 0,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 242,
|
||||
"ProgressionEventId": 16,
|
||||
"GiftDropId": 33274,
|
||||
"GiftDropIds": [
|
||||
33274
|
||||
],
|
||||
"ImageName": "524wcausenuws6tcy01f5kfj3.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 575,
|
||||
"RewardIndex": 1,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 243,
|
||||
"ProgressionEventId": 16,
|
||||
"GiftDropId": 33282,
|
||||
"GiftDropIds": [
|
||||
33282
|
||||
],
|
||||
"ImageName": "0wr3qcgxfrotusbxasn4xqxll.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1150,
|
||||
"RewardIndex": 2,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 244,
|
||||
"ProgressionEventId": 16,
|
||||
"GiftDropId": 33242,
|
||||
"GiftDropIds": [
|
||||
33242
|
||||
],
|
||||
"ImageName": "7ccyt74z6qe1a3gyhvgpajx6q.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 1600,
|
||||
"RewardIndex": 3,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 245,
|
||||
"ProgressionEventId": 16,
|
||||
"GiftDropId": 33252,
|
||||
"GiftDropIds": [
|
||||
33252
|
||||
],
|
||||
"ImageName": "9oufkpwzzblczfxvkhm42qz8q.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 2000,
|
||||
"RewardIndex": 4,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": null
|
||||
},
|
||||
{
|
||||
"ProgressionEventRewardId": 247,
|
||||
"ProgressionEventId": 16,
|
||||
"GiftDropId": 33237,
|
||||
"GiftDropIds": [
|
||||
33237,
|
||||
33238,
|
||||
33239
|
||||
],
|
||||
"ImageName": "e416lpu3mlynh1h3ace7bb66k.png",
|
||||
"ImageStream": null,
|
||||
"ImageContentType": null,
|
||||
"Xp": 2000,
|
||||
"RewardIndex": 5,
|
||||
"IsBonus": false,
|
||||
"IsRRPlusExclusive": false,
|
||||
"MaxGlobalCollections": 100
|
||||
}
|
||||
],
|
||||
"KeepsakeRoomLists": [
|
||||
{
|
||||
"KeepsakeRoomListId": 35,
|
||||
"ProgressionEventId": 16,
|
||||
"UnlockItemAvatarItemId": null,
|
||||
"UnlockItemGiftDropId": null,
|
||||
"UnlockItemLockDurationTicks": null,
|
||||
"KeepsakeRooms": [
|
||||
{
|
||||
"KeepsakeRoomId": 308,
|
||||
"RoomId": 8055432965292997844,
|
||||
"KeepsakeRoomListId": 35,
|
||||
"Type": 0,
|
||||
"Order": 0
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 303,
|
||||
"RoomId": 8623922633989916245,
|
||||
"KeepsakeRoomListId": 35,
|
||||
"Type": 0,
|
||||
"Order": 1
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 296,
|
||||
"RoomId": 2720689679841941691,
|
||||
"KeepsakeRoomListId": 35,
|
||||
"Type": 0,
|
||||
"Order": 2
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 306,
|
||||
"RoomId": 1198369430637700542,
|
||||
"KeepsakeRoomListId": 35,
|
||||
"Type": 0,
|
||||
"Order": 3
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 305,
|
||||
"RoomId": 5060281615610518191,
|
||||
"KeepsakeRoomListId": 35,
|
||||
"Type": 0,
|
||||
"Order": 4
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 299,
|
||||
"RoomId": 5664046151556621084,
|
||||
"KeepsakeRoomListId": 35,
|
||||
"Type": 0,
|
||||
"Order": 5
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 302,
|
||||
"RoomId": 3579409834391380003,
|
||||
"KeepsakeRoomListId": 35,
|
||||
"Type": 0,
|
||||
"Order": 6
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 300,
|
||||
"RoomId": 7430395197140819449,
|
||||
"KeepsakeRoomListId": 35,
|
||||
"Type": 0,
|
||||
"Order": 7
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 309,
|
||||
"RoomId": 170126,
|
||||
"KeepsakeRoomListId": 35,
|
||||
"Type": 0,
|
||||
"Order": 8
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 297,
|
||||
"RoomId": 5988747458686605260,
|
||||
"KeepsakeRoomListId": 35,
|
||||
"Type": 0,
|
||||
"Order": 9
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 301,
|
||||
"RoomId": 8323646838241355522,
|
||||
"KeepsakeRoomListId": 35,
|
||||
"Type": 0,
|
||||
"Order": 10
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 298,
|
||||
"RoomId": 4055690517114419831,
|
||||
"KeepsakeRoomListId": 35,
|
||||
"Type": 0,
|
||||
"Order": 11
|
||||
},
|
||||
{
|
||||
"KeepsakeRoomId": 304,
|
||||
"RoomId": 1408763564617909051,
|
||||
"KeepsakeRoomListId": 35,
|
||||
"Type": 0,
|
||||
"Order": 12
|
||||
}
|
||||
],
|
||||
"RoomUnlockStartOffsetTicks": 10000000,
|
||||
"RoomUnlockIntervalTicks": 10000000,
|
||||
"RoomUnlockBatchSize": 100,
|
||||
"RoomType": 0,
|
||||
"UnlockItemLockDuration": null,
|
||||
"RoomUnlockStartOffset": "00:00:01",
|
||||
"RoomUnlockInterval": "00:00:01"
|
||||
}
|
||||
],
|
||||
"StartTime": "2025-10-17T18:00:00Z",
|
||||
"EndTime": "2025-10-23T18:00:00Z",
|
||||
"CollectionEndTime": "2025-10-24T18:00:00Z",
|
||||
"UsesBoost": false,
|
||||
"BoostDailyGameplayMinutesLimit": 0,
|
||||
"BoostXpMultiplier": 0,
|
||||
"PurchasableXpBoostId": "af4c4b42-7d8b-4084-9e34-66d6da665f8f",
|
||||
"ActiveExperiment": "liveops_q42025_rectober_treats",
|
||||
"ChallengesIconImageName": null,
|
||||
"RewardsPipImageName": null,
|
||||
"EventInfoImageName": "485daw4zpr3dwyhrayusrr5hv.png",
|
||||
"EventLeaderboardId": null,
|
||||
"InitialCurrencyGrant": 0
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"ProgressionEventId": 17,
|
||||
"Name": "Tricks Challenge",
|
||||
"IsEnabled": true,
|
||||
"Rewards": [],
|
||||
"KeepsakeRoomLists": [],
|
||||
"StartTime": "2025-10-29T18:00:00Z",
|
||||
"EndTime": "2025-11-03T03:00:00Z",
|
||||
"CollectionEndTime": "2025-11-04T03:00:00Z",
|
||||
"UsesBoost": false,
|
||||
"BoostDailyGameplayMinutesLimit": 0,
|
||||
"BoostXpMultiplier": 0,
|
||||
"PurchasableXpBoostId": "6b162910-f07d-409d-bae8-be4854358059",
|
||||
"ActiveExperiment": "liveops_q42025_rectober_tricks",
|
||||
"ChallengesIconImageName": "15g8dw5os81x3wyjp1fcq20ah.png",
|
||||
"RewardsPipImageName": "0ibvu6e7i67j4cxkakbif3hwr.png",
|
||||
"EventInfoImageName": "ew5owqjujje8a4cloy6lifzqh.png",
|
||||
"EventLeaderboardId": 17,
|
||||
"InitialCurrencyGrant": 275
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"ProgressionEventId": 18,
|
||||
"Name": "Yetis Challenge",
|
||||
"IsEnabled": false,
|
||||
"Rewards": [],
|
||||
"KeepsakeRoomLists": [],
|
||||
"StartTime": "2025-12-03T19:00:00Z",
|
||||
"EndTime": "2025-12-08T03:00:00Z",
|
||||
"CollectionEndTime": "2025-12-09T03:00:00Z",
|
||||
"UsesBoost": false,
|
||||
"BoostDailyGameplayMinutesLimit": 0,
|
||||
"BoostXpMultiplier": 0,
|
||||
"PurchasableXpBoostId": "e4a7b4d4-f609-4008-9874-f6cd01b896ea",
|
||||
"ActiveExperiment": "liveops_2025q4_yeti",
|
||||
"ChallengesIconImageName": null,
|
||||
"RewardsPipImageName": null,
|
||||
"EventInfoImageName": "d99f392w7hv5r2wzwxpcxtwhi.png",
|
||||
"EventLeaderboardId": 18,
|
||||
"InitialCurrencyGrant": 300
|
||||
}
|
||||
@@ -4,6 +4,9 @@
|
||||
"main": "src/api.app.ts",
|
||||
"compatibility_date": "2026-06-16",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
// The progression events are bundled as TEXT, not parsed: they are served byte for byte
|
||||
// because their 64-bit room ids don't survive a JSON parse (see routes/progression.ts).
|
||||
"rules": [{ "type": "Text", "globs": ["**/progression-events/*.json"], "fallthrough": true }],
|
||||
// Shared `recflare` DB. Created manually with `wrangler d1 create recflare`; its
|
||||
// id lives in RECFLARE_D1 (see .env) and the "local" placeholder below is
|
||||
// spliced out at deploy time. The `api` worker owns the `relationships` table
|
||||
@@ -66,10 +69,7 @@
|
||||
],
|
||||
"upload_source_maps": true,
|
||||
"observability": {
|
||||
"logs": {
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 1 // 100%
|
||||
}
|
||||
"enabled": false
|
||||
},
|
||||
"vars": {
|
||||
"ENVIRONMENT": "development", // overridden during deployment
|
||||
|
||||
+18
-2
@@ -6,11 +6,14 @@ 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 | `/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 |
|
||||
@@ -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,15 @@ 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,
|
||||
or from a Discord link holding a role named in `STUDIO_ROLLOUT`
|
||||
(`RECFLARE_STUDIO_ROLLOUT` in the root `.env`) — a staged Studio rollout to a Discord
|
||||
role, read from the same role snapshot as the build rollout gate. Staff add and remove
|
||||
individual 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;
|
||||
+784
-83
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
countAccountsByDeviceId,
|
||||
countAccountsBySignupIp,
|
||||
createAccount,
|
||||
GAME_VERSION,
|
||||
@@ -13,16 +14,22 @@ import {
|
||||
getPasswordHash,
|
||||
getRoomById,
|
||||
hashPassword,
|
||||
isRolloutGameVersion,
|
||||
isSupportedGameVersion,
|
||||
rolloutGameBuilds,
|
||||
RoomInstanceType,
|
||||
setLastLoginTime,
|
||||
setLoginContext,
|
||||
setPasswordHash,
|
||||
setPresence,
|
||||
subRoomDataBlob,
|
||||
supportedGameBuilds,
|
||||
updateAccount,
|
||||
verifyPassword,
|
||||
writeAuditLog,
|
||||
} from '@repo/domain'
|
||||
import {
|
||||
flagVar,
|
||||
intVar,
|
||||
logger,
|
||||
withCleanSpec,
|
||||
@@ -30,16 +37,26 @@ import {
|
||||
withNotFound,
|
||||
withOnError,
|
||||
} from '@repo/hono-helpers'
|
||||
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
|
||||
import {
|
||||
generateToken,
|
||||
TOKEN_TTL_SECONDS,
|
||||
validateAndGetAccountId,
|
||||
validateIdentityAssertion,
|
||||
} from '@repo/jwt'
|
||||
|
||||
// The account-wide ban lives on a `report` row, whose table the api worker owns; its db
|
||||
// module is plain D1 queries with no runtime deps, so it imports cleanly here.
|
||||
import { banEvasionMatch, resolveBan } from '../../api/src/bans-db'
|
||||
import { buildEndpoints } from '../../ns/src/endpoints'
|
||||
import { verifyMetaNonce } from './meta-nonce'
|
||||
import {
|
||||
CachedLogin,
|
||||
ChangePasswordRequest,
|
||||
ChangePasswordResponse,
|
||||
DeviceAuthorizationRequest,
|
||||
DeviceAuthorizationResponse,
|
||||
DeviceDecisionRequest,
|
||||
DeviceDecisionResponse,
|
||||
FakeCachedLogin,
|
||||
form,
|
||||
json,
|
||||
@@ -47,23 +64,36 @@ import {
|
||||
PlatformIdsRequest,
|
||||
PlatformType,
|
||||
RestrictionDto,
|
||||
roleFilter,
|
||||
roleLookup,
|
||||
TokenRequest,
|
||||
TokenResponse,
|
||||
} from './openapi'
|
||||
import {
|
||||
accountHoldsDiscordRole,
|
||||
countAccountsForPlatformIdentity,
|
||||
getLinksForIdentity,
|
||||
getLinksForPlatformId,
|
||||
getLinksForPlatformIdentity,
|
||||
hasStudioAccess,
|
||||
isPlatformIdentityLinked,
|
||||
linkPlatformIdentity,
|
||||
parseRoleIdList,
|
||||
} 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 +101,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,10 +110,27 @@ 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'
|
||||
|
||||
/**
|
||||
* The website's "Sign in with Discord". `www` completes the Discord OAuth2 exchange (it
|
||||
* alone holds the client secret), then posts an `assertion` — an HS256 token signed with
|
||||
* the shared JWT_SECRET naming the platform identity it just proved (see `@repo/jwt`'s
|
||||
* `generateIdentityAssertion`). This grant trusts the assertion the way `cached_login`
|
||||
* trusts a Steam ticket, resolves it through the link table, and issues the ordinary
|
||||
* session. No account linked to the identity means a NEW one is made and linked, so a
|
||||
* Discord account is a way to join without Steam or Meta; the player then sets a password
|
||||
* on the website and signs in with it anywhere — a sideloaded Quest included.
|
||||
*/
|
||||
const IDENTITY_ASSERTION_GRANT = 'identity_assertion'
|
||||
|
||||
/** What the grant answers when one identity opens several accounts; never true today. */
|
||||
const AMBIGUOUS_IDENTITY_DESCRIPTION = 'platform identity is linked to more than one account'
|
||||
|
||||
/**
|
||||
* The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded
|
||||
* build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded
|
||||
@@ -112,24 +159,106 @@ 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'
|
||||
|
||||
/**
|
||||
* The build gates on `/connect/token`, both keyed on the grant's posted `ver` and both
|
||||
* driven by the operator's game-version vars (`SUPPORTED_GAME_VERSIONS`,
|
||||
* `ROLLOUT_GAME_VERSIONS` — see context.ts and `supportedGameBuilds`/`rolloutGameBuilds`
|
||||
* in @repo/domain, which `api` and `match` read the same way). A grant posting no `ver`
|
||||
* (a refresh, the website) is untouched by either.
|
||||
*
|
||||
* 1. The SUPPORT gate: a build outside `supportedGameBuilds` is refused as `invalid_grant`
|
||||
* with UNSUPPORTED_BUILD_DESCRIPTION, for every grant and every account — staff
|
||||
* included. `api`'s `/api/versioncheck/v4` tells the same builds to update; this is what
|
||||
* makes the update prompt stick, since a client that got a token anyway would play on.
|
||||
* Dropping a build from the list is how its players are made to upgrade.
|
||||
* 2. The ROLLOUT gate: a build under staged rollout is refused unless the account is STAFF
|
||||
* (developer or moderator) or its Discord link holds a role named in
|
||||
* `DISCORD_ROLLOUT_ROLE_IDS` (see `accountMayUseRolloutBuild`), as `invalid_grant` with
|
||||
* ROLLOUT_GATED_DESCRIPTION. `create_account` is refused before anything is minted: a
|
||||
* brand-new account has no Discord link to hold a role, so the player makes their
|
||||
* account on a released build, links Discord on the website, and comes back. Ending the
|
||||
* rollout (`ROLLOUT_GAME_VERSIONS=off`) opens the build to everyone.
|
||||
*
|
||||
* `ver` is client-reported and unverified, like everything else about the build. That is
|
||||
* fine for what these are: rollout and upgrade gates, not a security boundary. A player
|
||||
* who lies about their build gets a token the surfaces of the build they claim would have
|
||||
* handed them anyway, and then runs a client the server isn't answering for.
|
||||
*/
|
||||
const ROLLOUT_GATED_DESCRIPTION =
|
||||
'this build is limited to Discord supporters; link your Discord on the website first'
|
||||
const UNSUPPORTED_BUILD_DESCRIPTION =
|
||||
'this game version is not supported by this server; update your game'
|
||||
|
||||
/**
|
||||
* `DISCORD_ROLLOUT_ROLE_IDS` as a list of role ids: snowflakes separated by commas and/or
|
||||
* whitespace (`parseRoleIdList`, the grammar `www`'s parseRoleIds reads
|
||||
* DISCORD_BENEFITS_ROLE_IDS with). Unset or blank parses to an empty list, which
|
||||
* `accountMayUseRolloutBuild` treats as a gate closed to everyone but staff.
|
||||
*/
|
||||
const rolloutRoleIds = parseRoleIdList
|
||||
|
||||
/**
|
||||
* Whether an account may sign in on a rollout-gated build. STAFF — a developer or moderator
|
||||
* flag on the account, the same two `staff_login` audits — always may, configured roles or
|
||||
* not: they are who tests a build before it reaches supporters, so a closed gate stays open
|
||||
* to them. Anyone else needs a Discord link (platform 101, written by the website's benefits
|
||||
* claim and refreshed by `www`'s daily sweep) recording at least one of the configured
|
||||
* roles. The roles are a SNAPSHOT (see PlatformLink.roles), so a role granted in Discord
|
||||
* today opens the gate once the player re-claims or the sweep runs — not instantly.
|
||||
*
|
||||
* An empty role list skips the link read: with no supporter role named, nobody is a
|
||||
* supporter.
|
||||
*/
|
||||
async function accountMayUseRolloutBuild(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
roleIds: readonly string[]
|
||||
): Promise<boolean> {
|
||||
const account = await getAccount(db, accountId)
|
||||
if (account && (holdsRole(account, 'developer') || holdsRole(account, 'moderator'))) return true
|
||||
return accountHoldsDiscordRole(db, accountId, roleIds)
|
||||
}
|
||||
|
||||
/** New players start in the Orientation room (RoomId 13) — the new-user flow. */
|
||||
const ORIENTATION_ROOM_ID = 13
|
||||
@@ -200,22 +329,84 @@ 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' | 'screenshare'): boolean {
|
||||
// Matches accountTokenRoles: screenshare is a feature gate on every account token.
|
||||
if (role === 'screenshare') return true
|
||||
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' | 'screenshare',
|
||||
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 `studio` — the account's Studio access as `hasStudioAccess`
|
||||
* resolves it: the `hasStudio` flag staff set from the website, OR a Discord link holding
|
||||
* a role in the `STUDIO_ROLLOUT` var — and developer does not imply it. RecFlare Studio
|
||||
* treats that exact claim as Full access, which is what lets the editor upload. The
|
||||
* caller resolves it (it costs a link read) so this stays a pure projection of the
|
||||
* account. `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' | 'isInfluencer'> | null,
|
||||
studio: boolean
|
||||
): 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 (studio) roles.push('betastudio')
|
||||
if (account.isInfluencer) roles.push('influencer')
|
||||
return roles
|
||||
}
|
||||
|
||||
@@ -241,6 +432,226 @@ 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,
|
||||
/** Extra top-level fields on the token body — `created: true` on an identity signup. */
|
||||
extra: Record<string, unknown> = {}
|
||||
) {
|
||||
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, await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT)),
|
||||
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=',
|
||||
...extra,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The `identity_assertion` grant: sign into — or create — the account behind a platform
|
||||
* identity another worker has verified and signed for. See IDENTITY_ASSERTION_GRANT.
|
||||
*
|
||||
* Handled before the password fallback and outside the version/rollout gates, like the
|
||||
* Studio device grant: nothing here posts a `ver`, and the credential is the assertion
|
||||
* alone. The assertion names an IDENTITY, not an account; which account that is (or that
|
||||
* there is none yet) is decided here, from the same table `cached_login` reads, so the
|
||||
* website can never choose an account id for itself.
|
||||
*
|
||||
* Creating an account when the identity opens none is deliberate — it is the Discord
|
||||
* sign-up — and takes the same guards `create_account` does for a verified platform
|
||||
* identity: the evasion arms (a banned player's next move is a new account), the per-IP
|
||||
* cap (`www` forwards the browser's real address over the service binding) and the
|
||||
* per-identity cap. NOT the PASSWORD_SIGNUP switch: that exists because a password account
|
||||
* has no identity but its address, and this one has a Discord user Discord itself vouched
|
||||
* for, one account per user. The new account carries no `platform`/`platformId` on its
|
||||
* blob — only the link — so its DTO and token read like a password account's and the
|
||||
* Discord snowflake stays off the wire, exactly as the benefits claim keeps it.
|
||||
*/
|
||||
async function identityAssertionGrant(c: Context<App>, body: Record<string, unknown>) {
|
||||
const assertion = typeof body.assertion === 'string' ? body.assertion : ''
|
||||
const jwtSecret = await c.env.JWT_SECRET.get()
|
||||
if (jwtSecret === '') {
|
||||
logger.error('refusing an identity assertion: JWT_SECRET is empty')
|
||||
return oauthError(c, 'server_error', 'token signing is not configured', 500)
|
||||
}
|
||||
const identity = assertion ? await validateIdentityAssertion(assertion, jwtSecret) : null
|
||||
if (identity === null) {
|
||||
return oauthError(c, 'invalid_grant', 'invalid or expired identity assertion')
|
||||
}
|
||||
|
||||
const links = await getLinksForIdentity(c.env.DB, identity.platform, identity.platformId)
|
||||
if (links.length > 1) {
|
||||
// The benefits claim keeps a Discord identity on one account, so this is a data
|
||||
// problem rather than a player's choice to make; refused rather than guessed.
|
||||
logger.error('identity assertion names an identity linked to several accounts', {
|
||||
platform: identity.platform,
|
||||
accountIds: links.map((l) => l.accountId),
|
||||
})
|
||||
return oauthError(c, 'invalid_grant', AMBIGUOUS_IDENTITY_DESCRIPTION)
|
||||
}
|
||||
if (links.length === 1) {
|
||||
return issueSession(c, links[0].accountId, IDENTITY_ASSERTION_GRANT)
|
||||
}
|
||||
|
||||
// Nobody yet: the sign-up. Everything below mirrors create_account's platform path.
|
||||
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
||||
const blocked = await resolveBan(c.env.DB, null, {
|
||||
identity: { ip: clientIp, platform: identity.platform, platformId: identity.platformId },
|
||||
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
|
||||
})
|
||||
if (blocked) {
|
||||
logger.info('identity signup refused: player banned', {
|
||||
via: blocked.via,
|
||||
bannedAccountId: blocked.bannedAccountId,
|
||||
ip: clientIp,
|
||||
platform: identity.platform,
|
||||
})
|
||||
return oauthError(c, 'invalid_grant', BLOCKED_DESCRIPTION)
|
||||
}
|
||||
const maxPerPlatformId = intVar(
|
||||
c.env.MAX_ACCOUNTS_PER_PLATFORM_ID,
|
||||
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
|
||||
)
|
||||
if (
|
||||
maxPerPlatformId > 0 &&
|
||||
(await countAccountsForPlatformIdentity(c.env.DB, identity.platform, identity.platformId)) >=
|
||||
maxPerPlatformId
|
||||
) {
|
||||
logger.info('identity signup rejected: platform account limit', { platform: identity.platform })
|
||||
return oauthError(c, 'invalid_grant', 'account limit reached for this platform account')
|
||||
}
|
||||
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
|
||||
if (
|
||||
maxPerIp > 0 &&
|
||||
clientIp !== '' &&
|
||||
(await countAccountsBySignupIp(c.env.DB, clientIp)) >= maxPerIp
|
||||
) {
|
||||
logger.info('identity signup rejected: per-IP account limit', { ip: clientIp })
|
||||
return oauthError(c, 'invalid_grant', 'too many accounts created from this network')
|
||||
}
|
||||
|
||||
const account = await createAccount(c.env.DB, {
|
||||
platforms: 0,
|
||||
lastLoginTime: new Date().toISOString(),
|
||||
signupIp: clientIp || undefined,
|
||||
lastLoginIp: clientIp || undefined,
|
||||
})
|
||||
await linkPlatformIdentity(c.env.DB, account.accountId, identity.platform, identity.platformId)
|
||||
await placeNewPlayerInOrientation(c.env, account.accountId, 0)
|
||||
logger.info('created an account from an identity assertion', {
|
||||
accountId: account.accountId,
|
||||
platform: identity.platform,
|
||||
ip: clientIp,
|
||||
})
|
||||
return issueSession(c, account.accountId, IDENTITY_ASSERTION_GRANT, { created: true })
|
||||
}
|
||||
|
||||
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 +958,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',
|
||||
@@ -554,14 +1042,18 @@ const app = new Hono<App>()
|
||||
tags: ['Token'],
|
||||
summary: 'OAuth token endpoint — issues a JWT',
|
||||
description: [
|
||||
'Issues an access token (plus a single-use refresh token) for one of four grants,',
|
||||
'Issues an access token (plus a single-use refresh token) for one of six grants,',
|
||||
'selected by `grant_type`. Every grant returns the same body on success.',
|
||||
'',
|
||||
'**`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.',
|
||||
'',
|
||||
@@ -573,6 +1065,17 @@ const app = new Hono<App>()
|
||||
'**`refresh_token`** — redeems a stored single-use refresh token, rotating it. The',
|
||||
'platform and platform id come from what was stored at issue time, not the body.',
|
||||
'',
|
||||
'**`identity_assertion`** — the website’s "Sign in with Discord". `www` completes the',
|
||||
'Discord OAuth2 code exchange (it alone holds the client secret) and posts an',
|
||||
'`assertion`: an HS256 token signed with the shared JWT_SECRET naming the platform',
|
||||
'(`101`) and platform id it proved, good for a minute. The identity is resolved through',
|
||||
'the same link table as `cached_login`: one linked account is signed into; none means a',
|
||||
'NEW account is created and linked (the body then carries `created: true`), under the',
|
||||
'evasion check and the per-IP and per-identity signup caps but NOT the PASSWORD_SIGNUP',
|
||||
'switch — the Discord user is the identity a password account lacks. The new account',
|
||||
'has no password until its owner sets one on the website; with one, it signs in',
|
||||
'anywhere the `password` grant does. Several linked accounts is refused.',
|
||||
'',
|
||||
'**`password`** (the fallback for any unrecognised or absent `grant_type`) —',
|
||||
'identifies the account by `username` or numeric `account_id` and requires the',
|
||||
'matching `password`. An account with no stored hash cannot be logged into at all,',
|
||||
@@ -601,26 +1104,50 @@ 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) or from a Discord',
|
||||
'link holding a role in the `STUDIO_ROLLOUT` var. 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.',
|
||||
'',
|
||||
'**Support gate.** A grant whose `ver` names a build this server does not serve —',
|
||||
'outside the operator’s `SUPPORTED_GAME_VERSIONS` (default: the list in `@repo/domain`)',
|
||||
'and not under rollout — is refused as `invalid_grant` "this game version is not',
|
||||
'supported by this server; update your game", for every grant and every account.',
|
||||
'`api`’s `/api/versioncheck/v4` flags the same builds, so dropping a build from the',
|
||||
'list is how its players are made to upgrade. Grants posting no `ver` are untouched.',
|
||||
'',
|
||||
'**Rollout gate.** A grant whose `ver` names a build under staged rollout',
|
||||
'(`ROLLOUT_GAME_VERSIONS`, by default `20260323` — every point release of it; `off` ends',
|
||||
'the rollout and opens the build to everyone) is refused as `invalid_grant` "this build is limited to',
|
||||
'Discord supporters" unless the account is staff (developer or moderator) or its',
|
||||
'Discord link (made by the website’s benefits claim) holds one of the roles in the',
|
||||
'operator’s `DISCORD_ROLLOUT_ROLE_IDS`. With none configured the gate is closed to',
|
||||
'everyone but staff. `create_account` on such a',
|
||||
'build is refused before it mints anything, since a new account has no Discord link.',
|
||||
'Grants posting no `ver` (a refresh, the website) and every other build are untouched.',
|
||||
].join('\n'),
|
||||
requestBody: form(
|
||||
TokenRequest,
|
||||
@@ -651,6 +1178,35 @@ 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)
|
||||
}
|
||||
// The website's Discord sign-in, likewise before the fallback — and before the
|
||||
// version gates, which it never posts a `ver` for. See identityAssertionGrant.
|
||||
if (grantType === IDENTITY_ASSERTION_GRANT) return identityAssertionGrant(c, body)
|
||||
// `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 : ''
|
||||
@@ -674,9 +1230,9 @@ const app = new Hono<App>()
|
||||
// The client's own build (`ver`, e.g. `20250718.01`), stamped into the token's
|
||||
// `rn.ver` claim so everything downstream reports the build the player is ACTUALLY
|
||||
// running rather than this server's GAME_VERSION — `match` reads it back off the
|
||||
// token when it writes presence. Unverified like the device fields, and only ever
|
||||
// echoed, never trusted for a decision (the version CHECK is `api`'s
|
||||
// `/api/versioncheck/v4`, against its own list).
|
||||
// token when it writes presence. Unverified like the device fields: echoed, and
|
||||
// gated (below, and the rollout gate further down), but never trusted as a
|
||||
// credential.
|
||||
//
|
||||
// A grant that posts none — a refresh, or a caller that isn't the game — leaves it
|
||||
// undefined and generateToken falls back to GAME_VERSION. An empty string is
|
||||
@@ -690,6 +1246,23 @@ const app = new Hono<App>()
|
||||
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
|
||||
const clientIp = c.req.header('cf-connecting-ip') ?? ''
|
||||
|
||||
// The support gate (see UNSUPPORTED_BUILD_DESCRIPTION): a posted build this server
|
||||
// doesn't serve gets no token, whatever the grant and whoever the account. Decided
|
||||
// before any credential is read — it depends on nothing but the operator's list, so
|
||||
// there is nothing about an account to probe here, and a closed door costs no D1
|
||||
// round trip. The same list `api`'s `/api/versioncheck/v4` flags for an update.
|
||||
if (version !== undefined && !isSupportedGameVersion(version, supportedGameBuilds(c.env))) {
|
||||
logger.info('token refused: game version is not supported', {
|
||||
grantType,
|
||||
version,
|
||||
ip: clientIp,
|
||||
})
|
||||
return c.json(
|
||||
{ error: 'invalid_grant', error_description: UNSUPPORTED_BUILD_DESCRIPTION },
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// A platform-authenticated login proves who you are with the platform itself, and
|
||||
// we can verify exactly two: Steam (0), from its Steam-signed platform_auth ticket,
|
||||
// and Meta/Oculus (1), by asking Meta to validate the nonce in platform_auth (see
|
||||
@@ -700,7 +1273,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 +1362,32 @@ const app = new Hono<App>()
|
||||
// via create_account or /account/me/changepassword.
|
||||
let accountId: string
|
||||
if (grantType === 'create_account') {
|
||||
// A rollout-gated build can't sign UP: the account it would mint has no Discord
|
||||
// link, so it could never hold the role the gate wants. Refused before anything
|
||||
// is read or minted, like the switch below — no row left behind, no cap slot
|
||||
// burned.
|
||||
if (isRolloutGameVersion(version, rolloutGameBuilds(c.env))) {
|
||||
logger.info('signup refused: build is limited to Discord supporters', {
|
||||
version,
|
||||
ip: clientIp,
|
||||
})
|
||||
return c.json(
|
||||
{ error: 'invalid_grant', error_description: ROLLOUT_GATED_DESCRIPTION },
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// 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 +1419,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 +1427,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 +1465,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 +1604,63 @@ 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.
|
||||
// The rollout gate: a build under staged rollout (`rolloutGameBuilds`) is for staff
|
||||
// and Discord supporters only, so the account signing in on it must be staff or hold
|
||||
// one of the configured roles.
|
||||
// Deliberately AFTER the credential checks, like the ban lookup below — a wrong
|
||||
// password on a gated build is still "invalid account_id or password", so the
|
||||
// gate can't be used to probe whether an account exists. Unlike a ban, this IS a
|
||||
// refusal: there is no block screen to reach, and the client surfaces the
|
||||
// description on its sign-in failure.
|
||||
if (isRolloutGameVersion(version, rolloutGameBuilds(c.env))) {
|
||||
const roleIds = rolloutRoleIds(c.env.DISCORD_ROLLOUT_ROLE_IDS)
|
||||
if (!(await accountMayUseRolloutBuild(c.env.DB, Number(accountId), roleIds))) {
|
||||
logger.info('login refused: build is limited to Discord supporters', {
|
||||
accountId,
|
||||
grantType,
|
||||
version,
|
||||
// The COUNT, not the ids: zero here means the gate is closed because
|
||||
// nothing is configured, which reads very differently from "not a
|
||||
// supporter".
|
||||
roleIdCount: roleIds.length,
|
||||
})
|
||||
return c.json(
|
||||
{ error: 'invalid_grant', error_description: ROLLOUT_GATED_DESCRIPTION },
|
||||
400
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +1668,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
|
||||
@@ -1062,7 +1700,10 @@ const app = new Hono<App>()
|
||||
platformId,
|
||||
platform,
|
||||
jwtSecret,
|
||||
accountRoles(roleAccount),
|
||||
accountRoles(
|
||||
roleAccount,
|
||||
await hasStudioAccess(c.env.DB, roleAccount, c.env.STUDIO_ROLLOUT)
|
||||
),
|
||||
accountPrivileges(roleAccount),
|
||||
version,
|
||||
// Rec Room Plus, off the same account read as the roles above — `econ` decides the
|
||||
@@ -1076,6 +1717,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 +1865,34 @@ 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')
|
||||
)
|
||||
|
||||
// Studio queries each room owner's screenshare role before validating video players.
|
||||
.get('/role/screenshare/:id', describeRoute(roleLookup('screenshare')), (c) =>
|
||||
roleAnswer(c, 'screenshare', c.req.param('id'))
|
||||
)
|
||||
|
||||
// @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,63 @@ export type Env = SharedHonoEnv & {
|
||||
* Read through `banEvasionMatch`; the `match` worker reads the same knob.
|
||||
*/
|
||||
BAN_EVASION_MATCH?: string
|
||||
/**
|
||||
* The Discord role ids that may sign in on a ROLLOUT-GATED client build (see
|
||||
* `ROLLOUT_GAME_VERSIONS` in @repo/domain) — numeric snowflakes, separated by commas
|
||||
* and/or whitespace, exactly like `www`'s DISCORD_BENEFITS_ROLE_IDS. ANY one of them
|
||||
* qualifies. A login whose posted `ver` is a gated build is refused unless the account's
|
||||
* Discord link (`platform_account`, platform 101, written by the website's benefits
|
||||
* claim) records one of these roles — or the account is STAFF (developer or moderator),
|
||||
* who are always let in, configured roles or not.
|
||||
*
|
||||
* Unset — or parsing to no ids — leaves the gate CLOSED: nobody but staff gets a token on
|
||||
* a gated build. That is the safe reading of "supporters only" when no supporter role has been
|
||||
* named, and it keeps a build that isn't ready from being reachable by accident.
|
||||
* Logins on every other build are never touched by this.
|
||||
*/
|
||||
DISCORD_ROLLOUT_ROLE_IDS?: string
|
||||
/**
|
||||
* The Discord role ids whose holders get Rec Room Studio upload access — stamped into the
|
||||
* token as `betastudio` exactly as the account's `hasStudio` flag is, so a whole Discord
|
||||
* role can be let into Studio without touching anyone's account. Same grammar as
|
||||
* DISCORD_ROLLOUT_ROLE_IDS above (snowflakes, commas and/or whitespace; ANY one qualifies),
|
||||
* read from the same Discord link snapshot. Set for every worker from the root `.env`
|
||||
* (`RECFLARE_STUDIO_ROLLOUT`): `studio` and `www` read it through the same
|
||||
* `hasStudioAccess`, so what the token says, what the editor's access lookup says and
|
||||
* what the website's `/api/studio-access` says agree. Unset, or naming no role, grants
|
||||
* nobody — the flag alone decides. The flag still works beside it.
|
||||
*/
|
||||
STUDIO_ROLLOUT?: string
|
||||
/**
|
||||
* The client builds a `/connect/token` grant may post as `ver` and still get a token:
|
||||
* a comma-separated list of builds (`20230414,20250718`), or `off` for none. Unset means
|
||||
* the default `SUPPORTED_GAME_VERSIONS` in @repo/domain. Set for every worker from the
|
||||
* root `.env` (`RECFLARE_SUPPORTED_GAME_VERSIONS`), so this refuses exactly the builds
|
||||
* `api`'s `/api/versioncheck/v4` tells to update — dropping a build from the list is how
|
||||
* its players are made to upgrade. Read through `supportedGameBuilds`, which also folds
|
||||
* in every build under rollout (below). A grant posting no `ver` is never gated.
|
||||
*/
|
||||
SUPPORTED_GAME_VERSIONS?: string
|
||||
/**
|
||||
* The builds under staged rollout — the ones the Discord-role gate above applies to —
|
||||
* overriding `ROLLOUT_GAME_VERSIONS` in @repo/domain: same format, or `off` to end the
|
||||
* rollout and open those builds to every account. Set for every worker
|
||||
* (`RECFLARE_ROLLOUT_GAME_VERSIONS`), so `match`'s rollout voice pool follows the same
|
||||
* list. A build here is served whether or not `SUPPORTED_GAME_VERSIONS` names it. Read
|
||||
* through `rolloutGameBuilds`.
|
||||
*/
|
||||
ROLLOUT_GAME_VERSIONS?: 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 */
|
||||
|
||||
+112
-6
@@ -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,19 @@ 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',
|
||||
'identity_assertion',
|
||||
])
|
||||
.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, ' +
|
||||
'and identity_assertion, the website’s Discord sign-in'
|
||||
),
|
||||
account_id: z.string().optional().describe('Numeric account id, as a string'),
|
||||
username: z
|
||||
.string()
|
||||
@@ -156,6 +176,22 @@ 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'),
|
||||
assertion: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'identity_assertion grant: the HS256 assertion `www` minted with the shared JWT_SECRET ' +
|
||||
'after completing a Discord OAuth2 exchange, naming the platform and platform_id it proved'
|
||||
),
|
||||
device_id: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -171,6 +207,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'),
|
||||
@@ -232,14 +295,18 @@ export const RestrictionDto = z.object({
|
||||
DisplayReason: z.string().nullable().describe('Reason shown to the player. Free text'),
|
||||
})
|
||||
|
||||
export function roleLookup(role: 'developer' | 'moderator') {
|
||||
export function roleLookup(role: 'developer' | 'moderator' | 'screenshare') {
|
||||
return {
|
||||
tags: ['Roles'],
|
||||
summary: `Whether a player has the ${role} role`,
|
||||
description:
|
||||
`Returns a bare JSON boolean (\`true\`/\`false\`), not an object. Off by default and ` +
|
||||
role === 'screenshare'
|
||||
? 'Returns a bare JSON boolean. Screenshare is enabled for every existing account, matching the access-token role policy; unknown accounts return 404. Studio uses this to validate room-owner video permissions.'
|
||||
: `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 +323,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,148 @@ export async function getLinksForPlatformId(
|
||||
)
|
||||
.bind(platformId, ...CACHED_LOGIN_PLATFORMS)
|
||||
.all<LinkRow>()
|
||||
return results
|
||||
return results.map(toLink)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every account a platform identity is linked to, on ANY platform — including the ones
|
||||
* {@link CACHED_LOGIN_PLATFORMS} keeps out of the picker.
|
||||
*
|
||||
* This is the unfiltered twin of {@link getLinksForPlatformIdentity}, and it must stay off
|
||||
* every public route: the filter there exists so nobody can learn which account a Discord
|
||||
* user owns by naming their snowflake. The one caller is `auth`'s `identity_assertion`
|
||||
* grant, where the identity being asked about has just been PROVEN by the worker that
|
||||
* minted the assertion — the website's Discord sign-in — so the answer goes to the person
|
||||
* it is about and nobody else. Add a caller only under the same condition.
|
||||
*/
|
||||
export async function getLinksForIdentity(
|
||||
db: D1Database,
|
||||
platform: number,
|
||||
platformId: string
|
||||
): Promise<PlatformLink[]> {
|
||||
if (platformId === '') return []
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`${SELECT_LINK} WHERE platform = ?1 AND platform_id = ?2 ORDER BY linked_at, account_id`
|
||||
)
|
||||
.bind(platform, platformId)
|
||||
.all<LinkRow>()
|
||||
return results.map(toLink)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether any of the account's links on `platform` has roles RECORDED — a non-null `role`
|
||||
* column, which only the website's benefits claim (and the sweep behind it) ever writes.
|
||||
*
|
||||
* This is what "has this account claimed its Discord benefits" means now that a Discord
|
||||
* link can exist WITHOUT a claim: signing in with Discord (`auth`'s `identity_assertion`
|
||||
* grant) links the identity on a brand-new account with `role` NULL. The claim's one-time
|
||||
* gift and its page both ask this rather than "is there a link", so a player who made
|
||||
* their account from Discord is still owed, and still shown, their first claim.
|
||||
* An EMPTY role list counts as recorded: that is a claim that found no roles.
|
||||
*/
|
||||
export async function hasRecordedRoles(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number
|
||||
): Promise<boolean> {
|
||||
const row = await db
|
||||
.prepare(
|
||||
`SELECT 1 AS ok FROM platform_account
|
||||
WHERE account_id = ?1 AND platform = ?2 AND role IS NOT NULL LIMIT 1`
|
||||
)
|
||||
.bind(accountId, platform)
|
||||
.first<{ ok: number }>()
|
||||
return row !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Record roles on an existing link that has NONE yet (`role` NULL), atomically. True when
|
||||
* this call was the one that did it; false when the link is missing or already carries a
|
||||
* role list — in which case nothing is written, and {@link linkPlatformIdentity}'s refresh
|
||||
* is the caller's next step.
|
||||
*
|
||||
* The one-shot flip is the point: the benefits claim pays its first-claim gift on the
|
||||
* INSERT of a fresh link, which two racing claims cannot both win. A link made by a Discord
|
||||
* sign-in is already inserted by the time the claim comes, so this gives the claim the
|
||||
* same single-winner write on the roles column instead.
|
||||
*/
|
||||
export async function recordFirstRoles(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
platform: number,
|
||||
platformId: string,
|
||||
roles: readonly string[]
|
||||
): Promise<boolean> {
|
||||
if (platformId === '') return false
|
||||
const res = await db
|
||||
.prepare(
|
||||
`UPDATE platform_account SET role = ?4
|
||||
WHERE account_id = ?1 AND platform = ?2 AND platform_id = ?3 AND role IS NULL`
|
||||
)
|
||||
.bind(accountId, platform, platformId, JSON.stringify([...roles]))
|
||||
.run()
|
||||
return res.meta.changes > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* A list of Discord role ids out of an operator var — numeric snowflakes separated by
|
||||
* commas and/or whitespace, the grammar every `*_ROLE_IDS`-shaped var here shares
|
||||
* (`auth`'s DISCORD_ROLLOUT_ROLE_IDS, `STUDIO_ROLLOUT`; `www` reads
|
||||
* DISCORD_BENEFITS_ROLE_IDS the same way). Unset or blank parses to an empty list.
|
||||
*/
|
||||
export function parseRoleIdList(raw: string | undefined): string[] {
|
||||
return (raw ?? '')
|
||||
.split(/[\s,]+/)
|
||||
.map((id) => id.trim())
|
||||
.filter((id) => id !== '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account's Discord link records ANY of `roleIds`. False with none configured —
|
||||
* the link read is skipped, so a var that names no role grants nobody anything. Roles are
|
||||
* the SNAPSHOT the website's benefits claim and `www`'s daily sweep keep (see
|
||||
* {@link PlatformLink.roles}), so a role granted in Discord today counts once the player
|
||||
* re-claims or the sweep runs — not instantly.
|
||||
*/
|
||||
export async function accountHoldsDiscordRole(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
roleIds: readonly string[]
|
||||
): Promise<boolean> {
|
||||
if (roleIds.length === 0) return false
|
||||
const links = await getLinksForAccount(db, accountId)
|
||||
return links.some(
|
||||
(link) => link.platform === PlatformType.Discord && link.roles.some((r) => roleIds.includes(r))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an account has Rec Room Studio upload access — what `auth` stamps into the
|
||||
* token as the `betastudio` role, `studio` answers on its access lookups and `www`'s
|
||||
* `/api/studio-access` reports. Two grants, either suffices:
|
||||
*
|
||||
* - the account's own `hasStudio` flag, set by staff on the website or
|
||||
* `runx admin grant-studio`;
|
||||
* - the STUDIO ROLLOUT: a Discord link holding a role named in the `STUDIO_ROLLOUT` var
|
||||
* (`RECFLARE_STUDIO_ROLLOUT` in the root `.env`, reaching every worker), which hands a
|
||||
* whole Discord role the same access without touching anyone's account. Unset, or
|
||||
* naming no role, it grants nobody — the flag alone decides, as before.
|
||||
*
|
||||
* Every reader goes through this so the three surfaces cannot disagree: a role holder
|
||||
* whom `auth` stamps `betastudio` for must also be told "granted" by the website, and
|
||||
* `studio`'s lookups (which rebuild the role from the account rather than a token) must
|
||||
* see the same answer. The flag is checked first so an account that has it costs no
|
||||
* link read.
|
||||
*/
|
||||
export async function hasStudioAccess(
|
||||
db: D1Database,
|
||||
account: { accountId: number; hasStudio?: boolean } | null | undefined,
|
||||
studioRolloutRoleIds: string | undefined
|
||||
): Promise<boolean> {
|
||||
if (!account) return false
|
||||
if (account.hasStudio === true) return true
|
||||
return accountHoldsDiscordRole(db, account.accountId, parseRoleIdList(studioRolloutRoleIds))
|
||||
}
|
||||
|
||||
/** Every platform identity linked to an account (a player's PC and headset, say). */
|
||||
@@ -205,7 +464,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,7 +5,9 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
||||
import '../../auth.app'
|
||||
|
||||
import {
|
||||
AUDIT_LOG_SCHEMA_DDL,
|
||||
GAME_VERSION,
|
||||
getAccount,
|
||||
getAccountsByDeviceId,
|
||||
hashPassword,
|
||||
PRESENCE_SCHEMA_DDL,
|
||||
@@ -13,9 +15,16 @@ import {
|
||||
SCHEMA_DDL,
|
||||
seedRoomWithSubRooms,
|
||||
SUBROOM_SCHEMA_DDL,
|
||||
updateAccount,
|
||||
} from '@repo/domain'
|
||||
import { TOKEN_TTL_SECONDS } from '@repo/jwt'
|
||||
import {
|
||||
generateIdentityAssertion,
|
||||
generateToken,
|
||||
TOKEN_TTL_SECONDS,
|
||||
validateIdentityAssertion,
|
||||
} from '@repo/jwt'
|
||||
|
||||
import { SCHEMA_DDL as CUSTOM_AVATAR_ITEM_SCHEMA_DDL } from '../../../../api/src/custom-avatar-items-db'
|
||||
import {
|
||||
banFromReport,
|
||||
createReport,
|
||||
@@ -30,6 +39,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 +84,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 +109,10 @@ 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()
|
||||
// Custom avatar items (owned by the api worker) — every room read joins the published
|
||||
// room badges from this table.
|
||||
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of STUDIO_DEVICE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -469,6 +490,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 +698,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 +741,138 @@ 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')
|
||||
})
|
||||
|
||||
// STUDIO_ROLLOUT opens Studio to a Discord ROLE: a holder is stamped `betastudio` exactly
|
||||
// as a flagged account is, with no account flag involved, from the same Discord link
|
||||
// snapshot the build rollout gate reads. Unset, the flag alone decides.
|
||||
test('POST /connect/token stamps betastudio for a Discord role in STUDIO_ROLLOUT', async () => {
|
||||
const studioRole = '1077000000000000031'
|
||||
const otherRole = '1077000000000000032'
|
||||
await seedAccount(9813, 'StudioRoleHolder')
|
||||
await linkPlatformIdentity(env.DB, 9813, PlatformType.Discord, '500000000000009813', [
|
||||
studioRole,
|
||||
])
|
||||
await seedAccount(9814, 'StudioRoleOutsider')
|
||||
await linkPlatformIdentity(env.DB, 9814, PlatformType.Discord, '500000000000009814', [
|
||||
otherRole,
|
||||
])
|
||||
const login = (id: number) => tokenFor(`account_id=${id}&password=${LOGIN_PASSWORD}`)
|
||||
|
||||
const original = env.STUDIO_ROLLOUT
|
||||
try {
|
||||
// Unset, or naming no role: the link grants nothing.
|
||||
delete env.STUDIO_ROLLOUT
|
||||
expect((await login(9813)).role).not.toContain('betastudio')
|
||||
env.STUDIO_ROLLOUT = ' , '
|
||||
expect((await login(9813)).role).not.toContain('betastudio')
|
||||
|
||||
// Same list grammar as DISCORD_ROLLOUT_ROLE_IDS; any one named role qualifies.
|
||||
env.STUDIO_ROLLOUT = ` ${otherRole}x ,\n${studioRole} `
|
||||
expect((await login(9813)).role).toEqual(['gameClient', 'screenshare', 'betastudio'])
|
||||
// A Discord link holding only other roles is not in the rollout.
|
||||
expect((await login(9814)).role).not.toContain('betastudio')
|
||||
// The account flag never moved: this is the var, not a grant written anywhere.
|
||||
expect((await getAccount(env.DB, 9813))?.hasStudio).toBeUndefined()
|
||||
|
||||
// The refresh grant re-resolves it, so ending the rollout drops the claim on the
|
||||
// next refresh — the same path a revoked flag takes.
|
||||
const session = await postToken(`account_id=9813&password=${LOGIN_PASSWORD}`)
|
||||
delete env.STUDIO_ROLLOUT
|
||||
const refreshed = await postToken(
|
||||
`grant_type=refresh_token&refresh_token=${encodeURIComponent(session.json.refresh_token as string)}`
|
||||
)
|
||||
expect(refreshed.status).toBe(200)
|
||||
expect(decodePayload(refreshed.json.access_token as string).role).not.toContain('betastudio')
|
||||
} finally {
|
||||
if (original === undefined) delete env.STUDIO_ROLLOUT
|
||||
else env.STUDIO_ROLLOUT = original
|
||||
}
|
||||
})
|
||||
|
||||
// Rec Room Plus rides on the token as `rn.plus`, stamped from `account.hasPlus` — which
|
||||
@@ -743,6 +952,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 +1096,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 +1209,405 @@ 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
|
||||
}
|
||||
})
|
||||
|
||||
// The rollout gate: build 20260323.14 is for Discord supporters only. A login posting it
|
||||
// as `ver` is refused unless the account's Discord link holds a role named in
|
||||
// DISCORD_ROLLOUT_ROLE_IDS; every other build, and a grant posting no `ver`, is untouched.
|
||||
describe('rollout gate on build 20260323.14', () => {
|
||||
const GATED = '20260323.14'
|
||||
const REFUSED =
|
||||
'this build is limited to Discord supporters; link your Discord on the website first'
|
||||
const supporter = '1077000000000000001'
|
||||
const booster = '1077000000000000002'
|
||||
const outsider = '1077000000000000009'
|
||||
|
||||
async function seedAccount(id: number, username: string) {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: id,
|
||||
username,
|
||||
passwordHash: await hashPassword(LOGIN_PASSWORD),
|
||||
})
|
||||
)
|
||||
.run()
|
||||
}
|
||||
|
||||
const login = (username: string, ver?: string) =>
|
||||
postToken(
|
||||
`grant_type=password&username=${username}&password=${LOGIN_PASSWORD}` +
|
||||
(ver === undefined ? '' : `&ver=${ver}`)
|
||||
)
|
||||
|
||||
async function withRoleIds<T>(value: string | undefined, run: () => Promise<T>): Promise<T> {
|
||||
// `env` is shared by every test in this file, so the override is restored in
|
||||
// `finally` rather than leaking a configured gate into the tests around this one.
|
||||
const original = env.DISCORD_ROLLOUT_ROLE_IDS
|
||||
if (value === undefined) delete env.DISCORD_ROLLOUT_ROLE_IDS
|
||||
else env.DISCORD_ROLLOUT_ROLE_IDS = value
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
if (original === undefined) delete env.DISCORD_ROLLOUT_ROLE_IDS
|
||||
else env.DISCORD_ROLLOUT_ROLE_IDS = original
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await seedAccount(9301, 'gate_supporter')
|
||||
await linkPlatformIdentity(env.DB, 9301, PlatformType.Discord, '500000000000000001', [
|
||||
supporter,
|
||||
])
|
||||
await seedAccount(9302, 'gate_outsider')
|
||||
await linkPlatformIdentity(env.DB, 9302, PlatformType.Discord, '500000000000000002', [
|
||||
outsider,
|
||||
])
|
||||
await seedAccount(9303, 'gate_unlinked')
|
||||
// A Steam link is not a Discord link — it carries no roles to hold.
|
||||
await linkPlatformIdentity(env.DB, 9303, PlatformType.Steam, '76561198000009303')
|
||||
// Staff, with no Discord link at all.
|
||||
for (const [id, username, flag] of [
|
||||
[9304, 'gate_developer', 'isDeveloper'],
|
||||
[9305, 'gate_moderator', 'isModerator'],
|
||||
] as const) {
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId: id,
|
||||
username,
|
||||
passwordHash: await hashPassword(LOGIN_PASSWORD),
|
||||
[flag]: true,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
}
|
||||
})
|
||||
|
||||
test('staff are let in with no Discord link, even with the gate closed', async () => {
|
||||
for (const roleIds of [undefined, supporter]) {
|
||||
await withRoleIds(roleIds, async () => {
|
||||
expect((await login('gate_developer', GATED)).status).toBe(200)
|
||||
expect((await login('gate_moderator', GATED)).status).toBe(200)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('with no roles configured the gate is closed to every non-staff account', async () => {
|
||||
await withRoleIds(undefined, async () => {
|
||||
const res = await login('gate_supporter', GATED)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json).toEqual({ error: 'invalid_grant', error_description: REFUSED })
|
||||
})
|
||||
// A value that parses to no ids is the same as unset.
|
||||
await withRoleIds(' , ', async () => {
|
||||
expect((await login('gate_supporter', GATED)).status).toBe(400)
|
||||
})
|
||||
})
|
||||
|
||||
test('a configured role lets its holder in, and the token carries the build', async () => {
|
||||
await withRoleIds(`${booster},${supporter}`, async () => {
|
||||
const res = await login('gate_supporter', GATED)
|
||||
expect(res.status).toBe(200)
|
||||
const payload = decodePayload(res.json.access_token as string)
|
||||
expect(payload.sub).toBe('9301')
|
||||
expect(payload['rn.ver']).toBe(GATED)
|
||||
})
|
||||
})
|
||||
|
||||
test('the list is split on commas and/or whitespace, like DISCORD_BENEFITS_ROLE_IDS', async () => {
|
||||
await withRoleIds(` ${booster} ,\n${supporter} `, async () => {
|
||||
expect((await login('gate_supporter', GATED)).status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
test('a Discord link holding only other roles, or no Discord link at all, is refused', async () => {
|
||||
await withRoleIds(`${supporter},${booster}`, async () => {
|
||||
const other = await login('gate_outsider', GATED)
|
||||
expect(other.status).toBe(400)
|
||||
expect(other.json.error_description).toBe(REFUSED)
|
||||
|
||||
const unlinked = await login('gate_unlinked', GATED)
|
||||
expect(unlinked.status).toBe(400)
|
||||
expect(unlinked.json.error_description).toBe(REFUSED)
|
||||
})
|
||||
})
|
||||
|
||||
test('other builds, and grants posting no ver, are never gated', async () => {
|
||||
await withRoleIds(undefined, async () => {
|
||||
expect((await login('gate_outsider')).status).toBe(200)
|
||||
expect((await login('gate_outsider', GAME_VERSION)).status).toBe(200)
|
||||
expect((await login('gate_outsider', '20250718.01')).status).toBe(200)
|
||||
expect((await login('gate_unlinked', '20230414')).status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
test('every point release of the gated build is gated', async () => {
|
||||
// The gate is on the BUILD (the bare date): the client's `.NN` is a same-day
|
||||
// rebuild, so `.14`, `.2` and the date alone are all the rollout build.
|
||||
await withRoleIds(undefined, async () => {
|
||||
for (const version of ['20260323', '20260323.2', '20260323.14']) {
|
||||
const res = await login('gate_outsider', version)
|
||||
expect(res.status, version).toBe(400)
|
||||
expect(res.json.error_description, version).toBe(REFUSED)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('the credential is checked first, so the gate cannot probe for accounts', async () => {
|
||||
await withRoleIds(undefined, async () => {
|
||||
const res = await postToken(
|
||||
`grant_type=password&username=gate_outsider&password=wrong&ver=${GATED}`
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error_description).toBe('invalid account_id or password')
|
||||
})
|
||||
})
|
||||
|
||||
test('create_account on the build is refused before anything is minted', async () => {
|
||||
await withRoleIds(`${supporter}`, async () => {
|
||||
const before = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
|
||||
n: number
|
||||
}>()
|
||||
const res = await postToken(
|
||||
`grant_type=create_account&platform_id=steam-gated&password=hunter2&ver=${GATED}`
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json).toEqual({ error: 'invalid_grant', error_description: REFUSED })
|
||||
const after = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
|
||||
n: number
|
||||
}>()
|
||||
expect(after?.n).toBe(before?.n)
|
||||
})
|
||||
})
|
||||
|
||||
// The two lists behind the gates are vars (`SUPPORTED_GAME_VERSIONS`,
|
||||
// `ROLLOUT_GAME_VERSIONS`), so an operator can end the rollout or drop a build without
|
||||
// a deploy of new code. Same env-override discipline as withRoleIds.
|
||||
describe('the lists come from vars', () => {
|
||||
const UNSUPPORTED = 'this game version is not supported by this server; update your game'
|
||||
|
||||
async function withVars<T>(
|
||||
vars: { SUPPORTED_GAME_VERSIONS?: string; ROLLOUT_GAME_VERSIONS?: string },
|
||||
run: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const originals = {
|
||||
SUPPORTED_GAME_VERSIONS: env.SUPPORTED_GAME_VERSIONS,
|
||||
ROLLOUT_GAME_VERSIONS: env.ROLLOUT_GAME_VERSIONS,
|
||||
}
|
||||
const apply = (values: typeof originals) => {
|
||||
for (const key of ['SUPPORTED_GAME_VERSIONS', 'ROLLOUT_GAME_VERSIONS'] as const) {
|
||||
if (values[key] === undefined) delete env[key]
|
||||
else env[key] = values[key]
|
||||
}
|
||||
}
|
||||
apply({ ...originals, ...vars })
|
||||
try {
|
||||
return await run()
|
||||
} finally {
|
||||
apply(originals)
|
||||
}
|
||||
}
|
||||
|
||||
test('a build dropped from SUPPORTED_GAME_VERSIONS is refused a token, staff included', async () => {
|
||||
await withVars({ SUPPORTED_GAME_VERSIONS: '20230414,20260323' }, async () => {
|
||||
await withRoleIds(undefined, async () => {
|
||||
// The 2025 build, every point release of it: this is the forced upgrade.
|
||||
for (const version of ['20250718', '20250718.01', '20250718.2']) {
|
||||
const res = await login('gate_outsider', version)
|
||||
expect(res.status, version).toBe(400)
|
||||
expect(res.json, version).toEqual({
|
||||
error: 'invalid_grant',
|
||||
error_description: UNSUPPORTED,
|
||||
})
|
||||
}
|
||||
// Staff get no exception here — the build is not served, full stop.
|
||||
expect((await login('gate_developer', '20250718.01')).status).toBe(400)
|
||||
// The builds that ARE listed, and a grant posting no `ver`, are untouched;
|
||||
// the rollout gate still applies to the one under rollout.
|
||||
expect((await login('gate_outsider', GAME_VERSION)).status).toBe(200)
|
||||
expect((await login('gate_outsider')).status).toBe(200)
|
||||
expect((await login('gate_developer', GATED)).status).toBe(200)
|
||||
expect((await login('gate_outsider', GATED)).json.error_description).toBe(REFUSED)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('create_account on an unsupported build is refused before anything is minted', async () => {
|
||||
await withVars({ SUPPORTED_GAME_VERSIONS: '20230414' }, async () => {
|
||||
const before = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
|
||||
n: number
|
||||
}>()
|
||||
const res = await postToken(
|
||||
'grant_type=create_account&platform_id=steam-unsupported&password=hunter2&ver=20250718.01'
|
||||
)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json).toEqual({ error: 'invalid_grant', error_description: UNSUPPORTED })
|
||||
const after = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{
|
||||
n: number
|
||||
}>()
|
||||
expect(after?.n).toBe(before?.n)
|
||||
})
|
||||
})
|
||||
|
||||
test('ROLLOUT_GAME_VERSIONS=off ends the rollout: the build opens to everyone', async () => {
|
||||
await withVars({ ROLLOUT_GAME_VERSIONS: 'off' }, async () => {
|
||||
await withRoleIds(undefined, async () => {
|
||||
const res = await login('gate_outsider', GATED)
|
||||
expect(res.status).toBe(200)
|
||||
expect(decodePayload(res.json.access_token as string)['rn.ver']).toBe(GATED)
|
||||
expect((await login('gate_unlinked', GATED)).status).toBe(200)
|
||||
// ...and accounts can be created on it, since nothing needs a Discord link.
|
||||
const created = await postToken(
|
||||
`grant_type=create_account&platform_id=steam-rollout-over&password=hunter2&ver=${GATED}`
|
||||
)
|
||||
expect(created.status).toBe(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('ROLLOUT_GAME_VERSIONS names which builds are gated', async () => {
|
||||
// Move the gate onto the beta build: it is now supporters-only and the 2026
|
||||
// build is open. Both stay served.
|
||||
await withVars({ ROLLOUT_GAME_VERSIONS: '20250718' }, async () => {
|
||||
await withRoleIds(supporter, async () => {
|
||||
const outsider = await login('gate_outsider', '20250718.01')
|
||||
expect(outsider.status).toBe(400)
|
||||
expect(outsider.json.error_description).toBe(REFUSED)
|
||||
expect((await login('gate_supporter', '20250718.01')).status).toBe(200)
|
||||
expect((await login('gate_outsider', GATED)).status).toBe(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('a build under rollout is served even when SUPPORTED_GAME_VERSIONS omits it', async () => {
|
||||
await withVars({ SUPPORTED_GAME_VERSIONS: '20230414' }, async () => {
|
||||
await withRoleIds(supporter, async () => {
|
||||
// Rolling out, so the ROLLOUT gate answers, not the support one.
|
||||
expect((await login('gate_supporter', GATED)).status).toBe(200)
|
||||
expect((await login('gate_outsider', GATED)).json.error_description).toBe(REFUSED)
|
||||
})
|
||||
})
|
||||
// In neither list, nobody reaches it — a rollout pulled back entirely.
|
||||
await withVars(
|
||||
{ SUPPORTED_GAME_VERSIONS: '20230414', ROLLOUT_GAME_VERSIONS: 'off' },
|
||||
async () => {
|
||||
await withRoleIds(supporter, async () => {
|
||||
for (const username of ['gate_supporter', 'gate_developer']) {
|
||||
const res = await login(username, GATED)
|
||||
expect(res.status, username).toBe(400)
|
||||
expect(res.json.error_description, username).toBe(UNSUPPORTED)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('entries are read as builds, so a copied point release still matches', async () => {
|
||||
await withVars({ SUPPORTED_GAME_VERSIONS: ' 20230414, 20250718.01 ' }, async () => {
|
||||
expect((await login('gate_outsider', '20250718.2')).status).toBe(200)
|
||||
expect((await login('gate_outsider', '20250718')).status).toBe(200)
|
||||
})
|
||||
// Blank is unset: the default list applies, including the alpha builds.
|
||||
await withVars({ SUPPORTED_GAME_VERSIONS: ' ' }, async () => {
|
||||
expect((await login('gate_outsider', '20231207')).status).toBe(200)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
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 +1629,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 () => {
|
||||
@@ -1295,6 +2012,22 @@ describe('auth worker routes', () => {
|
||||
expect(await res.text()).toBe('')
|
||||
})
|
||||
|
||||
test('GET /role/screenshare/:id matches the token feature gate for existing accounts', async () => {
|
||||
for (const id of [42, 77]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/role/screenshare/${id}`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('GET /role/screenshare/:id does not grant a role to unknown accounts', async () => {
|
||||
for (const id of ['999999', 'nonsense']) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/role/screenshare/${id}`)
|
||||
expect(res.status).toBe(404)
|
||||
expect(await res.text()).toBe('')
|
||||
}
|
||||
})
|
||||
|
||||
test('GET /role/developer/:id returns a bare false for an un-flagged account', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/role/developer/42`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -1328,11 +2061,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 +2235,17 @@ 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}',
|
||||
'GET /role/screenshare/{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 +2401,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 +2425,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 +2485,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 +2498,283 @@ 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)
|
||||
})
|
||||
})
|
||||
|
||||
// The website's "Sign in with Discord". `www` proves the Discord user (it holds the client
|
||||
// secret) and posts an identity ASSERTION signed with the shared JWT_SECRET; this grant
|
||||
// resolves it through the link table like a cached_login, or makes the account when there
|
||||
// is none. Everything here mints the assertion the way www does, with the test key.
|
||||
describe('identity_assertion grant', () => {
|
||||
const SIGNING_KEY = 'test-signing-key'
|
||||
const assertionFor = (platformId: string, platform = PlatformType.Discord) =>
|
||||
generateIdentityAssertion({ platform, platformId }, SIGNING_KEY)
|
||||
const redeem = async (assertion: string, ip?: string) =>
|
||||
postToken(`grant_type=identity_assertion&assertion=${encodeURIComponent(assertion)}`, ip)
|
||||
|
||||
test('an assertion round-trips, and nothing else passes as one', async () => {
|
||||
const assertion = await assertionFor('700000000000000001')
|
||||
await expect(validateIdentityAssertion(assertion, SIGNING_KEY)).resolves.toEqual({
|
||||
platform: PlatformType.Discord,
|
||||
platformId: '700000000000000001',
|
||||
})
|
||||
// A different key, and an access token signed with the RIGHT key: both refused. The
|
||||
// second is the one that matters — the same secret signs sessions, so the audience is
|
||||
// what keeps a stolen token from being replayed as proof of a platform identity.
|
||||
await expect(validateIdentityAssertion(assertion, 'another-key')).resolves.toBeNull()
|
||||
const session = await generateToken('42', '', 4, SIGNING_KEY)
|
||||
await expect(validateIdentityAssertion(session, SIGNING_KEY)).resolves.toBeNull()
|
||||
// And the other direction: an assertion carries no `sub`, so it is no session.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/connect/device/approve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
Authorization: `Bearer ${assertion}`,
|
||||
},
|
||||
body: 'user_code=ABCDEFGH',
|
||||
})
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
|
||||
test('signs into the one account the identity is linked to', async () => {
|
||||
await seedAccount(9400, 'DiscordLinked')
|
||||
// Linked the way the benefits claim links: with roles recorded.
|
||||
await linkPlatformIdentity(env.DB, 9400, PlatformType.Discord, '700000000000009400', [])
|
||||
const res = await redeem(await assertionFor('700000000000009400'))
|
||||
expect(res.status).toBe(200)
|
||||
expect(decodePayload(res.json.access_token as string).sub).toBe('9400')
|
||||
expect(res.json).not.toHaveProperty('created')
|
||||
expect(typeof res.json.refresh_token).toBe('string')
|
||||
})
|
||||
|
||||
test('refuses a missing, forged or replayed-session assertion', async () => {
|
||||
expect((await postToken('grant_type=identity_assertion')).status).toBe(400)
|
||||
const forged = await generateIdentityAssertion(
|
||||
{ platform: PlatformType.Discord, platformId: '700000000000009400' },
|
||||
'not-the-key'
|
||||
)
|
||||
const res = await redeem(forged)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error_description).toBe('invalid or expired identity assertion')
|
||||
// An access token for the linked account, presented as the assertion.
|
||||
const session = await generateToken('9400', '', 4, SIGNING_KEY)
|
||||
expect((await redeem(session)).status).toBe(400)
|
||||
})
|
||||
|
||||
test('creates and links an account when the identity opens none, once', async () => {
|
||||
const discordId = '700000000000009401'
|
||||
const first = await redeem(await assertionFor(discordId))
|
||||
expect(first.status).toBe(200)
|
||||
expect(first.json.created).toBe(true)
|
||||
const accountId = Number(decodePayload(first.json.access_token as string).sub)
|
||||
expect(accountId).toBeGreaterThan(0)
|
||||
|
||||
// Linked on the Discord platform, and ONLY there: the blob carries no platform
|
||||
// identity (the snowflake stays off the DTO and the token), like a password account.
|
||||
const links = await getLinksForAccount(env.DB, accountId)
|
||||
expect(links.map((l) => [l.platform, l.platformId])).toEqual([
|
||||
[PlatformType.Discord, discordId],
|
||||
])
|
||||
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
|
||||
.bind(accountId)
|
||||
.first<{ data: string }>()
|
||||
const blob = JSON.parse(row!.data) as Record<string, unknown>
|
||||
expect(blob.platformId).toBeUndefined()
|
||||
expect(blob.passwordHash).toBeUndefined()
|
||||
// Placed in Orientation like any signup.
|
||||
const presence = await env.DB.prepare('SELECT 1 AS ok FROM presence WHERE account_id = ?1')
|
||||
.bind(accountId)
|
||||
.first()
|
||||
expect(presence).not.toBeNull()
|
||||
|
||||
// The same Discord again is a sign-IN to that account, not a second account.
|
||||
const again = await redeem(await assertionFor(discordId))
|
||||
expect(again.status).toBe(200)
|
||||
expect(again.json).not.toHaveProperty('created')
|
||||
expect(decodePayload(again.json.access_token as string).sub).toBe(String(accountId))
|
||||
await expect(
|
||||
countAccountsForPlatformIdentity(env.DB, PlatformType.Discord, discordId)
|
||||
).resolves.toBe(1)
|
||||
})
|
||||
|
||||
// The stretch of the feature: a Discord-made account has no password, and a password is
|
||||
// what signs it into the game (a sideloaded Quest has no Meta nonce to offer). The
|
||||
// website's password form posts with an EMPTY old password the first time.
|
||||
test('a Discord-made account can set a password and then sign in with it', async () => {
|
||||
const created = await redeem(await assertionFor('700000000000009402'))
|
||||
const accountId = decodePayload(created.json.access_token as string).sub as string
|
||||
expect((await postToken(`account_id=${accountId}&password=hunter22`)).status).toBe(400)
|
||||
|
||||
const set = await changePassword(
|
||||
'oldPassword=&newPassword=hunter22',
|
||||
created.json.access_token as string
|
||||
)
|
||||
expect(set.status).toBe(200)
|
||||
const login = await postToken(`grant_type=password&account_id=${accountId}&password=hunter22`)
|
||||
expect(login.status).toBe(200)
|
||||
expect(decodePayload(login.json.access_token as string).sub).toBe(accountId)
|
||||
})
|
||||
|
||||
test('refuses an identity that opens several accounts rather than guessing', async () => {
|
||||
await seedAccount(9403, 'TwoLinksA')
|
||||
await seedAccount(9404, 'TwoLinksB')
|
||||
await linkPlatformIdentity(env.DB, 9403, PlatformType.Discord, '700000000000009403', [])
|
||||
await linkPlatformIdentity(env.DB, 9404, PlatformType.Discord, '700000000000009403', [])
|
||||
const res = await redeem(await assertionFor('700000000000009403'))
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error_description).toBe('platform identity is linked to more than one account')
|
||||
})
|
||||
|
||||
// The signup caps reach this path as they reach create_account — www forwards the
|
||||
// browser's real address over the service binding, so the per-IP cap counts browsers.
|
||||
test('the per-IP signup cap applies to accounts made this way', async () => {
|
||||
const ip = '198.51.100.77'
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await redeem(await assertionFor(`70000000000000950${i}`), ip)
|
||||
expect(res.status).toBe(200)
|
||||
expect(res.json.created).toBe(true)
|
||||
}
|
||||
const fourth = await redeem(await assertionFor('700000000000009509'), ip)
|
||||
expect(fourth.status).toBe(400)
|
||||
expect(fourth.json.error_description).toBe('too many accounts created from this network')
|
||||
// Signing IN from that network is untouched: the cap is on creation.
|
||||
const existing = await redeem(await assertionFor('700000000000009500'), ip)
|
||||
expect(existing.status).toBe(200)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,6 +16,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"hono": "4.13.5",
|
||||
"workers-tagged-logger": "1.0.1"
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Hono } from 'hono'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
|
||||
import type { App } from './context'
|
||||
|
||||
@@ -22,5 +23,15 @@ const app = new Hono<App>()
|
||||
.get('/', async (c) => {
|
||||
return c.text('hello, world!')
|
||||
})
|
||||
.get('/iam/me/channels/:channel', async (c) => {
|
||||
const accountId = await validateAndGetAccountId(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
if (accountId === null) return c.body(null, 401)
|
||||
const channel = c.req.param('channel')
|
||||
if (!['None', 'Announcements', 'JustInTimeTutorials'].includes(channel)) {
|
||||
return c.body(null, 400)
|
||||
}
|
||||
// GetChannelMessages decodes a bare List<InAppMessageDTO>. No campaigns are configured.
|
||||
return c.json([])
|
||||
})
|
||||
|
||||
export default app
|
||||
|
||||
@@ -1,8 +1,44 @@
|
||||
import { SELF } from 'cloudflare:test'
|
||||
import { expect, it } from 'vitest'
|
||||
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
|
||||
import { sign } from 'hono/jwt'
|
||||
import { beforeAll, expect, it } from 'vitest'
|
||||
|
||||
import type { Env } from '../../context'
|
||||
|
||||
declare module 'cloudflare:test' {
|
||||
interface ProvidedEnv extends Env {}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await adminSecretsStore(env.JWT_SECRET).create('cards-test-secret')
|
||||
})
|
||||
|
||||
async function bearer() {
|
||||
const token = await sign(
|
||||
{ sub: '205', exp: Math.floor(Date.now() / 1000) + 60 },
|
||||
'cards-test-secret',
|
||||
'HS256'
|
||||
)
|
||||
return { Authorization: `Bearer ${token}` }
|
||||
}
|
||||
|
||||
it('response with hello world', async () => {
|
||||
const res = await SELF.fetch('https://example.com')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.text()).toMatchInlineSnapshot(`"hello, world!"`)
|
||||
})
|
||||
|
||||
it('in-app message channels return a bare list and require authentication', async () => {
|
||||
const url = 'https://example.com/iam/me/channels/Announcements'
|
||||
expect((await SELF.fetch(url)).status).toBe(401)
|
||||
for (const channel of ['Announcements', 'JustInTimeTutorials', 'None']) {
|
||||
const response = await SELF.fetch(`https://example.com/iam/me/channels/${channel}`, {
|
||||
headers: await bearer(),
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
expect(await response.json()).toEqual([])
|
||||
}
|
||||
expect(
|
||||
(await SELF.fetch('https://example.com/iam/me/channels/Unknown', { headers: await bearer() }))
|
||||
.status
|
||||
).toBe(400)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+186
-4
@@ -3,8 +3,10 @@ import { describeRoute, openAPIRouteHandler, resolver } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
applyNoExecuteHeaders,
|
||||
withCleanSpec,
|
||||
withDefaultCors,
|
||||
withNoExecute,
|
||||
withNotFound,
|
||||
withOnError,
|
||||
writeContentRange,
|
||||
@@ -167,6 +169,13 @@ const app = new Hono<App>()
|
||||
// unchanged by who may read a response they already had to name exactly.
|
||||
.use('*', withDefaultCors())
|
||||
|
||||
// The blob routes force `application/octet-stream` over whatever type an upload was
|
||||
// stored with, which is what keeps a stored HTML body from rendering; `nosniff` makes
|
||||
// that type binding on the browser, and the sandboxing CSP covers the config files,
|
||||
// which go out under their own types. The hand-rolled HEAD below is outside this chain
|
||||
// and stamps the same headers itself.
|
||||
.use('*', withNoExecute())
|
||||
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
@@ -329,6 +338,155 @@ 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')}`)
|
||||
)
|
||||
|
||||
// SDK source downloads use an opaque upload capability (no bearer on this
|
||||
// request). Only creator-authenticated save history exposes these filenames.
|
||||
.get('/avatarunityasset/:filename{.+}', (c) => {
|
||||
const name = c.req.param('filename')
|
||||
if (!/^\d{4}-\d{2}-\d{2}\/[0-9a-f-]{36}$/.test(name)) return c.notFound()
|
||||
return serveAsset(c, `avatarunityasset/${name}`)
|
||||
})
|
||||
// Editable Unity source manifests. Baked bundles are served separately.
|
||||
.get(
|
||||
'/roomunityasset/:filename{.+}',
|
||||
describeRoute({
|
||||
tags: ['Assets'],
|
||||
summary: 'Download a Studio authoring manifest',
|
||||
parameters: [keyParam('filename', 'Uploaded JSON source manifest', true)],
|
||||
responses: assetResponses('The source manifest'),
|
||||
}),
|
||||
async (c) => {
|
||||
const name = c.req.param('filename')
|
||||
if (name.includes('..')) return c.body(null, 400)
|
||||
return serveAsset(c, `roomunityasset/${name}`)
|
||||
}
|
||||
)
|
||||
|
||||
// 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(env: StudioAssetEnv, 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 env.DB.prepare(
|
||||
`SELECT 1 AS stored FROM unity_asset WHERE filename = ?1 LIMIT 1`
|
||||
)
|
||||
.bind(filename)
|
||||
.first<{ stored: number }>()
|
||||
if (!row) return null
|
||||
if (/^(quest\/)?studio-avatar\//.test(filename)) return `avatar/${filename}`
|
||||
// Room builds and consumable exports use the game's /room namespace.
|
||||
// Retain older installations that stored the same filename at the bucket root.
|
||||
if (filename.startsWith('studio/') && (await env.CDN_ASSETS.head(`room/${filename}`)))
|
||||
return `room/${filename}`
|
||||
return filename
|
||||
} 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, name)
|
||||
return serveAsset(c, key ?? `unityasset/${name}`)
|
||||
}
|
||||
|
||||
/** 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, name)
|
||||
const head = await env.CDN_ASSETS.head(key ?? `unityasset/${name}`)
|
||||
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', '*')
|
||||
applyNoExecuteHeaders(headers)
|
||||
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 +501,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 +532,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
|
||||
|
||||
@@ -12,6 +12,32 @@ declare module 'cloudflare:test' {
|
||||
|
||||
const ORIGIN = 'https://example.com'
|
||||
|
||||
test('serves opaque avatar source manifests and SDK avatar bundle GET/HEAD/ranges', async () => {
|
||||
const source = '2026-10-18/11111111-1111-1111-1111-111111111111'
|
||||
await env.CDN_ASSETS.put('avatarunityasset/' + source, '{"Version":3}')
|
||||
expect(await (await exports.default.fetch(ORIGIN + '/avatarunityasset/' + source)).text()).toBe(
|
||||
'{"Version":3}'
|
||||
)
|
||||
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,filename TEXT NOT NULL,hash TEXT NOT NULL,PRIMARY KEY(unity_asset_id,target,version,kind))'
|
||||
).run()
|
||||
const name = 'quest/studio-avatar/test/item/lease.material.assetbundle'
|
||||
await env.DB.prepare(
|
||||
'INSERT INTO unity_asset(unity_asset_id,target,version,kind,filename,hash) VALUES (?,2,6,?,?,?)'
|
||||
)
|
||||
.bind('avatar-fixture', 'material', name, 'hash')
|
||||
.run()
|
||||
await env.CDN_ASSETS.put('avatar/' + name, 'UnityFS\0avatar')
|
||||
expect(
|
||||
(await exports.default.fetch(ORIGIN + '/unityasset/' + name, { method: 'HEAD' })).status
|
||||
).toBe(200)
|
||||
const response = await exports.default.fetch(ORIGIN + '/unityasset/' + name, {
|
||||
headers: { Range: 'bytes=0-7' },
|
||||
})
|
||||
expect(response.status).toBe(206)
|
||||
expect(await response.text()).toBe('UnityFS\0')
|
||||
})
|
||||
|
||||
describe('cdn endpoints', () => {
|
||||
test('GET / reports service status', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/`)
|
||||
@@ -19,6 +45,38 @@ describe('cdn endpoints', () => {
|
||||
expect(await res.json()).toEqual({ service: 'cdn', status: 'ok' })
|
||||
})
|
||||
|
||||
// A stored blob carries whatever type it was uploaded with; the routes force
|
||||
// octet-stream, and these headers make that binding and sandbox anything that is served
|
||||
// under its own type (the configs). The hand-rolled HEAD sits outside Hono and must
|
||||
// carry them too.
|
||||
test('every response carries headers that stop a stored document executing', async () => {
|
||||
await env.CDN_ASSETS.put('data/a-stored-page', '<script>alert(1)</script>', {
|
||||
httpMetadata: { contentType: 'text/html' },
|
||||
})
|
||||
for (const path of [
|
||||
'/',
|
||||
'/config/LoadingScreenTipData',
|
||||
'/config/RRPlusConfig_v3',
|
||||
'/data/a-stored-page',
|
||||
]) {
|
||||
const res = await exports.default.fetch(`${ORIGIN}${path}`)
|
||||
expect(res.status, path).toBe(200)
|
||||
expect(res.headers.get('x-content-type-options'), path).toBe('nosniff')
|
||||
expect(res.headers.get('content-security-policy'), path).toBe("default-src 'none'; sandbox")
|
||||
}
|
||||
const blob = await exports.default.fetch(`${ORIGIN}/data/a-stored-page`)
|
||||
expect(blob.headers.get('content-type')).toBe('application/octet-stream')
|
||||
|
||||
await env.CDN_ASSETS.put('unityasset/plain.assetbundle', new Uint8Array([1, 2, 3]))
|
||||
const head = await exports.default.fetch(`${ORIGIN}/unityasset/plain.assetbundle`, {
|
||||
method: 'HEAD',
|
||||
})
|
||||
expect(head.status).toBe(200)
|
||||
expect(head.headers.get('content-length')).toBe('3')
|
||||
expect(head.headers.get('x-content-type-options')).toBe('nosniff')
|
||||
expect(head.headers.get('content-security-policy')).toBe("default-src 'none'; sandbox")
|
||||
})
|
||||
|
||||
test('GET /config/LoadingScreenTipData returns the tip array', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/config/LoadingScreenTipData`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -271,6 +329,81 @@ 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))
|
||||
// Current room/consumable builds live in the game's room namespace.
|
||||
await env.CDN_ASSETS.put('room/' + filename, new Uint8Array([5, 6, 7, 8, 9]))
|
||||
const current = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, {
|
||||
headers: { Range: 'bytes=1-2' },
|
||||
})
|
||||
expect(current.status).toBe(206)
|
||||
expect(new Uint8Array(await current.arrayBuffer())).toEqual(new Uint8Array([6, 7]))
|
||||
const currentHead = await exports.default.fetch(`${ORIGIN}/unityasset/${filename}`, {
|
||||
method: 'HEAD',
|
||||
})
|
||||
expect(currentHead.status).toBe(200)
|
||||
expect(currentHead.headers.get('content-length')).toBe('5')
|
||||
|
||||
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 +426,16 @@ 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 /roomunityasset/{filename}',
|
||||
'GET /sigs/{sigName}',
|
||||
'GET /unityasset/{filename}',
|
||||
'HEAD /unityasset/{filename}',
|
||||
])
|
||||
|
||||
// Every operation carries a summary — a path present but undescribed is not
|
||||
@@ -311,3 +448,27 @@ describe('cdn endpoints', () => {
|
||||
expect(JSON.stringify(spec).includes('"$ref"')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test('Studio authoring source manifests and source files remain distinct from baked bundles', async () => {
|
||||
await env.CDN_ASSETS.put(
|
||||
'roomunityasset/studio-source-fixture.json',
|
||||
'{"Version":3,"Files":[]}',
|
||||
{ httpMetadata: { contentType: 'application/json' } }
|
||||
)
|
||||
const source = await exports.default.fetch(`${ORIGIN}/roomunityasset/studio-source-fixture.json`)
|
||||
expect(source.status).toBe(200)
|
||||
expect(await source.json()).toEqual({ Version: 3, Files: [] })
|
||||
await env.CDN_ASSETS.put('unityasset/studio-source-fixture.txt', 'source data')
|
||||
const file = await exports.default.fetch(`${ORIGIN}/unityasset/studio-source-fixture.txt`)
|
||||
expect(await file.text()).toBe('source data')
|
||||
expect(
|
||||
(
|
||||
await exports.default.fetch(`${ORIGIN}/unityasset/studio-source-fixture.txt`, {
|
||||
method: 'HEAD',
|
||||
})
|
||||
).status
|
||||
).toBe(200)
|
||||
expect((await exports.default.fetch(`${ORIGIN}/roomunityasset/missing-source.json`)).status).toBe(
|
||||
404
|
||||
)
|
||||
})
|
||||
|
||||
+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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user