Files
recflare/apps/econ/README.md
T
2026-08-10 22:55:13 -04:00

409 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# econ
Economy Worker served on the `econ` subdomain (`econ.recflare.net`). Hosts the
avatar/economy endpoints the game client calls on the `econ` service (distinct from the
main `api` worker, which also serves many of them — the client may call either host).
Balances, inventory, consumables, saved outfits, avatars, gift boxes, weekly-challenge
progress and game-reward eligibility are D1-backed; storefront catalogs and the weekly-challenge rotation are static
assets (`static/`), the storefronts served via the ASSETS binding. Several routes are still
empty-list stubs.
## Routes
`✓` = auth-gated (validates the Bearer JWT from the `auth` worker; empty-body 401 when
missing/invalid). `~` = optional auth: served to anyone, personalised for a valid bearer.
| Method | Path | Auth | Description |
| -------- | ---------------------------------------------------- | ---- | --------------------------------------- |
| GET | `/api/avatar/v1/defaultunlocked` | | Default-unlocked avatar items (static) |
| GET | `/api/avatar/v1/defaultbaseavataritems` | | Default base avatar items (stub `[]`) |
| GET | `/api/avatar/v4/items` | ✓ | Owned items + the default catalog |
| GET | `/econ/customAvatarItems/v1/owned` | ✓ | Owned custom avatar items (stub) |
| GET | `/api/objectives/v1/myprogress` | | Objectives progress (static) |
| GET/POST | `/api/objectives/v1/cleargroup` | | Clear an objectives group (no-op `[]`) |
| 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/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 |
| POST | `/api/avatar/v2/gifts/consume` | | Open a gift box → success envelope |
| GET | `/api/avatar/v2/:id` | | Another player's avatar (render subset) |
| GET | `/api/equipment/v2/getUnlocked` | | Unlocked equipment (stub `[]`) |
| GET | `/api/roomconsumables/v1/roomConsumable/room/:id` | | Room consumables (stub `[]`) |
| GET | `/api/roomconsumables/v1/roomConsumable/room/:id/me` | | Caller's room consumables (stub `[]`) |
| GET | `/api/roomcurrencies/v1/currencies` | | Room currencies (stub `[]`) |
| GET | `/api/roomcurrencies/v1/getAllBalances` | | Room balances (stub `[]`) |
| 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 |
| 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 |
| GET | `/api/storefronts/v1/adcarouselitems` | | Ad-carousel items (static) |
| 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 (hourly, per type) |
| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) |
| GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) |
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | | Subscription lookup (both null) |
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
The app runs with `strict: false`, so trailing-slash variants match (the client posts
`/gifts/consume/` with a trailing slash).
## API documentation
`GET /openapi.json` serves a spec generated from `describeRoute` blocks alongside each
handler, with the schemas in `src/openapi.ts`. **Descriptive, not enforced** — same
rationale as the `auth`/`accounts`/`match` workers. A test asserts every route appears
in the spec, so adding one without documenting it fails.
## Purchases (`buyItem`)
The core flow. The client posts the storefront/item ids, the currency, and the
`RequestedPrice` it rendered; the handler:
1. looks the item up in `static/storefronts/sf{StorefrontType}.json`;
2. rejects a stale price (`409`) — this stops a stale or tampered client buying at a
price the catalog no longer offers;
3. debits the buyer **atomically** (`400` on insufficient balance);
4. grants the drop — an avatar item into the `inventory` table (own-once), equipment into
`equipment`, a consumable into `consumable` (each buy stacks a new instance), or, for a
query drop, whatever the roll lands on (below); currency/xp drops aren't granted yet;
5. returns a **gift box** and pushes a `StorefrontBalanceUpdate` over the socket.
Two things are easy to get wrong:
- **`Balance` in the response is the _change_ applied** (the negated price), not the
resulting total. The client reads its new total from `GET /balance/:type`.
- **Ownership is persisted at purchase**, not when the box is opened. Opening a box
(`/gifts/consume`) just deletes it — the item was already granted. So the grant never
waits on the cosmetic "open it" moment.
A `Gift` block routes the item (and box) to another player, but the caller always pays.
A self-buy or anonymous gift is attributed to the "Coach" system account (id 1).
## Query drops — the loot boxes (`IsQuery`)
A gift-drop with `IsQuery: true` is not an item, it is a **roll**: all of its item fields
(`AvatarItemDesc`, `EquipmentModificationGuid`, `ConsumableItemDesc`) are empty on purpose,
and what the player gets is picked at grant time. sf2's tooltip states the rule outright —
_"A random 4-star item that you don't have."_ Eight ship in the catalogs, two families of
the same ladder:
| sf2 "Star Boxes" (`ItemSetId` 44, `Unique`) | Rarity | sf3 "Random box" family |
| ------------------------------------------- | ------ | ----------------------- |
| — | 0 | Common Random box |
| 2-Star Unique Box | 10 | Uncommon Random box |
| 3-Star Unique Box | 20 | Rare Random box |
| 4-Star Unique Box | 30 | Epic Random box |
| — | 50 | Legendary Random box |
That table is the **star ↔ rarity ladder** (`STAR_RARITY` in `econ.app.ts`): sf2's three
boxes pin 2/3/4 → 10/20/30 by carrying both their name and their `QueryRedirectRarity`, and
sf3's five-name ladder fills in the ends. It's the same tier list twice, so read a rarity
number in either dialect.
`rollQueryDrop` resolves one inside `grantGiftDrop`, so both faucets — a purchase and the
weekly gift — hand over a real item rather than an unopenable box:
- **The pool is sf3**, the general store (`ROLL_STOREFRONT_TYPE`). It's the only catalog
with a real pool at every tier (1161 items against 840 in the themed ones), it's where
the Random box family itself sells, and "a random 4-star item" means the item universe,
not whichever seasonal shelf the box came off.
- **Filtered to what the player doesn't own**, which is the `Unique` promise and the only
reading of "an item you don't have" that means anything.
- **Avatar items and equipment only.** Other query drops are excluded (a box that rolls a
box), and so are consumables: they stack, so "don't have" never becomes false and they'd
crowd out the real prizes.
- **`QueryRedirectRarity` wins over `Rarity`** when present — sf2 carries both and they
agree; sf3's boxes carry only `Rarity`.
- **An empty pool grants nothing** (logged `query gift-drop rolled nothing`) — an owner of
every 4-star item still gets the box, just nothing in it. The `buyItem` response still
echoes the drop the player _bought_, i.e. the box; the rolled item shows up in the box
itself, via `GET /api/avatar/v2/gifts`.
## Consume envelopes
Both consume routes (`/gifts/consume`, `/consumables/consume`) always answer HTTP 200
with `{ error: "", success: true, value: null }` — even for a missing or already-gone
target. A captured real consume returns this envelope, not an empty body: the client
parses it to finish the action, so a bare 200 reads as a failure and the item never
finishes unlocking. Deletes are scoped to the caller, so an unauthenticated or
mismatched call is a harmless no-op (opening _another_ player's box is a 403).
## Weekly challenge (`static/weekly-challenge.json`)
Served by `GET /api/challenge/v2/getCurrent` (with each challenge's per-player `Complete`
stamped in — see Progress below). The server never evaluates the rules: the client reads
the rule tree in each challenge's `Config`, watches its own gameplay, and posts the tree
back to `/api/challenge/v2/updateProgress` with its verdict. So this file is the entire
definition of a week's challenges — ids, display strings, matching rules and the reward
preview.
Everything below was read off reference data (one captured live rotation), not a spec.
Field meanings marked _(inferred)_ are read from how the values line up with the strings
the client renders; the rest are pinned by the data itself. The file itself is edited
freely as rotations change — the examples here are the captured week, so expect the shipped
rotation to differ.
### Top level
| Field | Example | Notes |
| ---------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `ChallengeMapId` | `17` | Id of the rotation as a whole ("map" of challenges). Echoed back on `updateProgress`; bump it when you publish a new week. |
| `CompletedRequired` | `false` | _(inferred)_ Whether every challenge must be finished before the `Gift` is claimable. |
| `StartAt` / `EndAt` | `2026-03-25T21:00:00` | The window, 7 days apart, **no timezone suffix** — unlike `ServerTime`. Treat as UTC. |
| `ServerTime` | `2026-03-31T14:42:54.2754728Z` | .NET round-trip timestamp (7-digit fraction, `Z`). The client dates the countdown off this, so it is **frozen** — see below. |
| `Challenges` | array | The week's challenges, rendered in order. |
| `Gift` | object | The reward preview for finishing the set. |
| `FallbackGiftName` | `"4-Star Box"` | Shown when the client can't resolve `Gift` into a name. |
| `ChallengeThemeString` | a designer quote | Free text carried through from the captured rotation; a theme note, not a rendered UI string as far as we can tell. |
**The frozen clock:** `ServerTime` (Mar 31) sits _inside_ `StartAt``EndAt` (Mar 25 → Apr 1),
about a day before the end, and the file is static — so the client always sees an active
rotation with a ~1-day countdown rather than an expired one. If you edit the window, move
`ServerTime` inside the new one too, or the challenges may render as already over.
### A challenge entry
| Field | Notes |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ChallengeId` | Unique within the rotation, not sequential (`37, 38, 44, 49, 63`). Posted back on `updateProgress`. |
| `Name` | Internal slug, never displayed — and **not authoritative**: `63` is named `Complete3SpillwayGames` but its `Config` and description are Clearcut. Trust `Config`, not the name. |
| `Config` | The rule tree, as an **escaped JSON string** (not a nested object). See below. |
| `Description` | The one-line goal, e.g. `"Complete 10 games in ^Paintball"`. |
| `Tooltip` | The longer hint under it. |
| `Complete` | Per-player state, so always `false` in the file — `getCurrent` overwrites it per caller from `challenge_status`. |
`^Token` in `Description`/`Tooltip` is a client-side room link: the client resolves the
token to a room and renders a tappable name. Subrooms use a dotted path
(`^Paintball.Clearcut`). It is optional decoration, not markup the client requires — the
same rotation writes both `"Complete 10 games in ^Paintball"` and, plainly,
`"Complete 3 games of Paintball: Clear Cut"`.
### The `Config` rule tree
A tree of nodes, each with a numeric type in `ct`. Two node kinds appear:
- **Match** (`ct: 0`) — `wc` is a list of predicates that must _all_ hold for one game
result (AND).
- **Counter** (`ct: 1`) — `ctc` holds the child node to count and `t` is the target count.
Which slot a node uses (`wc` vs `ctc`) tells you what its children are; a node never has
both. `ipc` is `false` on every composite node in the reference data — purpose unknown, but
the client echoes it back, so keep emitting it.
Predicate leaves carry `vs`, a list of accepted values matched as OR:
| `ct` | Shape | Meaning |
| ---- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `6` | `{"ct":6,"vs":[2]}` | _(inferred)_ The kind of event being matched — a finished game/session. Present in **every** leaf group and always `[2]`; nothing observed varying it, so treat it as required boilerplate. |
| `7` | `{"ct":7,"vs":[{"l":"<guid>"}]}` | Scene allow-list: each `l` is a subroom's `UnitySceneId` (see `apps/rooms`). Matches if the game happened in any of them. |
| `9` | `{"ct":9,"vs":[true],"v":"won"}` | A named session variable (`v`) equals one of `vs` — here, the player won. |
The two idioms in the file, unescaped:
```jsonc
// "Complete ^TheRiseOfJumbotron quest" — one winning session in one scene
{ "ct": 0, "ipc": false, "wc": [
{ "ct": 6, "vs": [2] },
{ "ct": 9, "vs": [true], "v": "won" },
{ "ct": 7, "vs": [{ "l": "acc06e66-…" }] } // TheRiseofJumbotron / Home
]}
// "Complete 5 Charades games" — count matching sessions to a target
{ "ct": 1, "ipc": false, "t": 5, "ctc": [
{ "ct": 0, "ipc": false, "wc": [
{ "ct": 6, "vs": [2] },
{ "ct": 7, "vs": [{ "l": "a673712c-…" }, { "l": "4078dfed-…" }] } // 3DCharades + Legacy3DCharades
]}
]}
```
Note the quest challenges have **no `t`** (one qualifying session is the whole goal) and
the counted ones have **no `won` predicate** (finishing counts, winning is irrelevant).
On `updateProgress` the client posts the same tree back with its own progress written into
it: **`cc`** on the counter node is the current count (`…,"t":5,"cc":1`), and **`c`** (`"c":true`)
marks a node it now considers satisfied. Neither appears in this file — they are progress,
not definition, which is why the tree isn't stored (only the top-level `Complete` is). The
count itself lives only in the client.
**Scene ids, not room ids.** Because `ct: 7` matches `UnitySceneId`, a screens room and its
VR twin share ids and both count: the captured "Complete 10 games in Paintball" listed six
scenes, which are the subrooms of _both_ `Paintball` and `PaintballVR` — and each is also a
standalone base room (`River`, `Clearcut`, …). One list covers every way in. Resolve a guid
against the `SubRooms[].UnitySceneId` values in `apps/rooms/migrations/0002_import_rooms.sql`;
a "one map only" challenge is the same shape with a single-entry list. Watch for one trap
this creates: `Soccer / Home` and `Stadium / Home` are the same scene, so a soccer challenge
also completes in the Stadium.
### The `Gift` block
Same item vocabulary as a storefront `GiftDrop` (`AvatarItemDesc` — a comma-separated list
of avatar-item guids, `AvatarItemType`, `ConsumableItemDesc`, `EquipmentPrefabName`,
`EquipmentModificationGuid`) plus `Xp`, `Level` and `StorefrontType`, but two fields are
**renamed**: a storefront's `Context`/`Rarity` are `GiftContext`/`GiftRarity` here. Don't
feed one shape to the other's reader.
`EquipmentModificationGuid` is the Rec Room packed guid — 22-char URL-safe base64 of the 16
guid bytes in .NET little-endian order, padding stripped (`g5u0weNLmkCLeUXFUVn74Q`
`c1b49b83-4be3-409a-8b79-45c55159fbe1`). The reward is identified by prefab + that guid,
_not_ by `GiftDropId`: this block's `GiftDropId` is `3994`, while the same skin sells in
`sf3.json` as `2121` ("Camera Skin (Comic)").
**Granted when the set is finished** — see below. The grant path is `buyItem`'s, so the
block is translated into a storefront gift-drop first (`toChallengeGiftDrop`); the renamed
`GiftContext`/`GiftRarity` are exactly what that translation is for.
The block carries no display strings and a `GiftRarity` of `0` for an item that sells at
rarity `5`, so both are taken from the catalog entry selling the same item (matched on
equipment guid / avatar desc) — the reward reads as "Camera Skin (Comic)", not as the box it
might have arrived in. An explicit `FriendlyName`/`Tooltip` on the block wins over the
catalog if a rotation we publish sets them; neither is present in the captured one.
**`FallbackGiftName` is the other half of the reward, not just a label.** "4-Star Box" is
what the player gets _instead_ when they already own the item — the real game phrased it
"…or a 4-Star Box!" — so it is granted as a query drop (a roll) at the tier its star count
names, via the ladder in the query-drop section. Renaming it to `3-Star Box` retunes the
consolation tier with no code change; a name that doesn't parse falls back to 4 stars.
### Winning the gift (`challenge_gift`)
There is no claim endpoint and the client never asks: the reward is handed out from the
`updateProgress` call that completes the set. Every completing report on the **live**
rotation re-reads the caller's completions and, if every challenge in
`weekly-challenge.json` is there, grants the `Gift` the way a purchase grants a drop — the
item into `inventory`/`equipment`/`consumable`, plus a gift box (message
`Weekly challenge complete!`) the player finds in `GET /api/avatar/v2/gifts`.
**The item, or a roll.** If the player already owns the `Gift`'s item — likely, since the
rotation's reward is one fixed item that sells in the store — they get the
`FallbackGiftName` box instead, rolled at its star tier. Finishing the week can't be worth
nothing. A `Gift` block carrying no ownable item at all (no avatar desc, no equipment guid)
counts as "already owned", so a rotation whose reward is _only_ a box is written by leaving
the block empty and naming the tier.
- **`challenge_gift` makes it happen once.** One row per (account, rotation); the row's
existence _is_ the grant. The client keeps reporting after the set is finished, so the
insert is the gate: `ON CONFLICT … DO NOTHING … RETURNING` claims it in one statement, and
a second report returns no row and grants nothing.
- **Claim first, grant second** — at-most-once. If the grant then fails the reward is lost
rather than doubled; it's logged (`failed to grant weekly challenge gift`) and re-granted
by hand if it ever happens. A faucet that sticks is easier to spot than one that leaks.
- **The response is unchanged; the socket carries the news.** `updateProgress` answers the
same four fields whether or not a gift was won, and a `GiftPackageReceivedImmediate` (31)
frame goes out over the hub with the box — that's what pops the reward panel the moment
the set is finished, instead of the player finding it on the next read of the gifts list.
The payload is the reference server's field-for-field (`Id`, `FromGiftDropId: 0`,
`FromPlayerId`, the item fields, `Platform`/`PlatformsToSpawnOn: -1`, `BalanceType: -2`,
`Message`), and it names the **rolled** item when the fallback box is what was granted.
`Immediate` (31) rather than `GiftPackageReceived` (30) is what the reference sends for a
box the server hands over unasked; the sender is Coach (1). Best-effort — a hub failure is
logged and swallowed, since the gift is already granted and stored.
- **`CompletedRequired` is not consulted.** Its meaning is inferred, and the only reading
under which the gift is due _before_ the set is done would pay out on the first challenge.
- **`Xp`/`Level` on the block are ignored**, as on a purchase — same gap, and both are `0`
in the captured rotation.
- **A report against an old rotation never wins anything**, and an empty `Challenges` array
is not a finished set (without that guard "every challenge complete" is vacuously true).
- **Players who finished the set before this shipped still get it**: the client re-reports
completed challenges, and the first such report is a completing report.
### Progress (`challenge_status`)
`POST /api/challenge/v2/updateProgress` (auth-gated) upserts one row per (account,
challenge) into `challenge_status`, and `getCurrent` reads them back to stamp `Complete`.
The body is `{ ChallengeMapId, ChallengeId, Config, Complete }` with the ids as **strings**
and `Complete` as .NET's `"True"`/`"False"` — capitalized, so `Boolean(body.Complete)` reads
"not complete" as complete (`parseBool` handles both spellings and a real JSON `true`).
Only the completion is stored. `Config` is the catalog's own rule tree plus the client's
running count, so a per-player copy would just be a staler duplicate of static data — it is
echoed back untouched but never persisted. The response is the four posted fields, except
`Complete` is the **stored** value rather than the posted one, because:
- **Completion latches within a rotation.** The client reports repeatedly, and a later
report saying "not complete" (a fresh session, a retry arriving out of order) must not
un-finish something already finished.
- **A new rotation resets the row.** Challenge ids are only unique within a rotation, so
the same id in a later week would otherwise start out already complete. A report whose
`ChallengeMapId` differs from the stored one replaces the row instead of latching; reads
are scoped to the rotation for the same reason.
`getCurrent`'s auth is **optional** — an unauthenticated caller gets the static rotation
with every `Complete` false rather than a 401, since the rotation is public and a failure
on this route can stall the client's load. The overlay rebuilds the response object rather
than stamping the imported JSON in place: that import is module state shared across every
request an isolate serves, so mutating it would leak one player's completions to the next
caller.
## Game rewards (`reward_status`)
The client asks for a reward whenever it thinks one is due, posting a form body of the type
and the message to show for it:
```
rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day
rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Soccer
```
Since the client asks rather than the server offering, whether a reward is actually **owed**
is decided here, from `reward_status` — one row per (account, reward type) holding the last
claim and a count. One claim per type per hour (`REWARD_COOLDOWN_MS`), flat for every type
despite what a name like `FirstActivityOfDay` suggests; per-type windows would be a map
keyed by type.
- **The claim is one SQL statement** (`ON CONFLICT … DO UPDATE … WHERE`). The client fires
these off right after a match, so two can land together; a read-then-write would let both
see the same stale `granted_at` and pay out twice.
- **A rejected claim leaves `granted_at` alone.** If an on-cooldown ask pushed the timestamp
forward, a client that retries in a loop would never become eligible.
- **`giftContext` (the activity, e.g. `Soccer`) is accepted and ignored** — the cooldown is
per type, shared across activities, so it is not part of the key.
**The reward payload itself is a stub:** a successful claim records the cooldown, logs a
`game reward claimed` line, and grants nothing, so a claim and an on-cooldown ask both
answer the same empty list the client already accepts. Paying one out is the
`claimed !== null` branch in the handler. Getting eligibility right first is the point —
it's what stops a repeat ask paying twice once there's something to pay.
`GET /api/gamerewards/v1/pending` stays `[]`: with rewards claimed on request, nothing sits
waiting to be collected.
## Bindings
| Binding | Type | Notes |
| ---------------------------- | -------------- | -------------------------------------------------------- |
| `DB` | D1 | Shared `recflare` database — balances, inventory, etc. |
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
| `ASSETS` | static assets | Serves `sf{N}.json` storefront catalogs |
| `RECFLARE_NOTIFICATIONS_HUB` | Durable Object | Cross-worker RPC to the `notify` worker's hub |
| `STARTING_TOKENS` | var | Optional; new-player token grant (default in balance-db) |
Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no code change.
## Known gaps
- Gifting to another player grants the item and box but does not notify the recipient — the
reference sends `GiftPackageReceivedImmediate` there too (`buy.go`, when the body carries
a `Gift`), and `pushGiftReceived` is now sitting right there to do it.
- `buyItem` grants avatar-item, equipment, consumable and query (box) drops; currency/xp
drops aren't granted.
- A query drop rolls uniformly across the tier and can't run at a rarity sf3 doesn't
publish; per-item weighting and a multi-catalog pool would both need a manifest of the
storefronts, which the ASSETS binding can't enumerate.
- Consumables are granted and listed but never spent by gameplay, so `Count` only grows.
- Several routes (room keys, wishlist, equipment, room consumables/currencies) are
empty-list stubs pending their own stores.
- Game rewards gate correctly but pay nothing out — see the `reward_status` section.
- The weekly-challenge gift is granted but not announced: the box appears in the gifts list
with no `GiftPackageReceived` notification, so the player sees it the next time the client
reads that list rather than the moment they finish the set. Same gap as gifting to another
player, and the same reason — the frame's payload shape hasn't been captured.