mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
Compare commits
151 Commits
feature/turso
...
main
| 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 |
+105
-5
@@ -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
|
||||
|
||||
@@ -139,6 +152,21 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# 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
|
||||
@@ -199,9 +227,12 @@ 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.
|
||||
@@ -214,6 +245,52 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# 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
|
||||
@@ -223,5 +300,28 @@ RECFLARE_DOMAIN=rec.example.com
|
||||
# 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). Unset — or parsing to no roles — leaves the cron a logged no-op.
|
||||
# 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
|
||||
@@ -269,6 +293,25 @@ inconsistency here without checking the client first.
|
||||
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,19 @@ 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`.
|
||||
@@ -76,17 +89,41 @@ This command targets the `balance` table, so it runs under `apps/econ`'s wrangle
|
||||
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 # apps/econ/static/db/2025-1-cai.json → local
|
||||
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
|
||||
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 an export of `CustomAvatarItem` records (a JSON array, a `{ Results }` page, or one
|
||||
record) into the `custom_avatar_item` table, each stored as the row's JSON with its
|
||||
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
|
||||
|
||||
@@ -140,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]
|
||||
@@ -125,8 +125,8 @@ admin *args:
|
||||
reload-plus *args:
|
||||
bun runx admin reload-plus "$@"
|
||||
|
||||
# Load the first-party custom avatar items (apps/econ/static/db/2025-1-cai.json by default)
|
||||
# into the custom_avatar_item table. Merges: existing ids are replaced, nothing is deleted.
|
||||
# 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')]
|
||||
@@ -134,3 +134,12 @@ reload-plus *args:
|
||||
[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` |
|
||||
|
||||
@@ -347,6 +347,40 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// 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/username/:name',
|
||||
describeRoute({
|
||||
tags: ['Lookup'],
|
||||
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: 'name',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The username, exactly (case-insensitive)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(AccountDto, 'The public account'),
|
||||
404: { description: 'No account has that username' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
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)
|
||||
@@ -505,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,15 +198,15 @@ 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'
|
||||
|
||||
@@ -153,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: '' })
|
||||
@@ -272,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`, {
|
||||
@@ -689,6 +707,7 @@ 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}',
|
||||
@@ -741,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.
|
||||
//
|
||||
@@ -787,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
|
||||
|
||||
+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 () => {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
import { COACH_ACCOUNT_ID } from './custom-avatar-items-load'
|
||||
|
||||
/** Schema DDL (mirror of migrations/0022_custom_avatar_item_json.sql). */
|
||||
/** 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 (
|
||||
data TEXT NOT NULL,
|
||||
@@ -45,6 +45,9 @@ export const SCHEMA_DDL: string[] = [
|
||||
)`,
|
||||
`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`,
|
||||
]
|
||||
|
||||
/**
|
||||
@@ -72,6 +75,9 @@ export interface CustomAvatarItemSave {
|
||||
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). */
|
||||
@@ -131,6 +137,16 @@ export interface CustomAvatarItem {
|
||||
*/
|
||||
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. */
|
||||
@@ -287,10 +303,11 @@ 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 data 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(toDto).map((item) => [item.CustomAvatarItemId, item]))
|
||||
return ids.flatMap((id) => byId.get(id) ?? [])
|
||||
@@ -345,6 +362,8 @@ export function toQuestCustomAvatarItem(item: CustomAvatarItem): CustomAvatarIte
|
||||
...save,
|
||||
UnityAsset: questAsset(save.UnityAsset),
|
||||
UnityAsset2: questAsset(save.UnityAsset2),
|
||||
UnityAssetHash: save.QuestUnityAssetHash ?? save.UnityAssetHash,
|
||||
UnityAsset2Hash: save.QuestUnityAsset2Hash ?? save.UnityAsset2Hash,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
+101
-9
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
Accessibility,
|
||||
glyphLength,
|
||||
MAX_EVENT_DESCRIPTION_LENGTH,
|
||||
MAX_EVENT_DURATION_MS,
|
||||
@@ -925,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) {
|
||||
@@ -953,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
|
||||
@@ -960,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)
|
||||
}
|
||||
@@ -1012,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:
|
||||
*
|
||||
@@ -1035,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 `#`
|
||||
@@ -1067,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)
|
||||
}
|
||||
|
||||
+148
-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 (
|
||||
@@ -321,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,
|
||||
@@ -582,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),
|
||||
@@ -592,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))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -664,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,
|
||||
@@ -680,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(
|
||||
@@ -858,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,
|
||||
@@ -877,9 +964,9 @@ 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.
|
||||
@@ -992,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,
|
||||
@@ -1158,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>()
|
||||
|
||||
+104
-35
@@ -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(),
|
||||
@@ -434,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(),
|
||||
})
|
||||
|
||||
@@ -563,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(),
|
||||
@@ -756,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. */
|
||||
@@ -785,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. */
|
||||
@@ -1061,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(),
|
||||
@@ -1340,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'),
|
||||
@@ -1363,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`
|
||||
@@ -1384,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'),
|
||||
@@ -1404,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'),
|
||||
@@ -1572,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
|
||||
@@ -1615,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(),
|
||||
@@ -1650,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
|
||||
|
||||
+52
-33
@@ -8,8 +8,8 @@
|
||||
*
|
||||
* The `api` worker owns this schema/migration (migrations/0004_report.sql,
|
||||
* 0009_report_ban.sql, 0011_report_event.sql, 0016_report_invention.sql,
|
||||
* 0017_report_custom_avatar_item.sql, 0020_report_ban_audit.sql and
|
||||
* 0025_report_chat_message.sql, applied under its own `migrations_table` so it doesn't clash
|
||||
* 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` —
|
||||
@@ -24,7 +24,9 @@
|
||||
* CREATOR — see `POST /api/playerevents/v1/report`, `POST /api/inventions/v1/report` and
|
||||
* `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`. The four id columns are mutually exclusive; a row
|
||||
* `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.
|
||||
*
|
||||
@@ -42,9 +44,10 @@
|
||||
/**
|
||||
* 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 +
|
||||
* 0020_report_ban_audit.sql + 0025_report_chat_message.sql + 0029_report_ban_lifted.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`, `custom_avatar_item_id` or `chat_message_id` is
|
||||
* 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
|
||||
@@ -71,7 +74,8 @@ export const SCHEMA_DDL: string[] = [
|
||||
banned_at TEXT,
|
||||
chat_message_id INTEGER,
|
||||
unbanned_by_player_id INTEGER,
|
||||
unbanned_at TEXT
|
||||
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)`,
|
||||
@@ -125,9 +129,10 @@ export interface ReportRow {
|
||||
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. This is what the client's
|
||||
* block screen counts the ban from (see `banBlockDetails`); rows written before
|
||||
* 0020_report_ban_audit.sql carry NULL and fall back to `created_at`.
|
||||
* 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
|
||||
/**
|
||||
@@ -149,6 +154,13 @@ export interface ReportRow {
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -174,6 +186,8 @@ export interface NewReport {
|
||||
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). */
|
||||
@@ -183,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, chat_message_id
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
|
||||
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(
|
||||
@@ -200,7 +214,8 @@ export async function createReport(db: D1Database, input: NewReport): Promise<Re
|
||||
input.eventId ?? null,
|
||||
input.inventionId ?? null,
|
||||
input.customAvatarItemId ?? null,
|
||||
input.chatMessageId ?? null
|
||||
input.chatMessageId ?? null,
|
||||
input.imageId ?? null
|
||||
)
|
||||
.first<ReportRow>()
|
||||
// RETURNING always yields the inserted row; the non-null assert keeps the caller
|
||||
@@ -271,8 +286,8 @@ export async function isPlayerBanned(
|
||||
* 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` and is what the client's
|
||||
* block screen counts from. A lift clears all four columns together: an unbanned row must
|
||||
* 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.
|
||||
*
|
||||
@@ -507,9 +522,9 @@ export async function getBansInForce(
|
||||
// ---- Block details ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `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.
|
||||
* `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
|
||||
|
||||
@@ -547,18 +562,24 @@ export const NOT_BLOCKED = {
|
||||
* (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` and `TimeoutStartedAt` are a PAIR in the client: the block runs from the
|
||||
* start for the duration. The start is `banned_at`, the instant the ban was handed down
|
||||
* (see 0020_report_ban_audit.sql), 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.
|
||||
* `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.
|
||||
*
|
||||
* It falls back to the report's `created_at` for a row banned before that column existed.
|
||||
* The two can be months apart, and using `created_at` as the start — which is what this
|
||||
* did before there was anything else to use — misreports both halves of the pair: a 7-day
|
||||
* ban applied to a 30-day-old report told the player their block began a month ago and
|
||||
* ended three weeks ago. Every pre-migration row still reads exactly as it used to, which
|
||||
* is the point of the fallback rather than a coalesce to now.
|
||||
* `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
|
||||
@@ -568,19 +589,17 @@ export const NOT_BLOCKED = {
|
||||
* 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) {
|
||||
const startedAtIso = ban.banned_at ?? ban.created_at
|
||||
const startedAt = Date.parse(startedAtIso)
|
||||
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) - startedAt) / 1000))
|
||||
: 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: startedAtIso,
|
||||
TimeoutStartedAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
+194
-43
@@ -13,6 +13,7 @@ import {
|
||||
MAX_BULK_OUTFIT_ACCOUNTS,
|
||||
setOutfit,
|
||||
} from '@repo/domain'
|
||||
import { SNIFFED_IMAGE_FORMATS, sniffImage } from '@repo/hono-helpers'
|
||||
|
||||
import {
|
||||
createCustomAvatarItem,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
listCustomAvatarItemsByCreator,
|
||||
listFeaturedCustomAvatarItems,
|
||||
listHotCustomAvatarItems,
|
||||
MAX_PLAYER_ITEM_PRICE,
|
||||
searchCustomAvatarItems,
|
||||
toQuestCustomAvatarItem,
|
||||
updateCustomAvatarItem,
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
deleteInvention,
|
||||
getFeaturedInventions,
|
||||
getInventionById,
|
||||
getInventionsByCreators,
|
||||
getInventionsByIds,
|
||||
getInventionsByRoom,
|
||||
getInventionTagFilters,
|
||||
@@ -44,6 +47,7 @@ import {
|
||||
inventionDeleteResult,
|
||||
inventionSaveV9Failure,
|
||||
isInventionCheered,
|
||||
MAX_INVENTION_PRICE,
|
||||
normalizeInventionTags,
|
||||
ownsAllInventions,
|
||||
parsePermissionLevel,
|
||||
@@ -54,6 +58,7 @@ import {
|
||||
setInventionTags,
|
||||
toSaveResult,
|
||||
toSaveResultV9,
|
||||
unpublishInvention,
|
||||
updateInvention,
|
||||
} from '../inventions-db'
|
||||
import {
|
||||
@@ -81,6 +86,7 @@ import {
|
||||
InventionReportRequest,
|
||||
InventionSaveResult,
|
||||
InventionSaveV9Result,
|
||||
InventionV9Dto,
|
||||
InventionVersionDto,
|
||||
json,
|
||||
JsonArray,
|
||||
@@ -104,6 +110,7 @@ import {
|
||||
SuccessValueEnvelope,
|
||||
TagFilters,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
UnpublishInventionRequest,
|
||||
UpdateCustomAvatarItemRequest,
|
||||
UpdateInventionMetadataRequest,
|
||||
UpdateInventionRequest,
|
||||
@@ -467,18 +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. 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'),
|
||||
},
|
||||
@@ -504,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)
|
||||
@@ -532,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 },
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -572,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'),
|
||||
@@ -621,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')
|
||||
@@ -1458,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' },
|
||||
@@ -1478,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:
|
||||
@@ -1497,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' },
|
||||
@@ -1518,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))
|
||||
@@ -1677,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.
|
||||
@@ -1718,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') },
|
||||
@@ -1750,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,
|
||||
},
|
||||
}),
|
||||
@@ -1866,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.
|
||||
@@ -2244,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,
|
||||
@@ -2287,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'),
|
||||
@@ -2299,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)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3,7 +3,7 @@ import { describeRoute } from 'hono-openapi'
|
||||
|
||||
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.
|
||||
@@ -23,9 +23,11 @@ import {
|
||||
getEventTags,
|
||||
getGoingPlayerIds,
|
||||
getLiveEvents,
|
||||
getPublicEventsByPlayer,
|
||||
inviteToEvent,
|
||||
isEventResponseType,
|
||||
parseEventBody,
|
||||
parseEventSort,
|
||||
parseEventTags,
|
||||
parseEventTime,
|
||||
searchEvents,
|
||||
@@ -63,6 +65,7 @@ import {
|
||||
PlayerEventsPage,
|
||||
PlayerEventTagsRequest,
|
||||
PlayerEventTimeRequest,
|
||||
PlayerPublicEvents,
|
||||
stringQuery,
|
||||
SuccessErrorEnvelope,
|
||||
TagFilters,
|
||||
@@ -303,15 +306,16 @@ async function pushEventInvitation(
|
||||
* the split is the one `/api/gameconfigs/v1/all` already makes: anything NEWER than
|
||||
* `GAME_VERSION` (20230414) gets the pairs; that build, anything older, and a request
|
||||
* with no readable token version all get the 2023 names. Builds are date-stamped, so they
|
||||
* order as strings. `TagModifyResult.Tags` is a name list to every build — only the
|
||||
* inline field moves.
|
||||
* 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
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -446,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
|
||||
@@ -600,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),
|
||||
],
|
||||
@@ -616,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'))
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+230
-46
@@ -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'
|
||||
@@ -96,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. */
|
||||
@@ -115,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),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -215,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'),
|
||||
},
|
||||
@@ -260,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
|
||||
@@ -271,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.
|
||||
@@ -308,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'],
|
||||
@@ -349,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.
|
||||
@@ -468,10 +571,11 @@ export const imageRoutes = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -513,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.
|
||||
@@ -528,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 =
|
||||
@@ -542,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' },
|
||||
},
|
||||
@@ -571,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: '' })
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -368,12 +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 when the ban was handed down (`banned_at`, ' +
|
||||
'falling back to the report’s `created_at` for a ban set before that column ' +
|
||||
'existed) 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' +
|
||||
@@ -396,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 —
|
||||
|
||||
@@ -13,6 +13,20 @@ 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,
|
||||
@@ -20,15 +34,18 @@ import {
|
||||
BulkIdsRequest,
|
||||
CheerPlayerRequest,
|
||||
CheerPlayerResponse,
|
||||
SetSelectedCheerRequest,
|
||||
form,
|
||||
idParam,
|
||||
intQuery,
|
||||
json,
|
||||
JsonArray,
|
||||
JsonObject,
|
||||
ProgressionDto,
|
||||
ProgressionEventRecordDto,
|
||||
ReputationDto,
|
||||
SetSelectedCheerRequest,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
XpBoostActivationsResponse,
|
||||
} from '../openapi'
|
||||
import {
|
||||
addCheer,
|
||||
@@ -47,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'
|
||||
|
||||
@@ -256,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({
|
||||
@@ -524,23 +589,111 @@ export const progressionRoutes = new Hono<App>({ strict: false })
|
||||
// The progression event running right now — the limited-time XP event the client shows
|
||||
// a banner and a progress track for.
|
||||
//
|
||||
// STUB: a bare `-1`, the reference server's "no active event" value (the id of the
|
||||
// active event, or -1 when there is none). The client reads it as "no event on" and
|
||||
// skips the event UI entirely. That is the honest answer (nothing here runs events) and
|
||||
// the safe one: a fabricated event would draw a track that never fills. No auth —
|
||||
// whether an event is running is the same fact for everybody, and the client asks while
|
||||
// loading.
|
||||
// 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 event currently running (stub)',
|
||||
description:
|
||||
'The limited-time XP event in progress, as a bare event id. Always `-1` — the ' +
|
||||
'“no active event” value; nothing on this server runs one — which the client reads ' +
|
||||
'as “no event” and skips the event UI, where a 404 would stall the load. No auth: ' +
|
||||
'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 `-1` — no event is running') },
|
||||
responses: { 200: json(BareInteger, 'A bare `18` — the hardcoded active event') },
|
||||
}),
|
||||
(c) => c.json(-1)
|
||||
(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 })
|
||||
)
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -38,11 +38,15 @@ import {
|
||||
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 {
|
||||
@@ -51,7 +55,7 @@ import {
|
||||
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,
|
||||
@@ -91,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 = [
|
||||
@@ -282,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 () => {
|
||||
@@ -297,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 })
|
||||
@@ -307,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.
|
||||
@@ -327,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 () => {
|
||||
@@ -1719,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),
|
||||
@@ -2062,12 +2134,73 @@ describe('public endpoints', () => {
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
test('GET /api/progressionEvents/active is a bare -1 (no auth)', async () => {
|
||||
// -1 is the reference server's "no active event" value; the client reads it 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()).toBe(-1)
|
||||
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 () => {
|
||||
@@ -2119,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',
|
||||
@@ -2188,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(
|
||||
@@ -2307,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.
|
||||
@@ -2791,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' },
|
||||
@@ -2811,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}`
|
||||
)
|
||||
@@ -2826,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 () => {
|
||||
@@ -2860,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()
|
||||
})
|
||||
@@ -3365,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' })
|
||||
@@ -3578,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[]) =>
|
||||
({
|
||||
@@ -3821,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',
|
||||
@@ -4137,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',
|
||||
@@ -4189,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 () => {
|
||||
@@ -4363,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`, {
|
||||
@@ -4470,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'),
|
||||
@@ -4521,14 +5017,10 @@ 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(
|
||||
@@ -4606,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()
|
||||
@@ -4613,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',
|
||||
@@ -4719,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',
|
||||
@@ -6015,17 +6693,16 @@ 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 `banned_at`, the instant the ban was handed
|
||||
// down (see 0020_report_ban_audit.sql), NOT the report's created_at: the two can be
|
||||
// months apart. 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' },
|
||||
await bearer()
|
||||
)
|
||||
const [row] = await getReportsAgainst(env.DB, 221)
|
||||
const banned = await banFromReport(env.DB, row!.id)
|
||||
await banFromReport(env.DB, row!.id)
|
||||
|
||||
expect(await details('POST', '221')).toEqual({
|
||||
...NOT_BLOCKED,
|
||||
@@ -6036,21 +6713,15 @@ describe('player reports', () => {
|
||||
// the reporter is not shown to the player they reported (PlayerIdReporter
|
||||
// stays null).
|
||||
Message: 'Rule violation',
|
||||
TimeoutStartedAt: banned!.banned_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 banned_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)
|
||||
// The expiry is set relative to NOW, which is what a moderator handing down a
|
||||
// one-hour ban means — and now is `banned_at`, not the report's created_at.
|
||||
const banExpires = new Date(Date.now() + 3600 * 1000)
|
||||
const banned = await banFromReport(env.DB, row!.id, {
|
||||
banExpires: banExpires.toISOString(),
|
||||
})
|
||||
await banFromReport(env.DB, row!.id, { banExpires: banExpires.toISOString() })
|
||||
|
||||
// `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.
|
||||
@@ -6061,59 +6732,59 @@ describe('player reports', () => {
|
||||
ReportCategory: 103,
|
||||
IsBan: true,
|
||||
Message: 'Rule violation',
|
||||
TimeoutStartedAt: banned!.banned_at,
|
||||
TimeoutStartedAt: null,
|
||||
})
|
||||
// Within a second of the hour: `banned_at` is stamped by the write, so the span
|
||||
// is the hour minus however long the write took.
|
||||
// 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 whole point of `banned_at`: a ban applied to an OLD report used to be
|
||||
// described as having started when the report was filed, so a 7-day ban on a
|
||||
// month-old report told the player their block began a month ago — and the duration,
|
||||
// measured from there, came out as already served. Both halves of the pair now
|
||||
// start from the ban.
|
||||
test('counts a ban from when it was handed down, not from when the report was filed', async () => {
|
||||
// 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)
|
||||
// Backdate the report a month, as if it had sat in the queue.
|
||||
const filed = new Date(Date.now() - 30 * 86_400_000).toISOString()
|
||||
await env.DB.prepare('UPDATE report SET created_at = ?2 WHERE id = ?1')
|
||||
.bind(row!.id, filed)
|
||||
// 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 banned = await banFromReport(env.DB, row!.id, {
|
||||
banExpires: new Date(Date.now() + 7 * 86_400_000).toISOString(),
|
||||
})
|
||||
const body = (await details('GET', '224')) as Record<string, unknown>
|
||||
expect(body.TimeoutStartedAt).toBe(banned!.banned_at)
|
||||
expect(body.TimeoutStartedAt).not.toBe(filed)
|
||||
// Seven days, not the negative-then-clamped span the report's date would give.
|
||||
expect(body.Duration).toBeGreaterThan(7 * 86_400 - 5)
|
||||
expect(body.Duration).toBeLessThanOrEqual(7 * 86_400)
|
||||
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 the column carries no `banned_at`, and must keep
|
||||
// reading exactly as it used to rather than falling back to "now" — which would
|
||||
// silently restart every standing ban the moment this shipped.
|
||||
test('falls back to created_at for a ban with no recorded banned_at', async () => {
|
||||
// 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.parse(row!.created_at) + 3600 * 1000).toISOString())
|
||||
.bind(row!.id, new Date(Date.now() + 3600 * 1000).toISOString())
|
||||
.run()
|
||||
|
||||
expect(await details('GET', '225')).toEqual({
|
||||
...NOT_BLOCKED,
|
||||
const { Duration: _duration, ...unblocked } = NOT_BLOCKED
|
||||
const body = (await details('GET', '225')) as Record<string, unknown>
|
||||
expect(body).toMatchObject({
|
||||
...unblocked,
|
||||
ReportCategory: 101,
|
||||
Duration: 3600,
|
||||
IsBan: true,
|
||||
Message: 'Rule violation',
|
||||
TimeoutStartedAt: row!.created_at,
|
||||
})
|
||||
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
|
||||
@@ -6251,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' }))
|
||||
@@ -6276,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)')
|
||||
@@ -6327,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.
|
||||
@@ -6368,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,
|
||||
])
|
||||
|
||||
@@ -6390,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,
|
||||
])
|
||||
@@ -6488,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')
|
||||
@@ -6587,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,
|
||||
@@ -6595,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(
|
||||
@@ -6633,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'),
|
||||
@@ -6644,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')
|
||||
@@ -6671,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()
|
||||
})
|
||||
|
||||
@@ -6701,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)')
|
||||
@@ -6729,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 }),
|
||||
})
|
||||
@@ -6741,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' }),
|
||||
})
|
||||
@@ -6757,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' }))
|
||||
@@ -8316,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)
|
||||
@@ -8536,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)
|
||||
@@ -9248,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
|
||||
@@ -9268,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', () => {
|
||||
@@ -9296,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',
|
||||
@@ -9328,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',
|
||||
@@ -9353,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',
|
||||
@@ -9364,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',
|
||||
@@ -9409,6 +10490,10 @@ describe('openapi', () => {
|
||||
'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',
|
||||
@@ -9416,7 +10501,9 @@ 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',
|
||||
@@ -9450,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)
|
||||
})
|
||||
@@ -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
|
||||
|
||||
+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,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;
|
||||
+626
-26
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
countAccountsByDeviceId,
|
||||
countAccountsBySignupIp,
|
||||
createAccount,
|
||||
GAME_VERSION,
|
||||
@@ -13,17 +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,
|
||||
@@ -31,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,
|
||||
@@ -54,18 +70,30 @@ import {
|
||||
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`. */
|
||||
@@ -88,6 +116,21 @@ const TOKEN_SCOPE =
|
||||
*/
|
||||
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
|
||||
@@ -116,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
|
||||
@@ -205,7 +330,9 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
||||
}
|
||||
|
||||
/** Whether one account row holds a role. Absent/false both mean no role. */
|
||||
function holdsRole(account: Account, role: 'developer' | 'moderator'): boolean {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -215,7 +342,11 @@ function holdsRole(account: Account, role: 'developer' | 'moderator'): boolean {
|
||||
* mirroring the reference API. An id that is not a number is answered as unknown (404) rather
|
||||
* than as a 400: `/role/developer/nonsense` is just an id that matches nothing.
|
||||
*/
|
||||
async function roleAnswer(c: Context<App>, role: 'developer' | 'moderator', id: string) {
|
||||
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)
|
||||
@@ -254,17 +385,28 @@ async function roleFilterAnswer(c: Context<App>, role: 'developer' | 'moderator'
|
||||
* (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
|
||||
}
|
||||
|
||||
@@ -290,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
|
||||
@@ -596,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',
|
||||
@@ -603,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.',
|
||||
'',
|
||||
@@ -622,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,',
|
||||
@@ -650,7 +1104,11 @@ 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.',
|
||||
'',
|
||||
@@ -673,6 +1131,23 @@ const app = new Hono<App>()
|
||||
'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,
|
||||
@@ -703,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 : ''
|
||||
@@ -726,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
|
||||
@@ -742,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
|
||||
@@ -752,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
|
||||
@@ -841,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
|
||||
@@ -872,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(
|
||||
@@ -880,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 &&
|
||||
@@ -914,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)
|
||||
@@ -1039,6 +1604,33 @@ const app = new Hono<App>()
|
||||
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1108,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
|
||||
@@ -1294,6 +1889,11 @@ const app = new Hono<App>()
|
||||
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
|
||||
// Meta's own `GetUserProof` nonces have) and answers it as a bare JSON string. Nothing
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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,12 +295,14 @@ 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.\n\n` +
|
||||
`This is the ONE-player form. \`/role/${role}?id=\` is a different question with a ` +
|
||||
|
||||
@@ -314,6 +314,147 @@ export async function getLinksForPlatformId(
|
||||
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). */
|
||||
export async function getLinksForAccount(
|
||||
db: D1Database,
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import '../../auth.app'
|
||||
import {
|
||||
AUDIT_LOG_SCHEMA_DDL,
|
||||
GAME_VERSION,
|
||||
getAccount,
|
||||
getAccountsByDeviceId,
|
||||
hashPassword,
|
||||
PRESENCE_SCHEMA_DDL,
|
||||
@@ -14,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,
|
||||
@@ -31,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'
|
||||
|
||||
@@ -96,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()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -681,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
|
||||
@@ -722,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
|
||||
@@ -801,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)
|
||||
@@ -1035,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
|
||||
@@ -1056,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 () => {
|
||||
@@ -1421,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)
|
||||
@@ -1533,6 +2140,76 @@ describe('auth worker routes', () => {
|
||||
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)
|
||||
@@ -1562,9 +2239,13 @@ describe('auth worker routes', () => {
|
||||
'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',
|
||||
])
|
||||
|
||||
@@ -1955,3 +2636,145 @@ describe('staff login audit', () => {
|
||||
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',
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -57,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)
|
||||
})
|
||||
|
||||
+8
-1
@@ -17,11 +17,18 @@ 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), `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
|
||||
|
||||
|
||||
+158
-2
@@ -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())
|
||||
|
||||
@@ -353,6 +362,131 @@ const app = new Hono<App>()
|
||||
(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(
|
||||
@@ -376,7 +510,9 @@ app.get(
|
||||
'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.',
|
||||
@@ -396,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)
|
||||
@@ -286,6 +344,66 @@ describe('cdn endpoints', () => {
|
||||
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)
|
||||
@@ -314,7 +432,10 @@ describe('cdn endpoints', () => {
|
||||
'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
|
||||
@@ -327,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
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@2toad/profanity": "3.3.0",
|
||||
"@repo/domain": "workspace:*",
|
||||
"@repo/hono-helpers": "workspace:*",
|
||||
"@repo/jwt": "workspace:*",
|
||||
"@standard-community/standard-json": "0.3.5",
|
||||
|
||||
+46
-24
@@ -2,8 +2,9 @@ import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { mergePlayerSettings as mergeStoredPlayerSettings, readPlayerSettings } from '@repo/domain'
|
||||
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId } from '@repo/jwt'
|
||||
import { validateAndGetAccountId, validateAndGetBuild } from '@repo/jwt'
|
||||
|
||||
import { censorSwears } from '../../api/src/sanitize'
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
@@ -19,12 +20,13 @@ import {
|
||||
CreatePartyChatResponse,
|
||||
CreateThreadRequest,
|
||||
CreateThreadResponse,
|
||||
FavoriteChatResponse,
|
||||
FavoriteThreadRequest,
|
||||
form,
|
||||
json,
|
||||
messageCountParam,
|
||||
NOT_A_MEMBER_RESPONSE,
|
||||
PartyChatThread,
|
||||
PartyChatReadResponse,
|
||||
PartyInviteSettings,
|
||||
RenameThreadRequest,
|
||||
SendMessageRequest,
|
||||
@@ -40,9 +42,9 @@ import {
|
||||
ChatThreadType,
|
||||
createThread,
|
||||
getOrCreateThreadWithMembers,
|
||||
getPartyThreadForPlayer,
|
||||
getThreadForPlayer,
|
||||
getThreadMemberIds,
|
||||
getPartyThreadForPlayer,
|
||||
getThreadMeta,
|
||||
getThreadsForPlayer,
|
||||
isThreadMember,
|
||||
@@ -201,10 +203,7 @@ async function getPlayerSettings(
|
||||
env: Env,
|
||||
accountId: number
|
||||
): Promise<Record<string, string> | null> {
|
||||
return env.RECFLARE_PLAYER_SETTINGS.get<Record<string, string>>(
|
||||
`player:${accountId}`,
|
||||
'json'
|
||||
).catch(() => null)
|
||||
return readPlayerSettings(env.RECFLARE_PLAYER_SETTINGS, accountId).catch(() => null)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -251,15 +250,15 @@ async function writeChatPrivacy(
|
||||
* Merge keys into the player's settings map, the way the `playersettings` worker's own PUT
|
||||
* does. Never a whole-map write: the bag holds every setting the player has (OOBE state,
|
||||
* tutorial mask, …) and storing one key on its own would wipe the rest. Values are strings,
|
||||
* which is what that worker stores and what its GET serves back.
|
||||
* which is what that worker stores and what its GET serves back. Nothing is written when
|
||||
* the patch changes no value — the client re-posts its privacy settings freely.
|
||||
*/
|
||||
async function mergePlayerSettings(
|
||||
env: Env,
|
||||
accountId: number,
|
||||
patch: Record<string, string>
|
||||
): Promise<void> {
|
||||
const merged: Record<string, string> = { ...(await getPlayerSettings(env, accountId)), ...patch }
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put(`player:${accountId}`, JSON.stringify(merged))
|
||||
await mergeStoredPlayerSettings(env.RECFLARE_PLAYER_SETTINGS, accountId, patch)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -574,7 +573,7 @@ async function formField(c: Context<App>, name: string): Promise<string | undefi
|
||||
|
||||
/**
|
||||
* A concise `describeRoute` spec for one of the thread-scoped actions that answers the
|
||||
* bare ChatResult integer rather than an HTTP status — rename, leave, snooze, favorite,
|
||||
* bare ChatResult integer rather than an HTTP status — rename, leave, snooze,
|
||||
* add-member and the read-pointer moves. They share the auth gate, the `:id` path param,
|
||||
* and the "3 when the caller isn't on the thread" behaviour.
|
||||
*/
|
||||
@@ -893,7 +892,10 @@ const app = new Hono<App>()
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(PartyChatThread, 'The caller’s party, or `{}` when they have none'),
|
||||
200: json(
|
||||
PartyChatReadResponse,
|
||||
'2026: a list of party threads; older builds: a thread or {}'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
@@ -902,19 +904,22 @@ const app = new Hono<App>()
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
// Path 1: already in a party. One query, no settings read.
|
||||
const build = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
const listResponse = build !== null && build >= '20260323'
|
||||
const noParty = () => (listResponse ? c.json([]) : c.json({}))
|
||||
let thread = await getPartyThreadForPlayer(c.env.DB, id)
|
||||
|
||||
// Path 2: not in one — the key is the only thing that can name a party to join.
|
||||
if (thread === null) {
|
||||
const chatThreadId = await readLatestPartyChatId(c.env, id)
|
||||
if (chatThreadId === null) return c.json({})
|
||||
if (chatThreadId === null) return noParty()
|
||||
|
||||
// Checked BEFORE the join: a gone thread, one of some other type, or a party
|
||||
// too old to still be taking people isn't something to put anybody on —
|
||||
// whoever wrote the key.
|
||||
const meta = await getThreadMeta(c.env.DB, chatThreadId)
|
||||
if (meta === null || meta.chatThreadType !== ChatThreadType.Party) return c.json({})
|
||||
if (!isPartyJoinable(meta.createdAt)) return c.json({})
|
||||
if (meta === null || meta.chatThreadType !== ChatThreadType.Party) return noParty()
|
||||
if (!isPartyJoinable(meta.createdAt)) return noParty()
|
||||
|
||||
await addThreadMember(c.env.DB, chatThreadId, id)
|
||||
// The same announcement the add-member route makes: the party learns someone
|
||||
@@ -927,7 +932,8 @@ const app = new Hono<App>()
|
||||
const messages = await getThreadMessages(c.env.DB, thread.chatThreadId, {
|
||||
limit: DEFAULT_THREAD_MESSAGE_COUNT,
|
||||
})
|
||||
return c.json(toPartyChatThread(thread, messages))
|
||||
const party = toPartyChatThread(thread, messages)
|
||||
return listResponse ? c.json([party]) : c.json(party)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1356,28 +1362,44 @@ const app = new Hono<App>()
|
||||
|
||||
// Favorite or unfavorite a thread (`favorite=True`), for the caller alone — like
|
||||
// snoozing, it's a per-member flag that pins the thread in their own inbox.
|
||||
//
|
||||
// Answers the client's `FavoriteChatResponse` — a bare `{ ChatResult, IsFavorited }`
|
||||
// object, not the bare ChatResult integer the neighbouring thread actions answer.
|
||||
.on(
|
||||
['PUT', 'POST'],
|
||||
'/thread/:id{[0-9]+}/favorite',
|
||||
chatResultRoute(
|
||||
'Favorite or unfavorite a thread',
|
||||
[
|
||||
describeRoute({
|
||||
tags: ['Chat'],
|
||||
summary: 'Favorite or unfavorite a thread',
|
||||
description: [
|
||||
'Like snoozing, a per-member flag that pins the thread in the caller’s own inbox and',
|
||||
'leaves everyone else’s untouched.',
|
||||
'leaves everyone else’s untouched. Answers the client’s `FavoriteChatResponse`, a bare',
|
||||
'`{ ChatResult, IsFavorited }` object — not the bare ChatResult integer the other thread',
|
||||
'actions answer.',
|
||||
].join(' '),
|
||||
{ requestBody: form(FavoriteThreadRequest, 'The favorite flag') }
|
||||
security: AUTHED,
|
||||
parameters: [THREAD_ID_PARAM],
|
||||
requestBody: form(FavoriteThreadRequest, 'The favorite flag'),
|
||||
responses: {
|
||||
200: json(
|
||||
FavoriteChatResponse,
|
||||
'ChatResult 0 and the new flag on success; ChatResult 3 when the caller isn’t on the thread'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const chatThreadId = Number.parseInt(c.req.param('id'), 10)
|
||||
if (!(await isThreadMember(c.env.DB, chatThreadId, id))) {
|
||||
return c.json(CHAT_MEMBERSHIP_NOT_FOUND)
|
||||
return c.json({ ChatResult: CHAT_MEMBERSHIP_NOT_FOUND, IsFavorited: false })
|
||||
}
|
||||
|
||||
await setThreadFavorited(c.env.DB, chatThreadId, id, await formBool(c, 'favorite'))
|
||||
return c.json(CHAT_SUCCESS)
|
||||
const isFavorited = await formBool(c, 'favorite')
|
||||
await setThreadFavorited(c.env.DB, chatThreadId, id, isFavorited)
|
||||
return c.json({ ChatResult: CHAT_SUCCESS, IsFavorited: isFavorited })
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -274,6 +274,13 @@ export const PartyChatThread = z.object({
|
||||
ClubId: z.int().nullable().describe('Always null here — this worker serves no club threads'),
|
||||
})
|
||||
|
||||
/** 2026 reads a thread list; older clients read a thread or an empty object. */
|
||||
export const PartyChatReadResponse = z.union([
|
||||
PartyChatThread,
|
||||
z.array(PartyChatThread),
|
||||
z.object({}).strict(),
|
||||
])
|
||||
|
||||
/**
|
||||
* `POST /thread/party` — the client's CreatePartyChat. A bare two-key wrapper, PascalCase
|
||||
* like the thread inside it, with no `{ success, error, value }` envelope around it.
|
||||
@@ -381,6 +388,18 @@ export const FavoriteThreadRequest = z.object({
|
||||
.describe('`True`/`False` as the client spells it (`1`/`yes` also count as true)'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `PUT|POST /thread/:id/favorite` response — the client's `FavoriteChatResponse`, a bare
|
||||
* two-key object with no envelope. Unlike the other thread actions it is NOT a bare
|
||||
* ChatResult integer: the client parses this object either way.
|
||||
*/
|
||||
export const FavoriteChatResponse = z.object({
|
||||
ChatResult,
|
||||
IsFavorited: z
|
||||
.boolean()
|
||||
.describe('The caller’s favorite flag after the call (false when they aren’t on the thread)'),
|
||||
})
|
||||
|
||||
// ---- Shared parameters -----------------------------------------------------
|
||||
|
||||
/** The numeric `:id` path segment naming a thread (constrained to digits by the route). */
|
||||
|
||||
@@ -50,10 +50,10 @@ function b64url(input: ArrayBuffer | string): string {
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
async function bearer(sub: number): Promise<Record<string, string>> {
|
||||
async function bearer(sub: number, 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: String(sub), exp: now + 3600 })
|
||||
JSON.stringify({ sub: String(sub), exp: now + 3600, 'rn.ver': version })
|
||||
)}`
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
@@ -74,6 +74,18 @@ beforeAll(async () => {
|
||||
})
|
||||
|
||||
describe('chat endpoints', () => {
|
||||
it('2026 party reads return a list, including when the caller has no party', async () => {
|
||||
const empty = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
headers: await bearer(42601, '20260323.14'),
|
||||
})
|
||||
expect(empty.status).toBe(200)
|
||||
expect(await empty.json()).toEqual([])
|
||||
const threadId = await createThread(env.DB, [42601], null, undefined, ChatThreadType.Party)
|
||||
const present = await SELF.fetch(`${ORIGIN}/thread/party`, {
|
||||
headers: await bearer(42601, '20260323.14'),
|
||||
})
|
||||
expect(await present.json()).toMatchObject([{ ChatThreadId: threadId }])
|
||||
})
|
||||
it('GET / reports service status', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/`)
|
||||
expect(res.status).toBe(200)
|
||||
@@ -956,6 +968,17 @@ describe('PUT /thread/chatPrivacySetting', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Re-posting the stored value writes nothing to KV — writes are the cost, and the client
|
||||
// posts these freely. The raw value is seeded with whitespace JSON.stringify never
|
||||
// produces; surviving the PUT untouched means no write happened.
|
||||
it('does not write KV when the value is already stored', async () => {
|
||||
const padded = '{ "Recroom.OOBE": "77", "directMessagePrivacySetting": "Favorites" }'
|
||||
await env.RECFLARE_PLAYER_SETTINGS.put('player:887010', padded)
|
||||
const res = await put(887010, { directMessagePrivacySetting: 'Favorites' })
|
||||
expect(await res.json()).toMatchObject({ directMessagePrivacySetting: 1 })
|
||||
expect(await env.RECFLARE_PLAYER_SETTINGS.get('player:887010', 'text')).toBe(padded)
|
||||
})
|
||||
|
||||
it('accepts the enum by ordinal too, and is case-insensitive about the name', async () => {
|
||||
expect(await (await put(887005, { directMessagePrivacySetting: '2' })).json()).toMatchObject({
|
||||
directMessagePrivacySetting: 2,
|
||||
@@ -2141,13 +2164,17 @@ describe('PUT /thread/:id/favorite', () => {
|
||||
|
||||
const res = await favorite(caller, chatThreadId, 'favorite=True')
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(0)
|
||||
// The client's FavoriteChatResponse: a bare 2-key object, no envelope.
|
||||
expect(await res.json()).toEqual({ ChatResult: 0, IsFavorited: true })
|
||||
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(true)
|
||||
// Per-member, like snoozing: the other player's inbox is untouched.
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, other))?.isFavorited).toBe(false)
|
||||
|
||||
await favorite(caller, chatThreadId, 'favorite=False')
|
||||
expect(await (await favorite(caller, chatThreadId, 'favorite=False')).json()).toEqual({
|
||||
ChatResult: 0,
|
||||
IsFavorited: false,
|
||||
})
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(false)
|
||||
})
|
||||
|
||||
@@ -2155,13 +2182,19 @@ describe('PUT /thread/:id/favorite', () => {
|
||||
const caller = 893003
|
||||
const chatThreadId = await createThread(env.DB, [caller, 893004], null, caller)
|
||||
|
||||
expect(await (await favorite(caller, chatThreadId, 'favorite=True', 'POST')).json()).toBe(0)
|
||||
expect(await (await favorite(caller, chatThreadId, 'favorite=True', 'POST')).json()).toEqual({
|
||||
ChatResult: 0,
|
||||
IsFavorited: true,
|
||||
})
|
||||
expect((await getThreadForPlayer(env.DB, chatThreadId, caller))?.isFavorited).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a thread the caller is not on', async () => {
|
||||
const chatThreadId = await createThread(env.DB, [893005, 893006], null, 893005)
|
||||
expect(await (await favorite(893007, chatThreadId, 'favorite=True')).json()).toBe(3)
|
||||
expect(await (await favorite(893007, chatThreadId, 'favorite=True')).json()).toEqual({
|
||||
ChatResult: 3,
|
||||
IsFavorited: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('401s without a token', async () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
createClub,
|
||||
createClubAnnouncement,
|
||||
deleteClub,
|
||||
deleteClubAnnouncement,
|
||||
getClub,
|
||||
getClubAnnouncements,
|
||||
getClubDetails,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
getHomeClub,
|
||||
getMembership,
|
||||
getOrCreateSubscriptionClub,
|
||||
getPublicClubsByMember,
|
||||
getSubscriptionClub,
|
||||
getSubscriptionMemberships,
|
||||
glyphLength,
|
||||
@@ -55,6 +57,7 @@ import {
|
||||
ClubMembersEnvelope,
|
||||
ClubSearchResponse,
|
||||
CreateClubRequest,
|
||||
DeleteAnnouncementResponse,
|
||||
EmptyObject,
|
||||
ErrorEnvelope,
|
||||
form,
|
||||
@@ -642,6 +645,58 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// Delete one of a club's announcements. The club's owner (creator) only. Answers
|
||||
// `{ Success, Error, error_id }` — PascalCase, unlike the rest of this worker.
|
||||
.delete(
|
||||
'/announcements/club/:clubId{[0-9]+}/:announcementId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Announcements'],
|
||||
summary: 'Delete a club announcement',
|
||||
description: [
|
||||
'The club’s owner (creator) only. Answers `{ Success, Error, error_id }` —',
|
||||
'PascalCase, not the `{ error, success, value }` envelope the rest of this worker uses.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
CLUB_ID_PARAM,
|
||||
{
|
||||
name: 'announcementId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The announcement’s id',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(DeleteAnnouncementResponse, 'Deleted'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: json(DeleteAnnouncementResponse, 'Not the club’s owner'),
|
||||
404: json(DeleteAnnouncementResponse, 'No such club or announcement'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return c.body(null, 401)
|
||||
|
||||
const clubId = Number.parseInt(c.req.param('clubId'), 10)
|
||||
const announcementId = Number.parseInt(c.req.param('announcementId'), 10)
|
||||
const club = await getClub(c.env.DB, clubId)
|
||||
if (club === null) {
|
||||
return c.json({ Success: false, Error: 'Club not found.', error_id: null }, 404)
|
||||
}
|
||||
|
||||
const membership = await getMembership(c.env.DB, clubId, id)
|
||||
if (membership < ClubMembershipType.Creator) {
|
||||
return c.json({ Success: false, Error: 'Insufficient permissions.', error_id: null }, 403)
|
||||
}
|
||||
|
||||
if (!(await deleteClubAnnouncement(c.env.DB, clubId, announcementId))) {
|
||||
return c.json({ Success: false, Error: 'Announcement not found.', error_id: null }, 404)
|
||||
}
|
||||
return c.json({ Success: true, Error: null, error_id: null })
|
||||
}
|
||||
)
|
||||
|
||||
// The clubs the player is a member of (GetMyMembershipClubs). Reads the caller's
|
||||
// memberships from `club_member`. A caller with no valid token has no clubs, so
|
||||
// this answers an empty list rather than 401ing — the client shows the "my clubs"
|
||||
@@ -685,6 +740,35 @@ const app = new Hono<App>()
|
||||
}
|
||||
)
|
||||
|
||||
// The clubs shown on another player's profile: the PUBLIC clubs the account is a
|
||||
// member of. Public — a private club is left out for everyone, the account's own
|
||||
// owner included (they read theirs from /club/mine/member). An unknown account
|
||||
// simply has no clubs.
|
||||
.get(
|
||||
'/account/:accountId{[0-9]+}/clubs',
|
||||
describeRoute({
|
||||
tags: ['Clubs'],
|
||||
summary: 'The public clubs an account is a member of',
|
||||
description: [
|
||||
'The account’s memberships from `club_member`, oldest club first, PUBLIC clubs only',
|
||||
'(private clubs, subscription clubs and pending/denied/banned rows excluded). Public',
|
||||
'(no auth). An unknown account has `[]`.',
|
||||
].join(' '),
|
||||
parameters: [
|
||||
{
|
||||
name: 'accountId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'Account id (digits only)',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(ClubDto.array(), 'The account’s public clubs') },
|
||||
}),
|
||||
async (c) =>
|
||||
c.json(await getPublicClubsByMember(c.env.DB, Number.parseInt(c.req.param('accountId'), 10)))
|
||||
)
|
||||
|
||||
// Club search / browse. Public, non-subscription clubs; `category` filters to that
|
||||
// category, `query` matches the name or description, `sort` picks the order (1 =
|
||||
// newest, 2 = by name, default = most members first), and `count` caps the page
|
||||
|
||||
@@ -236,6 +236,17 @@ export const AnnouncementIdEnvelope = z.object({
|
||||
value: z.int().describe('The new announcement’s id'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The reply to deleting an announcement (`DELETE /announcements/club/:clubId/:announcementId`).
|
||||
* PascalCase with a snake_case `error_id` — not the `{ error, success, value }` envelope
|
||||
* the rest of this worker answers.
|
||||
*/
|
||||
export const DeleteAnnouncementResponse = z.object({
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable().describe('Null on success; the message shown otherwise'),
|
||||
error_id: z.null(),
|
||||
})
|
||||
|
||||
// ---- Other response shapes -------------------------------------------------
|
||||
|
||||
/** `GET /club/search` — a page of clubs plus the full match count. */
|
||||
|
||||
@@ -369,6 +369,64 @@ describe('clubs endpoints', () => {
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /account/:id/clubs lists only the PUBLIC clubs the account is a member of', async () => {
|
||||
const club = (ClubId: number, Visibility: number, ClubType = 0) => ({
|
||||
ClubId,
|
||||
Name: `Club ${ClubId}`,
|
||||
Description: '',
|
||||
Category: 'Social',
|
||||
Visibility,
|
||||
Joinability: 0,
|
||||
AllowJuniors: true,
|
||||
MainImageName: '',
|
||||
ClubType,
|
||||
ClubhouseRoomId: null,
|
||||
CreatorAccountId: 4500,
|
||||
IsRRO: false,
|
||||
MinLevel: 0,
|
||||
State: 0,
|
||||
MemberCount: 2,
|
||||
CreatedAt: `2026-07-0${ClubId - 9100}T00:00:00Z`,
|
||||
})
|
||||
// 9101 public, 9102 private, 9103 public but only a pending request, 9104 a
|
||||
// public subscription club, 9105 public.
|
||||
const clubs = [club(9101, 1), club(9102, 0), club(9103, 1), club(9104, 1, 1), club(9105, 1)]
|
||||
const insertClub = env.DB.prepare('INSERT INTO club (data) VALUES (?1)')
|
||||
const insertMember = env.DB.prepare(
|
||||
'INSERT INTO club_member (club_id, account_id, membership_type, created_at) VALUES (?1, 4501, ?2, ?3)'
|
||||
)
|
||||
await env.DB.batch([
|
||||
...clubs.map((c) => insertClub.bind(JSON.stringify(c))),
|
||||
...clubs.map((c) =>
|
||||
insertMember.bind(c.ClubId, c.ClubId === 9103 ? 1 : 10, '2026-07-10T00:00:00Z')
|
||||
),
|
||||
])
|
||||
const pending = await env.DB.prepare(
|
||||
'SELECT membership_type FROM club_member WHERE club_id = 9103 AND account_id = 4501'
|
||||
).first<{ membership_type: number }>()
|
||||
expect(pending?.membership_type).toBeLessThan(10)
|
||||
|
||||
// No auth needed; the private, pending and subscription clubs are left out.
|
||||
const res = await exports.default.fetch(`${ORIGIN}/account/4501/clubs`)
|
||||
expect(res.status).toBe(200)
|
||||
const listed = (await res.json()) as Array<{ ClubId: number; Visibility: number }>
|
||||
expect(listed.map((c) => c.ClubId)).toEqual([9101, 9105])
|
||||
expect(listed[0]).toMatchObject({ Name: 'Club 9101', Visibility: 1, MemberCount: 2 })
|
||||
|
||||
// The member themself gets the same public-only list here.
|
||||
const own = await exports.default.fetch(`${ORIGIN}/account/4501/clubs`, {
|
||||
headers: await bearer('4501'),
|
||||
})
|
||||
expect(((await own.json()) as Array<{ ClubId: number }>).map((c) => c.ClubId)).toEqual([
|
||||
9101, 9105,
|
||||
])
|
||||
|
||||
// An account with no memberships has no clubs; a non-numeric id doesn't match.
|
||||
const none = await exports.default.fetch(`${ORIGIN}/account/4599/clubs`)
|
||||
expect(await none.json()).toEqual([])
|
||||
expect((await exports.default.fetch(`${ORIGIN}/account/abc/clubs`)).status).toBe(404)
|
||||
})
|
||||
|
||||
test('the my-clubs lists exclude subscription clubs (ClubType 1)', async () => {
|
||||
// A subscription club the player both created and is a member of. It's reached
|
||||
// through /subscription/*, so it must not show up among their clubs.
|
||||
@@ -1209,6 +1267,59 @@ describe('clubs endpoints', () => {
|
||||
expect(missing.status).toBe(404)
|
||||
})
|
||||
|
||||
test('DELETE /announcements/club/:id/:announcementId deletes one, owner only', async () => {
|
||||
const create = await exports.default.fetch(`${ORIGIN}/club/create`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('9450')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: 'name=Retractions',
|
||||
})
|
||||
const clubId = ((await create.json()) as { value: { ClubId: number } }).value.ClubId
|
||||
const postOne = async (title: string): Promise<number> => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/announcements/club/${clubId}`, {
|
||||
method: 'POST',
|
||||
headers: { ...(await bearer('9450')), 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `title=${title}`,
|
||||
})
|
||||
return ((await res.json()) as { value: number }).value
|
||||
}
|
||||
const keepId = await postOne('Keep')
|
||||
const dropId = await postOne('Drop')
|
||||
const del = async (path: string, sub: string | null = '9450'): Promise<Response> =>
|
||||
exports.default.fetch(`${ORIGIN}/announcements/club/${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: sub === null ? {} : await bearer(sub),
|
||||
})
|
||||
const ids = async (): Promise<number[]> => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/announcements/club/${clubId}`)
|
||||
const body = (await res.json()) as {
|
||||
value: { Announcements: Array<{ AnnouncementId: number }> }
|
||||
}
|
||||
return body.value.Announcements.map((a) => a.AnnouncementId)
|
||||
}
|
||||
|
||||
// Signed out is a 401; a non-member is refused; neither touches the board.
|
||||
expect((await del(`${clubId}/${dropId}`, null)).status).toBe(401)
|
||||
const refused = await del(`${clubId}/${dropId}`, '9451')
|
||||
expect(refused.status).toBe(403)
|
||||
expect(await refused.json()).toEqual({
|
||||
Success: false,
|
||||
Error: 'Insufficient permissions.',
|
||||
error_id: null,
|
||||
})
|
||||
expect(await ids()).toEqual([dropId, keepId])
|
||||
|
||||
// The owner deletes it; the other stays.
|
||||
const ok = await del(`${clubId}/${dropId}`)
|
||||
expect(ok.status).toBe(200)
|
||||
expect(await ok.json()).toEqual({ Success: true, Error: null, error_id: null })
|
||||
expect(await ids()).toEqual([keepId])
|
||||
|
||||
// Gone now, so a repeat 404s; so does an unknown club, or an id from another club.
|
||||
expect((await del(`${clubId}/${dropId}`)).status).toBe(404)
|
||||
expect((await del(`99999/${keepId}`)).status).toBe(404)
|
||||
expect(await ids()).toEqual([keepId])
|
||||
})
|
||||
|
||||
test('PUT /club/:id/minlevel sets the join level, co-owner only', async () => {
|
||||
type Details = { error: string; success: boolean; value: { Club: { MinLevel: number } } }
|
||||
const create = await exports.default.fetch(`${ORIGIN}/club/create`, {
|
||||
@@ -1642,10 +1753,12 @@ describe('clubs endpoints', () => {
|
||||
)
|
||||
)
|
||||
expect([...documented].sort()).toEqual([
|
||||
'DELETE /announcements/club/{clubId}/{announcementId}',
|
||||
'DELETE /club/home/me',
|
||||
'DELETE /club/{clubId}',
|
||||
'DELETE /club/{clubId}/additionalimage/{index}',
|
||||
'DELETE /club/{clubId}/clubhouse',
|
||||
'GET /account/{accountId}/clubs',
|
||||
'GET /announcements/club/{clubId}',
|
||||
'GET /announcements/v2/mine/unread',
|
||||
'GET /announcements/v2/subscription/mine/unread',
|
||||
|
||||
@@ -72,6 +72,18 @@ const app = new Hono<App>()
|
||||
(c) => c.json(false)
|
||||
)
|
||||
|
||||
.get(
|
||||
'/purchaseRestriction/isplayerrestricted',
|
||||
describeRoute({
|
||||
tags: ['Purchase'],
|
||||
summary: 'Player purchase restriction status',
|
||||
description:
|
||||
'No external payment restrictions are configured. The 2026 client expects a bare boolean.',
|
||||
responses: { 200: json(BareBoolean, 'No purchase restriction') },
|
||||
}),
|
||||
(c) => c.json(false)
|
||||
)
|
||||
|
||||
// Begin a purchase. The client asks for a transaction handle before it takes the
|
||||
// player to the platform store; nothing is charged or recorded here, so the id is a
|
||||
// fixed placeholder and the posted body is ignored.
|
||||
|
||||
@@ -18,6 +18,12 @@ describe('commerce endpoints', () => {
|
||||
expect(await res.json()).toBe(false)
|
||||
})
|
||||
|
||||
it('2026 purchase restriction response is a bare boolean', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/purchaseRestriction/isplayerrestricted`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(false)
|
||||
})
|
||||
|
||||
it('POST /purchase/v1/initiatepurchase returns a transaction id', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/purchase/v1/initiatepurchase`, {
|
||||
method: 'POST',
|
||||
@@ -88,6 +94,7 @@ describe('commerce endpoints', () => {
|
||||
'GET /api/catalog/v1/all',
|
||||
'GET /purchase/v1/cleanuppending',
|
||||
'GET /purchase/v1/hasspentmoney',
|
||||
'GET /purchaseRestriction/isplayerrestricted',
|
||||
'GET /purchasecampaign/allcurrent/v2',
|
||||
'GET /reminder/currentTokenBundles/v2',
|
||||
'POST /purchase/v1/cleanuppending',
|
||||
|
||||
@@ -48,12 +48,13 @@ const app = new Hono<App>()
|
||||
return c.json([])
|
||||
})
|
||||
|
||||
// Sampling configuration, asked for once per session (`?sessionId=<guid>`). An empty
|
||||
// object carries no per-event overrides, which the client reads as "sample everything
|
||||
// at the built-in default rates". Since the events are discarded anyway, the rate it
|
||||
// picks makes no difference here.
|
||||
// Sampling configuration, asked for once per session (`?sessionId=<guid>`). A BARE ARRAY
|
||||
// of `{ EventType, ShouldBeSampled }` entries (the client decodes a `List<EventDTO>`),
|
||||
// e.g. `[{ "EventType": "player_join", "ShouldBeSampled": true }]` — not an object: the
|
||||
// `{}` this once served made the client's decoder throw. Empty says "sample nothing",
|
||||
// which is the right answer since the events are discarded anyway.
|
||||
.get('/sampling', async (c) => {
|
||||
return c.json({})
|
||||
return c.json([])
|
||||
})
|
||||
|
||||
export default app
|
||||
|
||||
@@ -49,7 +49,7 @@ it('acknowledges an unreadable body on both event paths', async () => {
|
||||
it('serves an empty sampling configuration', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/sampling?sessionId=e9a899ce-ce46-447d-bb22-11e82ed68f8d`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({})
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
// The client always sends `sessionId`, but nothing here reads it, so a missing one must
|
||||
@@ -57,5 +57,5 @@ it('serves an empty sampling configuration', async () => {
|
||||
it('serves the sampling configuration without a sessionId', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/sampling`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({})
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
+56
-30
@@ -25,7 +25,11 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
|
||||
| GET | `/api/avatar/v2` | ✓ | The player's own avatar |
|
||||
| POST | `/api/avatar/v2/set` | ✓ | Save the player's avatar |
|
||||
| GET | `/api/checklist/v1/current` | ✓ | NUX checklist (stub `[]`) |
|
||||
| GET | `/api/itemWishlists/v1/wishlist/me` | ✓ | Item wishlist (stub `[]`) |
|
||||
| GET | `/api/itemWishlists/v1/wishlist/me` | ✓ | The caller's item wishlist |
|
||||
| GET | `/api/itemWishlists/v1/wishlist/:accountId` | ✓ | Another player's item wishlist |
|
||||
| POST | `/api/itemWishlists/v1/wishlist/add` | ✓ | Wish for an item (form, enveloped) |
|
||||
| POST | `/api/itemWishlists/v1/wishlist/remove` | ✓ | Un-wish an item (a POST, not DELETE) |
|
||||
| POST | `/api/itemWishlists/v1/isonwishlist/bulk` | ✓ | Per-player "has it wished for" booleans |
|
||||
| GET | `/api/avatar/v3/saved` | ✓ | Saved outfits |
|
||||
| POST | `/api/avatar/v3/saved/set` | ✓ | Save an outfit into a slot |
|
||||
| GET | `/api/avatar/v2/gifts` | ✓ | Pending (unopened) gift boxes |
|
||||
@@ -39,6 +43,7 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
|
||||
| POST | `/api/settings/v2/set` | ✓ | Persist settings (accept-and-ack) |
|
||||
| GET | `/api/consumables/v2/getUnlocked` | ✓ | Unlocked consumables |
|
||||
| POST | `/api/consumables/v1/consume` | ✓ | Consume an owned consumable |
|
||||
| POST | `/api/consumables/v1/updateActive` | ✓ | Mark a consumable active (stub, empty) |
|
||||
| GET | `/api/storefronts/v4/balance/:currencyType` | ✓ | Currency balance |
|
||||
| GET | `/api/storefronts/v3/giftdropstore/:id` | | Gift-drop storefront catalog |
|
||||
| POST | `/api/storefronts/v2/buyItem` | ✓ | Buy a storefront item |
|
||||
@@ -47,10 +52,14 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
|
||||
| GET | `/api/challenge/v2/getCurrent` | ~ | Weekly rotation + the caller's progress |
|
||||
| POST | `/api/challenge/v2/updateProgress` | ✓ | Report challenge progress |
|
||||
| GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) |
|
||||
| POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 5 XP + gift box |
|
||||
| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) |
|
||||
| POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 10 XP + gift box |
|
||||
| GET | `/api/roomkeys/v1/mine` | ✓ | The player's held room keys |
|
||||
| GET | `/api/roomkeys/v1/room` | | The keys a room has listed |
|
||||
| POST | `/api/roomkeys/v1/create` | ✓ | List a key for a room (owner/co-owner) |
|
||||
| PUT | `/api/roomkeys/v1/updateAll` | ✓ | Edit one key (owner/co-owner) |
|
||||
| GET | `/api/storefronts/v1/buyRoomKey` | ✓ | Buy a room key; pays the room owner |
|
||||
| POST | `/api/storefronts/v1/PurchaseRoomKeyWithCurrency` | ✓ | Buy a room key with its room currency |
|
||||
| POST | `/api/roomCurrencies/v2/purchase` | ✓ | Buy room currency with tokens (offer) |
|
||||
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | ~ | Gold year for `developer`s, else `{}` |
|
||||
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
|
||||
|
||||
@@ -457,17 +466,19 @@ rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Soccer
|
||||
Since the client asks rather than the server offering, whether a reward is actually **owed**
|
||||
is decided here, from `reward_status` — one row per (account, reward type, gift context)
|
||||
holding the last claim and a count. One claim per type per activity per hour
|
||||
(`REWARD_COOLDOWN_MS`), flat for every type despite what a name like `FirstActivityOfDay`
|
||||
suggests; per-type windows would be a map keyed by type.
|
||||
(`REWARD_COOLDOWN_MS`) — except `FirstActivityOfDay`, which is one per PLAYER per rolling
|
||||
24h (`DAILY_REWARD_COOLDOWN_MS`): it is always claimed on the `''` context, whichever
|
||||
activity asked, so its row's `granted_at` is simply the player's last one.
|
||||
|
||||
- **The claim is one SQL statement** (`ON CONFLICT … DO UPDATE … WHERE`). The client fires
|
||||
these off right after a match, so two can land together; a read-then-write would let both
|
||||
see the same stale `granted_at` and pay out twice.
|
||||
- **A rejected claim leaves `granted_at` alone.** If an on-cooldown ask pushed the timestamp
|
||||
forward, a client that retries in a loop would never become eligible.
|
||||
- **`giftContext` (the activity, e.g. `Soccer`) is part of the key** — the "first activity of
|
||||
the day" is per activity, so a player who moves from Soccer to Paintball is owed another
|
||||
reward while a second Soccer match inside the hour is not.
|
||||
- **`giftContext` (the activity, e.g. `Soccer`) is part of the key** for the hourly types,
|
||||
so a player who moves from Soccer to Paintball is owed another reward while a second Soccer
|
||||
match inside the hour is not. The prize is still picked from the `giftContext` that asked,
|
||||
`FirstActivityOfDay` included.
|
||||
- **A contextless ask keys on `''`, not NULL.** SQLite allows — and does not dedupe — NULLs
|
||||
in a non-INTEGER primary key, so a NULL context would insert a fresh row on every ask
|
||||
instead of hitting the conflict, and the cooldown would never apply. Migration
|
||||
@@ -475,29 +486,35 @@ suggests; per-type windows would be a map keyed by type.
|
||||
primary key) and lands the pre-existing rows on that same `''` bucket, so cooldowns from
|
||||
before it keep counting.
|
||||
|
||||
**What a claim pays: 5 XP, in a gift box.** The XP (`GAME_REWARD_XP`) is banked in
|
||||
**What a claim pays: 10 XP, in a gift box.** The XP (`GAME_REWARD_XP`) is banked in
|
||||
`progression` and the box is the wrapper the client shows for it — no item, every item field
|
||||
empty, `GiftContext` 50 (`GameRewards`). The box wears the `Message` the client posted
|
||||
(`First Game of the Day`), and a `GiftPackageReceivedImmediate` frame goes out with it, the
|
||||
same push the weekly-challenge gift uses. XP is banked **before** the box is created, so a
|
||||
failure can't leave a box promising XP nobody was credited.
|
||||
|
||||
- **One flat amount for every reward type**, matching the one flat cooldown they share.
|
||||
Pricing `FirstActivityOfDay` differently from `PostGameActivity` is a map keyed by type,
|
||||
the same shape the per-type cooldown would take.
|
||||
- **Deliberately smaller than a level.** The first level costs 10 XP, so a single action
|
||||
can't be a level-up — it takes two rewards to reach level 2, and the early levels are paced
|
||||
by the hourly cooldown rather than cleared in one match.
|
||||
- **A NEW activity pays 250 tokens on top** (`NEW_ACTIVITY_BONUS_TOKENS`): the first claim
|
||||
ever in a `giftContext`, whichever reward type asked for it — `isNewActivity` counts the
|
||||
player's rows in that context right after the claim and is true when the claim's own row
|
||||
is the only one. Counting after the write is what stops two asks landing together
|
||||
(`FirstActivityOfDay` and `PostGameActivity` for the same match) from both paying: a race
|
||||
can skip the bonus, never double it. The tokens are credited to the RecCenterTokens
|
||||
balance (`StorefrontBalanceUpdate` with the resulting total) and arrive in a **second box**
|
||||
of their own — `GiftContext` 51 (`GameRewardsTokens`), `Currency` 250, no XP — after the
|
||||
reward's, so the player sees what they were for. The contextless `''` bucket is not an
|
||||
activity and never pays it.
|
||||
- **One flat amount for every reward type.** Pricing `FirstActivityOfDay` differently from
|
||||
`PostGameActivity` is a map keyed by type.
|
||||
- **Exactly an early level.** Each of the first three levels costs 10 XP, so a fresh
|
||||
player's first rewards each level them up; from level 4 (20 XP a level) it takes two or
|
||||
more, and the daily XP cap paces it from there.
|
||||
- **`PostGameActivity`/`PostQuestActivity` pay 250 tokens, up to 1,250 a day**
|
||||
(`GAME_REWARD_TOKENS`, `GAME_REWARD_DAILY_TOKEN_CAP`) — per player, whatever the
|
||||
`giftContext`. `FirstActivityOfDay` pays 250 more OUTSIDE that cap — its own once-a-day
|
||||
claim already limits it, so it has no allowance row. The running total
|
||||
is one row per player in `reward_status` under the reserved type `$tokens` (context `''`):
|
||||
`grant_count` is the TOKENS paid in the window and `granted_at` when the window opened.
|
||||
`claimAllowance` adds to it in one atomic upsert, refusing a grant that would cross the cap
|
||||
(all or nothing, so the cap is a multiple of the amount), and resets it in place once the
|
||||
window is 24h old — a rolling window, so no time zone's midnight decides it. The tokens are
|
||||
credited to the RecCenterTokens balance (`StorefrontBalanceUpdate` with the resulting total)
|
||||
and arrive in a **second box** of their own — `GiftContext` 51 (`GameRewardsTokens`),
|
||||
`Currency` 250, no XP — after the reward's, so the player sees what they were for.
|
||||
- **XP is capped too: 150 a day** (`GAME_REWARD_DAILY_XP_CAP`), fifteen claims' worth, on the
|
||||
same machinery — the player's `$xp` row, `grant_count` the XP paid in the window. Every
|
||||
type's XP draws on it but `FirstActivityOfDay`'s, a bonus on top like its tokens. Past the
|
||||
cap a claim still counts against its cooldown and still pays its activity's item (in a box
|
||||
with `Xp` 0) and its tokens; a claim with nothing left to carry gets no box at all.
|
||||
- **The response stays `[]`.** It's what the client already accepts, and the reward is
|
||||
delivered as a box, so there's nothing to put in the body. The reference answers its own
|
||||
(different) flow with `{ error, success, value: null }`, not a list of rewards.
|
||||
@@ -537,7 +554,7 @@ costs are easy to edit one at a time and hard to eyeball as a curve.
|
||||
| 50 | 5-Star Clothing (rarity 50) — the only one |
|
||||
|
||||
**One reward per level crossed** — a grant spanning several levels pays each of them. In
|
||||
practice a 5 XP game reward crosses at most one, so the second reward a fresh player claims
|
||||
practice a 10 XP game reward crosses at most one, so the first reward a fresh player claims
|
||||
hands over two boxes: the XP reward itself and the 2-Star Clothing for reaching level 2. Each
|
||||
arrives as a gift box announced like any other (`Level 2!`).
|
||||
|
||||
@@ -600,6 +617,15 @@ role that role's RecCenterTokens in a gift box, every time it fires. The map is
|
||||
for the box. An offline player finds the box on their next `GET /api/avatar/v2/gifts`.
|
||||
- A grant that fails partway is logged with the account, role and amount and is not retried —
|
||||
a retry that also failed partway is how a balance gets credited twice.
|
||||
- **The first link is paid on demand.** `www`'s benefits claim calls `grantDiscordRoleGift`
|
||||
— one account, the roles Discord just served, the same map and box — the first time an
|
||||
account links its Discord, so a new supporter gets one box now rather than after the next
|
||||
run. `www` decides "first" from the link table; this module keeps no ledger either way, and
|
||||
the cron pays that account again on its next run like everyone else.
|
||||
- **A whole run can be paid by hand.** `www`'s staff panel ("Bonus reward!", developer-only,
|
||||
`POST /api/staff/discord-roles/supporter-gift`) calls `grantDiscordRoleGifts` with its own
|
||||
bindings — the same map, audience and box as the cron — and answers with the run's summary.
|
||||
No ledger here either: it pays everyone again on top of the schedule, which is the point.
|
||||
|
||||
One D1 read for the links, then a handful of writes and two hub calls per box, sequentially.
|
||||
A cron invocation has a fixed subrequest budget (50 free, 1000 paid); a community whose
|
||||
@@ -629,10 +655,10 @@ Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no cod
|
||||
publish; per-item weighting and a multi-catalog pool would both need a manifest of the
|
||||
storefronts, which the ASSETS binding can't enumerate.
|
||||
- Consumables are granted and listed but never spent by gameplay, so `Count` only grows.
|
||||
- Several routes (room keys, wishlist, equipment, room consumables/currencies) are
|
||||
empty-list stubs pending their own stores.
|
||||
- Game rewards pay a flat 5 XP; there is no daily XP cap beyond the hourly cooldown (the
|
||||
reference caps activity XP per day in `daily_xp_ledgers`).
|
||||
- Several routes (equipment, room consumables/currencies) are empty-list stubs pending
|
||||
their own stores.
|
||||
- Game rewards pay a flat 10 XP, capped at 150 a day (the reference caps activity XP per day
|
||||
in `daily_xp_ledgers`).
|
||||
- The level-reward table and the served config's `GiftRarity` disagree in places (see the
|
||||
level section); we grant from the table and leave the config as captured, so a client that
|
||||
previews an upcoming reward would preview the config's answer, not ours.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- Who HOLDS a room key — one row per (key, player).
|
||||
--
|
||||
-- Written by `POST /api/roomkeys/v1/awardbulk`, which awards the CALLER the keys its body
|
||||
-- names. A key is held or it isn't — there is no quantity — so the pair is the primary key
|
||||
-- and awarding a key a player already holds changes nothing (`awarded_at` keeps the first
|
||||
-- award). `room_key_id` is `room_key.room_key_id`. Owned by the `econ` worker; generated
|
||||
-- from src/room-key-db.ts (ROOM_KEY_SCHEMA_DDL) — keep in sync.
|
||||
CREATE TABLE IF NOT EXISTS room_key_player (
|
||||
room_key_id INTEGER NOT NULL,
|
||||
account_id INTEGER NOT NULL,
|
||||
awarded_at TEXT NOT NULL,
|
||||
PRIMARY KEY (room_key_id, account_id)
|
||||
);
|
||||
|
||||
-- "This player's keys" — the primary key only serves lookups that lead with the key.
|
||||
CREATE INDEX IF NOT EXISTS idx_room_key_player_account ON room_key_player (account_id);
|
||||
@@ -0,0 +1,20 @@
|
||||
-- An invention's `NumDownloads` is the number of players who have it: the rows in
|
||||
-- `inventory_invention` that name it. `grantInvention` now re-derives it on every grant
|
||||
-- (packages/domain/src/inventory-invention-db.ts); every purchase before that left it at
|
||||
-- the 0 a save mints, so this backfills the inventions already bought.
|
||||
--
|
||||
-- The `invention` table is the `api` worker's (same database). `ModifiedAt` is left alone:
|
||||
-- being bought is not an edit of the invention.
|
||||
|
||||
-- Backs that per-invention count; the primary key leads with account_id.
|
||||
-- Generated from INVENTORY_INVENTION_SCHEMA_DDL — keep in sync.
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_invention_invention
|
||||
ON inventory_invention (invention_id);
|
||||
|
||||
UPDATE invention
|
||||
SET data = json_set(
|
||||
data,
|
||||
'$.NumDownloads',
|
||||
(SELECT COUNT(*) FROM inventory_invention WHERE invention_id = invention.id)
|
||||
)
|
||||
WHERE id IN (SELECT invention_id FROM inventory_invention);
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Item wishlists, owned by the `econ` worker. One row per item a player has wished for —
|
||||
-- what the store's heart toggle writes (`POST /api/itemWishlists/v1/wishlist/add|remove`,
|
||||
-- form-encoded `purchasableItemId=1534&customAvatarItemId=`) and the wishlist page reads
|
||||
-- (`GET /api/itemWishlists/v1/wishlist/me|{accountId}`, a bare array of entries).
|
||||
--
|
||||
-- `wishlist_item_id` is a GUID: the client's `WishlistItemId` is a `Guid`, not an int, so the
|
||||
-- row's key has to be one. `player_id` is the account the list belongs to.
|
||||
--
|
||||
-- A row names ONE item in one of two ways — a catalog item by `purchasable_item_id` (the
|
||||
-- storefront's `PurchasableItemId`) or a player-made/first-party custom avatar item by
|
||||
-- `custom_avatar_item_id` (its GUID, stored lowercase) — so exactly one of the two is set.
|
||||
-- On the wire the client's `PurchasableItemId` is a plain int, so a custom-item row is served
|
||||
-- as `PurchasableItemId: 0` with the GUID beside it; the column is still NULL here so the two
|
||||
-- kinds of row can't be confused.
|
||||
--
|
||||
-- The two partial unique indexes make a wish boolean: a second `add` of the same item finds
|
||||
-- the existing row rather than making a twin. `idx_wishlist_player` is the read's access
|
||||
-- pattern (one player's whole list).
|
||||
--
|
||||
-- Kept in sync with WISHLIST_SCHEMA_DDL in src/wishlist-db.ts.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS wishlist (
|
||||
wishlist_item_id TEXT PRIMARY KEY,
|
||||
player_id INTEGER NOT NULL,
|
||||
purchasable_item_id INTEGER,
|
||||
custom_avatar_item_id TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_wishlist_player ON wishlist (player_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_wishlist_player_purchasable
|
||||
ON wishlist (player_id, purchasable_item_id) WHERE purchasable_item_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_wishlist_player_custom
|
||||
ON wishlist (player_id, custom_avatar_item_id) WHERE custom_avatar_item_id IS NOT NULL;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- The served store listing behind a catalog row, verbatim as JSON: the `StoreItems[]` entry
|
||||
-- of `static/storefronts/sf3-2025.json` whose `PurchasableItemId` is the row's `catalog_id`.
|
||||
--
|
||||
-- `POST /api/storefronts/v1/purchasableitems` answers with whole listings — prices, item set,
|
||||
-- `AvatarItemInfo` and the rest — which the catalog's own columns do not carry. Keeping the
|
||||
-- listing on the row lets that lookup be one indexed query instead of fetching and parsing the
|
||||
-- 4 MB storefront file per request.
|
||||
--
|
||||
-- Filled by `runx catalog load`, like every other column. NULL on a row the served store does
|
||||
-- not list (the same rows whose `catalog_id` is NULL), and until the first load after this
|
||||
-- migration.
|
||||
--
|
||||
-- Its own migration because 0015 and 0016 are already applied.
|
||||
|
||||
ALTER TABLE catalog ADD COLUMN listing TEXT;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- What a player holds of a room consumable now remembers its last write: `concurrency_code`
|
||||
-- (the request's `NewConcurrencyCode`) and `modified_at`. Owned by the `econ` worker; generated
|
||||
-- from src/room-consumable-db.ts (ROOM_INVENTORY_SCHEMA_DDL, which mirrors the table as it
|
||||
-- stands AFTER this migration) — keep in sync.
|
||||
--
|
||||
-- Both are served on the client's `RoomConsumableInventoryItem` (`GET
|
||||
-- …/roomConsumable/room/{roomId}/me`, and the purchase's `InventoryItem`) as `ConcurrencyCode`
|
||||
-- and `ModifiedAt`. The code is stored to be echoed, not checked — no optimistic concurrency is
|
||||
-- implemented. Nullable: rows written before this have neither, and are served a null code and
|
||||
-- the listing's own `ModifiedAt`.
|
||||
ALTER TABLE room_inventory ADD COLUMN concurrency_code TEXT;
|
||||
|
||||
ALTER TABLE room_inventory ADD COLUMN modified_at TEXT;
|
||||
@@ -16,10 +16,12 @@ export const SCHEMA_DDL: string[] = [
|
||||
avatar TEXT,
|
||||
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL,
|
||||
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.username'))) VIRTUAL,
|
||||
has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL
|
||||
has_plus INTEGER GENERATED ALWAYS AS (json_extract(data, '$.hasPlus')) VIRTUAL,
|
||||
is_influencer INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInfluencer')) VIRTUAL
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON account (account_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_account_has_plus ON account (has_plus) WHERE has_plus = 1`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_account_is_influencer ON account (is_influencer) WHERE is_influencer = 1`,
|
||||
]
|
||||
|
||||
/** The stored avatar payload — opaque JSON the client sets and reads back. */
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BalancePlatform } from '../../notify/src/notification-payloads'
|
||||
import { CurrencyType } from './currency'
|
||||
|
||||
import type { GiftContent } from '@repo/domain'
|
||||
|
||||
// The currency vocabulary and the Plus reload SQL live in the import-free `currency.ts` so
|
||||
// the Node CLI in @repo/tools can share them; re-exported here so this stays the module
|
||||
// everything else reads balances from.
|
||||
@@ -199,3 +201,136 @@ export async function spendCurrency(
|
||||
.run()
|
||||
return meta.changes > 0
|
||||
}
|
||||
|
||||
/** One token box to hand over: whose, how many, and the stored box. See {@link payTokenGifts}. */
|
||||
export interface TokenGift {
|
||||
accountId: number
|
||||
/** Signed: positive credits, negative debits (guarded — see below), zero only boxes. */
|
||||
amount: number
|
||||
/** The box as stored in `received_gift`, serialized as-is. */
|
||||
content: GiftContent
|
||||
}
|
||||
|
||||
/** One box handed over by {@link payTokenGifts}: the resulting balance and the box's id. */
|
||||
export interface PaidTokenGift extends TokenGift {
|
||||
balance: number
|
||||
giftId: number
|
||||
}
|
||||
|
||||
/**
|
||||
* How many accounts one round of {@link payTokenGifts} writes at once. Every statement
|
||||
* binds three values per account, and D1 allows 100 bound parameters a statement.
|
||||
*/
|
||||
export const TOKEN_GIFT_CHUNK = 30
|
||||
|
||||
/**
|
||||
* Hand RecCenterTokens in a gift box to MANY accounts at once — the staff drops, the room
|
||||
* gift and the Discord supporter cron, which all pay a crowd. Paid one account at a time
|
||||
* (seed, credit, re-read, box: five D1 round trips each) a full server was ~1,250 queries
|
||||
* in series, past D1's 1,000-queries-an-invocation cap somewhere around 200 players, and
|
||||
* died partway with the first players paid and the rest not. This pays
|
||||
* {@link TOKEN_GIFT_CHUNK} accounts a round in two batches of multi-row statements.
|
||||
*
|
||||
* Per account it is exactly the one-player grant: the signup grant seeded first (a
|
||||
* never-touched balance must not start from the gift alone), the balance moved, then a box
|
||||
* stored. A credit is an upsert; a debit is `amount >= ?` guarded in the UPDATE itself, so
|
||||
* nobody is overdrawn, and an account that can't cover it is SKIPPED — no box — rather than
|
||||
* failing the rest. `paid` keeps the order of `gifts`; `skipped` is the rest. A duplicate
|
||||
* account among `gifts` is paid once, the first entry's amount: a multi-row upsert may not
|
||||
* name one key twice.
|
||||
*
|
||||
* Sends NOTHING: the caller builds the balance frame (the RESULTING total, `balance`, into
|
||||
* the -2 bucket) and the box's announcement from the rows returned, and should push them in
|
||||
* one hub call. Nothing here is transactional across rounds: a round that throws leaves the
|
||||
* earlier ones paid, which the caller reports.
|
||||
*/
|
||||
export async function payTokenGifts(
|
||||
db: D1Database,
|
||||
gifts: readonly TokenGift[],
|
||||
startingTokens: number
|
||||
): Promise<{ paid: PaidTokenGift[]; skipped: number[] }> {
|
||||
const grants = startingBalances(startingTokens)
|
||||
const paid: PaidTokenGift[] = []
|
||||
const skipped: number[] = []
|
||||
|
||||
const seen = new Set<number>()
|
||||
const unique = gifts.filter((g) => !seen.has(g.accountId) && seen.add(g.accountId))
|
||||
for (let i = 0; i < unique.length; i += TOKEN_GIFT_CHUNK) {
|
||||
const chunk = unique.slice(i, i + TOKEN_GIFT_CHUNK)
|
||||
const credits = chunk.filter((g) => g.amount > 0)
|
||||
const debits = chunk.filter((g) => g.amount < 0)
|
||||
const holds = chunk.filter((g) => g.amount === 0)
|
||||
|
||||
// Seed the signup grant, then move the money: one batch, one transaction. A debit's
|
||||
// guard is per row, so a chunk of debits is one UPDATE per distinct amount.
|
||||
const statements: D1PreparedStatement[] = [
|
||||
db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO balance (account_id, currency_type, amount) VALUES ${chunk
|
||||
.flatMap(() => grants.map(() => '(?, ?, ?)'))
|
||||
.join(', ')}`
|
||||
)
|
||||
.bind(
|
||||
...chunk.flatMap((g) => grants.flatMap((b) => [g.accountId, b.currencyType, b.amount]))
|
||||
),
|
||||
]
|
||||
if (credits.length > 0) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO balance (account_id, currency_type, amount) VALUES ${credits
|
||||
.map(() => '(?, ?, ?)')
|
||||
.join(', ')}
|
||||
ON CONFLICT (account_id, currency_type) DO UPDATE SET amount = amount + excluded.amount
|
||||
RETURNING account_id, amount`
|
||||
)
|
||||
.bind(...credits.flatMap((g) => [g.accountId, CurrencyType.RecCenterTokens, g.amount]))
|
||||
)
|
||||
}
|
||||
for (const amount of new Set(debits.map((g) => g.amount))) {
|
||||
const ids = debits.filter((g) => g.amount === amount).map((g) => g.accountId)
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`UPDATE balance SET amount = amount - ?1
|
||||
WHERE currency_type = ?2 AND amount >= ?1
|
||||
AND account_id IN (${ids.map(() => '?').join(', ')})
|
||||
RETURNING account_id, amount`
|
||||
)
|
||||
.bind(-amount, CurrencyType.RecCenterTokens, ...ids)
|
||||
)
|
||||
}
|
||||
if (holds.length > 0) {
|
||||
statements.push(
|
||||
db
|
||||
.prepare(
|
||||
`SELECT account_id, amount FROM balance
|
||||
WHERE currency_type = ?1 AND account_id IN (${holds.map(() => '?').join(', ')})`
|
||||
)
|
||||
.bind(CurrencyType.RecCenterTokens, ...holds.map((g) => g.accountId))
|
||||
)
|
||||
}
|
||||
const [, ...moved] = await db.batch<{ account_id: number; amount: number }>(statements)
|
||||
// RETURNING comes back in no promised order; key it, then walk the chunk in its order.
|
||||
const balances = new Map(moved.flatMap((r) => r.results).map((r) => [r.account_id, r.amount]))
|
||||
const debited = chunk.filter((g) => balances.has(g.accountId))
|
||||
for (const g of chunk) if (!balances.has(g.accountId)) skipped.push(g.accountId)
|
||||
if (debited.length === 0) continue
|
||||
|
||||
const createdAt = new Date().toISOString()
|
||||
const { results: boxes } = await db
|
||||
.prepare(
|
||||
`INSERT INTO received_gift (account_id, data, created_at) VALUES ${debited
|
||||
.map(() => '(?, ?, ?)')
|
||||
.join(', ')} RETURNING id, account_id`
|
||||
)
|
||||
.bind(...debited.flatMap((g) => [g.accountId, JSON.stringify(g.content), createdAt]))
|
||||
.all<{ id: number; account_id: number }>()
|
||||
const giftIds = new Map(boxes.map((r) => [r.account_id, r.id]))
|
||||
|
||||
for (const g of debited) {
|
||||
paid.push({ ...g, balance: balances.get(g.accountId)!, giftId: giftIds.get(g.accountId)! })
|
||||
}
|
||||
}
|
||||
return { paid, skipped }
|
||||
}
|
||||
|
||||
+48
-13
@@ -1,6 +1,6 @@
|
||||
import { CatalogKind } from './catalog-load'
|
||||
|
||||
import type { CatalogKindValue } from './catalog-load'
|
||||
import type { CatalogKindValue, StoreListing } from './catalog-load'
|
||||
|
||||
/**
|
||||
* The item CATALOG on the shared `recflare` D1 database — every avatar item, consumable and
|
||||
@@ -72,9 +72,9 @@ export {
|
||||
*
|
||||
* `catalog_id` is the small NUMERIC handle the site uses where a key would be unwieldy. For a
|
||||
* row the store lists it is the listing's own `PurchasableItemId` — the number `sf3-2025.json`
|
||||
* sells it under — and for a skin the store never listed it is handed out by the loader from
|
||||
* `CATALOG_ID_BASE`, well above any real one. It is unique where set, and the migration that
|
||||
* adds it is 0016, since 0015 was already applied.
|
||||
* sells it under, including the skins we added to the store (numbered there from
|
||||
* `UNLISTED_SKIN_ID_BASE`, 1 000 000). A row the file does not list has none and is NULL. It is
|
||||
* unique where set, and the migration that adds it is 0016, since 0015 was already applied.
|
||||
*
|
||||
* Nullability is load-bearing rather than incidental: `tooltip` is genuinely NULL on some rows
|
||||
* of BOTH kinds and `""` on others, and the client's DTOs serve the distinction through, so the
|
||||
@@ -96,7 +96,8 @@ export const CATALOG_SCHEMA_DDL: string[] = [
|
||||
tag_list TEXT,
|
||||
created_at TEXT,
|
||||
prefab_name TEXT,
|
||||
unlocked_level INTEGER
|
||||
unlocked_level INTEGER,
|
||||
listing TEXT
|
||||
)`,
|
||||
// The search index. Folded, because a name search is case-insensitive and SQLite's LIKE is
|
||||
// only case-insensitive for ASCII — which these names are not all of.
|
||||
@@ -120,10 +121,9 @@ export interface CatalogRow {
|
||||
* the store's `PurchasableItemId`, so the number resolves a row here and a listing in
|
||||
* `sf3-2025.json` alike.
|
||||
*
|
||||
* Still not an identity: an unlisted skin's number is a load-order surrogate, and a store
|
||||
* could renumber. Never store it, never treat it as what an item IS — `item_key` is that,
|
||||
* and it is what the inventory holds. NULL only between a row existing and a load numbering
|
||||
* it.
|
||||
* Still not an identity: a store could renumber. Never store it, never treat it as what an
|
||||
* item IS — `item_key` is that. NULL for a row `sf3-2025.json` does not list (a merge keeps
|
||||
* such rows); every reader filters on `catalog_id IS NOT NULL`.
|
||||
*/
|
||||
catalog_id: number | null
|
||||
kind: string
|
||||
@@ -139,6 +139,12 @@ export interface CatalogRow {
|
||||
created_at: string | null
|
||||
prefab_name: string | null
|
||||
unlocked_level: number | null
|
||||
/**
|
||||
* The served store listing behind the row — its `sf3-2025.json` `StoreItems[]` entry,
|
||||
* verbatim, as JSON (migration 0028). NULL where `catalog_id` is: a row the store does not
|
||||
* list has no listing. Read it through {@link getStoreListings}.
|
||||
*/
|
||||
listing: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -259,8 +265,8 @@ export async function getCatalogItem(db: D1Database, itemKey: string): Promise<C
|
||||
/**
|
||||
* One catalog row by its numeric handle — a store `PurchasableItemId` for a listed item.
|
||||
*
|
||||
* Fine for a request that looked the number up moments ago; never for anything stored, since
|
||||
* an unlisted skin's number is reassigned by every load.
|
||||
* The number the client looks items up by, since it is the one sf3-2025.json serves. Not for
|
||||
* anything stored: `item_key` is the identity, and a fresher store dump could renumber.
|
||||
*/
|
||||
export async function getCatalogItemById(
|
||||
db: D1Database,
|
||||
@@ -272,6 +278,35 @@ export async function getCatalogItemById(
|
||||
.first<CatalogRow>()
|
||||
}
|
||||
|
||||
/**
|
||||
* Store listings by `PurchasableItemId` (the `catalog_id`), as the served store lists them —
|
||||
* what `POST /api/storefronts/v1/purchasableitems` answers. In the order asked; an unknown id,
|
||||
* a repeat, or a row without a listing is skipped rather than left as a hole, so the result may
|
||||
* be SHORTER than the input and must not be read positionally.
|
||||
*
|
||||
* Every kind resolves — avatar items, consumables, skins and custom avatar items alike — and the
|
||||
* listing is served exactly as the store lists it. Only listings that load as no row at all
|
||||
* (loot boxes, token bundles, consumable packs; see `ListingKind`) are unknown here.
|
||||
*/
|
||||
export async function getStoreListings(db: D1Database, ids: number[]): Promise<StoreListing[]> {
|
||||
const unique = [...new Set(ids)]
|
||||
if (unique.length === 0) return []
|
||||
// One bound JSON array rather than a placeholder per id: D1 caps a statement at 100 bound
|
||||
// variables, and nothing stops the client asking for more.
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT catalog_id, listing FROM catalog
|
||||
WHERE catalog_id IN (SELECT value FROM json_each(?1)) AND listing IS NOT NULL`
|
||||
)
|
||||
.bind(JSON.stringify(unique))
|
||||
.all<{ catalog_id: number; listing: string }>()
|
||||
const byId = new Map(results.map((r) => [r.catalog_id, r.listing]))
|
||||
return unique.flatMap((id) => {
|
||||
const listing = byId.get(id)
|
||||
return listing === undefined ? [] : [JSON.parse(listing) as StoreListing]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve many keys at once, in the order asked; unknown keys are skipped rather than left as
|
||||
* holes, so the result may be SHORTER than the input and must not be read positionally. One
|
||||
@@ -351,8 +386,8 @@ export async function getAvatarItemsByTag(
|
||||
tag: string
|
||||
): Promise<CatalogAvatarItem[]> {
|
||||
const { results } = await db
|
||||
.prepare('SELECT * FROM catalog WHERE tag_list = ?1 ORDER BY friendly_name')
|
||||
.bind(tag)
|
||||
.prepare('SELECT * FROM catalog WHERE kind = ?1 AND tag_list = ?2 ORDER BY friendly_name')
|
||||
.bind(CatalogKind.AvatarItem, tag)
|
||||
.all<CatalogRow>()
|
||||
return results.map(toCatalogAvatarItem)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,13 @@ export const CatalogKind = {
|
||||
Skin: 'skin',
|
||||
/** A consumable: food, a potion, a KO icon. Its `item_key` is the `ConsumableItemDesc`. */
|
||||
Consumable: 'consumable',
|
||||
/**
|
||||
* A first-party custom avatar item the store lists. Its `item_key` is the
|
||||
* `CustomAvatarItemId`. The ITEM lives in `api`'s `custom_avatar_item` table (`just cai-load`)
|
||||
* and is resolved there; the row here exists for its LISTING, so a `PurchasableItemId` lookup
|
||||
* answers for every item the store sells. Only the shared columns are filled.
|
||||
*/
|
||||
CustomAvatarItem: 'custom_avatar_item',
|
||||
} as const
|
||||
|
||||
export type CatalogKindValue = (typeof CatalogKind)[keyof typeof CatalogKind]
|
||||
@@ -114,15 +121,13 @@ export interface SkinCapture {
|
||||
/**
|
||||
* What a store listing SELLS — the catalog kind it loads as, or why it loads as nothing.
|
||||
*
|
||||
* `custom_avatar_item` listings are first-party UGC: they live in the `custom_avatar_item`
|
||||
* table (`just cai-load`), not here. `query` is a loot box and `currency` a token bundle —
|
||||
* `query` is a loot box and `currency` a token bundle —
|
||||
* neither is an item. `unknown` carries no id at all (two expo items the dump prices at
|
||||
* nothing). `consumable_pack` is assigned by the load rather than by {@link listingKind}: a
|
||||
* consumable the store sells singly AND as a pack ("Disco Dance Break", "… 3-Pack") is one
|
||||
* consumable listed twice, and the catalog is of things, not of listings.
|
||||
*/
|
||||
export type ListingKind =
|
||||
CatalogKindValue | 'custom_avatar_item' | 'query' | 'currency' | 'unknown' | 'consumable_pack'
|
||||
export type ListingKind = CatalogKindValue | 'query' | 'currency' | 'unknown' | 'consumable_pack'
|
||||
|
||||
/** Classify one listing by which of its item ids is set. */
|
||||
export function listingKind(listing: StoreListing): ListingKind {
|
||||
@@ -131,7 +136,7 @@ export function listingKind(listing: StoreListing): ListingKind {
|
||||
if (drop.ConsumableItemDesc !== '') return CatalogKind.Consumable
|
||||
if (drop.EquipmentModificationGuid !== '') return CatalogKind.Skin
|
||||
if (drop.CustomAvatarItemId !== null && drop.CustomAvatarItemId !== '') {
|
||||
return 'custom_avatar_item'
|
||||
return CatalogKind.CustomAvatarItem
|
||||
}
|
||||
if (drop.IsQuery) return 'query'
|
||||
if (drop.Currency > 0) return 'currency'
|
||||
@@ -186,7 +191,7 @@ export const UNLISTED_SKIN_RARITY = 50
|
||||
* What a GOLD skin costs — any skin whose name carries `(Gold)`, thirteen of them, all from the
|
||||
* capture. A prestige price, and the same for a subscriber: no discount, no sale.
|
||||
*/
|
||||
export const GOLD_SKIN_PRICE = 1_000_000
|
||||
export const GOLD_SKIN_PRICE = 5_000_000
|
||||
|
||||
/** Whether a listing is a gold skin — a skin whose name says `(Gold)`. */
|
||||
export const isGoldSkin = (listing: StoreListing): boolean =>
|
||||
@@ -270,6 +275,7 @@ export const CATALOG_INSERT_COLUMNS = [
|
||||
'created_at',
|
||||
'prefab_name',
|
||||
'unlocked_level',
|
||||
'listing',
|
||||
] as const
|
||||
|
||||
/** A value bound into a load's INSERT. `undefined` is a key the capture omitted. */
|
||||
@@ -332,6 +338,7 @@ function avatarItemRow(listing: StoreListing): CatalogLoadRow {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
JSON.stringify(listing),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -359,6 +366,7 @@ function consumableRow(listing: StoreListing): CatalogLoadRow {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
JSON.stringify(listing),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -386,6 +394,39 @@ function listedSkinRow(listing: StoreListing): CatalogLoadRow {
|
||||
null,
|
||||
drop.EquipmentPrefabName,
|
||||
null,
|
||||
JSON.stringify(listing),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A listed first-party custom avatar item: the shared columns, keyed by its
|
||||
* `CustomAvatarItemId`. The record itself is `api`'s; this row carries the listing.
|
||||
*/
|
||||
function customAvatarItemRow(listing: StoreListing): CatalogLoadRow {
|
||||
const drop = listing.GiftDrop
|
||||
const guid = drop.CustomAvatarItemId as string
|
||||
return {
|
||||
key: guid,
|
||||
id: listing.PurchasableItemId,
|
||||
label: `${drop.FriendlyName} (custom avatar item)`,
|
||||
values: [
|
||||
guid,
|
||||
listing.PurchasableItemId,
|
||||
CatalogKind.CustomAvatarItem,
|
||||
drop.FriendlyName,
|
||||
drop.Tooltip,
|
||||
drop.Rarity,
|
||||
ALL_PLATFORMS,
|
||||
drop.ThumbnailImageName,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
drop.TagList,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
JSON.stringify(listing),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -445,6 +486,9 @@ export function buildCatalogLoad(listings: StoreListing[]): CatalogLoad {
|
||||
case CatalogKind.Skin:
|
||||
push(listedSkinRow(listing))
|
||||
break
|
||||
case CatalogKind.CustomAvatarItem:
|
||||
push(customAvatarItemRow(listing))
|
||||
break
|
||||
default:
|
||||
skipped[kind] = (skipped[kind] ?? 0) + 1
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
* An operator maps Discord ROLE ids to a token amount — `DISCORD_ROLE_TOKENS`,
|
||||
* `<roleId>=<tokens>,<roleId>=<tokens>` — and every time the cron fires, every account
|
||||
* holding one of those roles is handed that many RecCenterTokens in a gift box. ONE box per
|
||||
* account per run: an account holding several mapped roles is paid the HIGHEST amount among
|
||||
* them, not the sum, so the map reads as tiers however Discord stacks the roles.
|
||||
* account per run: an account holding several mapped roles — across every Discord link it
|
||||
* has — is paid the HIGHEST amount among them, not the sum, so the map reads as tiers
|
||||
* however Discord stacks the roles.
|
||||
*
|
||||
* HOW OFTEN is the cron schedule's business, not this module's: `triggers.crons` in
|
||||
* wrangler.jsonc says weekly, and an operator who wants a daily gift changes that line. There
|
||||
@@ -20,6 +21,12 @@
|
||||
* never claimed on the website has no Discord link, and so holds no roles here, however
|
||||
* many they hold in the guild.
|
||||
*
|
||||
* The same box is ALSO handed once, on demand, the first time an account links its Discord
|
||||
* (`www`'s benefits claim calls `grantDiscordRoleGift` with the roles it just read), so a
|
||||
* supporter who claims the day after a run isn't waiting a whole schedule for their first
|
||||
* gift. That is the one grant here with a "first time" to it, and `www` decides it — this
|
||||
* module keeps no ledger either way.
|
||||
*
|
||||
* Each grant is the same three moves econ's other server-handed tokens make (the game
|
||||
* reward's tickets, the new-activity bonus): seed the signup grant, credit the balance,
|
||||
* then a box that displays the amount — announced with `StorefrontBalanceUpdate` (the
|
||||
@@ -36,14 +43,21 @@ import { PlatformType } from '@repo/domain/src/enums'
|
||||
|
||||
import { getLinksForPlatform } from '../../auth/src/platform-db'
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
import { ALL_PLATFORMS, creditCurrency, CurrencyType, ensureStartingBalances } from './balance-db'
|
||||
import {
|
||||
ALL_PLATFORMS,
|
||||
creditCurrency,
|
||||
CurrencyType,
|
||||
ensureStartingBalances,
|
||||
payTokenGifts,
|
||||
TOKEN_GIFT_CHUNK,
|
||||
} from './balance-db'
|
||||
|
||||
import type { GiftContent } from '@repo/domain'
|
||||
import type { PlatformLink } from '../../auth/src/platform-db'
|
||||
import type {
|
||||
BalanceResponsePayload,
|
||||
GiftPackagePayload,
|
||||
} from '../../notify/src/notification-payloads'
|
||||
import type { PlayerNotification } from '../../notify/src/notifications-hub'
|
||||
import type { Env } from './context'
|
||||
|
||||
/** The system "Coach" account — who a box the server hands over is from. */
|
||||
@@ -55,6 +69,13 @@ const HUB_INSTANCE = 'global'
|
||||
/** The message on the box. */
|
||||
export const DISCORD_ROLE_GIFT_MESSAGE = 'Thanks for supporting the server!'
|
||||
|
||||
/**
|
||||
* What a grant needs off the bindings: the database, the hub, and the role map. Named as a
|
||||
* Pick so `www` — whose `Env` declares the same three — can hand its own bindings to
|
||||
* `grantDiscordRoleGift` without pretending to be econ.
|
||||
*/
|
||||
export type RoleGiftEnv = Pick<Env, 'DB' | 'RECFLARE_NOTIFICATIONS_HUB' | 'DISCORD_ROLE_TOKENS'>
|
||||
|
||||
/** One role's gift, as configured. */
|
||||
export interface RoleTokens {
|
||||
/** The Discord role id — a snowflake, kept as a string (one exceeds 2^53). */
|
||||
@@ -95,13 +116,32 @@ export function parseRoleTokens(raw: string | undefined): {
|
||||
}
|
||||
|
||||
/**
|
||||
* The gift a link earns: of the mapped roles it holds, the one paying the most — or null
|
||||
* when it holds none. Ties go to whichever is mapped first; they pay the same either way.
|
||||
* The role map off the bindings, with every rejected entry logged — once per run for the
|
||||
* cron, once per claim for the on-demand grant, which is seldom enough to be a reminder
|
||||
* rather than noise. Empty when the gift is off.
|
||||
*/
|
||||
export function bestRoleGift(link: PlatformLink, roles: readonly RoleTokens[]): RoleTokens | null {
|
||||
function configuredRoles(env: RoleGiftEnv): RoleTokens[] {
|
||||
const { roles, rejected } = parseRoleTokens(env.DISCORD_ROLE_TOKENS)
|
||||
for (const entry of rejected) {
|
||||
console.error(
|
||||
`discord role gift: ignoring "${entry}" in DISCORD_ROLE_TOKENS — expected <roleId>=<tokens>`
|
||||
)
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
||||
/**
|
||||
* The gift a set of held roles earns: of the mapped roles among them, the one paying the
|
||||
* most — or null when none is mapped. Ties go to whichever is mapped first; they pay the
|
||||
* same either way.
|
||||
*/
|
||||
export function bestRoleGift(
|
||||
held: readonly string[],
|
||||
roles: readonly RoleTokens[]
|
||||
): RoleTokens | null {
|
||||
let best: RoleTokens | null = null
|
||||
for (const role of roles) {
|
||||
if (!link.roles.includes(role.roleId)) continue
|
||||
if (!held.includes(role.roleId)) continue
|
||||
if (best === null || role.tokens > best.tokens) best = role
|
||||
}
|
||||
return best
|
||||
@@ -147,22 +187,31 @@ export interface RoleGiftSummary {
|
||||
granted: number
|
||||
/** Tokens handed out this run, across every box. */
|
||||
tokens: number
|
||||
/** Grants that failed partway — logged with the account and role to sort out by hand. */
|
||||
/** Accounts whose grant failed — logged with the account and role to sort out by hand. */
|
||||
failed: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One run of the gift: every Discord link holding a mapped role is paid its best role's
|
||||
* tokens. Sequential: each grant is a handful of D1 writes and two hub calls, and a cron
|
||||
* invocation has a fixed subrequest budget (50 free, 1000 paid) — a community whose
|
||||
* supporters outgrow that is the point to batch it, not before.
|
||||
* One run of the gift: every account whose Discord links hold a mapped role is paid its
|
||||
* best role's tokens. Batched, as the staff drops are: `payTokenGifts` banks the boxes a
|
||||
* few dozen accounts a round, and the frames go out in ONE hub call — grant by grant this
|
||||
* was five D1 round trips and two hub calls an account, in series, against a cron
|
||||
* invocation's fixed subrequest budget (50 free, 1,000 paid).
|
||||
*
|
||||
* `startingTokens` is the signup grant to seed before the credit — `creditCurrency` upserts
|
||||
* the balance row, so a never-touched balance would otherwise start from this gift instead
|
||||
* of the grant plus this gift.
|
||||
* A round that throws fails every account in it: they are logged, with their roles, and
|
||||
* counted in `failed` rather than retried — a retry that ALSO failed partway is how a
|
||||
* balance gets credited twice — and the later rounds still run.
|
||||
*
|
||||
* `startingTokens` is the signup grant to seed before the credit — the credit upserts the
|
||||
* balance row, so a never-touched balance would otherwise start from this gift instead of
|
||||
* the grant plus this gift.
|
||||
*
|
||||
* Takes the {@link RoleGiftEnv} pick, not econ's whole `Env`: `www`'s staff panel runs this
|
||||
* same function on a button (`POST /api/staff/discord-roles/supporter-gift`) with its own
|
||||
* bindings, and the three it reads are declared on both workers.
|
||||
*/
|
||||
export async function grantDiscordRoleGifts(
|
||||
env: Env,
|
||||
env: RoleGiftEnv,
|
||||
startingTokens: number
|
||||
): Promise<RoleGiftSummary> {
|
||||
const summary: RoleGiftSummary = {
|
||||
@@ -174,12 +223,7 @@ export async function grantDiscordRoleGifts(
|
||||
failed: 0,
|
||||
}
|
||||
|
||||
const { roles, rejected } = parseRoleTokens(env.DISCORD_ROLE_TOKENS)
|
||||
for (const entry of rejected) {
|
||||
console.error(
|
||||
`discord role gift: ignoring "${entry}" in DISCORD_ROLE_TOKENS — expected <roleId>=<tokens>`
|
||||
)
|
||||
}
|
||||
const roles = configuredRoles(env)
|
||||
if (roles.length === 0) {
|
||||
console.log('discord role gift: off (no roles in DISCORD_ROLE_TOKENS)')
|
||||
return { ...summary, skipped: true }
|
||||
@@ -189,19 +233,53 @@ export async function grantDiscordRoleGifts(
|
||||
const links = await getLinksForPlatform(env.DB, PlatformType.Discord)
|
||||
summary.links = links.length
|
||||
|
||||
// One box per ACCOUNT: an account with two Discord links is paid the best role across
|
||||
// both, once. Kept in link order (oldest first) so the frames go out in a stable order.
|
||||
const best = new Map<number, RoleTokens>()
|
||||
for (const link of links) {
|
||||
const role = bestRoleGift(link, roles)
|
||||
const role = bestRoleGift(link.roles, roles)
|
||||
if (role === null) continue
|
||||
const held = best.get(link.accountId)
|
||||
if (held === undefined || role.tokens > held.tokens) best.set(link.accountId, role)
|
||||
}
|
||||
|
||||
const frames: PlayerNotification[] = []
|
||||
const due = [...best]
|
||||
for (let i = 0; i < due.length; i += TOKEN_GIFT_CHUNK) {
|
||||
const round = due.slice(i, i + TOKEN_GIFT_CHUNK)
|
||||
try {
|
||||
await grantOne(env, link, role, startingTokens)
|
||||
const { paid } = await payTokenGifts(
|
||||
env.DB,
|
||||
round.map(([accountId, role]) => ({
|
||||
accountId,
|
||||
amount: role.tokens,
|
||||
content: roleGiftContent(role.tokens),
|
||||
})),
|
||||
startingTokens
|
||||
)
|
||||
for (const p of paid) {
|
||||
summary.granted++
|
||||
summary.tokens += role.tokens
|
||||
summary.tokens += p.amount
|
||||
frames.push(...giftFrames(p.accountId, p.balance, p.giftId, p.content))
|
||||
}
|
||||
} catch (err) {
|
||||
// Logged with everything needed to check what did land rather than retried: a
|
||||
// retry that ALSO failed partway is how a balance gets credited twice.
|
||||
summary.failed++
|
||||
summary.failed += round.length
|
||||
console.error(
|
||||
`discord role gift: account ${link.accountId}'s grant for role ${role.roleId} (${role.tokens} tokens) failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
`discord role gift: a round of ${round.length} grant(s) failed (${round
|
||||
.map(
|
||||
([accountId, role]) => `account ${accountId} role ${role.roleId} ${role.tokens} tokens`
|
||||
)
|
||||
.join('; ')}): ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (frames.length > 0) {
|
||||
try {
|
||||
await env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayers(frames)
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`discord role gift: could not notify ${summary.granted} account(s) of their box: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -212,39 +290,88 @@ export async function grantDiscordRoleGifts(
|
||||
return summary
|
||||
}
|
||||
|
||||
/**
|
||||
* The on-demand grant: pay ONE account the gift its roles earn right now, exactly as a cron
|
||||
* run would — same map, same best-role pick, same box. Returns the role paid, or null when
|
||||
* none of `held` is mapped (which includes the map being off), in which case nothing is
|
||||
* written. Throws as `grantOne` does, so the caller decides what a half-landed grant means.
|
||||
*
|
||||
* `www`'s benefits claim calls this with the roles Discord just served, the first time an
|
||||
* account links its Discord; whether it IS the first time is the caller's call, since this
|
||||
* keeps no ledger. It reads the roles it is handed rather than the stored link so the
|
||||
* caller can pay before, after or without writing the row.
|
||||
*/
|
||||
export async function grantDiscordRoleGift(
|
||||
env: RoleGiftEnv,
|
||||
accountId: number,
|
||||
held: readonly string[],
|
||||
startingTokens: number
|
||||
): Promise<RoleTokens | null> {
|
||||
const role = bestRoleGift(held, configuredRoles(env))
|
||||
if (role === null) return null
|
||||
await grantOne(env, accountId, role, startingTokens)
|
||||
return role
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay one account its role's gift: balance, box, then the two frames. The frames are
|
||||
* best-effort — the tokens are credited and the box stored before either is sent, and an
|
||||
* offline player meets both on their next login.
|
||||
*/
|
||||
async function grantOne(
|
||||
env: Env,
|
||||
link: PlatformLink,
|
||||
env: RoleGiftEnv,
|
||||
accountId: number,
|
||||
role: RoleTokens,
|
||||
startingTokens: number
|
||||
): Promise<void> {
|
||||
await ensureStartingBalances(env.DB, link.accountId, startingTokens)
|
||||
await ensureStartingBalances(env.DB, accountId, startingTokens)
|
||||
const balance = await creditCurrency(
|
||||
env.DB,
|
||||
link.accountId,
|
||||
accountId,
|
||||
CurrencyType.RecCenterTokens,
|
||||
role.tokens,
|
||||
startingTokens
|
||||
)
|
||||
const content = roleGiftContent(role.tokens)
|
||||
const gift = await createGift(env.DB, link.accountId, content)
|
||||
const gift = await createGift(env.DB, accountId, content)
|
||||
|
||||
const hub = env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE)
|
||||
try {
|
||||
// The balance first, so the box's announcement lands on a total that includes it.
|
||||
// `Balance` is the RESULTING total, into the one -2 bucket: the frame SETS it.
|
||||
await hub.notifyPlayer(link.accountId, NotificationType.StorefrontBalanceUpdate, {
|
||||
await env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayers(
|
||||
giftFrames(accountId, balance, gift.id, content)
|
||||
)
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`discord role gift: could not notify account ${accountId} of box ${gift.id}: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The two frames announcing a stored box, in order: the balance first — the RESULTING
|
||||
* total, into the one -2 bucket; the frame SETS it — so the box's announcement lands on a
|
||||
* total that already includes it.
|
||||
*/
|
||||
function giftFrames(
|
||||
accountId: number,
|
||||
balance: number,
|
||||
giftId: number,
|
||||
content: GiftContent
|
||||
): PlayerNotification[] {
|
||||
return [
|
||||
{
|
||||
playerId: accountId,
|
||||
notificationType: NotificationType.StorefrontBalanceUpdate,
|
||||
data: {
|
||||
Balance: balance,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
Platform: ALL_PLATFORMS,
|
||||
} satisfies BalanceResponsePayload)
|
||||
await hub.notifyPlayer(link.accountId, NotificationType.GiftPackageReceivedImmediate, {
|
||||
Id: gift.id,
|
||||
} satisfies BalanceResponsePayload,
|
||||
},
|
||||
{
|
||||
playerId: accountId,
|
||||
notificationType: NotificationType.GiftPackageReceivedImmediate,
|
||||
data: {
|
||||
Id: giftId,
|
||||
FromPlayerId: COACH_ACCOUNT_ID,
|
||||
ConsumableItemDesc: content.ConsumableItemDesc,
|
||||
AvatarItemType: content.AvatarItemType,
|
||||
@@ -260,10 +387,7 @@ async function grantOne(
|
||||
Platform: -1,
|
||||
PlatformsToSpawnOn: -1,
|
||||
BalanceType: ALL_PLATFORMS,
|
||||
} satisfies GiftPackagePayload)
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`discord role gift: could not notify account ${link.accountId} of box ${gift.id}: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
}
|
||||
} satisfies GiftPackagePayload,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
+1901
-170
@@ -1,6 +1,7 @@
|
||||
import { Hono } from 'hono'
|
||||
import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
import { z } from 'zod'
|
||||
|
||||
import {
|
||||
addXp,
|
||||
@@ -15,11 +16,13 @@ import {
|
||||
grantInvention,
|
||||
levelReward,
|
||||
levelsReached,
|
||||
listInfluencerIds,
|
||||
ownsInvention,
|
||||
setOutfit,
|
||||
updateAccount,
|
||||
} from '@repo/domain'
|
||||
import { intVar, logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
import { validateAndGetAccountId, validateAndGetPlus, validateAndGetVersion } from '@repo/jwt'
|
||||
import { validateAndGetAccountId, validateAndGetBuild, validateAndGetPlus } from '@repo/jwt'
|
||||
|
||||
import {
|
||||
getCustomAvatarItems,
|
||||
@@ -39,6 +42,7 @@ import { censorSwears } from '../../api/src/sanitize'
|
||||
import { BalanceAddType } from '../../notify/src/notification-payloads'
|
||||
import { NotificationType } from '../../notify/src/notification-types'
|
||||
import adCarouselItems from '../static/ad-carousel-items.json'
|
||||
import consumableDefinitions from '../static/consumables.json'
|
||||
import defaultAvatarItems from '../static/default-avatar-items.json'
|
||||
import defaultAvatar from '../static/default-avatar.json'
|
||||
import defaultBaseAvatarItems from '../static/default-base-avatar-items.json'
|
||||
@@ -55,7 +59,7 @@ import {
|
||||
isSpendable,
|
||||
spendCurrency,
|
||||
} from './balance-db'
|
||||
import { getCatalogItem, toCatalogAvatarItem } from './catalog-db'
|
||||
import { getCatalogItem, getStoreListings, toCatalogAvatarItem } from './catalog-db'
|
||||
// `LEGACY_CLIENT_BUILD` is shared with the storefront generator rather than restated: it picks
|
||||
// which store FILE a caller is served here, and is what the generator's help names.
|
||||
import { CatalogKind, LEGACY_CLIENT_BUILD } from './catalog-load'
|
||||
@@ -83,6 +87,8 @@ import {
|
||||
AwardRoomConsumablesRequest,
|
||||
AwardRoomCurrencyRequest,
|
||||
AwardRoomCurrencyResultList,
|
||||
AwardRoomKeyResultList,
|
||||
AwardRoomKeysRequest,
|
||||
BalanceEntry,
|
||||
BulkPurchaseRequest,
|
||||
BulkPurchaseResponse,
|
||||
@@ -91,6 +97,7 @@ import {
|
||||
BuyInventionV3Response,
|
||||
BuyItemRequest,
|
||||
BuyItemResponse,
|
||||
BuyRoomKeyResponse,
|
||||
ChallengeProgressRequest,
|
||||
ChallengeProgressResponse,
|
||||
ChecklistCompleteResponse,
|
||||
@@ -103,12 +110,15 @@ import {
|
||||
CreateRoomCurrencyRequest,
|
||||
CreateRoomKeyRequest,
|
||||
CustomAvatarItemsResponse,
|
||||
DeletePurchaseOfferRequest,
|
||||
EquipmentUpdateRequest,
|
||||
ErrorResponse,
|
||||
form,
|
||||
GameRewardRequest,
|
||||
HeldRoomKeyDto,
|
||||
InfluencerIdsResponse,
|
||||
InfluencerTierResponse,
|
||||
IsOnWishlistBulkRequest,
|
||||
IsOnWishlistBulkResponse,
|
||||
ItemPurchaseInfoList,
|
||||
ItemPurchaseInfosRequest,
|
||||
json,
|
||||
@@ -119,9 +129,22 @@ import {
|
||||
MakerAiFreeTrialEligibilityResponse,
|
||||
OpaqueJsonBody,
|
||||
OPTIONAL_AUTHED,
|
||||
OwnsRoomKeysRequest,
|
||||
OwnsRoomKeysResponse,
|
||||
PurchasableItemsRequest,
|
||||
PurchaseRoomConsumableRequest,
|
||||
PurchaseRoomConsumableResponse,
|
||||
PurchaseRoomCurrencyEnvelope,
|
||||
PurchaseRoomCurrencyRequest,
|
||||
PurchaseRoomKeyWithCurrencyRequest,
|
||||
PurchaseRoomKeyWithCurrencyResponse,
|
||||
QueryConsumablesRequest,
|
||||
ReferralProgressResponse,
|
||||
RemoveInfluencerBody,
|
||||
RoomConsumableDto,
|
||||
RoomConsumableEnvelope,
|
||||
RoomConsumableInventoryItemDto,
|
||||
RoomCurrencyBalance,
|
||||
RoomCurrencyDto,
|
||||
RoomCurrencyEnvelope,
|
||||
RoomCurrencyPurchaseOfferDto,
|
||||
@@ -130,35 +153,69 @@ import {
|
||||
RoomEconConfig,
|
||||
RoomKeyDto,
|
||||
RoomKeyEnvelope,
|
||||
RoomPurchasableList,
|
||||
RRPlusSignUpBonus,
|
||||
SaveOutfitRequest,
|
||||
SaveOutfitV4Response,
|
||||
SubscriptionResponse,
|
||||
SupportedInfluencerResponse,
|
||||
SupportInfluencerBody,
|
||||
SupportInfluencerResponse,
|
||||
UgcPurchasableBulkRequest,
|
||||
UgcPurchasableItemList,
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
UpdateActiveConsumableRequest,
|
||||
UpdateObjectiveRequest,
|
||||
UpdateObjectiveResponse,
|
||||
UpdateRoomCurrencyRequest,
|
||||
UpdateRoomKeyRequest,
|
||||
UpsertRoomConsumableRequest,
|
||||
WishlistEnvelope,
|
||||
WishlistItemList,
|
||||
WishlistItemRequest,
|
||||
} from './openapi'
|
||||
import { claimReward, isNewActivity } from './reward-db'
|
||||
import { claimAllowance, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db'
|
||||
import {
|
||||
awardRoomConsumable,
|
||||
getRoomConsumable,
|
||||
getRoomConsumables,
|
||||
getRoomInventory,
|
||||
toInventoryItem,
|
||||
upsertRoomConsumable,
|
||||
} from './room-consumable-db'
|
||||
import {
|
||||
awardRoomCurrency,
|
||||
createPurchaseOffer,
|
||||
createRoomCurrency,
|
||||
deletePurchaseOffer,
|
||||
findPurchaseOffer,
|
||||
getPurchaseOffers,
|
||||
getRoomBalance,
|
||||
getRoomCurrencies,
|
||||
getRoomCurrency,
|
||||
spendRoomCurrency,
|
||||
updateRoomCurrency,
|
||||
} from './room-currency-db'
|
||||
import { createRoomKey, getRoomKeys } from './room-key-db'
|
||||
import {
|
||||
awardRoomKey,
|
||||
claimRoomKey,
|
||||
createRoomKey,
|
||||
getHeldRoomKeys,
|
||||
getRoomKey,
|
||||
getRoomKeys,
|
||||
getRoomOwnerId,
|
||||
ownsRoomKeys,
|
||||
releaseRoomKey,
|
||||
updateRoomKey,
|
||||
} from './room-key-db'
|
||||
import { getRoomPurchasables } from './room-purchasables-db'
|
||||
import {
|
||||
addWishlistItem,
|
||||
getWishlist,
|
||||
isOnWishlists,
|
||||
parseWishlistTarget,
|
||||
removeWishlistItem,
|
||||
} from './wishlist-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
|
||||
@@ -166,6 +223,7 @@ import type { CustomAvatarItem } from '../../api/src/custom-avatar-items-db'
|
||||
import type { SavedInvention } from '../../api/src/inventions-db'
|
||||
import type {
|
||||
BalanceResponsePayload,
|
||||
ConsumableMappingPayload,
|
||||
LocalRoomKeyPayload,
|
||||
PurchaseBalanceModificationPayload,
|
||||
RoomCurrencyPayload,
|
||||
@@ -181,9 +239,10 @@ import type { ConsumeResult } from './consumables-db'
|
||||
import type { App, Env } from './context'
|
||||
import type { Equipment } from './equipment-db'
|
||||
import type { AvatarItem } from './inventory-db'
|
||||
import type { RoomConsumable } from './room-consumable-db'
|
||||
import type { RoomConsumable, RoomConsumableInventoryItem } from './room-consumable-db'
|
||||
import type { RoomCurrency, RoomCurrencyPurchaseOffer } from './room-currency-db'
|
||||
import type { RoomKey } from './room-key-db'
|
||||
import type { RoomKey, RoomKeyHolding } from './room-key-db'
|
||||
import type { WishlistItem } from './wishlist-db'
|
||||
|
||||
// Invention storage (owned by the `api` worker, on this same `recflare` database).
|
||||
// Imported directly rather than copied: these are plain D1 helpers with no bindings of
|
||||
@@ -197,7 +256,7 @@ import type { RoomKey } from './room-key-db'
|
||||
* inventory (avatar items, equipment, bought inventions), consumables, saved outfits,
|
||||
* avatars, gift boxes, weekly-challenge progress and game-reward eligibility are D1-backed;
|
||||
* storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS
|
||||
* binding. Some routes are still empty-list stubs (room keys, wishlist, …).
|
||||
* binding. Some routes are still empty-list stubs (equipment, room consumables, …).
|
||||
*
|
||||
* Auth-gated routes validate the Bearer JWT issued by the `auth` worker.
|
||||
*/
|
||||
@@ -213,16 +272,17 @@ async function authedId(c: Context<App>): Promise<number | null> {
|
||||
/**
|
||||
* The client build this request's token was minted for (`rn.ver`), as a comparable NUMBER —
|
||||
* the leading `YYYYMMDD` of e.g. `20250718.01`, whose `.01` is a same-day rebuild and not a
|
||||
* version to order by. `null` when there is no valid token, when it carries no `rn.ver` (an
|
||||
* older token, issued before the claim did), or when the claim isn't a build at all.
|
||||
* version to order by (`validateAndGetBuild` drops it). `null` when there is no valid
|
||||
* token, when it carries no `rn.ver` (an older token, issued before the claim did), or
|
||||
* when the claim isn't a build at all.
|
||||
*
|
||||
* Unverified — a client can claim any build — which is fine for what it gates here: a build
|
||||
* lying about itself only changes which storefront its own player is shown.
|
||||
*/
|
||||
async function authedBuild(c: Context<App>): Promise<number | null> {
|
||||
const version = await validateAndGetVersion(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
const version = await validateAndGetBuild(c.req.raw, await c.env.JWT_SECRET.get())
|
||||
if (version === null) return null
|
||||
const build = Number.parseInt(version.split('.')[0] ?? '', 10)
|
||||
const build = Number.parseInt(version, 10)
|
||||
return Number.isInteger(build) ? build : null
|
||||
}
|
||||
|
||||
@@ -231,6 +291,39 @@ function unauthorized(c: Context<App>) {
|
||||
return c.body(null, 401)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Error` a wishlist write answers when its body names no item. Shown to the player
|
||||
* verbatim behind the client's “Wishlist error: ” prefix, so it is a sentence, not a code.
|
||||
*/
|
||||
const WISHLIST_NO_ITEM = 'No item was specified.'
|
||||
|
||||
/**
|
||||
* The `{ Value, Success, Error, error_id }` envelope the wishlist writes answer in — the
|
||||
* same shape the room-currency writes use. A null `item` with no `error` is still a success
|
||||
* (removing something that wasn't listed); an `error` makes it a refusal.
|
||||
*/
|
||||
function wishlistEnvelope(c: Context<App>, item: WishlistItem | null, error?: string) {
|
||||
return c.json({
|
||||
Value: item,
|
||||
Success: error === undefined,
|
||||
Error: error ?? null,
|
||||
error_id: null,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The item a wishlist write's form body names — `purchasableItemId` or `customAvatarItemId`,
|
||||
* as the client spells them, with the PascalCase spellings accepted for a hand-written
|
||||
* request. Null when the body names nothing usable (see `parseWishlistTarget`).
|
||||
*/
|
||||
async function wishlistTargetFromBody(c: Context<App>) {
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
return parseWishlistTarget(
|
||||
body.purchasableItemId ?? body.PurchasableItemId,
|
||||
body.customAvatarItemId ?? body.CustomAvatarItemId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A boolean the client may send either as a JSON `true` or as .NET's `bool.ToString()`
|
||||
* output — `"True"`/`"False"`, capitalized. `Boolean(value)` is a trap here: the string
|
||||
@@ -276,6 +369,7 @@ const HUB_INSTANCE = 'global'
|
||||
const CREATE_CURRENCY_FAILED = 'Failed to create currency'
|
||||
const UPDATE_CURRENCY_FAILED = 'Failed to update currency'
|
||||
const CREATE_OFFER_FAILED = 'Failed to create purchase offer'
|
||||
const DELETE_OFFER_FAILED = 'Failed to delete purchase offer'
|
||||
const SAVE_CONSUMABLE_FAILED = 'Failed to save consumable'
|
||||
/**
|
||||
* `Status` on the room-key create's `{ Status, RoomKey }` reply. 0 is the observed success
|
||||
@@ -291,6 +385,14 @@ const ROOM_KEY_STATUS_FAILED = 1
|
||||
*/
|
||||
const MAX_KEYS_PER_ROOM = 10
|
||||
|
||||
/**
|
||||
* The most a room key may cost, by what it is priced in: tokens (a null `PurchaseCurrencyId`)
|
||||
* are real money to the player paying, so they are capped low; a room's own currency is the
|
||||
* room's to inflate.
|
||||
*/
|
||||
const MAX_KEY_PRICE_TOKENS = 1000
|
||||
const MAX_KEY_PRICE_ROOM_CURRENCY = 1_000_000_000
|
||||
|
||||
/**
|
||||
* The envelope the create endpoint answers in: `{ Value, Success, Error, error_id }`.
|
||||
*
|
||||
@@ -335,8 +437,8 @@ function roomConsumableEnvelope(c: Context<App>, value: RoomConsumable | null, e
|
||||
}
|
||||
|
||||
/**
|
||||
* The create-offer envelope — the same `{ Value, Success, Error, error_id }` shape again,
|
||||
* carrying one purchase offer.
|
||||
* The create- and delete-offer envelope — the same `{ Value, Success, Error, error_id }`
|
||||
* shape again, carrying one purchase offer.
|
||||
*/
|
||||
function purchaseOfferEnvelope(
|
||||
c: Context<App>,
|
||||
@@ -470,6 +572,24 @@ async function pushRoomKeyCreated(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a consumable is a first-party (Rec Room Original) item, as its definition in
|
||||
* `static/consumables.json` says — the value every consumable instance we serve must carry.
|
||||
*
|
||||
* The 2026 client keys an instance on this flag and checks it against the definition it holds:
|
||||
* a mismatch throws (`Key IsRRO=False does not match Definition IsRRO=True!`) and the frame is
|
||||
* lost, so the purchase never shows. Every definition in the file says true; a desc with none
|
||||
* is still a built-in store item, so it defaults to true too.
|
||||
*/
|
||||
const RRO_BY_DESC = new Map(
|
||||
(consumableDefinitions as Array<{ ConsumableItemDesc: string; IsRRO?: boolean }>).map((d) => [
|
||||
d.ConsumableItemDesc,
|
||||
d.IsRRO !== false,
|
||||
])
|
||||
)
|
||||
const isRroConsumable = (consumableItemDesc: string): boolean =>
|
||||
RRO_BY_DESC.get(consumableItemDesc) ?? true
|
||||
|
||||
/**
|
||||
* Push a ConsumableMappingRemoved notification to a player after they consume a
|
||||
* consumable, mirroring the reference's
|
||||
@@ -495,7 +615,9 @@ async function pushConsumableRemoved(
|
||||
IsActive: false,
|
||||
ActiveDurationMinutes: 0,
|
||||
IsTransferable: false,
|
||||
}
|
||||
ActivatedByRoomie: false,
|
||||
IsRRO: isRroConsumable(consumed.consumableItemDesc),
|
||||
} satisfies ConsumableMappingPayload
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push ConsumableMappingRemoved notification', {
|
||||
@@ -517,21 +639,49 @@ async function pushConsumableAdded(
|
||||
c: Context<App>,
|
||||
accountId: number,
|
||||
gift: StoredGift
|
||||
): Promise<void> {
|
||||
await pushConsumableMappingAdded(c, accountId, {
|
||||
mappingId: gift.ConsumableMappingId ?? 0,
|
||||
consumableItemDesc: gift.ConsumableItemDesc,
|
||||
count: gift.ConsumableCount,
|
||||
preExistingCount: gift.ConsumablePreExistingCount ?? 0,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The `ConsumableMappingAdded` frame itself — one granted consumable instance (its row id,
|
||||
* how many it holds, and the player's count before it). Sent when a box carrying it is
|
||||
* opened ({@link pushConsumableAdded}) or, when there IS no box, at grant time: a purchase
|
||||
* under `BypassGiftPackages` is never opened, and without this the consumable didn't show
|
||||
* until the client refetched its inventory. Never both for one grant — a second frame for
|
||||
* the same row id could be counted twice. Best-effort like the removed push.
|
||||
*/
|
||||
async function pushConsumableMappingAdded(
|
||||
c: Context<App>,
|
||||
accountId: number,
|
||||
granted: {
|
||||
mappingId: number
|
||||
consumableItemDesc: string
|
||||
count: number
|
||||
preExistingCount: number
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||
accountId,
|
||||
NotificationType.ConsumableMappingAdded,
|
||||
{
|
||||
Id: gift.ConsumableMappingId ?? 0,
|
||||
ConsumableItemDesc: gift.ConsumableItemDesc,
|
||||
Id: granted.mappingId,
|
||||
ConsumableItemDesc: granted.consumableItemDesc,
|
||||
CreatedAt: new Date().toISOString(),
|
||||
Count: gift.ConsumableCount,
|
||||
InitialCount: gift.ConsumablePreExistingCount ?? 0,
|
||||
Count: granted.count,
|
||||
InitialCount: granted.preExistingCount,
|
||||
IsActive: false,
|
||||
ActiveDurationMinutes: 0,
|
||||
IsTransferable: false,
|
||||
}
|
||||
ActivatedByRoomie: false,
|
||||
IsRRO: isRroConsumable(granted.consumableItemDesc),
|
||||
} satisfies ConsumableMappingPayload
|
||||
)
|
||||
} catch (err) {
|
||||
logger.error('failed to push ConsumableMappingAdded notification', {
|
||||
@@ -648,11 +798,17 @@ async function pushBalancePurchase(
|
||||
}
|
||||
|
||||
/**
|
||||
* The influencer partner tier every account has here — the "not an influencer" one. It is
|
||||
* the whole body of both `/api/influencerpartnerprogram/influencer` and `…/myinfluencer`,
|
||||
* served as a bare number rather than wrapped in anything.
|
||||
* The influencer an account supports in the partner program, as its reads serve it — the
|
||||
* `supportedInfluencerId` on the account, or 0 for nobody (and for an account that does not
|
||||
* exist, which supports nobody too). The whole body of `/api/influencerpartnerprogram/influencer`,
|
||||
* `…/myinfluencer`, served as a bare number rather than wrapped in anything; the `…/support`
|
||||
* and `…/remove` writes answer a status envelope instead. The stored id is served as-is: it was an influencer when picked, and a later
|
||||
* revoke of their flag drops them from `…/influencers` but not from their supporters.
|
||||
*/
|
||||
const NOT_AN_INFLUENCER = 0
|
||||
async function supportedInfluencer(db: D1Database, accountId: number): Promise<number> {
|
||||
const account = await getAccount(db, accountId)
|
||||
return account?.supportedInfluencerId ?? 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the caller holds a Rec Room Plus subscription — the ONE definition, shared by
|
||||
@@ -1734,7 +1890,19 @@ async function grantGiftDrop(
|
||||
consumableCount
|
||||
)
|
||||
}
|
||||
if (options.skipGiftBox === true) return { id: 0, drop: giftDrop }
|
||||
if (options.skipGiftBox === true) {
|
||||
// No box means nothing will ever be opened to announce the consumable, so announce it
|
||||
// now — otherwise a bag bought with `BypassGiftPackages` doesn't show it until a refetch.
|
||||
if (isConsumable) {
|
||||
await pushConsumableMappingAdded(c, accountId, {
|
||||
mappingId: consumableMappingId,
|
||||
consumableItemDesc: giftDrop.ConsumableItemDesc,
|
||||
count: consumableCount,
|
||||
preExistingCount: consumablePreExisting,
|
||||
})
|
||||
}
|
||||
return { id: 0, drop: giftDrop }
|
||||
}
|
||||
const { id } = await createGift(
|
||||
db,
|
||||
accountId,
|
||||
@@ -2166,15 +2334,24 @@ function isBulkLine(resolved: BulkPurchaseLine | BulkLineFailure): resolved is B
|
||||
}
|
||||
|
||||
/**
|
||||
* XP paid for a claimed game reward. One flat amount for every reward type, matching the
|
||||
* one flat cooldown they share — "First Game of the Day" and "Activity completed!" are the
|
||||
* same size of pat on the back until there's reason to price them apart.
|
||||
* XP paid for a claimed game reward. One flat amount for every reward type — "First Game of
|
||||
* the Day" and "Activity completed!" are the same size of pat on the back until there's
|
||||
* reason to price them apart.
|
||||
*
|
||||
* Deliberately smaller than the 10 XP the first level costs: a single action shouldn't be a
|
||||
* level-up, let alone two of them. At 5 it takes two rewards to reach level 2, and the early
|
||||
* levels are paced by the hourly cooldown rather than cleared in one match.
|
||||
* Exactly what each of the first three levels costs, so a fresh player's first rewards each
|
||||
* level them up; from level 4 (20 XP a level) it takes two or more. The daily
|
||||
* {@link GAME_REWARD_DAILY_XP_CAP} paces it from there.
|
||||
*/
|
||||
const GAME_REWARD_XP = 5
|
||||
const GAME_REWARD_XP = 10
|
||||
|
||||
/**
|
||||
* The most game-reward XP a player earns in one rolling 24h window: fifteen claims' worth,
|
||||
* whatever the type or activity — `FirstActivityOfDay` aside, whose XP is a bonus on top, like
|
||||
* its tokens. The running total is the player's `$xp` row in `reward_status`
|
||||
* (`claimAllowance`). A multiple of {@link GAME_REWARD_XP} — a claim that would cross it pays
|
||||
* no XP.
|
||||
*/
|
||||
const GAME_REWARD_DAILY_XP_CAP = 150
|
||||
|
||||
/**
|
||||
* `GiftContext.GameRewards` — what the box says it came from, so the client files it under
|
||||
@@ -2187,29 +2364,46 @@ const GIFT_CONTEXT_GAME_REWARDS = 50
|
||||
const DEFAULT_GAME_REWARD_MESSAGE = 'Reward earned!'
|
||||
|
||||
/**
|
||||
* Tokens paid the FIRST time a player completes an activity — a `giftContext` no row of
|
||||
* theirs in `reward_status` has seen before, whatever the reward type. A one-off per
|
||||
* activity on top of the hourly XP: the reward for trying something new, sized as a
|
||||
* visible nudge (a cheap catalog item) rather than an income, since the catalog of
|
||||
* activities is finite and each pays it exactly once.
|
||||
* Tokens paid with each claimed {@link TOKEN_REWARD_TYPES} reward, until the player has had
|
||||
* {@link GAME_REWARD_DAILY_TOKEN_CAP} in the current 24h window — whatever the activity.
|
||||
* The running total is the player's `$tokens` row in `reward_status` (`claimAllowance`).
|
||||
*/
|
||||
const NEW_ACTIVITY_BONUS_TOKENS = 250
|
||||
const GAME_REWARD_TOKENS = 250
|
||||
|
||||
/**
|
||||
* The most game-reward tokens a player earns in one rolling 24h window: five claims' worth.
|
||||
* A multiple of {@link GAME_REWARD_TOKENS} — a claim that would cross it pays nothing.
|
||||
*/
|
||||
const GAME_REWARD_DAILY_TOKEN_CAP = 1250
|
||||
|
||||
/**
|
||||
* The reward types whose tokens draw on the daily allowance: the client's ask after a match
|
||||
* and after a quest. {@link FIRST_ACTIVITY_OF_DAY} pays its own on top, outside the cap.
|
||||
*/
|
||||
const TOKEN_REWARD_TYPES = new Set(['PostGameActivity', 'PostQuestActivity'])
|
||||
|
||||
/**
|
||||
* The once-a-day bonus. Its claim is per PLAYER — keyed on the empty context whatever
|
||||
* activity asked — on a 24h cooldown, and it pays {@link GAME_REWARD_TOKENS} outside the
|
||||
* daily cap: the claim itself is what limits it, so it needs no allowance row.
|
||||
*/
|
||||
const FIRST_ACTIVITY_OF_DAY = 'FirstActivityOfDay'
|
||||
|
||||
/**
|
||||
* `GiftContext.GameRewardsTokens` — the tokens variant of {@link GIFT_CONTEXT_GAME_REWARDS},
|
||||
* on the box the new-activity bonus arrives in.
|
||||
* on the box the token payout arrives in.
|
||||
*/
|
||||
const GIFT_CONTEXT_GAME_REWARDS_TOKENS = 51
|
||||
|
||||
/** The message on the new-activity bonus box. */
|
||||
const NEW_ACTIVITY_BONUS_MESSAGE = 'New activity bonus!'
|
||||
/** The message on the token payout's box. */
|
||||
const GAME_REWARD_TOKENS_MESSAGE = 'Activity bonus!'
|
||||
|
||||
/**
|
||||
* The gift-drop the new-activity bonus hands over: tokens in a box, no item, no XP. Like
|
||||
* The gift-drop a claimed game reward's token payout hands over: tokens in a box, no item, no XP. Like
|
||||
* {@link toGameRewardDrop}, `grantGiftDrop` creates only the box from it — the tokens are
|
||||
* credited to the balance separately, and the copy here is what the box displays.
|
||||
*/
|
||||
function toNewActivityBonusDrop(): StoreGiftDrop {
|
||||
function toGameRewardTokensDrop(): StoreGiftDrop {
|
||||
return {
|
||||
FriendlyName: '',
|
||||
Tooltip: '',
|
||||
@@ -2220,7 +2414,7 @@ function toNewActivityBonusDrop(): StoreGiftDrop {
|
||||
EquipmentModificationGuid: '',
|
||||
Rarity: 0,
|
||||
Context: GIFT_CONTEXT_GAME_REWARDS_TOKENS,
|
||||
Currency: NEW_ACTIVITY_BONUS_TOKENS,
|
||||
Currency: GAME_REWARD_TOKENS,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
Xp: 0,
|
||||
}
|
||||
@@ -2232,7 +2426,7 @@ function toNewActivityBonusDrop(): StoreGiftDrop {
|
||||
* nothing into the inventory and only creates the box. The XP is banked in `progression`;
|
||||
* the copy here is what the box and its notification display.
|
||||
*/
|
||||
function toGameRewardDrop(): StoreGiftDrop {
|
||||
function toGameRewardDrop(xp: number): StoreGiftDrop {
|
||||
return {
|
||||
FriendlyName: '',
|
||||
Tooltip: '',
|
||||
@@ -2245,7 +2439,7 @@ function toGameRewardDrop(): StoreGiftDrop {
|
||||
Context: GIFT_CONTEXT_GAME_REWARDS,
|
||||
Currency: 0,
|
||||
CurrencyType: 0,
|
||||
Xp: GAME_REWARD_XP,
|
||||
Xp: xp,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2310,10 +2504,14 @@ async function pickQuestReward(
|
||||
* row, so the item IS granted — unlike {@link toGameRewardDrop}'s empty box. The catalog
|
||||
* row for the item, when it resolves, supplies what the table doesn't carry (name, tooltip,
|
||||
* `AvatarItemType`), so the inventory entry reads like a bought one rather than blank.
|
||||
* The XP is the flat game-reward amount, not the row's (always 0): the reward is the item,
|
||||
* and the XP is the same pat on the back every claim gets.
|
||||
* The XP is the game reward's (`xp`), not the row's (always 0): the reward is the item, and
|
||||
* the XP is the same pat on the back every claim gets while the daily cap has room.
|
||||
*/
|
||||
function toQuestRewardDrop(reward: QuestReward, catalog: CatalogRow | null): StoreGiftDrop {
|
||||
function toQuestRewardDrop(
|
||||
reward: QuestReward,
|
||||
catalog: CatalogRow | null,
|
||||
xp: number
|
||||
): StoreGiftDrop {
|
||||
return {
|
||||
FriendlyName: catalog?.friendly_name ?? '',
|
||||
Tooltip: catalog?.tooltip ?? '',
|
||||
@@ -2326,7 +2524,7 @@ function toQuestRewardDrop(reward: QuestReward, catalog: CatalogRow | null): Sto
|
||||
Context: reward.Context,
|
||||
Currency: reward.Currency,
|
||||
CurrencyType: reward.CurrencyType,
|
||||
Xp: GAME_REWARD_XP,
|
||||
Xp: xp,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2644,9 +2842,8 @@ interface SettledInventionPurchase {
|
||||
*
|
||||
* Ownership is recorded in `inventory_invention`; the creator is not sold their own
|
||||
* invention (they own it already, via CreatorPlayerId) and a re-buy is a 409 rather
|
||||
* than a second row. The invention’s `NumDownloads` counter is deliberately NOT
|
||||
* bumped: that column lives on the `invention` table the `api` worker owns, and this
|
||||
* worker only reads it.
|
||||
* than a second row. The grant also resyncs the invention’s `NumDownloads` — the count
|
||||
* of players who have it — and the invention returned carries the new number.
|
||||
*/
|
||||
async function settleInventionPurchase(
|
||||
c: Context<App>,
|
||||
@@ -2692,7 +2889,7 @@ async function settleInventionPurchase(
|
||||
// around them, so order them by what a failure costs. A buyer who paid and got the
|
||||
// invention but left the creator unpaid is recoverable; a buyer charged for nothing
|
||||
// is not.
|
||||
await grantInvention(c.env.DB, id, inventionId)
|
||||
const downloads = await grantInvention(c.env.DB, id, inventionId)
|
||||
|
||||
if (price > 0) {
|
||||
// Seed the creator's signup grant BEFORE crediting them: `creditCurrency` upserts
|
||||
@@ -2729,7 +2926,7 @@ async function settleInventionPurchase(
|
||||
if (price > 0) {
|
||||
await pushBalancePurchase(c, id, CurrencyType.RecCenterTokens, -price, balance)
|
||||
}
|
||||
return { invention, balance }
|
||||
return { invention: { ...invention, NumDownloads: downloads }, balance }
|
||||
}
|
||||
|
||||
// strict: false so trailing-slash routes (e.g. `/gifts/consume/`, which the client
|
||||
@@ -3083,20 +3280,41 @@ const app = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// The caller's item wishlist. [Authorize]; empty — nothing stores wishlists yet.
|
||||
// The caller's item wishlist. [Authorize]. A bare array of entries, newest wish first;
|
||||
// `[]` for a player who has wished for nothing. The client appends a KEYLESS query string
|
||||
// (`/me?True`, `/me?False` — a raw `bool.ToString()` after the `?`, no parameter name) that
|
||||
// no server can bind by name, so it is ignored: both spellings serve the same list. The
|
||||
// client caches this for five seconds and drops the cache when an add succeeds.
|
||||
.get(
|
||||
'/api/itemWishlists/v1/wishlist/me',
|
||||
listRoute('The player’s item wishlist', 'Empty for now', true),
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'The player’s item wishlist',
|
||||
description: [
|
||||
'The caller’s wishlist as a bare array of entries, newest first — `[]` when empty.',
|
||||
'Each entry names ONE item: a storefront item by `PurchasableItemId`, or a custom',
|
||||
'avatar item by `CustomAvatarItemId` (with `PurchasableItemId` 0, the client’s field',
|
||||
'being a plain int).',
|
||||
'',
|
||||
'The client appends `?True` or `?False` with no parameter name; it cannot be read',
|
||||
'and is ignored — both serve the same list.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(WishlistItemList, 'The caller’s wishlist, newest first'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([])
|
||||
return c.json(await getWishlist(c.env.DB, id))
|
||||
}
|
||||
)
|
||||
|
||||
// Another player's item wishlist, by account id — what the client reads to show what
|
||||
// somebody else is hoping for (and to mark items in the store as already wished for).
|
||||
// Empty like `/me`: nothing stores wishlists, so there is nothing to show for anyone.
|
||||
// somebody else is hoping for. Public to any signed-in player: a wishlist exists to be
|
||||
// read by others. An unknown account has simply wished for nothing.
|
||||
//
|
||||
// Registered AFTER `/me` so that path stays its own route rather than being read as an
|
||||
// account id — the pattern here is digits-only, so it could not swallow `me`, but the
|
||||
@@ -3107,10 +3325,11 @@ const app = new Hono<App>({ strict: false })
|
||||
tags: ['Econ'],
|
||||
summary: 'Another player’s item wishlist',
|
||||
description: [
|
||||
'The wishlist of the account named in the path, as a bare array. Empty for now —',
|
||||
'nothing on this server stores wishlists, so every player’s is empty, and an empty',
|
||||
'list is what the client renders as “nothing wished for” where a 404 would read as a',
|
||||
'failed load.',
|
||||
'The wishlist of the account named in the path, as a bare array of entries, newest',
|
||||
'first. Readable by any signed-in player — a wishlist is for others to see. An',
|
||||
'unknown account answers `[]`, which the client renders as “nothing wished for”',
|
||||
'where a 404 would read as a failed load. The keyless `?True`/`?False` the client',
|
||||
'appends is ignored, as on `/me`.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
@@ -3123,14 +3342,136 @@ const app = new Hono<App>({ strict: false })
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(JsonArray, 'That player’s wishlist — empty for now'),
|
||||
200: json(WishlistItemList, 'That player’s wishlist, newest first'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json([])
|
||||
const accountId = Number.parseInt(c.req.param('accountId'), 10)
|
||||
if (!Number.isInteger(accountId)) return c.json([])
|
||||
return c.json(await getWishlist(c.env.DB, accountId))
|
||||
}
|
||||
)
|
||||
|
||||
// Wish for an item. [Authorize]. Form-encoded: the client posts BOTH `purchasableItemId`
|
||||
// and `customAvatarItemId` every time with the unused one empty (its two nullables are
|
||||
// boxed without a HasValue guard), so either may be empty or absent; exactly one has to
|
||||
// name an item. Answers the `{ Value, Success, Error, error_id }` envelope with the entry.
|
||||
// An item already on the list answers its EXISTING entry: the toggle is driven by the
|
||||
// client's idea of the current state, and re-adding changes nothing.
|
||||
//
|
||||
// The row has to persist: on success the client drops its cached list and re-reads it,
|
||||
// so a 200 that stored nothing shows the heart un-filling again at once.
|
||||
.post(
|
||||
'/api/itemWishlists/v1/wishlist/add',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Wish for an item',
|
||||
description: [
|
||||
'Adds an item to the caller’s wishlist. Form-encoded; the client posts both',
|
||||
'`purchasableItemId` and `customAvatarItemId` with the unused one EMPTY',
|
||||
'(`purchasableItemId=1534&customAvatarItemId=`), and either may also be absent —',
|
||||
'exactly one has to name an item.',
|
||||
'',
|
||||
'Answers `{ Value, Success, Error, error_id }` with the entry as `Value`. An item',
|
||||
'already on the list answers its existing entry rather than a twin or a refusal.',
|
||||
'A body naming no item is `Success: false` with a player-readable `Error` — the',
|
||||
'client shows it verbatim behind “Wishlist error: ”. A bare entry or a 204 fails the',
|
||||
'client the same way `Success: false` does.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
requestBody: form(WishlistItemRequest, 'The item to wish for'),
|
||||
responses: {
|
||||
200: json(WishlistEnvelope, 'The entry, or a refusal in the same shape'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await wishlistTargetFromBody(c)
|
||||
if (target === null) return wishlistEnvelope(c, null, WISHLIST_NO_ITEM)
|
||||
return wishlistEnvelope(c, await addWishlistItem(c.env.DB, id, target))
|
||||
}
|
||||
)
|
||||
|
||||
// Take an item off the caller's wishlist. [Authorize]. A POST, not a DELETE, with the same
|
||||
// form body as `add`. The client parses the same envelope and discards `Value`, so the
|
||||
// removed entry is served anyway; an item that wasn't on the list is still a success —
|
||||
// the list ends up as asked — with a null `Value`.
|
||||
.post(
|
||||
'/api/itemWishlists/v1/wishlist/remove',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Un-wish an item',
|
||||
description: [
|
||||
'Removes an item from the caller’s wishlist. A POST (not a DELETE) with the same',
|
||||
'form body as `add`. Answers the same envelope with the REMOVED entry as `Value`;',
|
||||
'the client discards it but parses the shape, so a bare body or 204 fails it. An',
|
||||
'item that wasn’t on the list is still `Success: true`, with a null `Value`. A body',
|
||||
'naming no item is `Success: false`.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
requestBody: form(WishlistItemRequest, 'The item to un-wish'),
|
||||
responses: {
|
||||
200: json(WishlistEnvelope, 'The removed entry, or a refusal in the same shape'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const target = await wishlistTargetFromBody(c)
|
||||
if (target === null) return wishlistEnvelope(c, null, WISHLIST_NO_ITEM)
|
||||
return wishlistEnvelope(c, await removeWishlistItem(c.env.DB, id, target))
|
||||
}
|
||||
)
|
||||
|
||||
// Whether each of several players has an item wished for. [Authorize]. Form-encoded:
|
||||
// `accountIds` repeated once per player plus the item named as on add/remove. A BARE
|
||||
// positional array of booleans back, one per id in the order posted — a short array would
|
||||
// leave the tail reading as unset, so every id is answered, unknown players as false.
|
||||
.post(
|
||||
'/api/itemWishlists/v1/isonwishlist/bulk',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Whether players have an item wished for',
|
||||
description: [
|
||||
'For each `accountIds` entry, whether that player’s wishlist has the item named by',
|
||||
'`purchasableItemId` or `customAvatarItemId`. Form-encoded, `accountIds` repeated',
|
||||
'once per id (a comma-separated single value is accepted too). The answer is a BARE',
|
||||
'array of booleans, one per id in the order posted. A body naming no item answers',
|
||||
'false for everyone.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(IsOnWishlistBulkRequest, 'The players to ask about, and the item'),
|
||||
responses: {
|
||||
200: json(IsOnWishlistBulkResponse, 'One boolean per `accountIds` entry, in order'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const body = await c.req
|
||||
.parseBody({ all: true })
|
||||
.catch(() => ({}) as Record<string, string | string[] | File | File[]>)
|
||||
// `all: true` keeps the repeated field a list; a single value arrives as a string.
|
||||
const accountIds = [body.accountIds, body.AccountIds]
|
||||
.flat()
|
||||
.filter((v): v is string => typeof v === 'string')
|
||||
.flatMap((v) => v.split(','))
|
||||
.map((v) => Number.parseInt(v.trim(), 10))
|
||||
.filter((n) => Number.isInteger(n))
|
||||
const first = (v: unknown): unknown => (Array.isArray(v) ? v[0] : v)
|
||||
const target = parseWishlistTarget(
|
||||
first(body.purchasableItemId ?? body.PurchasableItemId),
|
||||
first(body.customAvatarItemId ?? body.CustomAvatarItemId)
|
||||
)
|
||||
if (target === null) return c.json(accountIds.map(() => false))
|
||||
return c.json(await isOnWishlists(c.env.DB, accountIds, target))
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3515,7 +3856,15 @@ const app = new Hono<App>({ strict: false })
|
||||
continue
|
||||
}
|
||||
|
||||
const owned = await awardRoomConsumable(c.env.DB, accountId, consumableId, quantity)
|
||||
const code = (request as { ConcurrencyCodes?: { NewConcurrencyCode?: unknown } })
|
||||
.ConcurrencyCodes?.NewConcurrencyCode
|
||||
const owned = await awardRoomConsumable(
|
||||
c.env.DB,
|
||||
accountId,
|
||||
consumableId,
|
||||
quantity,
|
||||
typeof code === 'string' ? code : null
|
||||
)
|
||||
results.push({
|
||||
ConsumableId: consumableId,
|
||||
Success: true,
|
||||
@@ -3523,7 +3872,7 @@ const app = new Hono<App>({ strict: false })
|
||||
Response: {
|
||||
PlayerId: accountId,
|
||||
ConsumableId: consumableId,
|
||||
Quantity: owned,
|
||||
Quantity: owned.quantity,
|
||||
AwardedAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
@@ -3533,6 +3882,202 @@ const app = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Buy some of a room's consumables, for the caller or as a gift. One route for both kinds of
|
||||
// price: a listing in TOKENS pays the room's owner, as a room key does; a listing in a ROOM
|
||||
// CURRENCY pays nobody — that money is the room's own invention. What is bought lands in
|
||||
// `room_inventory`, the same holding `awardBulk` adds to.
|
||||
.put(
|
||||
// Two paths, one sale: the client posts a token listing to `/purchase/tokens` and a
|
||||
// room-currency one to `/purchase/currency`, with the same body. The body's
|
||||
// `ExpectedPriceAndCurrency` is what is checked against the listing, not the path.
|
||||
'/api/roomconsumables/v1/roomconsumable/:id/purchase/:via{currency|tokens}',
|
||||
describeRoute({
|
||||
tags: ['Storefront'],
|
||||
summary: 'Buy a room consumable',
|
||||
description: [
|
||||
'Buys `Quantity` of the consumable named in the path, at its listed price EACH.',
|
||||
'The client posts a listing priced in tokens to `…/purchase/tokens` and one priced',
|
||||
'in a room currency to `…/purchase/currency`; both take the same body and answer',
|
||||
'the same way, and it is the body’s `ExpectedPriceAndCurrency` that must match.',
|
||||
'',
|
||||
'`ExpectedPriceAndCurrency` must be the listing’s own: `Price` its unit price and',
|
||||
'`CurrencyId` its `PurchaseCurrencyId` — null for a listing priced in tokens.',
|
||||
'`Quantity` must be at least 1 and, when the listing sets one, at most its',
|
||||
'`MaximumCountPerPurchase`. `ConcurrencyCodes` is accepted and IGNORED.',
|
||||
'',
|
||||
'- In TOKENS: the total leaves the buyer and is paid to the room’s OWNER, who gets a',
|
||||
' StorefrontBalanceUpdate; the buyer gets a StorefrontBalancePurchase. Both carry',
|
||||
' a resulting total.',
|
||||
'- In a ROOM CURRENCY: the total leaves the buyer’s holding of it (`room_balance`)',
|
||||
' and NOBODY is paid.',
|
||||
'',
|
||||
'The room’s creator and co-owners pay nothing: the client asks them `Price` 0 (the',
|
||||
'listed price is accepted too).',
|
||||
'',
|
||||
'`GiftRecipientAccountId` sends what is bought to that player; the buyer still pays.',
|
||||
'Null (or the buyer’s own id) keeps it. Either way the items are added to the',
|
||||
'recipient’s `room_inventory`.',
|
||||
'',
|
||||
'Answers the client’s purchase result, observed from the live client:',
|
||||
'`BalanceUpdateResult` (an UpdateResponse — 0 OK), `CurrencyBalanceResponse` (what',
|
||||
'the BUYER now holds of what they paid in: `{ AccountId, CurrencyId, Balance,',
|
||||
'ModifiedAt }`, `CurrencyId` null and `Balance` their token total for a token',
|
||||
'listing) and `OperationResult` — `{ Status, InventoryItem }`, the item being what',
|
||||
'the RECIPIENT now owns — the same `RoomConsumableInventoryItem` that',
|
||||
'`…/roomConsumable/room/{roomId}/me` lists: `Count` the resulting total,',
|
||||
'`ConcurrencyCode` the request’s `NewConcurrencyCode` (stored, not checked),',
|
||||
'`Consumable` the listing plus `IsGiftable`/`Visibility`.',
|
||||
'',
|
||||
'A refusal is a 200 in the same shape — `Status` 1, a null `InventoryItem`, the',
|
||||
'balance as it stands — with the reason in `BalanceUpdateResult`: 4 no such',
|
||||
'consumable or room, 7 a bad `Quantity`, 8 a bad gift recipient, 6 a price or',
|
||||
'currency that is not the listing’s, 2 not enough to pay. Nothing is charged or',
|
||||
'given. The refusal form is an ASSUMPTION — only success has been observed.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'id',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The consumable to buy — a `room_consumable` id',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'via',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: '`tokens` for a listing priced in tokens, `currency` for a room currency',
|
||||
schema: { type: 'string', enum: ['tokens', 'currency'] },
|
||||
},
|
||||
],
|
||||
requestBody: jsonBody(PurchaseRoomConsumableRequest, 'What to buy, and what the client saw'),
|
||||
responses: {
|
||||
200: json(PurchaseRoomConsumableResponse, 'What the recipient now owns, or a refusal'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||
const currencyType = CurrencyType.RecCenterTokens
|
||||
const body = await c.req
|
||||
.json<{
|
||||
ConcurrencyCodes?: { NewConcurrencyCode?: unknown } | null
|
||||
ExpectedPriceAndCurrency?: { Price?: unknown; CurrencyId?: unknown } | null
|
||||
Quantity?: unknown
|
||||
GiftRecipientAccountId?: unknown
|
||||
}>()
|
||||
.catch(() => null)
|
||||
const request = body !== null && typeof body === 'object' ? body : {}
|
||||
const expected = request.ExpectedPriceAndCurrency
|
||||
const expectedCurrency =
|
||||
typeof expected?.CurrencyId === 'string' && expected.CurrencyId !== ''
|
||||
? expected.CurrencyId
|
||||
: null
|
||||
const consumable = await getRoomConsumable(c.env.DB, c.req.param('id'))
|
||||
// The balance the answer reports on: what the listing is priced in once it is known,
|
||||
// what the request named on a refusal that never got that far. Null is tokens.
|
||||
const balanceCurrency = consumable ? consumable.PurchaseCurrencyId : expectedCurrency
|
||||
|
||||
// Every key is always sent: the client reads an omitted `Status` or
|
||||
// `BalanceUpdateResult` as its enum's 0 member, which is success.
|
||||
const answer = async (code: number, item: RoomConsumableInventoryItem | null) =>
|
||||
c.json({
|
||||
BalanceUpdateResult: code,
|
||||
CurrencyBalanceResponse: {
|
||||
AccountId: id,
|
||||
CurrencyId: balanceCurrency,
|
||||
Balance:
|
||||
balanceCurrency === null
|
||||
? await getBalance(c.env.DB, id, currencyType, startingTokens)
|
||||
: await getRoomBalance(c.env.DB, balanceCurrency, id),
|
||||
ModifiedAt: new Date().toISOString(),
|
||||
},
|
||||
OperationResult: {
|
||||
Status: item === null ? ROOM_KEY_STATUS_FAILED : ROOM_KEY_STATUS_OK,
|
||||
InventoryItem: item,
|
||||
},
|
||||
})
|
||||
|
||||
if (!consumable) return answer(UpdateResponse.NoItemAvailable, null)
|
||||
|
||||
const quantity = request.Quantity
|
||||
if (
|
||||
typeof quantity !== 'number' ||
|
||||
!Number.isInteger(quantity) ||
|
||||
quantity < 1 ||
|
||||
(consumable.MaximumCountPerPurchase > 0 && quantity > consumable.MaximumCountPerPurchase)
|
||||
) {
|
||||
return answer(UpdateResponse.RequestedAmountNotAllowed, null)
|
||||
}
|
||||
|
||||
const gift = request.GiftRecipientAccountId
|
||||
if (
|
||||
gift !== null &&
|
||||
gift !== undefined &&
|
||||
!(Number.isInteger(gift) && (gift as number) > 0)
|
||||
) {
|
||||
return answer(UpdateResponse.PlayerNotEligible, null)
|
||||
}
|
||||
const recipientId = (gift as number | null | undefined) ?? id
|
||||
|
||||
// Paid in the listing's own currency only — never whichever the request would rather.
|
||||
if (expectedCurrency?.toLowerCase() !== consumable.PurchaseCurrencyId?.toLowerCase()) {
|
||||
return answer(UpdateResponse.RequestedPriceDoesNotMatch, null)
|
||||
}
|
||||
|
||||
const canManage = await canManageRoomById(c.env.DB, consumable.RoomId, id)
|
||||
if (canManage === null) return answer(UpdateResponse.NoItemAvailable, null)
|
||||
const expectedPrice = expected?.Price
|
||||
if (expectedPrice !== consumable.Price && !(canManage && expectedPrice === 0)) {
|
||||
return answer(UpdateResponse.RequestedPriceDoesNotMatch, null)
|
||||
}
|
||||
|
||||
const total = canManage ? 0 : consumable.Price * quantity
|
||||
if (total > 0 && consumable.PurchaseCurrencyId !== null) {
|
||||
if (!(await spendRoomCurrency(c.env.DB, consumable.PurchaseCurrencyId, id, total))) {
|
||||
return answer(UpdateResponse.NotEnoughCredit, null)
|
||||
}
|
||||
} else if (total > 0) {
|
||||
const ownerId = await getRoomOwnerId(c.env.DB, consumable.RoomId)
|
||||
if (ownerId === null) return answer(UpdateResponse.NoItemAvailable, null)
|
||||
if (!(await spendCurrency(c.env.DB, id, currencyType, total, startingTokens))) {
|
||||
return answer(UpdateResponse.NotEnoughCredit, null)
|
||||
}
|
||||
// Seed the owner's signup grant BEFORE crediting them, as the key sale does.
|
||||
await ensureStartingBalances(c.env.DB, ownerId, startingTokens)
|
||||
const ownerBalance = await creditCurrency(
|
||||
c.env.DB,
|
||||
ownerId,
|
||||
currencyType,
|
||||
total,
|
||||
startingTokens
|
||||
)
|
||||
await pushBalanceUpdate(c, ownerId, currencyType, ownerBalance)
|
||||
await pushBalancePurchase(
|
||||
c,
|
||||
id,
|
||||
currencyType,
|
||||
-total,
|
||||
await getBalance(c.env.DB, id, currencyType, startingTokens)
|
||||
)
|
||||
}
|
||||
|
||||
const newCode = request.ConcurrencyCodes?.NewConcurrencyCode
|
||||
const owned = await awardRoomConsumable(
|
||||
c.env.DB,
|
||||
recipientId,
|
||||
consumable.RoomConsumableId,
|
||||
quantity,
|
||||
typeof newCode === 'string' ? newCode : null
|
||||
)
|
||||
return answer(UpdateResponse.OK, toInventoryItem(recipientId, owned, consumable))
|
||||
}
|
||||
)
|
||||
|
||||
// Create a consumable, or replace one when the body names an existing id. Auth-gated (401)
|
||||
// and gated to the room's creator or a co-owner (403) — a shop is the room's to stock.
|
||||
.put(
|
||||
@@ -3632,10 +4177,48 @@ const app = new Hono<App>({ strict: false })
|
||||
return roomConsumableEnvelope(c, consumable)
|
||||
}
|
||||
)
|
||||
// What the CALLER owns of one room's consumables. NOT the catalogue the same path without
|
||||
// `/me` serves: a different element type (`RoomConsumableInventoryItem`), so a separate handler.
|
||||
.get(
|
||||
'/api/roomconsumables/v1/roomConsumable/room/:roomId/me',
|
||||
listRoute('The caller’s room consumables', 'Empty stub'),
|
||||
(c) => c.json([])
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'The caller’s room consumables',
|
||||
description: [
|
||||
'A bare array of what the caller owns of the room’s consumables, in the shop’s order —',
|
||||
'the client’s `RoomConsumableInventoryItem`, observed from the live client:',
|
||||
'`{ RoomConsumableId, AccountId, Count, ConcurrencyCode, ModifiedAt, Consumable }`.',
|
||||
'',
|
||||
'`Count` is what they hold now; a consumable they hold none of (never had, or spent',
|
||||
'down to zero) is absent. `ConcurrencyCode` is the last `NewConcurrencyCode` written',
|
||||
'(stored, not checked). `Consumable` is the listing plus `IsGiftable` (false) and',
|
||||
'`Visibility` (0), with `ImageName` as "" rather than null — NOT the catalogue',
|
||||
'element `…/room/{roomId}` serves.',
|
||||
'',
|
||||
'An unknown room, or one the caller owns nothing of, is an empty list.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'roomId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The room whose consumables to list',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(RoomConsumableInventoryItemDto.array(), 'What the caller owns, shop order'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
if (!Number.isInteger(roomId)) return c.json([])
|
||||
return c.json(await getRoomInventory(c.env.DB, roomId, id))
|
||||
}
|
||||
)
|
||||
// The custom currencies a room has minted. Public, like the room itself: a room's
|
||||
// currencies are shown to every player who walks into it, so the list is not a secret
|
||||
@@ -3988,6 +4571,64 @@ const app = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Take a purchase offer down. Auth-gated (401) and gated to the creator or a co-owner of
|
||||
// the room that minted the currency the offer sells (403). The body names ONLY the offer,
|
||||
// so the currency — and through it the room — is found by walking the shops for the id;
|
||||
// nothing about the caller's whereabouts is consulted.
|
||||
.post(
|
||||
'/api/roomcurrencies/v1/deletePurchaseOffer',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Remove a purchase offer from a room currency',
|
||||
description: [
|
||||
'Removes the offer named by `PurchaseOfferId` (its `CurrencyPurchaseOfferId`) from',
|
||||
'whichever currency’s shop lists it. Form-encoded (`PurchaseOfferId=…`). The body',
|
||||
'names nothing else: the currency, and the room that gates the delete, are found',
|
||||
'from the offer.',
|
||||
'',
|
||||
'Gated to the creator or a co-owner of the room that minted the currency. A valid',
|
||||
'token from anyone else is a 403. Answers the same',
|
||||
'`{ Value, Success, Error, error_id }` envelope the create answers in, with the',
|
||||
'offer as it stood when removed in `Value`, or a 200 carrying `Success: false` when',
|
||||
'no shop lists that id. What the client does with `Value` here is an assumption —',
|
||||
'only the create’s reply has been observed.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
requestBody: form(DeletePurchaseOfferRequest, 'The offer to remove'),
|
||||
responses: {
|
||||
200: json(
|
||||
RoomCurrencyPurchaseOfferEnvelope,
|
||||
'The offer as removed, or a rejection with `Success: false`'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: { description: 'Not the creator or a co-owner of the minting room (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const purchaseOfferId = (
|
||||
typeof body.PurchaseOfferId === 'string' ? body.PurchaseOfferId : ''
|
||||
).trim()
|
||||
if (purchaseOfferId === '') return purchaseOfferEnvelope(c, null, DELETE_OFFER_FAILED)
|
||||
|
||||
// Found BEFORE it is removed, because the find is what says which room gates the
|
||||
// delete: an unknown id is a refusal, and a known one is somebody's to refuse.
|
||||
const found = await findPurchaseOffer(c.env.DB, purchaseOfferId)
|
||||
if (!found) return purchaseOfferEnvelope(c, null, DELETE_OFFER_FAILED)
|
||||
|
||||
const canManage = await canManageRoomById(c.env.DB, found.RoomId, accountId)
|
||||
if (canManage === null) return purchaseOfferEnvelope(c, null, DELETE_OFFER_FAILED)
|
||||
if (!canManage) return c.body(null, 403)
|
||||
|
||||
const removed = await deletePurchaseOffer(c.env.DB, purchaseOfferId)
|
||||
if (!removed) return purchaseOfferEnvelope(c, null, DELETE_OFFER_FAILED)
|
||||
return purchaseOfferEnvelope(c, removed.Offer)
|
||||
}
|
||||
)
|
||||
|
||||
// Award room currency to players, several awards per call. Auth-gated (401); everything
|
||||
// else is reported PER ENTRY, including whether the caller may award that currency at all.
|
||||
.post(
|
||||
@@ -4257,6 +4898,198 @@ const app = new Hono<App>({ strict: false })
|
||||
c.json([])
|
||||
)
|
||||
|
||||
// What one player holds of one room currency. Any signed-in caller may read any player's,
|
||||
// as the query names the player rather than taking the caller.
|
||||
.get(
|
||||
'/api/roomcurrencies/v1/getBalance',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'A player’s balance of one room currency',
|
||||
description: [
|
||||
'What the player named by `accountId` holds of the room currency `currencyId`',
|
||||
'(`room_balance`). A player who has never earned any — and a currency that does not',
|
||||
'exist — is a `Balance` of 0, not a 404.',
|
||||
'',
|
||||
'`ModifiedAt` is the time of the response: `room_balance` keeps no modification time.',
|
||||
'A missing `currencyId` or a non-integer `accountId` is a 400.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'currencyId',
|
||||
in: 'query',
|
||||
required: true,
|
||||
description: 'The room currency',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
{
|
||||
name: 'accountId',
|
||||
in: 'query',
|
||||
required: true,
|
||||
description: 'The player whose balance to read',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(RoomCurrencyBalance, 'The player’s balance'),
|
||||
400: { description: 'A missing `currencyId` or a non-integer `accountId`' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
if ((await authedId(c)) === null) return unauthorized(c)
|
||||
const currencyId = c.req.query('currencyId')?.trim() ?? ''
|
||||
const rawAccountId = c.req.query('accountId')?.trim() ?? ''
|
||||
if (currencyId === '' || !/^\d+$/.test(rawAccountId)) return c.body(null, 400)
|
||||
const accountId = Number(rawAccountId)
|
||||
return c.json({
|
||||
AccountId: accountId,
|
||||
CurrencyId: currencyId,
|
||||
Balance: await getRoomBalance(c.env.DB, currencyId, accountId),
|
||||
ModifiedAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Buy room currency with tokens, through one of the currency's purchase offers. The body
|
||||
// names only the OFFER — which currency it sells is whichever one lists it — and the
|
||||
// amount and price the client rendered, both of which must still match the offer.
|
||||
.post(
|
||||
'/api/roomCurrencies/v2/purchase',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Buy room currency with tokens',
|
||||
description: [
|
||||
'Buys from a room currency’s shop. Form-encoded',
|
||||
'(`PurchaseOfferId=ffab4157-…&RequestedAmount=444&RequestedPrice=555`): the caller',
|
||||
'pays the offer’s `Price` in RecCenterTokens and is given its `CurrencyAmount` of the',
|
||||
'currency the offer belongs to (`room_balance`).',
|
||||
'',
|
||||
'`PurchaseOfferId` is a `CurrencyPurchaseOfferId` out of a currency’s purchase offers,',
|
||||
'and is all that says which currency is bought. `RequestedAmount` and `RequestedPrice`',
|
||||
'must EQUAL the offer’s `CurrencyAmount` and `Price` — an offer edited since the client',
|
||||
'drew it is refused rather than sold at numbers the player did not see.',
|
||||
'',
|
||||
'The tokens are paid to the room’s OWNER (its `CreatorAccountId`), as a room-key sale',
|
||||
'is. The room’s owner and co-owners (the `canManageRoom` set) take from their own shop',
|
||||
'for free: the client shows them the offer at no cost and posts `RequestedPrice=0`, so',
|
||||
'their posted price is not checked and no tokens move. A free offer moves none either.',
|
||||
'',
|
||||
'Answers the `{ Value, Success, Error, error_id }` envelope the room-currency writes',
|
||||
'use. `Value` is BOTH balances the purchase moved, each a RESULTING total:',
|
||||
'`CurrencyBalanceResponse` (`AccountId`, `CurrencyId`, `Balance`, `ModifiedAt`) is',
|
||||
'what the buyer now holds of the room currency, and `TokenBalanceResponse`',
|
||||
'(`Balance`, `CurrencyType`, `Platform`) their tokens. `Platform` is -2, the one',
|
||||
'bucket every balance surface here names — any other value shows the buyer a phantom',
|
||||
'second token balance. A refusal (no such offer, a mismatch, not enough tokens) is a',
|
||||
'200 with `Success: false` and a null `Value` — that form is an assumption — and',
|
||||
'nothing moved.',
|
||||
'',
|
||||
'When tokens moved, both players get a socket push carrying their resulting token',
|
||||
'total — the buyer a StorefrontBalancePurchase, the owner a StorefrontBalanceUpdate.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
requestBody: form(PurchaseRoomCurrencyRequest, 'The offer, and the numbers the client saw'),
|
||||
responses: {
|
||||
200: json(PurchaseRoomCurrencyEnvelope, 'The buyer’s new holding, or a refusal'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '')
|
||||
const int = (v: unknown): number => (/^-?\d+$/.test(str(v)) ? Number(str(v)) : Number.NaN)
|
||||
const refuse = (error: string) =>
|
||||
c.json({ Value: null, Success: false, Error: error, error_id: null })
|
||||
|
||||
const offerId = str(body.PurchaseOfferId)
|
||||
const found = offerId === '' ? null : await findPurchaseOffer(c.env.DB, offerId)
|
||||
if (found === null) return refuse('No such purchase offer')
|
||||
const { Offer: offer, RoomId: roomId } = found
|
||||
|
||||
// An offer that hands over nothing, or costs less than nothing, is not for sale.
|
||||
if (!Number.isInteger(offer.CurrencyAmount) || offer.CurrencyAmount <= 0) {
|
||||
return refuse('Purchase offer is not available')
|
||||
}
|
||||
if (!Number.isInteger(offer.Price) || offer.Price < 0) {
|
||||
return refuse('Purchase offer is not available')
|
||||
}
|
||||
if (int(body.RequestedAmount) !== offer.CurrencyAmount) {
|
||||
return refuse('Requested amount does not match')
|
||||
}
|
||||
|
||||
const ownerId = await getRoomOwnerId(c.env.DB, roomId)
|
||||
if (ownerId === null) return refuse('Purchase offer is not available')
|
||||
|
||||
// The room's owner and co-owners take from their own shop for free: the client
|
||||
// shows THEM the offer at no cost and posts `RequestedPrice=0`, so holding a
|
||||
// contributor to the offer's price refused every purchase they made. Nobody is
|
||||
// charged — the owner paying themselves would be a debit and a credit of the same
|
||||
// tokens, and a co-owner is building the shop, not buying from it — so the posted
|
||||
// price is not checked for them at all; everyone else must have seen the real one.
|
||||
const canManage = (await canManageRoomById(c.env.DB, roomId, id)) === true
|
||||
if (!canManage && int(body.RequestedPrice) !== offer.Price) {
|
||||
return refuse('Requested price does not match')
|
||||
}
|
||||
|
||||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||
const tokens = CurrencyType.RecCenterTokens
|
||||
const charged = offer.Price > 0 && !canManage
|
||||
// Charged FIRST, and atomically: a buyer who cannot pay is handed nothing.
|
||||
if (charged && !(await spendCurrency(c.env.DB, id, tokens, offer.Price, startingTokens))) {
|
||||
return refuse('Not enough tokens')
|
||||
}
|
||||
|
||||
const { Balance: currencyBalance } = await awardRoomCurrency(
|
||||
c.env.DB,
|
||||
offer.CurrencyId,
|
||||
id,
|
||||
offer.CurrencyAmount
|
||||
)
|
||||
|
||||
if (charged) {
|
||||
// Seed the owner's signup grant BEFORE crediting them — `creditCurrency` upserts
|
||||
// the row, which would otherwise cost a never-seen owner their starting tokens.
|
||||
await ensureStartingBalances(c.env.DB, ownerId, startingTokens)
|
||||
const ownerBalance = await creditCurrency(
|
||||
c.env.DB,
|
||||
ownerId,
|
||||
tokens,
|
||||
offer.Price,
|
||||
startingTokens
|
||||
)
|
||||
// Both frames carry a RESULTING total — see the frame rule above pushBalanceUpdate.
|
||||
await pushBalanceUpdate(c, ownerId, tokens, ownerBalance)
|
||||
}
|
||||
const tokenBalance = await getBalance(c.env.DB, id, tokens, startingTokens)
|
||||
if (charged) await pushBalancePurchase(c, id, tokens, -offer.Price, tokenBalance)
|
||||
|
||||
// Both balances are RESULTING totals. `Platform` names the one account-wide bucket,
|
||||
// like every other token balance this worker reports.
|
||||
return c.json({
|
||||
Value: {
|
||||
CurrencyBalanceResponse: {
|
||||
AccountId: id,
|
||||
CurrencyId: offer.CurrencyId,
|
||||
Balance: currencyBalance,
|
||||
ModifiedAt: new Date().toISOString(),
|
||||
},
|
||||
TokenBalanceResponse: {
|
||||
Balance: tokenBalance,
|
||||
CurrencyType: tokens,
|
||||
Platform: ALL_PLATFORMS,
|
||||
},
|
||||
},
|
||||
Success: true,
|
||||
Error: null,
|
||||
error_id: null,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// The room-economy surface the client asks for on entering a room: the room's own
|
||||
// inventory/offers/gift-drop shops and the caller's slice of them. Nothing here is
|
||||
// stored yet, so every one is an empty list — the client reads that as "this room
|
||||
@@ -4286,8 +5119,30 @@ const app = new Hono<App>({ strict: false })
|
||||
)
|
||||
.get(
|
||||
'/econ/roomOffer/room/:roomId/purchaseCounts',
|
||||
listRoute('Per-offer purchase counts for a room', 'Empty stub'),
|
||||
(c) => c.json([])
|
||||
describeRoute({
|
||||
summary: 'Per-offer purchase counts for a room',
|
||||
responses: {
|
||||
200: json(
|
||||
z.union([
|
||||
JsonArray,
|
||||
z.object({
|
||||
Value: JsonArray,
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable(),
|
||||
error_id: z.string().nullable(),
|
||||
}),
|
||||
]),
|
||||
'2026: RecNet result envelope; older builds: a list'
|
||||
),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
if (((await authedBuild(c)) ?? 0) >= 20260323) {
|
||||
// The 2026 client decodes RecNetResultListObject<RoomOfferPurchaseCountDTO>.
|
||||
return c.json({ Value: [], Success: true, Error: null, error_id: null })
|
||||
}
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/econ/roomGiftDropShops/room/:roomId',
|
||||
@@ -4325,14 +5180,109 @@ const app = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// The UGC items a room sells (the creator-made things on sale inside it). Same empty
|
||||
// stub as the room-economy routes above and asked for on the same room load: nothing
|
||||
// stores room UGC purchasables yet, and an empty list reads as "this room sells
|
||||
// nothing" where a 404 stalls the load.
|
||||
// The paintball battle pass's season config. A stub: nothing stores a season yet, so
|
||||
// every caller is served the one example season below, verbatim.
|
||||
.get(
|
||||
'/api/ugcPurchasables/v1/items/room/:roomId',
|
||||
listRoute('A room’s UGC purchasables', 'Empty stub so the client doesn’t 404'),
|
||||
(c) => c.json([])
|
||||
'/paintball-battle-pass/config',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'The paintball battle pass config (stub)',
|
||||
description: 'A fixed example season — no battle pass is stored yet.',
|
||||
responses: { 200: json(JsonObject, 'The battle pass config') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
BattlePassId: 1,
|
||||
Name: 'Season 1',
|
||||
ValidRoomIds: [112],
|
||||
StartTime: '2026-10-01T00:00:00Z',
|
||||
EndTime: '2026-12-01T00:00:00Z',
|
||||
Levels: [{ XpRequired: 100, RewardGiftDropIds: [1] }],
|
||||
EventXP: { '1': 10, '2': 25, '3': 50, '4': 100 },
|
||||
BoostConfig: {
|
||||
TemporaryBoostDurationInMinutes: 60,
|
||||
TemporaryBoostXpMultiplierPercentage: 50,
|
||||
PermanentBoostXpMultiplierPercentage: 25,
|
||||
SocialBoostXpMultiplierPercentage: 10,
|
||||
MaxBoostXpMultiplierPercentage: 100,
|
||||
},
|
||||
DailyParticipationBonusLimit: 5,
|
||||
MinimumParticipationTimeInSeconds: 120,
|
||||
PurchasableSkus: [{ Type: 1, TokenPrice: 1000, BonusLevels: 0, PermanentBoost: false }],
|
||||
PermanentBoostTokenPrice: 500,
|
||||
LimitedTimeReward: { CompletionDeadline: '2026-11-01T00:00:00Z', GiftDropIds: [9] },
|
||||
})
|
||||
)
|
||||
|
||||
// The timed currency grant the client polls. A stub: nothing accrues or is granted yet,
|
||||
// so every caller is served the one example grant below, verbatim. Takes no body.
|
||||
.post(
|
||||
'/api/timedCurrencyGrants/update',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'The timed currency grant (stub)',
|
||||
description: 'A fixed example grant — no timed grant is stored or paid out yet.',
|
||||
responses: { 200: json(JsonObject, 'The timed currency grant') },
|
||||
}),
|
||||
(c) =>
|
||||
c.json({
|
||||
TimedCurrencyGrantId: 1,
|
||||
Name: 'Daily Tokens',
|
||||
StartTime: '2026-10-01T00:00:00Z',
|
||||
EndTime: '2026-12-01T00:00:00Z',
|
||||
CurrencyType: 0,
|
||||
OfflineRatePerHour: 1.0,
|
||||
OnlineRatePerHour: 5.0,
|
||||
ActiveExperiment: null,
|
||||
})
|
||||
)
|
||||
|
||||
// The client reporting how far the player has moved, towards a distance-moved reward
|
||||
// track. A stub: the form body (`distanceMeters=12.5`) is not read, nothing is tracked,
|
||||
// and the answer is an empty 200.
|
||||
.post(
|
||||
'/api/distanceMovedRewardTracks/distance',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Report distance moved (stub)',
|
||||
description:
|
||||
'Takes a form-encoded `distanceMeters` and stores nothing — no reward track exists yet.',
|
||||
responses: { 200: { description: 'Empty body' } },
|
||||
}),
|
||||
(c) => c.body(null, 200)
|
||||
)
|
||||
|
||||
// Everything a room sells, in one list: its keys, its consumables and the purchase offers
|
||||
// of its currencies, each as the client's 10-key `UgcPurchasableItem` with `ItemType`
|
||||
// saying which (0 key, 1 consumable, 3 currency pack). Asked for on every room load. A
|
||||
// room that sells nothing is `[]` — the client's decoder throws on `{}` or an empty body.
|
||||
.get(
|
||||
'/api/ugcPurchasables/v1/items/room/:roomId{[0-9]+}',
|
||||
describeRoute({
|
||||
tags: ['Rooms'],
|
||||
summary: 'A room’s purchasables',
|
||||
description:
|
||||
'The room’s shop as one flat list: its room keys (`ItemType` 0), room consumables ' +
|
||||
'(1) and the purchase offers of each of its currencies (3), assembled from the ' +
|
||||
'`room_key`, `room_consumable` and `room_currency` tables. `ItemId` is a bare GUID ' +
|
||||
'— a key’s `ReplicationId`, a consumable’s `RoomConsumableId`, an offer’s ' +
|
||||
'`CurrencyPurchaseOfferId` — with the kind beside it as `ItemType`, not the ' +
|
||||
'`{ itemType, itemId }` struct the bulk lookup takes. `PurchaseCurrencyId` is the ' +
|
||||
'room currency a line is charged in, or null for tokens (every currency pack). A room ' +
|
||||
'that sells nothing is an empty array.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'roomId',
|
||||
in: 'path',
|
||||
required: true,
|
||||
description: 'The room whose shop to list',
|
||||
schema: { type: 'string', pattern: '^[0-9]+$' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(RoomPurchasableList, 'The room’s shop') },
|
||||
}),
|
||||
async (c) =>
|
||||
c.json(await getRoomPurchasables(c.env.DB, Number.parseInt(c.req.param('roomId'), 10)))
|
||||
)
|
||||
|
||||
// Bulk lookup of UGC purchasables by `{ itemType, itemId }`. Only custom avatar items
|
||||
@@ -4422,6 +5372,89 @@ const app = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Store listings by `PurchasableItemId` — `{ PurchasableItemIds: [2594], GiftDropIds: null }`.
|
||||
// Answers a bare array of the listings exactly as the served 2025 store (`sf3-2025.json`)
|
||||
// lists them, read from the `catalog` table's `listing` column rather than the 4 MB file
|
||||
// (a row's `catalog_id` IS its `PurchasableItemId`). Every item kind resolves; unknown ids
|
||||
// are dropped, so the answer is in request order but not positional. `GiftDropIds` has only
|
||||
// been seen null and is ignored. No auth: a listing is what the public store shows anyone.
|
||||
.post(
|
||||
'/api/storefronts/v1/purchasableitems',
|
||||
describeRoute({
|
||||
tags: ['Storefront'],
|
||||
summary: 'Store listings by PurchasableItemId',
|
||||
description: [
|
||||
'Resolves `PurchasableItemIds[]` against the `catalog` table and answers each listing',
|
||||
'verbatim as the served 2025 store (`sf3-2025.json`) lists it — `GiftDrop`, `Prices`,',
|
||||
'`SubscriberPrices` and the rest. Avatar items, consumables, skins and custom avatar',
|
||||
'items all resolve. Unknown ids (and repeats) are dropped, so the response follows',
|
||||
'request order but is never a positional match. `GiftDropIds` is ignored.',
|
||||
].join(' '),
|
||||
requestBody: jsonBody(PurchasableItemsRequest, 'The listings to resolve'),
|
||||
responses: {
|
||||
200: json(JsonArray, 'The resolved listings (unknown ids omitted)'),
|
||||
400: json(ErrorResponse, 'Malformed body'),
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
|
||||
if (!body || typeof body !== 'object')
|
||||
return c.json({ error: 'A JSON body is required' }, 400)
|
||||
const raw = body.PurchasableItemIds
|
||||
if (raw !== undefined && raw !== null && !Array.isArray(raw)) {
|
||||
return c.json({ error: 'PurchasableItemIds must be an array' }, 400)
|
||||
}
|
||||
const ids = ((raw as unknown[] | null | undefined) ?? []).filter((id): id is number =>
|
||||
Number.isInteger(id)
|
||||
)
|
||||
return c.json(await getStoreListings(c.env.DB, ids))
|
||||
}
|
||||
)
|
||||
|
||||
// The consumable DEFINITIONS the client knows how to use (`?supportedVersion=2`), as
|
||||
// opposed to the ones a player owns. Served verbatim from `static/consumables.json`, the
|
||||
// live game's list; `supportedVersion` is accepted and unused. A row's `ConfigurationData`
|
||||
// is JSON escaped into a STRING (`"{\"MeshConfigurationData\":null,…}"`), or null.
|
||||
.get(
|
||||
'/api/consumables/v1/all',
|
||||
describeRoute({
|
||||
tags: ['Consumables'],
|
||||
summary: 'All consumable definitions',
|
||||
description:
|
||||
'Every consumable definition, verbatim from static/consumables.json. supportedVersion is ignored.',
|
||||
responses: { 200: json(JsonArray, 'The consumable definitions') },
|
||||
}),
|
||||
(c) => c.json(consumableDefinitions)
|
||||
)
|
||||
|
||||
// STUB. The client's lookup of consumable definitions by descriptor
|
||||
// (`?configurationVersion=2`), form-encoded with one repeated key:
|
||||
// `descriptors=xfeDIDboPES-AS90CCn2xQ&descriptors=test_bad`. The answer is an array of
|
||||
// definitions — `{ ConsumableItemDesc, InitialCount, CreatedAt, ThumbnailImageName,
|
||||
// FriendlyName, RoomieCategory, ConsumableToolName, ConfigurationData }`, with
|
||||
// `ConfigurationData` JSON escaped into a string as on `/all` — holding only the
|
||||
// descriptors that resolve. Nothing here can resolve one yet, so every query answers [].
|
||||
.post(
|
||||
'/api/consumables/v1/query/bulk',
|
||||
describeRoute({
|
||||
tags: ['Consumables'],
|
||||
summary: 'Look up consumable definitions by descriptor (stub)',
|
||||
description: [
|
||||
'STUB. Takes a form body repeating `descriptors` once per consumable descriptor and',
|
||||
'answers the definitions that resolve. There is no lookup behind it yet, so the',
|
||||
'answer is always []. configurationVersion is ignored.',
|
||||
].join(' '),
|
||||
requestBody: form(QueryConsumablesRequest, 'The descriptors to look up'),
|
||||
responses: { 200: json(JsonArray, 'The matching definitions (always empty for now)') },
|
||||
}),
|
||||
async (c) => {
|
||||
const body = await c.req.parseBody({ all: true }).catch(() => ({}) as Record<string, unknown>)
|
||||
const descriptors = body['descriptors']
|
||||
logger.info('consumable query/bulk (stub, nothing resolved)', { descriptors })
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
|
||||
// Unlocked consumables. [Authorize]. The consumables the player has bought (from
|
||||
// `buyItem`, stored in the `consumable` table), grouped by item into the client's
|
||||
// unlocked-consumable DTO. A player who has bought none gets an empty list.
|
||||
@@ -4444,7 +5477,17 @@ const app = new Hono<App>({ strict: false })
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(await getConsumables(c.env.DB, id))
|
||||
const consumables = await getConsumables(c.env.DB, id)
|
||||
// The 2026 client includes this flag in the instance key and verifies it against the
|
||||
// definition, so it comes from the definition — see `isRroConsumable`.
|
||||
return c.json(
|
||||
((await authedBuild(c)) ?? 0) >= 20260323
|
||||
? consumables.map((item) => ({
|
||||
...item,
|
||||
IsRRO: isRroConsumable(item.ConsumableItemDesc),
|
||||
}))
|
||||
: consumables
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -4486,6 +5529,54 @@ const app = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// STUB. The client posts `{ Id, IsActive, ActivatedByRoomie }` when a consumable starts
|
||||
// or stops being used — `Id` is the consumable row id, as on `/consume`. Nothing is
|
||||
// stored: the body is read only to log it, and the client ignores the response entirely,
|
||||
// so none is sent.
|
||||
//
|
||||
// What this most likely means: the consumable is IN USE, and marking it so is what keeps
|
||||
// a player from reusing one before it is over (a second Confetti Cannon while the first
|
||||
// is still firing), and `ActivatedByRoomie` says a roommate set it off rather than its
|
||||
// owner. That is a guess from the field names, not observed behaviour, so no row flips
|
||||
// and `/consume` is unchanged. When the client's reading of it is known, this is where
|
||||
// an `is_active` flag on the consumable row (and a refusal on `/consume`) would go.
|
||||
.post(
|
||||
'/api/consumables/v1/updateActive',
|
||||
describeRoute({
|
||||
tags: ['Consumables'],
|
||||
summary: 'Mark a consumable active or inactive (stub)',
|
||||
description: [
|
||||
'STUB. Accepts `{ Id, IsActive, ActivatedByRoomie }` — the client posting that one',
|
||||
'of its consumables has started or stopped being used — and stores nothing. The',
|
||||
'client ignores the response entirely, so this answers an empty 200.',
|
||||
'',
|
||||
'Believed to mark the consumable as IN USE so it cannot be reused before it is over,',
|
||||
'with `ActivatedByRoomie` saying a roommate set it off; that is a guess from the field',
|
||||
'names, and nothing acts on it yet. `/consume` is unaffected.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(UpdateActiveConsumableRequest, 'The consumable and its new state'),
|
||||
responses: {
|
||||
200: { description: 'Empty body — the client ignores the response entirely' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const body = await c.req
|
||||
.json<Record<string, unknown>>()
|
||||
.catch(() => ({}) as Record<string, unknown>)
|
||||
logger.info('consumable updateActive (stub, not stored)', {
|
||||
accountId: id,
|
||||
consumableId: body.Id,
|
||||
isActive: body.IsActive,
|
||||
activatedByRoomie: body.ActivatedByRoomie,
|
||||
})
|
||||
return c.body(null, 200)
|
||||
}
|
||||
)
|
||||
|
||||
// Currency balance. [Authorize]. The trailing int is a CurrencyType — the client
|
||||
// fetches `/balance/2` (RecCenterTokens) on load. Backed by the `balance` table; a
|
||||
// player who has never been granted gets their starting balance on this first read.
|
||||
@@ -5377,9 +6468,11 @@ const app = new Hono<App>({ strict: false })
|
||||
// posting the type and the message to show for it (`rewardType=FirstActivityOfDay&
|
||||
// Message=First Game of the Day`, or `rewardType=PostGameActivity&Message=Activity
|
||||
// completed!&giftContext=Soccer`) — so whether a reward is actually OWED is decided
|
||||
// here, from `reward_status`: one claim per type per activity per hour, atomically.
|
||||
// here, from `reward_status`: one claim per type per activity per hour, atomically —
|
||||
// except FirstActivityOfDay, one per player per 24h.
|
||||
//
|
||||
// A claim pays GAME_REWARD_XP into `progression` and hands over a gift box carrying that
|
||||
// A claim pays GAME_REWARD_XP into `progression` (up to GAME_REWARD_DAILY_XP_CAP per player
|
||||
// per rolling 24h, FirstActivityOfDay's on top) and hands over a gift box carrying that
|
||||
// XP, announced with the same GiftPackageReceivedImmediate frame the weekly gift uses —
|
||||
// the client posted the message to show, so the box wears it. An on-cooldown ask changes
|
||||
// nothing and pays nothing.
|
||||
@@ -5389,19 +6482,20 @@ const app = new Hono<App>({ strict: false })
|
||||
// reference answers its own (different, selection-based) flow with a success envelope,
|
||||
// not a list of rewards.
|
||||
//
|
||||
// `giftContext` (the activity, e.g. `Soccer`) is part of the cooldown key: the first
|
||||
// activity of the day is per ACTIVITY, so a player who moves from Soccer to Paintball is
|
||||
// owed another reward while a second Soccer match inside the hour is not. An ask that
|
||||
// sends no context keys on `''`.
|
||||
// `giftContext` (the activity, e.g. `Soccer`) is part of the cooldown key, so a player who
|
||||
// moves from Soccer to Paintball is owed another reward while a second Soccer match inside
|
||||
// the hour is not. An ask that sends no context keys on `''` — as FirstActivityOfDay always
|
||||
// does, being the player's first activity of the day, not each activity's.
|
||||
//
|
||||
// It also picks the PRIZE: a context that is a key of `static/quest-rewards.json`
|
||||
// (`Dodgeball`, `Quest_Goblin_S`, …) draws one of that activity's rewards — an avatar item
|
||||
// granted into the inventory, or Laser Tag's ticket payout — and the box carries it, with
|
||||
// the activity's own `GiftContext`. A context the table doesn't know gets the XP-only box.
|
||||
//
|
||||
// And the FIRST claim ever in a context — a new activity for this player, whichever type
|
||||
// asked — pays NEW_ACTIVITY_BONUS_TOKENS on top, credited to the balance and delivered in
|
||||
// a second box of its own so the player sees what the tokens were for.
|
||||
// A `PostGameActivity`/`PostQuestActivity` claim also pays GAME_REWARD_TOKENS, up to
|
||||
// GAME_REWARD_DAILY_TOKEN_CAP per player per rolling 24h across every activity, and a
|
||||
// FirstActivityOfDay claim pays them on top of the cap — credited to the balance and
|
||||
// delivered in a second box of its own so the player sees what the tokens were for.
|
||||
.post(
|
||||
'/api/gamerewards/v1/request',
|
||||
describeRoute({
|
||||
@@ -5413,9 +6507,11 @@ const app = new Hono<App>({ strict: false })
|
||||
'owed another reward while the same one is not; an ask with no `giftContext` keys on',
|
||||
'the empty context. A `giftContext` that names an activity in `quest-rewards.json`',
|
||||
'(`Dodgeball`, `Quest_Goblin_S`, …) draws one of that activity’s rewards and grants it;',
|
||||
'any other claim pays XP only. The first claim ever in a `giftContext` — a new activity',
|
||||
'for the player — also pays a one-off 250 tokens, in a box of its own. The reward rides',
|
||||
'in a gift box, so a claim and a rejected (on-cooldown) ask both answer `[]`.',
|
||||
'any other claim pays XP only. A `PostGameActivity` or `PostQuestActivity` claim also',
|
||||
'pays 250 tokens, in a box of its own, up to 1250 per player per rolling 24 hours',
|
||||
'whatever the activity. `FirstActivityOfDay` is once per player per 24 hours, whatever',
|
||||
'the `giftContext`, and pays 250 tokens outside that cap. The reward rides in a gift',
|
||||
'box, so a claim and a rejected (on-cooldown) ask both answer `[]`.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(GameRewardRequest, 'The reward type and its display message'),
|
||||
@@ -5432,29 +6528,44 @@ const app = new Hono<App>({ strict: false })
|
||||
// No type, nothing to gate: don't write a row keyed on an empty string.
|
||||
if (rewardType === '') return c.json([])
|
||||
const giftContext = typeof body.giftContext === 'string' ? body.giftContext : ''
|
||||
const claimed = await claimReward(c.env.DB, id, rewardType, giftContext)
|
||||
// FirstActivityOfDay is the player's, not the activity's: claimed on the empty context
|
||||
// and a day's cooldown. Its prize is still picked from the activity that asked.
|
||||
const daily = rewardType === FIRST_ACTIVITY_OF_DAY
|
||||
const claimed = daily
|
||||
? await claimReward(c.env.DB, id, rewardType, '', new Date(), DAILY_REWARD_COOLDOWN_MS)
|
||||
: await claimReward(c.env.DB, id, rewardType, giftContext)
|
||||
// On cooldown: nothing was claimed, so nothing is paid and nothing is announced.
|
||||
if (claimed === null) return c.json([])
|
||||
const message =
|
||||
typeof body.Message === 'string' && body.Message !== ''
|
||||
? body.Message
|
||||
: DEFAULT_GAME_REWARD_MESSAGE
|
||||
// The XP draws on the player's daily allowance — FirstActivityOfDay's is a bonus on top,
|
||||
// its once-a-day claim being limit enough. Past the cap the claim pays no XP.
|
||||
const xpToday = daily
|
||||
? null
|
||||
: await claimAllowance(c.env.DB, id, '$xp', GAME_REWARD_XP, GAME_REWARD_DAILY_XP_CAP)
|
||||
const xp = daily || xpToday !== null ? GAME_REWARD_XP : 0
|
||||
// Bank the XP first: it is the reward, and the box is the wrapper the client shows.
|
||||
// A failure here must not leave a box promising XP that was never credited.
|
||||
const { progression, levelsGained } = await addXp(c.env.DB, id, GAME_REWARD_XP)
|
||||
const gained = xp > 0 ? await addXp(c.env.DB, id, xp) : null
|
||||
// An activity the reward table knows pays one of ITS rewards the player lacks — the
|
||||
// item rides in the box and is granted with it. Anything else gets the plain XP box.
|
||||
const questReward = await pickQuestReward(c.env.DB, id, giftContext)
|
||||
const itemKey = questReward?.AvatarItemDesc || questReward?.EquipmentModificationGuid
|
||||
const drop =
|
||||
questReward === null
|
||||
? toGameRewardDrop()
|
||||
: toQuestRewardDrop(questReward, itemKey ? await getCatalogItem(c.env.DB, itemKey) : null)
|
||||
? toGameRewardDrop(xp)
|
||||
: toQuestRewardDrop(
|
||||
questReward,
|
||||
itemKey ? await getCatalogItem(c.env.DB, itemKey) : null,
|
||||
xp
|
||||
)
|
||||
// A currency reward (Laser Tag's tickets) is credited here: `grantGiftDrop` grants
|
||||
// items, not balances. Seed the signup grant first — `creditCurrency` upserts the
|
||||
// row, and a never-touched RecCenterTokens balance would otherwise lose it.
|
||||
if (drop.Currency > 0 && drop.CurrencyType !== CurrencyType.Invalid) {
|
||||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||
if (drop.Currency > 0 && drop.CurrencyType !== CurrencyType.Invalid) {
|
||||
await ensureStartingBalances(c.env.DB, id, startingTokens)
|
||||
const balance = await creditCurrency(
|
||||
c.env.DB,
|
||||
@@ -5465,53 +6576,95 @@ const app = new Hono<App>({ strict: false })
|
||||
)
|
||||
await pushBalanceUpdate(c, id, drop.CurrencyType, balance)
|
||||
}
|
||||
// A box with nothing in it — the XP capped and no item or currency to carry — is not
|
||||
// handed over at all.
|
||||
let rewardGiftId: number | null = null
|
||||
if (questReward !== null || xp > 0) {
|
||||
const granted = await grantGiftDrop(c, id, drop, message)
|
||||
await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID)
|
||||
// A new activity pays its token bonus in a second box — after the reward's own, so
|
||||
// the player sees the reward they asked for first and the bonus as the extra it is.
|
||||
// Same order as the currency reward above: balance first, then the box that shows it.
|
||||
let bonusGiftId: number | null = null
|
||||
if (await isNewActivity(c.env.DB, id, giftContext, claimed)) {
|
||||
const bonus = toNewActivityBonusDrop()
|
||||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||
await ensureStartingBalances(c.env.DB, id, startingTokens)
|
||||
const balance = await creditCurrency(
|
||||
rewardGiftId = granted.id
|
||||
}
|
||||
// The tokens ride in a second box — after the reward's own, so the player sees the
|
||||
// reward they asked for first — while the player's daily allowance has room. Same
|
||||
// order as the currency reward above: balance first, then the box that shows it.
|
||||
let tokensGiftId: number | null = null
|
||||
let tokensToday: number | null = null
|
||||
if (TOKEN_REWARD_TYPES.has(rewardType)) {
|
||||
tokensToday = await claimAllowance(
|
||||
c.env.DB,
|
||||
id,
|
||||
bonus.CurrencyType,
|
||||
bonus.Currency,
|
||||
'$tokens',
|
||||
GAME_REWARD_TOKENS,
|
||||
GAME_REWARD_DAILY_TOKEN_CAP
|
||||
)
|
||||
}
|
||||
// FirstActivityOfDay's claim already held it to once a day: it pays without the cap.
|
||||
if (daily || tokensToday !== null) {
|
||||
const tokensDrop = toGameRewardTokensDrop()
|
||||
await ensureStartingBalances(c.env.DB, id, startingTokens)
|
||||
const tokenBalance = await creditCurrency(
|
||||
c.env.DB,
|
||||
id,
|
||||
tokensDrop.CurrencyType,
|
||||
tokensDrop.Currency,
|
||||
startingTokens
|
||||
)
|
||||
await pushBalanceUpdate(c, id, bonus.CurrencyType, balance)
|
||||
const bonusGift = await grantGiftDrop(c, id, bonus, NEW_ACTIVITY_BONUS_MESSAGE)
|
||||
await pushGiftReceived(c, id, bonusGift, NEW_ACTIVITY_BONUS_MESSAGE, COACH_ACCOUNT_ID)
|
||||
bonusGiftId = bonusGift.id
|
||||
await pushBalanceUpdate(c, id, tokensDrop.CurrencyType, tokenBalance)
|
||||
const tokensGift = await grantGiftDrop(c, id, tokensDrop, GAME_REWARD_TOKENS_MESSAGE)
|
||||
await pushGiftReceived(c, id, tokensGift, GAME_REWARD_TOKENS_MESSAGE, COACH_ACCOUNT_ID)
|
||||
tokensGiftId = tokensGift.id
|
||||
}
|
||||
if (gained !== null) {
|
||||
// Every grant moves the bar, whether or not it crossed a level.
|
||||
await pushProgressionUpdate(c, id, progression)
|
||||
await pushProgressionUpdate(c, id, gained.progression)
|
||||
// …and every level crossed is worth a box of its own tier.
|
||||
await grantLevelUpGifts(c, id, { progression, levelsGained })
|
||||
await grantLevelUpGifts(c, id, gained)
|
||||
}
|
||||
logger.info('game reward claimed', {
|
||||
accountId: id,
|
||||
rewardType,
|
||||
giftContext,
|
||||
grantCount: claimed,
|
||||
message,
|
||||
xp: GAME_REWARD_XP,
|
||||
level: progression.Level,
|
||||
levelsGained,
|
||||
levelXp: progression.XP,
|
||||
giftId: granted.id,
|
||||
newActivityBonus: bonusGiftId === null ? 0 : NEW_ACTIVITY_BONUS_TOKENS,
|
||||
bonusGiftId,
|
||||
xp,
|
||||
xpToday,
|
||||
level: gained?.progression.Level,
|
||||
levelsGained: gained?.levelsGained,
|
||||
levelXp: gained?.progression.XP,
|
||||
giftId: rewardGiftId,
|
||||
tokens: tokensGiftId === null ? 0 : GAME_REWARD_TOKENS,
|
||||
tokensToday,
|
||||
tokensGiftId,
|
||||
})
|
||||
return c.json([])
|
||||
}
|
||||
)
|
||||
|
||||
// The player's room keys. Returns "[]": nothing sells a key yet, so nobody holds one.
|
||||
.get('/api/roomkeys/v1/mine', listRoute('The player’s room keys', 'Empty for now'), (c) =>
|
||||
c.json([])
|
||||
// The caller's room keys: every key they hold (`room_key_player`), across all rooms.
|
||||
.get(
|
||||
'/api/roomkeys/v1/mine',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'The player’s room keys',
|
||||
description: [
|
||||
'Every key the caller holds — bought, awarded, or taken free as a room manager —',
|
||||
'across every room, oldest key first. No parameters.',
|
||||
'',
|
||||
'Each entry is the room key plus `IsGiftable` (false) and `Visibility` (0), with',
|
||||
'`ImageName` as "" rather than null when the key has no art — the shape observed',
|
||||
'from the live client, which differs from `/api/roomkeys/v1/room`’s.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(HeldRoomKeyDto.array(), 'The caller’s keys, oldest first'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(await getHeldRoomKeys(c.env.DB, id))
|
||||
}
|
||||
)
|
||||
// The keys a room has listed. Public, like the room's currencies: a key is offered to
|
||||
// whoever walks up to the door it opens. A room with none, an unknown room and a missing
|
||||
@@ -5561,12 +6714,13 @@ const app = new Hono<App>({ strict: false })
|
||||
'',
|
||||
'`Name` and `Description` are masked by the same word list as every other string a',
|
||||
'player types. A room may list at most 10 keys — the `RoomKeyConfig.MaxKeysPerRoom`',
|
||||
'the `api` config tells the client — and the eleventh is refused.',
|
||||
'the `api` config tells the client — and the eleventh is refused. A new key is priced',
|
||||
'in tokens (the body names no currency), so a `Price` over 1000 is refused too.',
|
||||
'',
|
||||
'Answers `{ Status, RoomKey }` — `Status` 0 and the created key, as the live client',
|
||||
'reads it — not the `{ Value, Success, Error, error_id }` envelope the room-currency',
|
||||
'writes use. A recoverable refusal (an unusable `RoomId`, an empty `Name`, an unknown',
|
||||
'room, a full room) is a 200 carrying a non-zero `Status` and a null `RoomKey`; only',
|
||||
'room, a full room, a `Price` over the limit) is a 200 carrying a non-zero `Status` and a null `RoomKey`; only',
|
||||
'the auth gates answer with an HTTP status of their own.',
|
||||
'',
|
||||
'Pushes `LocalRoomKeyCreated` (120), carrying the same object, to everyone in the',
|
||||
@@ -5595,6 +6749,10 @@ const app = new Hono<App>({ strict: false })
|
||||
if (!Number.isInteger(roomId)) return roomKeyEnvelope(c, null)
|
||||
const name = str(body.Name).trim()
|
||||
if (name === '') return roomKeyEnvelope(c, null)
|
||||
// A key cannot cost less than nothing — nor, being priced in tokens until an edit
|
||||
// says otherwise, more than the token cap.
|
||||
const price = Math.max(0, int(body.Price, 0))
|
||||
if (price > MAX_KEY_PRICE_TOKENS) return roomKeyEnvelope(c, null)
|
||||
|
||||
const canManage = await canManageRoomById(c.env.DB, roomId, accountId)
|
||||
if (canManage === null) return roomKeyEnvelope(c, null)
|
||||
@@ -5609,8 +6767,7 @@ const app = new Hono<App>({ strict: false })
|
||||
Type: str(body.Type).trim() || 'Key',
|
||||
Name: censorSwears(name),
|
||||
Description: censorSwears(str(body.Description)),
|
||||
// A key cannot cost less than nothing.
|
||||
Price: Math.max(0, int(body.Price, 0)),
|
||||
Price: price,
|
||||
})
|
||||
|
||||
await pushRoomKeyCreated(c, key, accountId)
|
||||
@@ -5618,6 +6775,466 @@ const app = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// Buy a room key with tokens. A GET with query params — that is how the client sends it.
|
||||
// The key's price leaves the buyer and is paid to the room's owner; holding the key is a
|
||||
// `room_key_player` row. Every refusal is a 200 in the same shape, told apart by the
|
||||
// entry's `UpdateResponse` — only a missing token answers with a status of its own.
|
||||
.get(
|
||||
'/api/storefronts/v1/buyRoomKey',
|
||||
describeRoute({
|
||||
tags: ['Storefront'],
|
||||
summary: 'Buy a room key',
|
||||
description: [
|
||||
'Looks the key up by `RoomKeyId`, confirms the client’s `RequestedPrice` matches its',
|
||||
'stored `Price`, debits the buyer that price in RecCenterTokens, pays it to the',
|
||||
'room’s OWNER (its `CreatorAccountId`), and records the buyer as holding the key',
|
||||
'(`room_key_player`). A free key moves no tokens, and nor does a key bought by the',
|
||||
'room’s creator or a CO-OWNER: they get their own room’s keys free, and the client',
|
||||
'asks them `RequestedPrice=0` (the stored price is accepted too). A GET because that',
|
||||
'is how the client sends it.',
|
||||
'',
|
||||
'Answers the client’s `RoomKeyPurchaseResponseDTO`: `RoomKeyResponse` is the',
|
||||
'`{ Status, RoomKey }` the create answers in, and `BalanceUpdateResponse` carries one',
|
||||
'entry (`UpdateResponse`, `Data` = the key) and the buyer’s RESULTING `Balance`.',
|
||||
'`Platform` is -2, the one bucket every balance surface here names — any other value',
|
||||
'shows the buyer a phantom second balance.',
|
||||
'',
|
||||
'A refusal is a 200 in the same shape — `Status` 1, a null `RoomKey`, the balance',
|
||||
'untouched — with the reason in the entry’s `UpdateResponse`: 6 the price does not',
|
||||
'match, 3 already held, 2 not enough tokens, 4 no such key (or no such room, or a key',
|
||||
'priced in a ROOM CURRENCY, which this token route does not sell).',
|
||||
'',
|
||||
'When tokens moved, both players get a socket push carrying their resulting total —',
|
||||
'the buyer a StorefrontBalancePurchase, the owner a StorefrontBalanceUpdate.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'RoomKeyId',
|
||||
in: 'query',
|
||||
required: true,
|
||||
description: 'The key to buy — a `room_key` id',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
{
|
||||
name: 'RequestedPrice',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'The price the client rendered; must equal the key’s. Defaults to 0',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(BuyRoomKeyResponse, 'The purchase result, or a refusal in the same shape'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||
const currencyType = CurrencyType.RecCenterTokens
|
||||
const roomKeyId = Number.parseInt(c.req.query('RoomKeyId') ?? '', 10)
|
||||
// Absent/non-numeric reads as 0, which only matches a free key — a priced one then
|
||||
// fails the confirmation below rather than selling for nothing.
|
||||
const requestedPrice = Number.parseInt(c.req.query('RequestedPrice') ?? '0', 10) || 0
|
||||
const key = Number.isInteger(roomKeyId) ? await getRoomKey(c.env.DB, roomKeyId) : null
|
||||
|
||||
// Every key is always sent: the client reads an omitted one as its enum's 0 member,
|
||||
// which is success for `Status`/`UpdateResponse` and SteamPurchased for `Platform`.
|
||||
const answer = async (code: number) =>
|
||||
c.json({
|
||||
RoomKeyResponse: {
|
||||
Status: code === UpdateResponse.OK ? ROOM_KEY_STATUS_OK : ROOM_KEY_STATUS_FAILED,
|
||||
RoomKey: code === UpdateResponse.OK ? key : null,
|
||||
},
|
||||
BalanceUpdateResponse: {
|
||||
BalanceUpdates: [{ UpdateResponse: code, Data: key }],
|
||||
Balance: await getBalance(c.env.DB, id, currencyType, startingTokens),
|
||||
CurrencyType: currencyType,
|
||||
Platform: ALL_PLATFORMS,
|
||||
},
|
||||
})
|
||||
|
||||
// A key priced in a room currency is not for sale in tokens.
|
||||
if (key === null || key.PurchaseCurrencyId !== null) {
|
||||
return answer(UpdateResponse.NoItemAvailable)
|
||||
}
|
||||
const ownerId = await getRoomOwnerId(c.env.DB, key.RoomId)
|
||||
if (ownerId === null) return answer(UpdateResponse.NoItemAvailable)
|
||||
// The room's creator and co-owners get their own room's keys free — the client asks
|
||||
// them `RequestedPrice=0` — and are never charged, whatever price they name.
|
||||
const canManage = (await canManageRoomById(c.env.DB, key.RoomId, id)) === true
|
||||
if (requestedPrice !== key.Price && !(canManage && requestedPrice === 0)) {
|
||||
return answer(UpdateResponse.RequestedPriceDoesNotMatch)
|
||||
}
|
||||
|
||||
// Claim the key BEFORE charging: the insert is atomic, so of two concurrent buys only
|
||||
// one gets past here and only one is charged. A buyer who cannot pay gives it back.
|
||||
if (!(await claimRoomKey(c.env.DB, id, key.RoomKeyId))) {
|
||||
return answer(UpdateResponse.AlreadyOwned)
|
||||
}
|
||||
if (key.Price > 0 && !canManage) {
|
||||
if (!(await spendCurrency(c.env.DB, id, currencyType, key.Price, startingTokens))) {
|
||||
await releaseRoomKey(c.env.DB, id, key.RoomKeyId)
|
||||
return answer(UpdateResponse.NotEnoughCredit)
|
||||
}
|
||||
// Seed the owner's signup grant BEFORE crediting them — `creditCurrency` upserts
|
||||
// the row, which would otherwise cost a never-seen owner their starting tokens.
|
||||
await ensureStartingBalances(c.env.DB, ownerId, startingTokens)
|
||||
const ownerBalance = await creditCurrency(
|
||||
c.env.DB,
|
||||
ownerId,
|
||||
currencyType,
|
||||
key.Price,
|
||||
startingTokens
|
||||
)
|
||||
// Both frames carry a RESULTING total — see the frame rule above pushBalanceUpdate.
|
||||
await pushBalanceUpdate(c, ownerId, currencyType, ownerBalance)
|
||||
await pushBalancePurchase(
|
||||
c,
|
||||
id,
|
||||
currencyType,
|
||||
-key.Price,
|
||||
await getBalance(c.env.DB, id, currencyType, startingTokens)
|
||||
)
|
||||
}
|
||||
|
||||
return answer(UpdateResponse.OK)
|
||||
}
|
||||
)
|
||||
|
||||
// Buy a room key with the room currency it is priced in — buyRoomKey's mirror image, and
|
||||
// almost nothing shared on the wire: a POST with a form body, a bare `{ Balance,
|
||||
// RoomKeyResponse }`, and a `Balance` that is an OBJECT. Nobody is paid: room currency is
|
||||
// the room's own play money, so the price simply leaves the buyer.
|
||||
.post(
|
||||
'/api/storefronts/v1/PurchaseRoomKeyWithCurrency',
|
||||
describeRoute({
|
||||
tags: ['Storefront'],
|
||||
summary: 'Buy a room key with room currency',
|
||||
description: [
|
||||
'Buys a key priced in a room currency. Form-encoded',
|
||||
'(`RoomKeyId=43&RequestedPrice=1000&RequestedPurchaseCurrencyId=b9f41a7c-…`): the',
|
||||
'key’s `Price` is subtracted from what the caller holds of that currency',
|
||||
'(`room_balance`) and they are recorded as holding the key (`room_key_player`).',
|
||||
'NOBODY IS PAID — unlike the token sale, the price does not reach the room’s owner.',
|
||||
'',
|
||||
'`RequestedPurchaseCurrencyId` must be the key’s own `PurchaseCurrencyId` and',
|
||||
'`RequestedPrice` its `Price` — except that the room’s creator or a CO-OWNER gets',
|
||||
'their own room’s keys free: the client asks them `RequestedPrice=0` (the stored',
|
||||
'price is accepted too) and nothing is subtracted.',
|
||||
'A key priced in TOKENS (a null `PurchaseCurrencyId`)',
|
||||
'is not sold here — that is `GET /api/storefronts/v1/buyRoomKey`.',
|
||||
'',
|
||||
'Answers the client’s `RoomKeyPurchaseWithCurrencyResponseDTO`, a bare object with no',
|
||||
'envelope: `Balance` is an OBJECT — `{ AccountId, CurrencyId, Balance, ModifiedAt }`,',
|
||||
'the buyer’s RESULTING holding — and `RoomKeyResponse` the `{ Status, RoomKey }` the',
|
||||
'create answers in. Not buyRoomKey’s shape; there is no `Platform` here.',
|
||||
'',
|
||||
'A refusal (no such key, a currency or price that is not the key’s, a key already',
|
||||
'held, too little of the currency) is a 200 in the same shape: `Status` 1, a null',
|
||||
'`RoomKey`, and the holding as it stands. That form is an assumption — only success',
|
||||
'has been observed.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
requestBody: form(PurchaseRoomKeyWithCurrencyRequest, 'The key, and what the client saw'),
|
||||
responses: {
|
||||
200: json(
|
||||
PurchaseRoomKeyWithCurrencyResponse,
|
||||
'The purchase result, or a refusal in the same shape'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '')
|
||||
const int = (v: unknown): number => (/^-?\d+$/.test(str(v)) ? Number(str(v)) : Number.NaN)
|
||||
|
||||
const requestedCurrencyId = str(body.RequestedPurchaseCurrencyId)
|
||||
const roomKeyId = int(body.RoomKeyId)
|
||||
const key = Number.isInteger(roomKeyId) ? await getRoomKey(c.env.DB, roomKeyId) : null
|
||||
// The currency the answer reports on: the key's own once it is known to be the one
|
||||
// asked for, the requested one on a refusal that never got that far.
|
||||
const currencyId = key?.PurchaseCurrencyId ?? requestedCurrencyId
|
||||
|
||||
// `Status` is always sent: the client reads an omitted one as 0, which is success.
|
||||
const answer = async (sold: boolean) =>
|
||||
c.json({
|
||||
Balance: {
|
||||
AccountId: id,
|
||||
CurrencyId: currencyId,
|
||||
Balance: await getRoomBalance(c.env.DB, currencyId, id),
|
||||
ModifiedAt: new Date().toISOString(),
|
||||
},
|
||||
RoomKeyResponse: {
|
||||
Status: sold ? ROOM_KEY_STATUS_OK : ROOM_KEY_STATUS_FAILED,
|
||||
RoomKey: sold ? key : null,
|
||||
},
|
||||
})
|
||||
|
||||
// A key priced in tokens is buyRoomKey's to sell, and a key is paid for in its own
|
||||
// currency only — never whichever one the request would rather spend.
|
||||
if (key === null || key.PurchaseCurrencyId === null) return answer(false)
|
||||
if (requestedCurrencyId.toLowerCase() !== key.PurchaseCurrencyId.toLowerCase()) {
|
||||
return answer(false)
|
||||
}
|
||||
// The room's creator and co-owners get their own room's keys free, as with the token
|
||||
// sale — the client asks them `RequestedPrice=0` — and are never charged.
|
||||
const canManage = await canManageRoomById(c.env.DB, key.RoomId, id)
|
||||
if (canManage === null) return answer(false)
|
||||
const requestedPrice = int(body.RequestedPrice)
|
||||
if (requestedPrice !== key.Price && !(canManage && requestedPrice === 0)) {
|
||||
return answer(false)
|
||||
}
|
||||
|
||||
// Claim the key BEFORE charging, as the token sale does: the insert is atomic, so of
|
||||
// two concurrent buys only one is charged. A buyer who cannot pay gives it back.
|
||||
if (!(await claimRoomKey(c.env.DB, id, key.RoomKeyId))) return answer(false)
|
||||
if (
|
||||
key.Price > 0 &&
|
||||
!canManage &&
|
||||
!(await spendRoomCurrency(c.env.DB, key.PurchaseCurrencyId, id, key.Price))
|
||||
) {
|
||||
await releaseRoomKey(c.env.DB, id, key.RoomKeyId)
|
||||
return answer(false)
|
||||
}
|
||||
|
||||
return answer(true)
|
||||
}
|
||||
)
|
||||
|
||||
// Edit a room key. "All" is the key's FIELDS, not the room's keys: the body names one
|
||||
// `RoomKeyId` and carries everything editable about it. Gated like the create — the room
|
||||
// comes off the KEY, never the request.
|
||||
.put(
|
||||
'/api/roomkeys/v1/updateAll',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Update a room key',
|
||||
description: [
|
||||
'Edits ONE key — despite the name, "all" is its fields — form-encoded',
|
||||
'(`RoomKeyId=43&Name=key1&Description=…&Price=1000&PurchaseCurrencyId=`).',
|
||||
'',
|
||||
'Gated to the CREATOR or a CO-OWNER of the room that lists the key; a valid token',
|
||||
'from anyone else is a 403.',
|
||||
'',
|
||||
'`PurchaseCurrencyId` sent EMPTY prices the key in tokens (stored and served as',
|
||||
'null) — that is how a key on a room currency is taken back off it; otherwise it',
|
||||
'must be a `room_currency` id belonging to the key’s own room.',
|
||||
'`ImageName` is the key’s art, stored as posted; sent EMPTY it clears the art',
|
||||
'(served as null). That `ImageName` rides in this form is an ASSUMPTION.',
|
||||
'`Price` is capped by what the key ends up priced in: 1000 in tokens, 1000000000 in',
|
||||
'a room currency. A field left out of the body is left alone rather than reset, and',
|
||||
'the cap is checked against the resulting pair.',
|
||||
'',
|
||||
'`Name` and `Description` are masked by the same word list as every other string a',
|
||||
'player types. What can never change: the ids, the room, the `Type`, `CreatedAt`.',
|
||||
'',
|
||||
'Answers the create’s `{ Status, RoomKey }` envelope with the key as it now stands',
|
||||
'— an ASSUMPTION: what the client reads from this response has not been observed.',
|
||||
'A recoverable refusal (no such key, a blank `Name`, a `Price` over the cap, an',
|
||||
'unknown or foreign currency) is a 200 carrying a non-zero `Status` and a null',
|
||||
'`RoomKey`.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
requestBody: form(UpdateRoomKeyRequest, 'The key and its fields'),
|
||||
responses: {
|
||||
200: json(RoomKeyEnvelope, 'The key as it now stands, or a refusal'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
|
||||
|
||||
const roomKeyId = Number.parseInt(str(body.RoomKeyId) ?? '', 10)
|
||||
if (!Number.isInteger(roomKeyId)) return roomKeyEnvelope(c, null)
|
||||
|
||||
const existing = await getRoomKey(c.env.DB, roomKeyId)
|
||||
if (!existing) return roomKeyEnvelope(c, null)
|
||||
|
||||
const canManage = await canManageRoomById(c.env.DB, existing.RoomId, accountId)
|
||||
if (canManage === null) return roomKeyEnvelope(c, null)
|
||||
if (!canManage) return c.body(null, 403)
|
||||
|
||||
// A name sent but blank is a bad edit, not an instruction to leave it alone.
|
||||
const name = str(body.Name)?.trim()
|
||||
if (name === '') return roomKeyEnvelope(c, null)
|
||||
|
||||
let price = existing.Price
|
||||
if (str(body.Price) !== undefined) {
|
||||
price = Number.parseInt(str(body.Price)!, 10)
|
||||
if (!Number.isInteger(price) || price < 0) return roomKeyEnvelope(c, null)
|
||||
}
|
||||
|
||||
// Present-but-empty is a VALUE here — tokens — unlike the fields above, where only
|
||||
// absence means "leave alone".
|
||||
let purchaseCurrencyId = existing.PurchaseCurrencyId
|
||||
const postedCurrencyId = str(body.PurchaseCurrencyId)?.trim()
|
||||
if (postedCurrencyId === '') {
|
||||
purchaseCurrencyId = null
|
||||
} else if (postedCurrencyId !== undefined && postedCurrencyId !== purchaseCurrencyId) {
|
||||
// A key is sold in its own room's money, not another's.
|
||||
const currency = await getRoomCurrency(c.env.DB, postedCurrencyId)
|
||||
if (!currency || currency.RoomId !== existing.RoomId) return roomKeyEnvelope(c, null)
|
||||
purchaseCurrencyId = currency.CurrencyId
|
||||
}
|
||||
|
||||
const maxPrice =
|
||||
purchaseCurrencyId === null ? MAX_KEY_PRICE_TOKENS : MAX_KEY_PRICE_ROOM_CURRENCY
|
||||
if (price > maxPrice) return roomKeyEnvelope(c, null)
|
||||
|
||||
// Blank is a value here too: no art. Only absence leaves the stored name alone.
|
||||
const postedImageName = str(body.ImageName)?.trim()
|
||||
const imageName = postedImageName === undefined ? existing.ImageName : postedImageName || null
|
||||
|
||||
const description = str(body.Description)
|
||||
return roomKeyEnvelope(
|
||||
c,
|
||||
await updateRoomKey(c.env.DB, roomKeyId, {
|
||||
Name: name === undefined ? existing.Name : censorSwears(name),
|
||||
Description: description === undefined ? existing.Description : censorSwears(description),
|
||||
Price: price,
|
||||
PurchaseCurrencyId: purchaseCurrencyId,
|
||||
ImageName: imageName,
|
||||
})
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Award the CALLER some room keys. Auth-gated (401); everything else is reported per
|
||||
// entry, each in its OWN `{ Value, Success, Error, error_id }` envelope — the client
|
||||
// decodes a list of single-value envelopes here. The body is a bare array of `{ RoomKeyId,
|
||||
// AccountId }` — `AccountId` is accepted and unused: the token says who gets the key.
|
||||
.post(
|
||||
'/api/roomkeys/v1/awardbulk',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Award room keys to the caller',
|
||||
description: [
|
||||
'Records the CALLER as holding each key the body names (`room_key_player`). The body',
|
||||
'is a BARE JSON ARRAY, `[{ "RoomKeyId": 43, "AccountId": 205 }]`; `AccountId` is',
|
||||
'accepted and IGNORED — the token says who is awarded.',
|
||||
'',
|
||||
'A key is held or it isn’t: awarding one the caller already holds succeeds and',
|
||||
'changes nothing.',
|
||||
'',
|
||||
'The answer is an ARRAY OF ENVELOPES — one `{ Value, Success, Error, error_id }` per',
|
||||
'entry, in the order posted, as the client’s decoder reads it; not a bare array of',
|
||||
'awards, and not one envelope around a list. `Value` is `{ RoomKeyId, AccountId }`,',
|
||||
'`AccountId` being the caller. Each entry stands alone: a `RoomKeyId` naming no listed',
|
||||
'key answers `Success` false with a null `Value` (the failure form is an assumption —',
|
||||
'only success has been observed) and leaves the rest to land.',
|
||||
'',
|
||||
'NOTE: this awards to whoever is asking, and nothing is spent for it — the key’s',
|
||||
'`Price` is not charged and no room membership is checked.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(AwardRoomKeysRequest, 'The keys to give the caller'),
|
||||
responses: {
|
||||
200: json(AwardRoomKeyResultList, 'One result per entry, in the order posted'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.json<unknown>().catch(() => null)
|
||||
// A body that is no list has no entries to report on.
|
||||
if (!Array.isArray(body)) return c.json([])
|
||||
|
||||
const failed = (error: string) => ({
|
||||
Value: null,
|
||||
Success: false,
|
||||
Error: error,
|
||||
error_id: null,
|
||||
})
|
||||
const results = []
|
||||
for (const entry of body as unknown[]) {
|
||||
const roomKeyId =
|
||||
typeof entry === 'object' && entry !== null
|
||||
? (entry as { RoomKeyId?: unknown }).RoomKeyId
|
||||
: undefined
|
||||
if (typeof roomKeyId !== 'number' || !Number.isInteger(roomKeyId)) {
|
||||
results.push(failed('Invalid room key'))
|
||||
continue
|
||||
}
|
||||
|
||||
const awardedAt = await awardRoomKey(c.env.DB, accountId, roomKeyId)
|
||||
results.push(
|
||||
awardedAt === null
|
||||
? failed('No such room key')
|
||||
: {
|
||||
Value: { RoomKeyId: roomKeyId, AccountId: accountId },
|
||||
Success: true,
|
||||
Error: null,
|
||||
error_id: null,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return c.json(results)
|
||||
}
|
||||
)
|
||||
|
||||
// Who holds which room key. Auth-gated (401). The body is a bare array of `{ AccountId,
|
||||
// RoomKeyId }` pairs and — unlike the award's — `AccountId` is READ here: a room asks
|
||||
// about the players standing in it, not only the caller. A bare array back, one answer
|
||||
// per pair.
|
||||
.post(
|
||||
'/api/roomkeys/v1/owns/bulk',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Whether players hold room keys',
|
||||
description: [
|
||||
'Answers, for each `{ AccountId, RoomKeyId }` pair posted, whether that player holds',
|
||||
'that key (`room_key_player`). The body is a BARE JSON ARRAY, and so is the answer:',
|
||||
'`[{ "AccountId": 205, "RoomKeyId": 43, "DoesPlayerOwnRoomKey": true }]`, one entry',
|
||||
'per pair in the order posted — no envelope, unlike the award beside it.',
|
||||
'',
|
||||
'`AccountId` is honoured, not replaced by the caller: any signed-in player may ask',
|
||||
'about any player. An unknown key or player is simply not held; an entry without two',
|
||||
'integer ids is skipped.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
requestBody: jsonBody(OwnsRoomKeysRequest, 'The (player, key) pairs to check'),
|
||||
responses: {
|
||||
200: json(OwnsRoomKeysResponse, 'One answer per pair, in the order posted'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const body = await c.req.json<unknown>().catch(() => null)
|
||||
if (!Array.isArray(body)) return c.json([])
|
||||
|
||||
const pairs: RoomKeyHolding[] = []
|
||||
for (const entry of body as unknown[]) {
|
||||
if (typeof entry !== 'object' || entry === null) continue
|
||||
const { AccountId, RoomKeyId } = entry as Record<string, unknown>
|
||||
if (typeof AccountId !== 'number' || !Number.isInteger(AccountId)) continue
|
||||
if (typeof RoomKeyId !== 'number' || !Number.isInteger(RoomKeyId)) continue
|
||||
pairs.push({ AccountId, RoomKeyId })
|
||||
}
|
||||
|
||||
const owned = await ownsRoomKeys(c.env.DB, pairs)
|
||||
return c.json(pairs.map((pair, i) => ({ ...pair, DoesPlayerOwnRoomKey: owned[i] })))
|
||||
}
|
||||
)
|
||||
|
||||
// The Rec Room Plus sign-up bonus: which bonus is running and the token price window
|
||||
// the free items are drawn from. Fixed numbers, the same for every caller.
|
||||
//
|
||||
@@ -5686,13 +7303,41 @@ const app = new Hono<App>({ strict: false })
|
||||
}
|
||||
)
|
||||
|
||||
// The subscription seasons running right now (the RR+ seasonal reward tracks). Nothing
|
||||
// here runs a season, so this is an empty-list stub — the client reads it as "no season
|
||||
// in progress" and skips the seasonal UI, where a 404 stalls the RR+ page load.
|
||||
// The 2026 decoder requires an object; RecNet rejects JSON null as an empty response.
|
||||
.get(
|
||||
'/api/subscriptionseasons/v1/seasons/current',
|
||||
listRoute('Current subscription seasons', 'Empty stub — no RR+ season is running'),
|
||||
(c) => c.json([])
|
||||
describeRoute({
|
||||
summary: 'Current subscription season',
|
||||
responses: {
|
||||
200: json(
|
||||
z.union([
|
||||
JsonArray,
|
||||
z.object({
|
||||
SubscriptionSeasonId: z.string().uuid(),
|
||||
Name: z.string(),
|
||||
ImageName: z.string(),
|
||||
StartAt: z.string(),
|
||||
NextSeasonStart: z.string().nullable(),
|
||||
Milestones: JsonArray,
|
||||
}),
|
||||
]),
|
||||
'2026: season object without milestones; older builds: []'
|
||||
),
|
||||
},
|
||||
}),
|
||||
async (c) =>
|
||||
c.json(
|
||||
((await authedBuild(c)) ?? 0) >= 20260323
|
||||
? {
|
||||
SubscriptionSeasonId: '00000000-0000-0000-0000-000000000000',
|
||||
Name: '',
|
||||
ImageName: '',
|
||||
StartAt: '0001-01-01T00:00:00Z',
|
||||
NextSeasonStart: null,
|
||||
Milestones: [],
|
||||
}
|
||||
: []
|
||||
)
|
||||
)
|
||||
|
||||
// Whether the caller can start a Maker AI free trial. Always false, mirroring the
|
||||
@@ -5762,22 +7407,24 @@ const app = new Hono<App>({ strict: false })
|
||||
|
||||
// Everyone in the influencer partner program, by account id — the list the client keeps
|
||||
// so it can badge an influencer wherever they turn up, rather than asking per player.
|
||||
// Membership is the account's `isInfluencer` flag (`runx admin grant-influencer`), read
|
||||
// through the `is_influencer` generated column's partial index so the account table is
|
||||
// never scanned.
|
||||
//
|
||||
// Empty: no programme runs here, so there is nobody to list. Note this is the LIST
|
||||
// counterpart of the single-account check below, and the two answer very differently —
|
||||
// that one 404s to say "not an influencer", this one is a 200 carrying an empty list,
|
||||
// because "nobody is" is a complete answer to "who is?".
|
||||
//
|
||||
// `take` is accepted and ignored; there is nothing to page through.
|
||||
// Paged in shape only: the client asks for `take=1000` and this server will not have
|
||||
// that many, so the whole list is one page and `ContinuationToken` is always null. `take`
|
||||
// is accepted and ignored. Note this is the LIST counterpart of the single-account tier
|
||||
// check below, which still answers 0 for everyone.
|
||||
.get(
|
||||
'/api/influencerpartnerprogram/influencers',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Every influencer in the partner program',
|
||||
description: [
|
||||
'The account ids in the influencer partner program, as `{ InfluencerIds }` — an object',
|
||||
'around the list, not a bare array. Always empty here: no programme runs on this',
|
||||
'server. `take` is accepted and ignored, there being nothing to page.',
|
||||
'The account ids in the influencer partner program — every account flagged',
|
||||
'`isInfluencer` — as `{ InfluencerIds, ContinuationToken }`: an object around the',
|
||||
'list, not a bare array. Served whole: `take` is accepted and ignored, and',
|
||||
'`ContinuationToken` is always null.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
@@ -5785,90 +7432,174 @@ const app = new Hono<App>({ strict: false })
|
||||
name: 'take',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'How many ids to return. Accepted and ignored.',
|
||||
description: 'How many ids to return. Accepted and ignored — the list is served whole.',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(InfluencerIdsResponse, 'The influencer ids — always empty'),
|
||||
200: json(InfluencerIdsResponse, 'The influencer ids, lowest first'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json({ InfluencerIds: [] })
|
||||
const InfluencerIds = await listInfluencerIds(c.env.DB)
|
||||
return c.json({ InfluencerIds, ContinuationToken: null })
|
||||
}
|
||||
)
|
||||
|
||||
// One account's standing in the influencer partner program. NOBODY here has one: this
|
||||
// server runs no such program, so the answer is the literal `0` — the "not an influencer"
|
||||
// tier — for every account.
|
||||
// The influencer one account SUPPORTS in the partner program, by account id — every
|
||||
// player picks one (`…/support` below), and this is how anyone reads anyone's pick.
|
||||
//
|
||||
// A BARE NUMBER is the whole body, like `…/makerai/checkfreetrialeligibility`'s bare
|
||||
// `false`, not a number wrapped in an object. This used to answer 404 with an empty body;
|
||||
// the tier is what the client actually reads.
|
||||
//
|
||||
// `accountId` names the account being asked about. It makes no difference to the answer
|
||||
// while nobody is an influencer, but it is read rather than ignored so this stays the
|
||||
// question it looks like — the caller's own standing is `…/myinfluencer` below.
|
||||
// `false`, not a number wrapped in an object; 0 is "supports nobody", which is also what
|
||||
// an account that does not exist answers. The caller's own pick is `…/myinfluencer`.
|
||||
.get(
|
||||
'/api/influencerpartnerprogram/influencer',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'An account’s influencer partner program tier',
|
||||
summary: 'The influencer an account supports',
|
||||
description: [
|
||||
'The partner tier of the account named by `accountId`, as a BARE NUMBER — the whole',
|
||||
'body is `0`, not an object around it. Always 0: this server runs no partner program,',
|
||||
'so no account is an influencer. Auth-gated; a missing or invalid token is a 401.',
|
||||
'The account id of the influencer the account named by `accountId` supports, as a',
|
||||
'BARE NUMBER — the whole body is the id, not an object around it; `0` when they',
|
||||
'support nobody (or no such account exists). Auth-gated; a missing or invalid token',
|
||||
'is a 401. A missing or non-numeric `accountId` is a 400.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
parameters: [
|
||||
{
|
||||
name: 'accountId',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'The account being asked about. Every account answers 0.',
|
||||
required: true,
|
||||
description: 'The account being asked about.',
|
||||
schema: { type: 'integer' },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: json(InfluencerTierResponse, 'The account’s tier — always 0'),
|
||||
200: json(SupportedInfluencerResponse, 'The supported influencer’s id, or 0'),
|
||||
400: { description: '`accountId` missing or not an integer (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(NOT_AN_INFLUENCER)
|
||||
const accountId = Number.parseInt(c.req.query('accountId') ?? '', 10)
|
||||
if (!Number.isInteger(accountId)) return c.body(null, 400)
|
||||
return c.json(await supportedInfluencer(c.env.DB, accountId))
|
||||
}
|
||||
)
|
||||
|
||||
// The same question about the CALLER — the `my` form, which names no account because the
|
||||
// token already does. Same bare `0`, for the same reason: nobody here is an influencer.
|
||||
// token already does. Same bare number, same 0 for nobody.
|
||||
//
|
||||
// Its own route rather than an alias of the one above, because the two differ in who they
|
||||
// are about; they agree today only because the answer is currently the same for everyone.
|
||||
// are about.
|
||||
.get(
|
||||
'/api/influencerpartnerprogram/myinfluencer',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'The caller’s influencer partner program tier',
|
||||
summary: 'The influencer the caller supports',
|
||||
description: [
|
||||
'The caller’s own partner tier — the `my` form of the route above, taking the account',
|
||||
'from the token rather than a query parameter. A BARE NUMBER, always `0`: this server',
|
||||
'runs no partner program. Auth-gated; a missing or invalid token is a 401.',
|
||||
'The account id of the influencer the caller supports — the `my` form of the route',
|
||||
'above, taking the account from the token rather than a query parameter. A BARE',
|
||||
'NUMBER; `0` when they support nobody. Auth-gated; a missing or invalid token is a 401.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
responses: {
|
||||
200: json(InfluencerTierResponse, 'The caller’s tier — always 0'),
|
||||
200: json(SupportedInfluencerResponse, 'The supported influencer’s id, or 0'),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
return c.json(NOT_AN_INFLUENCER)
|
||||
return c.json(await supportedInfluencer(c.env.DB, id))
|
||||
}
|
||||
)
|
||||
|
||||
// Pick the influencer the caller supports: `influencerAccountId=<id>`, form-encoded. ONE
|
||||
// per player, stored on the account as `supportedInfluencerId`; posting again replaces
|
||||
// the pick. The id has to name an account currently flagged `isInfluencer` — anything
|
||||
// else, the caller's own id included, is a 400 and changes nothing. The reply is a bare
|
||||
// `{ Success, Error, error_id }` status envelope — no `Value`: the client reads the pick
|
||||
// back from `…/myinfluencer` rather than from this reply.
|
||||
.post(
|
||||
'/api/influencerpartnerprogram/support',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Pick the influencer the caller supports',
|
||||
description: [
|
||||
'Set the ONE influencer the caller supports in the partner program, replacing any',
|
||||
'earlier pick. `influencerAccountId` must name an account that is currently an',
|
||||
'influencer (listed by `…/influencers`); the caller cannot support themselves. The',
|
||||
'reply is a `{ Success, Error, error_id }` status envelope with no `Value`; read the',
|
||||
'new pick back from `…/myinfluencer`.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(SupportInfluencerBody, 'The influencer to support'),
|
||||
responses: {
|
||||
200: json(SupportInfluencerResponse, 'The pick was recorded'),
|
||||
400: {
|
||||
description:
|
||||
'`influencerAccountId` missing, not an integer, the caller’s own id, or not an influencer (empty body)',
|
||||
},
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const raw = body.influencerAccountId
|
||||
const influencerId = Number.parseInt(typeof raw === 'string' ? raw : '', 10)
|
||||
if (!Number.isInteger(influencerId) || influencerId === id) return c.body(null, 400)
|
||||
const influencer = await getAccount(c.env.DB, influencerId)
|
||||
if (influencer?.isInfluencer !== true) return c.body(null, 400)
|
||||
await updateAccount(c.env.DB, id, { supportedInfluencerId: influencerId })
|
||||
return c.json({ Success: true, Error: null, error_id: null })
|
||||
}
|
||||
)
|
||||
|
||||
// Stop supporting an influencer: `influencerAccountId=<id>`, form-encoded, the same body
|
||||
// as `…/support`. The field is UNSET on the account (dropped from the JSON, not written
|
||||
// as 0), so `…/myinfluencer` goes back to 0. The id has to be an integer (else 400), but
|
||||
// it only has to NAME THE CURRENT PICK to do anything: naming anyone else — a stale pick
|
||||
// from before a `…/support` that replaced it, or nobody at all — is a no-op that still
|
||||
// answers success, like a wishlist remove of something not listed. Guarding on the match
|
||||
// keeps a stale client from clearing a pick it never saw.
|
||||
.post(
|
||||
'/api/influencerpartnerprogram/remove',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Stop supporting an influencer',
|
||||
description: [
|
||||
'Clear the influencer the caller supports in the partner program. `influencerAccountId`',
|
||||
'names the current pick; the field is unset and `…/myinfluencer` answers 0 again.',
|
||||
'An id that is not the current pick changes nothing and still answers success. The',
|
||||
'reply is the same `{ Success, Error, error_id }` status envelope as `…/support`.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(RemoveInfluencerBody, 'The influencer to stop supporting'),
|
||||
responses: {
|
||||
200: json(SupportInfluencerResponse, 'The pick was cleared (or was not this one)'),
|
||||
400: { description: '`influencerAccountId` missing or not an integer (empty body)' },
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const id = await authedId(c)
|
||||
if (id === null) return unauthorized(c)
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
|
||||
const raw = body.influencerAccountId
|
||||
const influencerId = Number.parseInt(typeof raw === 'string' ? raw : '', 10)
|
||||
if (!Number.isInteger(influencerId)) return c.body(null, 400)
|
||||
if ((await supportedInfluencer(c.env.DB, id)) === influencerId) {
|
||||
// `undefined` wins the spread in updateAccount and JSON.stringify drops the key.
|
||||
await updateAccount(c.env.DB, id, { supportedInfluencerId: undefined })
|
||||
}
|
||||
return c.json({ Success: true, Error: null, error_id: null })
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+475
-15
@@ -222,25 +222,55 @@ export const RRPlusSignUpBonus = z.object({
|
||||
/**
|
||||
* `GET /api/influencerpartnerprogram/influencers` — the ids of every influencer in the
|
||||
* partner program, which the client uses to badge them wherever they appear. An object
|
||||
* around the list, not a bare array.
|
||||
* around the list, not a bare array. `ContinuationToken` is the paging cursor; always null
|
||||
* here, the whole list fitting in one page.
|
||||
*/
|
||||
export const InfluencerIdsResponse = z.object({
|
||||
InfluencerIds: z
|
||||
.array(z.int())
|
||||
.describe('Account ids in the partner program. Empty — no programme runs here'),
|
||||
.describe('Account ids in the partner program — every account flagged `isInfluencer`'),
|
||||
ContinuationToken: z
|
||||
.null()
|
||||
.describe('Paging cursor for the next page. Always null — the list is served whole'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /api/influencerpartnerprogram/influencer` and `…/myinfluencer` — one account's
|
||||
* standing in the partner program.
|
||||
* `GET /api/influencerpartnerprogram/influencer` and `…/myinfluencer` — the account id of
|
||||
* the influencer an account supports.
|
||||
*
|
||||
* A BARE NUMBER, not an object: the body is the literal `0`, which is the "not an
|
||||
* influencer" tier. Nobody on this server is one, so 0 is the answer for every account, the
|
||||
* caller's own included.
|
||||
* A BARE NUMBER, not an object: the body is the literal id, or `0` when the account
|
||||
* supports nobody. The write (`POST …/support`) does NOT answer in this shape; see
|
||||
* `SupportInfluencerResponse`.
|
||||
*/
|
||||
export const InfluencerTierResponse = z
|
||||
.literal(0)
|
||||
.describe('The account’s partner tier. Always 0 — nobody here is an influencer')
|
||||
export const SupportedInfluencerResponse = z
|
||||
.int()
|
||||
.describe('The account id of the influencer this account supports; 0 for nobody')
|
||||
|
||||
/** `POST /api/influencerpartnerprogram/support` — the influencer the caller picks. */
|
||||
export const SupportInfluencerBody = z.object({
|
||||
influencerAccountId: z
|
||||
.int()
|
||||
.describe('The account id of the influencer to support. Must be flagged `isInfluencer`'),
|
||||
})
|
||||
|
||||
/** `POST /api/influencerpartnerprogram/remove` — the influencer the caller stops supporting. */
|
||||
export const RemoveInfluencerBody = z.object({
|
||||
influencerAccountId: z
|
||||
.int()
|
||||
.describe('The account id of the influencer to stop supporting — the caller’s current pick'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The reply to `POST /api/influencerpartnerprogram/support` and `…/remove` — a bare
|
||||
* `{ Success, Error, error_id }` status envelope with NO `Value`: the pick is read back from
|
||||
* `…/myinfluencer`, not from this reply. The same mixed casing as `RoomCurrencyEnvelope`
|
||||
* (PascalCase `Success`/`Error` beside a lowercase `error_id`), minus the payload.
|
||||
*/
|
||||
export const SupportInfluencerResponse = z.object({
|
||||
Success: z.boolean().describe('Always true — every refusal is an empty 400 instead'),
|
||||
Error: z.null().describe('Always null'),
|
||||
error_id: z.null().describe('Always null. Present as a key, and lowercase'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /api/incentivizedreferrals/progress` — how far the caller has got with the
|
||||
@@ -425,6 +455,68 @@ export const BuyInventionResponse = z.object({
|
||||
.describe('The same envelope `POST /api/inventions/v6/save` returns'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `GET /api/storefronts/v1/buyRoomKey` — the client's `RoomKeyPurchaseResponseDTO`, recovered
|
||||
* from its decoder: the room-key envelope `/api/roomkeys/v1/create` answers in, beside a
|
||||
* BalanceUpdateResponse whose bucket key is `Platform` (NOT the `BalanceType` the invention
|
||||
* buys echo) and whose entry `Data` is the key again.
|
||||
*
|
||||
* Every enum here reads an OMITTED key as its 0 member — `Status` 0 is success,
|
||||
* `UpdateResponse` 0 is OK, `Platform` 0 is SteamPurchased — so all three are always sent.
|
||||
*/
|
||||
export const BuyRoomKeyResponse = z.object({
|
||||
RoomKeyResponse: z.object({
|
||||
Status: z.int().describe('0 on success; 1 on a refusal'),
|
||||
RoomKey: JsonObject.nullable().describe(
|
||||
'The bought key (the 10-key room key); null on a refusal'
|
||||
),
|
||||
}),
|
||||
BalanceUpdateResponse: z.object({
|
||||
BalanceUpdates: z.array(
|
||||
z.object({
|
||||
UpdateResponse: z
|
||||
.int()
|
||||
.describe(
|
||||
'0 OK, 2 NotEnoughCredit, 3 AlreadyOwned, 4 NoItemAvailable, ' +
|
||||
'6 RequestedPriceDoesNotMatch'
|
||||
),
|
||||
Data: JsonObject.nullable().describe('The key the request named; null when it named none'),
|
||||
})
|
||||
),
|
||||
Balance: z.int().describe('The buyer’s RESULTING token total, not the change'),
|
||||
CurrencyType: z.int().describe('2 = RecCenterTokens'),
|
||||
Platform: z.int().describe('-2 — the one account-wide bucket every balance surface names'),
|
||||
}),
|
||||
})
|
||||
|
||||
/** `POST /api/storefronts/v1/PurchaseRoomKeyWithCurrency` — form-encoded. */
|
||||
export const PurchaseRoomKeyWithCurrencyRequest = z.object({
|
||||
RoomKeyId: z.string().describe('The key to buy — a `room_key` id'),
|
||||
RequestedPrice: z.string().describe('The price the client rendered; must equal the key’s'),
|
||||
RequestedPurchaseCurrencyId: z
|
||||
.string()
|
||||
.describe('The room currency to pay in; must be the key’s own `PurchaseCurrencyId`'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/storefronts/v1/PurchaseRoomKeyWithCurrency` — the client's
|
||||
* `RoomKeyPurchaseWithCurrencyResponseDTO`, recovered from its decoder. A BARE object, no
|
||||
* envelope, and NOT buyRoomKey's shape: the outer `Balance` is an OBJECT (the 4-key
|
||||
* room-currency balance) holding a numeric `Balance`, and there is no `Platform` anywhere.
|
||||
*/
|
||||
export const PurchaseRoomKeyWithCurrencyResponse = z.object({
|
||||
Balance: z.object({
|
||||
AccountId: z.int().describe('The buyer'),
|
||||
CurrencyId: z.string().describe('The room currency paid in'),
|
||||
Balance: z.int().describe('What the buyer holds of it afterwards — the RESULTING total'),
|
||||
ModifiedAt: z.string().describe('ISO-8601 UTC'),
|
||||
}),
|
||||
RoomKeyResponse: z.object({
|
||||
Status: z.int().describe('0 on success; 1 on a refusal. Always sent — omitted reads as 0'),
|
||||
RoomKey: JsonObject.nullable().describe('The bought key; null on a refusal'),
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/storefronts/v3/buyInvention` — the purchase result the 2025 client wants,
|
||||
* which is NOT v2's despite settling the identical purchase. Two differences, both
|
||||
@@ -502,6 +594,30 @@ export const UgcPurchasableItemDto = z.object({
|
||||
/** What the bulk lookup answers: the resolved items, unknown ids omitted. */
|
||||
export const UgcPurchasableItemList = z.array(UgcPurchasableItemDto)
|
||||
|
||||
/**
|
||||
* `GET /api/ugcPurchasables/v1/items/room/{roomId}` — one line of a room's shop. The same
|
||||
* 10 keys as the bulk lookup's item, with `ItemType` naming the kind (0 room key, 1 room
|
||||
* consumable, 3 currency purchase offer) and `ImageName` nullable, as a key or consumable
|
||||
* without art is served everywhere else.
|
||||
*/
|
||||
export const RoomPurchasableDto = UgcPurchasableItemDto.extend({
|
||||
ItemType: z
|
||||
.number()
|
||||
.int()
|
||||
.describe('0 RoomKey, 1 RoomConsumable, 3 RoomCurrencyItem (a currency pack for tokens)'),
|
||||
ItemId: z
|
||||
.string()
|
||||
.describe('A bare GUID — the key’s ReplicationId, the consumable’s or offer’s id'),
|
||||
ImageName: z.string().nullable().describe('Null for a listing with no art'),
|
||||
PurchaseCurrencyId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('The room currency the line is charged in; null for tokens'),
|
||||
})
|
||||
|
||||
/** The room’s shop — a bare array, empty for a room that sells nothing. */
|
||||
export const RoomPurchasableList = z.array(RoomPurchasableDto)
|
||||
|
||||
/**
|
||||
* `POST /api/items/purchaseInfos` JSON body — the same `{ itemType, itemId }` reference
|
||||
* shape the UGC bulk lookup takes, minus the room. camelCase INSIDE the reference, which is
|
||||
@@ -517,6 +633,18 @@ export const ItemPurchaseInfosRequest = z.object({
|
||||
),
|
||||
})
|
||||
|
||||
/** `POST /api/storefronts/v1/purchasableitems` — store listings by id. */
|
||||
export const PurchasableItemsRequest = z.object({
|
||||
PurchasableItemIds: z
|
||||
.array(z.number().int())
|
||||
.nullable()
|
||||
.describe('The listings to resolve, by `PurchasableItemId`'),
|
||||
GiftDropIds: z
|
||||
.array(z.number().int())
|
||||
.nullable()
|
||||
.describe('Sent by the client (observed null); accepted and ignored'),
|
||||
})
|
||||
|
||||
/** One `Prices[]` entry: what the item costs in one currency, and any sale on top. */
|
||||
export const ItemPriceDto = z.object({
|
||||
CurrencyType: z.number().int().describe('2 = RecCenterTokens — what UGC items are priced in'),
|
||||
@@ -645,12 +773,19 @@ export const RoomKeyDto = z.object({
|
||||
PurchaseCurrencyId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('A `room_currency` id, or null — the create body names none'),
|
||||
.describe('A `room_currency` id, or null for tokens — which a new key is priced in'),
|
||||
CreatedAt: z.string().describe('ISO-8601 UTC'),
|
||||
ImageName: z.string().nullable().describe('Null — a key cannot carry art yet'),
|
||||
ImageName: z.string().nullable().describe('The key’s art; null until an edit sets one'),
|
||||
Type: z.int().describe('The key type’s ordinal: 0 `Key`, the only one seen'),
|
||||
})
|
||||
|
||||
/** A held key as `GET /api/roomkeys/v1/mine` serves it — the room key plus two fields. */
|
||||
export const HeldRoomKeyDto = RoomKeyDto.extend({
|
||||
ImageName: z.string().describe('The key’s art; "" when it has none (NOT null, unlike the list)'),
|
||||
IsGiftable: z.boolean().describe('Always false — gifting keys is not modelled'),
|
||||
Visibility: z.int().describe('Always 0 — not modelled'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The envelope `POST /api/roomkeys/v1/create` answers in: `{ Status, RoomKey }`, observed from
|
||||
* the live client — NOT the `{ Value, Success, Error, error_id }` the room-currency and
|
||||
@@ -672,9 +807,167 @@ export const CreateRoomKeyRequest = z.object({
|
||||
RoomId: z.string().describe('The room the key opens'),
|
||||
Name: z.string().describe('Shown to players; profanity-masked like every typed string'),
|
||||
Description: z.string().optional().describe('Defaults to empty'),
|
||||
Price: z.string().optional().describe('What the key costs; defaults to 0'),
|
||||
Price: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('What the key costs, in tokens; defaults to 0. At most 1000'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `PUT /api/roomkeys/v1/updateAll` — form-encoded. "All" is the FIELDS, not the keys: the body
|
||||
* names one key and carries every editable field of it
|
||||
* (`RoomKeyId=43&Name=key1&Description=…&Price=1000&PurchaseCurrencyId=`). For the two
|
||||
* fields that can be emptied — `PurchaseCurrencyId` and `ImageName` — a BLANK value is an
|
||||
* instruction to clear it; only a field absent from the body is left alone.
|
||||
*/
|
||||
export const UpdateRoomKeyRequest = z.object({
|
||||
RoomKeyId: z.string().describe('The key to edit'),
|
||||
Name: z.string().optional().describe('Left alone when absent; profanity-masked'),
|
||||
Description: z.string().optional().describe('Left alone when absent; profanity-masked'),
|
||||
Price: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Left alone when absent. At most 1000 in tokens, 1000000000 in a room currency'),
|
||||
PurchaseCurrencyId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'EMPTY means tokens; otherwise a `room_currency` id of the key’s own room. Left alone when absent'
|
||||
),
|
||||
ImageName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The key’s art. EMPTY clears it (served as null). Left alone when absent'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/roomkeys/v1/awardbulk` — a BARE JSON ARRAY of the keys to award, as the client
|
||||
* posts it. `AccountId` is accepted and IGNORED: the token says who is awarded.
|
||||
*/
|
||||
export const AwardRoomKeysRequest = z.array(
|
||||
z.object({
|
||||
RoomKeyId: z.int().describe('The key to award — a `room_key` id'),
|
||||
AccountId: z
|
||||
.int()
|
||||
.optional()
|
||||
.describe('Accepted and IGNORED — the caller is who is awarded the key'),
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* `POST /api/roomkeys/v1/awardbulk` answers an ARRAY OF ENVELOPES — every element its own
|
||||
* `{ Value, Success, Error, error_id }` around one award (recovered from the client's
|
||||
* decoder). Not a bare array of awards, and not one envelope wrapping a list. Only the
|
||||
* success form has been observed; a failed entry's null `Value` is an assumption.
|
||||
*/
|
||||
export const AwardRoomKeyResultList = z.array(
|
||||
z.object({
|
||||
Value: z
|
||||
.object({
|
||||
RoomKeyId: z.int(),
|
||||
AccountId: z.int().describe('Who now holds the key — the caller'),
|
||||
})
|
||||
.nullable()
|
||||
.describe('The award, or null on a failure'),
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable().describe('Null on success; the failure message otherwise'),
|
||||
error_id: z.null().describe('Always null. Present as a key, and lowercase'),
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* `POST /api/roomkeys/v1/owns/bulk` — a BARE JSON ARRAY of (player, key) pairs to check.
|
||||
* Unlike the award's, this `AccountId` is READ: it names whose keys are being asked about.
|
||||
*/
|
||||
export const OwnsRoomKeysRequest = z.array(
|
||||
z.object({
|
||||
AccountId: z.int().describe('The player being asked about'),
|
||||
RoomKeyId: z.int().describe('The key — a `room_key` id'),
|
||||
})
|
||||
)
|
||||
|
||||
/** `POST /api/roomkeys/v1/owns/bulk` — a bare array, one answer per pair in the order asked. */
|
||||
export const OwnsRoomKeysResponse = z.array(
|
||||
z.object({
|
||||
AccountId: z.int(),
|
||||
RoomKeyId: z.int(),
|
||||
DoesPlayerOwnRoomKey: z.boolean(),
|
||||
})
|
||||
)
|
||||
|
||||
/**
|
||||
* One wishlist entry — the client's five-key DTO, in its order. An element of the GET's bare
|
||||
* array and the `Value` of the add/remove envelope.
|
||||
*
|
||||
* `PurchasableItemId` is a plain int on the client, so an entry for a custom avatar item
|
||||
* carries 0 there and the item's GUID in `CustomAvatarItemId`; a storefront-item entry has
|
||||
* the id and a null GUID. Exactly one of the two names the item.
|
||||
*/
|
||||
export const WishlistItemDto = z.object({
|
||||
WishlistItemId: z.string().describe('GUID — the client’s `Guid`, so never an int'),
|
||||
AccountId: z.int().describe('Whose list the entry is on'),
|
||||
PurchasableItemId: z
|
||||
.int()
|
||||
.describe('The storefront item wished for, or 0 when the entry names a custom avatar item'),
|
||||
CustomAvatarItemId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('The custom avatar item wished for, or null when the entry names a storefront item'),
|
||||
CreatedAt: z.string().describe('ISO-8601 UTC'),
|
||||
})
|
||||
|
||||
/** `GET /api/itemWishlists/v1/wishlist/me|{accountId}` — a bare array, newest wish first. */
|
||||
export const WishlistItemList = z.array(WishlistItemDto)
|
||||
|
||||
/**
|
||||
* The envelope `wishlist/add` and `wishlist/remove` answer in — the same `{ Value, Success,
|
||||
* Error, error_id }` the room-currency writes use. The client reads `add`’s `Value` and
|
||||
* discards `remove`’s, but parses both as this shape: a bare entry or a 204 fails the same
|
||||
* way `Success: false` does. `Error` is shown to the player verbatim behind “Wishlist error: ”.
|
||||
*/
|
||||
export const WishlistEnvelope = z.object({
|
||||
Value: WishlistItemDto.nullable().describe(
|
||||
'The entry added or removed; null on a refusal, or on removing an item that wasn’t listed'
|
||||
),
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable().describe('Null on success; the failure message otherwise'),
|
||||
error_id: z.null().describe('Always null. Present as a key, and lowercase'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `wishlist/add` and `wishlist/remove` — form-encoded. The client posts BOTH fields every
|
||||
* time with the unused one empty (`purchasableItemId=1534&customAvatarItemId=`); either may
|
||||
* also be omitted. Exactly one has to name an item.
|
||||
*/
|
||||
export const WishlistItemRequest = z.object({
|
||||
purchasableItemId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The storefront item, as a positive integer; empty or absent for a custom item'),
|
||||
customAvatarItemId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The custom avatar item’s GUID; empty or absent for a storefront item'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/itemWishlists/v1/isonwishlist/bulk` — form-encoded. `accountIds` is REPEATED,
|
||||
* once per account (`accountIds=205&accountIds=207`); a comma-separated single value is
|
||||
* accepted too. The item is named as on add/remove.
|
||||
*/
|
||||
export const IsOnWishlistBulkRequest = WishlistItemRequest.extend({
|
||||
accountIds: z
|
||||
.array(z.string())
|
||||
.describe('The players to ask about, repeated once per id — answered in this order'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `isonwishlist/bulk`’s answer: a bare POSITIONAL array of booleans, one per `accountIds`
|
||||
* entry in the order posted. A short array leaves the tail reading as “not wished for”, so
|
||||
* every id gets an element — an unknown player’s is false.
|
||||
*/
|
||||
export const IsOnWishlistBulkResponse = z.array(z.boolean())
|
||||
|
||||
/**
|
||||
* One purchase offer on a room currency — a way to BUY that currency, priced in another
|
||||
* ("5 SuperTokens for 500 Rec Center Tokens"). The client's own model, member for member and
|
||||
@@ -786,6 +1079,86 @@ export const AwardRoomConsumableResult = z.object({
|
||||
|
||||
export const AwardRoomConsumableResultList = z.array(AwardRoomConsumableResult)
|
||||
|
||||
/**
|
||||
* The client's `RoomConsumableInventoryItem` — what a player owns of one consumable. The
|
||||
* element of `GET …/roomConsumable/room/{roomId}/me` and the purchase's `InventoryItem`.
|
||||
*/
|
||||
export const RoomConsumableInventoryItemDto = z.object({
|
||||
RoomConsumableId: z.string(),
|
||||
AccountId: z.int().describe('Who owns them'),
|
||||
Count: z.int().describe('The RESULTING total owned, never the change'),
|
||||
ConcurrencyCode: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('The last `NewConcurrencyCode` written — stored, not checked'),
|
||||
ModifiedAt: z.string().describe('ISO-8601 UTC — the last write'),
|
||||
Consumable: RoomConsumableDto.extend({
|
||||
ImageName: z.string().describe('"" when the listing has no picture (NOT null)'),
|
||||
IsGiftable: z.boolean().describe('Always false — not modelled'),
|
||||
Visibility: z.int().describe('Always 0 — not modelled'),
|
||||
}).describe('The listing — the catalogue element plus two fields'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/{tokens|currency}` — the client's
|
||||
* `RoomConsumablePurchaseRequest`, four keys in declaration order (from its formatter). One
|
||||
* route for both kinds of price: `ExpectedPriceAndCurrency.CurrencyId` null is TOKENS.
|
||||
*/
|
||||
export const PurchaseRoomConsumableRequest = z.object({
|
||||
ConcurrencyCodes: z
|
||||
.object({
|
||||
CurrentConcurrencyCode: z.string().nullable().optional(),
|
||||
NewConcurrencyCode: z.string().nullable().optional(),
|
||||
})
|
||||
.optional()
|
||||
.describe('Accepted and IGNORED — no optimistic concurrency is implemented yet'),
|
||||
ExpectedPriceAndCurrency: z
|
||||
.object({
|
||||
Price: z.int().describe('The UNIT price the client rendered; must equal the listing’s'),
|
||||
CurrencyId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('The listing’s room currency, or null for a listing priced in tokens'),
|
||||
})
|
||||
.describe('What the client showed — refused unless it is the listing’s own'),
|
||||
Quantity: z.int().describe('How many to buy; at least 1, at most `MaximumCountPerPurchase`'),
|
||||
GiftRecipientAccountId: z
|
||||
.int()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Who receives them; null for the buyer. The buyer pays either way'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The consumable purchase's answer, observed from the live client. The refusal form (`Status`
|
||||
* 1, a null `InventoryItem`) is an assumption — only success has been observed.
|
||||
*/
|
||||
export const PurchaseRoomConsumableResponse = z.object({
|
||||
BalanceUpdateResult: z
|
||||
.int()
|
||||
.describe(
|
||||
'An UpdateResponse: 0 OK, 2 NotEnoughCredit, 4 NoItemAvailable, ' +
|
||||
'6 RequestedPriceDoesNotMatch, 7 RequestedAmountNotAllowed, 8 PlayerNotEligible'
|
||||
),
|
||||
CurrencyBalanceResponse: z
|
||||
.object({
|
||||
AccountId: z.int().describe('The BUYER — who paid, even for a gift'),
|
||||
CurrencyId: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe('The room currency paid in; null for a listing priced in tokens'),
|
||||
Balance: z.int().describe('What the buyer holds of it afterwards — the RESULTING total'),
|
||||
ModifiedAt: z.string().describe('ISO-8601 UTC'),
|
||||
})
|
||||
.describe('What the buyer now holds of what they paid in'),
|
||||
OperationResult: z.object({
|
||||
Status: z.int().describe('0 on success; 1 on a refusal. Always sent — omitted reads as 0'),
|
||||
InventoryItem: RoomConsumableInventoryItemDto.nullable().describe(
|
||||
'What the recipient now owns of the consumable; null on a refusal'
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
||||
/**
|
||||
* The envelope the consumable write answers in — the same
|
||||
* `{ Value, Success, Error, error_id }` the room-currency writes use.
|
||||
@@ -827,9 +1200,17 @@ export const CreatePurchaseOfferRequest = z.object({
|
||||
Order: z.string().optional().describe('Where it sits in the shop; defaults to 0'),
|
||||
})
|
||||
|
||||
/** `POST /api/roomcurrencies/v1/deletePurchaseOffer` — form-encoded. */
|
||||
export const DeletePurchaseOfferRequest = z.object({
|
||||
PurchaseOfferId: z
|
||||
.string()
|
||||
.describe('The offer to take down, by its `CurrencyPurchaseOfferId` (any letter case)'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The envelope the create-offer endpoint answers in — the same
|
||||
* `{ Value, Success, Error, error_id }` the currency writes use, with the offer in `Value`.
|
||||
* The envelope the create- and delete-offer endpoints answer in — the same
|
||||
* `{ Value, Success, Error, error_id }` the currency writes use, with the offer (as created,
|
||||
* or as it stood when removed) in `Value`.
|
||||
*/
|
||||
export const RoomCurrencyPurchaseOfferEnvelope = z.object({
|
||||
Value: RoomCurrencyPurchaseOfferDto.nullable(),
|
||||
@@ -889,6 +1270,61 @@ export const AwardRoomCurrencyResult = z.object({
|
||||
|
||||
export const AwardRoomCurrencyResultList = z.array(AwardRoomCurrencyResult)
|
||||
|
||||
/**
|
||||
* `POST /api/roomCurrencies/v2/purchase` — form-encoded
|
||||
* (`PurchaseOfferId=ffab4157-…&RequestedAmount=444&RequestedPrice=555`). The offer alone says
|
||||
* which currency is being bought; the two numbers are what the client rendered, and must match
|
||||
* the offer.
|
||||
*/
|
||||
export const PurchaseRoomCurrencyRequest = z.object({
|
||||
PurchaseOfferId: z.string().describe('The offer — a `CurrencyPurchaseOfferId`'),
|
||||
RequestedAmount: z.string().describe('How much room currency; must equal the offer’s'),
|
||||
RequestedPrice: z
|
||||
.string()
|
||||
.describe(
|
||||
'What it costs in tokens; must equal the offer’s, except for the room’s owner and co-owners, who are shown 0 and pay nothing'
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* What a player holds of one room currency, as `GET /api/roomcurrencies/v1/getBalance` answers
|
||||
* it. `ModifiedAt` is the time of the response: `room_balance` keeps no modification time.
|
||||
*/
|
||||
export const RoomCurrencyBalance = z.object({
|
||||
AccountId: z.int().describe('The player the balance belongs to'),
|
||||
CurrencyId: z.string().describe('The room currency'),
|
||||
Balance: z.int().describe('What the player holds of it; 0 when they have never earned any'),
|
||||
ModifiedAt: z.string().describe('ISO-8601 UTC; the time of the response'),
|
||||
})
|
||||
|
||||
/**
|
||||
* What `POST /api/roomCurrencies/v2/purchase` answers: both balances the purchase moved,
|
||||
* inside the `{ Value, Success, Error, error_id }` envelope the room-currency writes use.
|
||||
* Each `Balance` is a RESULTING total. Only the success form has been observed; a refusal's
|
||||
* null `Value` is an assumption.
|
||||
*/
|
||||
export const PurchaseRoomCurrencyEnvelope = z.object({
|
||||
Value: z
|
||||
.object({
|
||||
CurrencyBalanceResponse: z.object({
|
||||
AccountId: z.int().describe('The buyer'),
|
||||
CurrencyId: z.string().describe('The room currency bought'),
|
||||
Balance: z.int().describe('What the buyer now holds of it'),
|
||||
ModifiedAt: z.string().describe('ISO-8601 UTC'),
|
||||
}),
|
||||
TokenBalanceResponse: z.object({
|
||||
Balance: z.int().describe('The buyer’s resulting token total'),
|
||||
CurrencyType: z.int().describe('2 = RecCenterTokens'),
|
||||
Platform: z.int().describe('-2 — the one account-wide bucket every balance surface names'),
|
||||
}),
|
||||
})
|
||||
.nullable()
|
||||
.describe('Null on a refusal'),
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable().describe('Null on success; the refusal otherwise'),
|
||||
error_id: z.null().describe('Always null. Present as a key, and lowercase'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/roomcurrencies/v1/updateCurrency` — form-encoded. Names the currency by id
|
||||
* rather than by room: the room it belongs to is whichever one minted it, and an edit cannot
|
||||
@@ -973,6 +1409,30 @@ export const ConsumeConsumableRequest = z.object({
|
||||
DeltaCount: z.int().optional().describe('How many to spend; defaults to 1'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/consumables/v1/query/bulk` form body — `descriptors` repeated once per
|
||||
* consumable to look up.
|
||||
*/
|
||||
export const QueryConsumablesRequest = z.object({
|
||||
descriptors: z
|
||||
.union([z.string(), z.array(z.string())])
|
||||
.optional()
|
||||
.describe('A ConsumableItemDesc; the key repeats for each one asked about'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/consumables/v1/updateActive` JSON body — the client marking one of its
|
||||
* consumables active or not. Accepted and NOT acted on yet; see the route.
|
||||
*/
|
||||
export const UpdateActiveConsumableRequest = z.object({
|
||||
Id: z.int().describe('The consumable row id'),
|
||||
IsActive: z.boolean().describe('Whether the consumable is now in use'),
|
||||
ActivatedByRoomie: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe('Whether a roommate, rather than the owner, activated it'),
|
||||
})
|
||||
|
||||
/** `POST /api/avatar/v2/gifts/consume` form body (posted with a trailing slash). */
|
||||
export const ConsumeGiftRequest = z.object({
|
||||
Id: z.string().describe('The gift-box id to open'),
|
||||
|
||||
+56
-25
@@ -30,13 +30,29 @@ export const REWARD_STATUS_SCHEMA_DDL: string[] = [
|
||||
]
|
||||
|
||||
/**
|
||||
* How long a player must wait between rewards of the same type in the same activity. One
|
||||
* hour flat, for every type — despite what a name like `FirstActivityOfDay` suggests.
|
||||
* Per-type windows would be a map keyed by reward type; there's one window until a reward
|
||||
* type needs its own.
|
||||
* How long a player must wait between rewards of the same type in the same activity: one
|
||||
* hour, for every type but `FirstActivityOfDay`, which waits {@link DAILY_REWARD_COOLDOWN_MS}.
|
||||
*/
|
||||
export const REWARD_COOLDOWN_MS = 60 * 60 * 1000
|
||||
|
||||
/** `FirstActivityOfDay`'s cooldown: a rolling 24h since the player's last one. */
|
||||
export const DAILY_REWARD_COOLDOWN_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* The game reward's per-PLAYER daily allowances, each kept in `reward_status` as one row under
|
||||
* its reserved type and the empty context: `grant_count` is the AMOUNT paid in the current
|
||||
* window (tokens, or XP — not a count of claims) and `granted_at` is when that window opened.
|
||||
* The `$` keeps them apart from the client's own type names (`FirstActivityOfDay`,
|
||||
* `PostGameActivity`), which a client-sent type could otherwise forge.
|
||||
*/
|
||||
export type Allowance = '$tokens' | '$xp'
|
||||
|
||||
/**
|
||||
* How long an allowance window lasts: a rolling 24h from the first grant in it, rather than a
|
||||
* calendar day — so no time zone's midnight decides when a player's allowance comes back.
|
||||
*/
|
||||
export const ALLOWANCE_WINDOW_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Claim a reward if the player is due one, returning how many of that type they have now
|
||||
* claimed in that context — or `null` when the cooldown hasn't elapsed and nothing was
|
||||
@@ -61,9 +77,10 @@ export async function claimReward(
|
||||
accountId: number,
|
||||
rewardType: string,
|
||||
giftContext = '',
|
||||
now: Date = new Date()
|
||||
now: Date = new Date(),
|
||||
cooldownMs: number = REWARD_COOLDOWN_MS
|
||||
): Promise<number | null> {
|
||||
const cutoff = new Date(now.getTime() - REWARD_COOLDOWN_MS).toISOString()
|
||||
const cutoff = new Date(now.getTime() - cooldownMs).toISOString()
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO reward_status (account_id, reward_type, gift_context, granted_at, grant_count)
|
||||
@@ -80,28 +97,42 @@ export async function claimReward(
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the claim just written by {@link claimReward} is the player's FIRST in
|
||||
* `giftContext` — across every reward type, so an activity is "new" once, not once per
|
||||
* type it happens to be asked under. Call it right after the claim with the count it
|
||||
* returned: it is true when that claim INSERTED its row (`grantCount` 1; a repeat past the
|
||||
* cooldown is 2+) and the player's rows in that context are exactly that one.
|
||||
* Add `amount` to the player's daily `allowance` if it fits under `cap`, returning the
|
||||
* window's new total — or `null` when it doesn't fit and nothing was granted.
|
||||
*
|
||||
* Counting AFTER the claim is what keeps two asks landing together (say
|
||||
* `FirstActivityOfDay` and `PostGameActivity` for the same match) from both reading "new":
|
||||
* D1 serializes the inserts, so at most the first insert's count is 1 and the second's is
|
||||
* 2 — a race can skip the bonus, never pay it twice. The contextless `''` bucket is not an
|
||||
* activity and is never new.
|
||||
* A window older than {@link ALLOWANCE_WINDOW_MS} is reset in place (the row's key allows
|
||||
* one per player): `granted_at` moves to now and the total restarts at `amount`. Otherwise the
|
||||
* amount is added to the running total, and `granted_at` is left alone so the window keeps its
|
||||
* start. All grant-or-nothing: a claim that would cross the cap pays nothing rather than the
|
||||
* remainder, so `cap` should be a multiple of `amount`.
|
||||
*
|
||||
* One statement, for the same reason as {@link claimReward}: asks landing together can't both
|
||||
* read a total under the cap and both pay. SQLite evaluates every `SET` against the row as it
|
||||
* was, so both `CASE`s see the old `granted_at`.
|
||||
*/
|
||||
export async function isNewActivity(
|
||||
export async function claimAllowance(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
giftContext: string,
|
||||
grantCount: number
|
||||
): Promise<boolean> {
|
||||
if (giftContext === '' || grantCount !== 1) return false
|
||||
allowance: Allowance,
|
||||
amount: number,
|
||||
cap: number,
|
||||
now: Date = new Date()
|
||||
): Promise<number | null> {
|
||||
if (amount > cap) return null
|
||||
const cutoff = new Date(now.getTime() - ALLOWANCE_WINDOW_MS).toISOString()
|
||||
const row = await db
|
||||
.prepare('SELECT COUNT(*) AS n FROM reward_status WHERE account_id = ?1 AND gift_context = ?2')
|
||||
.bind(accountId, giftContext)
|
||||
.first<{ n: number }>()
|
||||
return row?.n === 1
|
||||
.prepare(
|
||||
`INSERT INTO reward_status (account_id, reward_type, gift_context, granted_at, grant_count)
|
||||
VALUES (?1, ?2, '', ?3, ?4)
|
||||
ON CONFLICT (account_id, reward_type, gift_context) DO UPDATE SET
|
||||
granted_at = CASE WHEN reward_status.granted_at <= ?5
|
||||
THEN excluded.granted_at ELSE reward_status.granted_at END,
|
||||
grant_count = CASE WHEN reward_status.granted_at <= ?5
|
||||
THEN ?4 ELSE reward_status.grant_count + ?4 END
|
||||
WHERE reward_status.granted_at <= ?5 OR reward_status.grant_count + ?4 <= ?6
|
||||
RETURNING grant_count`
|
||||
)
|
||||
.bind(accountId, allowance, now.toISOString(), amount, cutoff, cap)
|
||||
.first<{ grant_count: number }>()
|
||||
return row?.grant_count ?? null
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user