7 Commits

Author SHA1 Message Date
Devin Zuczek 0f726b35da [www] maybe add a fun little globe of players 2026-08-25 19:33:11 -04:00
Devin Zuczek 4bacdb0312 [econ] friendly name for weekly 2026-08-25 17:40:44 -04:00
Devin Zuczek da62d7138d [rooms] add support for beta/limitsv2 2026-08-25 17:20:17 -04:00
Devin Zuczek 751e1f28c2 [leaderboard] stub 2 endpoints 2026-08-25 16:43:00 -04:00
Devin Zuczek c5421539c3 [econ] add the POST equipment update endpoint 2026-08-25 16:36:20 -04:00
Devin Zuczek 7275176734 [econ] auto challenges 2026-08-25 16:18:32 -04:00
Devin Zuczek 7c3f2a36cd [econ] fix challenges not persisting 2026-08-25 15:46:02 -04:00
38 changed files with 2991 additions and 1548 deletions
+60 -20
View File
@@ -1,12 +1,21 @@
---
name: weekly-challenge-config
description: Read and author the `Config` rule tree in apps/econ/static/weekly-challenge.json — the full node-type enum, event types, event variables, named scene constants, and the shared-scene traps
description: Read and author the `Config` rule tree a weekly challenge carries — the full node-type enum, event types, event variables, named scene constants, the shared-scene traps, and where the generator in apps/econ/src/challenge-rotation.ts emits them from
---
# The weekly-challenge `Config` rule tree
Reference for reading and writing the `Config` field of a challenge in
`apps/econ/static/weekly-challenge.json` (served by `GET /api/challenge/v2/getCurrent`).
Reference for reading and writing the `Config` field of a weekly challenge (served by
`GET /api/challenge/v2/getCurrent`).
**Rotations are generated, so there are two places a tree comes from.** Normally
`apps/econ/src/challenge-rotation.ts` emits it: a week is five (room, kind) pairs drawn from
`CHALLENGE_ROOMS` with the week's seed, and the tree is built by `configFor` from one of the
three idioms below. Adding variety means adding a room or a kind there, not hand-writing a
tree. The other place is `apps/econ/static/weekly-challenge.json`: a non-empty `Challenges`
array in that file PINS the week to a hand-authored rotation and skips generation, which is
how a one-off debug or event week gets served. Both end up as the same `Config` string on
the wire, and everything below applies to both.
**The server never evaluates these rules.** The client reads the tree, watches its own
gameplay, and posts the tree back to `/api/challenge/v2/updateProgress` with its verdict.
@@ -43,13 +52,16 @@ Author the tree as an object and stringify it into the field — don't hand-esca
bun -e 'const t={ct:0,ipc:false,wc:[{ct:6,vs:[2]}]}; console.log(JSON.stringify(JSON.stringify(t)))'
```
To read one back:
To read this week's back (the generated rotation, or the pinned file if one is in place):
```sh
bun -e 'const c=require("./apps/econ/static/weekly-challenge.json");
for (const x of c.Challenges) console.log(x.ChallengeId, x.Description, "\n ", JSON.parse(x.Config))'
bun -e 'const {buildRotation}=await import("./apps/econ/src/challenge-rotation.ts");
for (const x of buildRotation(new Date()).Challenges)
console.log(x.ChallengeId, x.Description, "\n ", JSON.parse(x.Config))'
```
Pass a date to look at any other week — the rotation is a pure function of which week it is.
## Node types (`ct`) — the full enum
**(lib)** `ChallengeTypes`. Every node carries one. Bold rows are the ones the captured
@@ -293,10 +305,12 @@ On `updateProgress` the client posts the same tree back with its own progress wr
it: **`cc`** on a counter is the current count (`…,"t":5,"cc":1`), and **`c`** (`"c":true`)
marks a node it now considers satisfied.
Neither belongs in `weekly-challenge.json` — they are progress, not definition. The server
echoes the posted `Config` back untouched and never persists it (`challenge_status` stores
only the completion flag; see `apps/econ/src/challenge-db.ts`), so the running count lives
only in the client. Don't author `cc`/`c`, and don't try to read progress out of one.
Neither belongs in an authored tree — they are progress, not definition. The server
stores the posted tree per player (`challenge_status.config`; see
`apps/econ/src/challenge-db.ts`) and `getCurrent` serves it back in place of the authored
tree, which is how a half-finished challenge survives a session — but it still evaluates
none of it: the counting is the client's. Don't author `cc`/`c`, and don't try to read
progress out of the tree you author.
This is also the cheapest way to decode an unfamiliar tree: serve it, play the activity, and
watch which node grows a `cc`.
@@ -322,7 +336,30 @@ all worth checking before trusting a lib-only field:
- **`SpawnableToolTypes` re-rolls per build**, so `ct:5` and any `t_t` comparison is pinned
to one client version.
## Authoring a new challenge
## Adding to the generator
This is the usual way a new challenge ships: the week picks from `CHALLENGE_ROOMS` in
`apps/econ/src/challenge-rotation.ts`, so a room added there starts appearing in rotations
on its own.
1. **A new room** — add an entry with its `UnitySceneId`(s) from
`apps/rooms/static/ImportRooms.json`. A scene no room on this server hosts can never be
completed and nothing will tell you. Check the shared-scene table above and record the
extra rooms in `shares`. Set `kinds` conservatively: `win` reads the `won` variable, so
only rooms where winning is a real outcome; `ai` is quests. **Append, never insert**
`ChallengeId` is the candidate's index, so inserting renumbers every challenge after it.
2. **A new kind** — add it to `ChallengeKind` and give it a branch in all three of
`configFor` (the tree), `copyFor` (the strings) and `nameFor` (the slug). The compiler
will point at the two you forget. Build the tree from an idiom below; the copy is
generated from the same inputs so it can't drift out of step with the tree.
3. Keep the target constants (`GAMES_TARGET`, `AI_TARGET`) as the single source for both the
tree and the copy.
## Authoring a pinned rotation
For a one-off week: put challenges in `apps/econ/static/weekly-challenge.json` and the file
takes over completely — generation is skipped, and its `Gift`, window and `ChallengeMapId`
are served as written.
1. Pick the idiom: one-shot (`ct:0` root, add the `won` predicate if winning is required),
counted (`ct:1` root with `t`), or buffered/streak (`ct:2` root, `rc` on the child).
@@ -338,21 +375,24 @@ all worth checking before trusting a lib-only field:
client renders the strings and evaluates the tree independently, so a mismatch ships a
challenge that advances somewhere the text never mentions.
6. Leave `Complete: false`; `getCurrent` stamps it per caller.
7. Bump `ChallengeMapId` if this is a new rotation — ids only need to be unique within one,
and a new map id is what resets stored completions.
7. Set a `ChallengeMapId` that no recent week has used — a new map id is what resets stored
completions, and generated weeks are `1000 + weekIndex`, so stay well clear of that range.
8. Keep `ServerTime` inside `StartAt``EndAt`, or the client renders the rotation as expired.
A pinned file is static, so its clock has to be frozen there; a generated week doesn't,
because its window is really the current one.
Sanity check the file parses and every tree parses:
Sanity check that every tree in this week's rotation parses, pinned or generated:
```sh
bun -e 'const c=require("./apps/econ/static/weekly-challenge.json");
c.Challenges.forEach(x => JSON.parse(x.Config)); console.log("ok", c.Challenges.length)'
bun -e 'const {buildRotation}=await import("./apps/econ/src/challenge-rotation.ts");
const c=buildRotation(new Date());
c.Challenges.forEach(x => JSON.parse(x.Config)); console.log("ok", c.ChallengeMapId, c.Challenges.length)'
```
Then `bun vitest run apps/econ``src/test/integration/api.test.ts` imports the file and
asserts `getCurrent` against it. Note the gift threshold follows the rotation size
(`CHALLENGES_REQUIRED_FOR_GIFT` clamps to what you publish), so a rotation of three or fewer
asks for all of them.
Then `bun vitest run apps/econ``src/test/integration/api.test.ts` builds the same rotation
and asserts `getCurrent` against it, and walks two years of generated weeks checking every
tree. Note the gift threshold follows the rotation size (`CHALLENGES_REQUIRED_FOR_GIFT`
clamps to what you publish), so a pinned rotation of three or fewer asks for all of them.
## Credits
+13 -3
View File
@@ -13,6 +13,7 @@ import {
getPasswordHash,
getRoomById,
hashPassword,
presenceGeoFromCf,
RoomInstanceType,
setLastLoginTime,
setLoginContext,
@@ -62,7 +63,7 @@ import { consumeRefreshToken, issueRefreshToken } from './refresh-db'
import { verifySteamTicket } from './steam-ticket'
import type { Context } from 'hono'
import type { Account } from '@repo/domain'
import type { Account, PresenceGeo } from '@repo/domain'
import type { App } from './context'
import type { PlatformLink } from './platform-db'
@@ -154,7 +155,8 @@ const ORIENTATION_INSTANCE_ID = -2
async function placeNewPlayerInOrientation(
env: App['Bindings'],
accountId: number,
deviceClass: number
deviceClass: number,
geo: PresenceGeo | null
): Promise<void> {
// getRoomById hydrates the room's SubRooms from the subroom table (they no longer
// live in the room blob), so the Orientation scene resolves the same way match does.
@@ -195,6 +197,9 @@ async function placeNewPlayerInOrientation(
vrMovementMode: 1,
platform: 0,
appVersion: GAME_VERSION,
// The first pin a new player gets — the sign-in that made the account is the only
// request we've seen from them, and match's heartbeat refreshes it from there.
geo: geo ?? undefined,
})
}
@@ -893,7 +898,12 @@ const app = new Hono<App>()
await setPasswordHash(c.env.DB, account.accountId, await hashPassword(password))
}
// Place the new player in Orientation (they don't explicitly matchmake into it).
await placeNewPlayerInOrientation(c.env, account.accountId, deviceClass)
await placeNewPlayerInOrientation(
c.env,
account.accountId,
deviceClass,
presenceGeoFromCf(c.req.raw.cf)
)
} else if (grantType === 'refresh_token') {
const presented = typeof body.refresh_token === 'string' ? body.refresh_token : ''
const refreshed = presented ? await consumeRefreshToken(c.env.DB, presented) : null
+106 -58
View File
@@ -220,50 +220,78 @@ parses it to finish the action, so a bare 200 reads as a failure and the item ne
finishes unlocking. Deletes are scoped to the caller, so an unauthenticated or
mismatched call is a harmless no-op (opening _another_ player's box is a 403).
## Weekly challenge (`static/weekly-challenge.json`)
## Weekly challenge (`src/challenge-rotation.ts`)
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.
and `Config` 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 a rotation is the
entire definition of a week's challenges — ids, display strings, matching rules and the
reward preview.
**Rotations are generated from the calendar week, not authored.** `buildRotation(now)` in
`src/challenge-rotation.ts` derives everything from the week index: five challenges drawn
from a pool of rooms crossed with the challenge kinds each room supports, the week's window,
and a `ChallengeMapId` of `1000 + weekIndex`. It is a pure function of which week it is, and
that is load-bearing rather than tidy — `challenge_status` rows are scoped by
`ChallengeMapId` and the gift threshold counts completions against `Challenges`, so two
callers who disagreed about what the week holds would disagree about who had finished it.
Selection runs off a seeded PRNG (mulberry32 over the week index); nothing calls
`Math.random()`.
The week rolls at **Wednesday 21:00 UTC**, the boundary both captured rotations sit on,
counted from an epoch of 2020-01-01. Each week publishes five challenges, no room twice and
at most two of any one kind, so a week is never five variations of "finish some games".
| Kind | Asks for | Target | Rooms |
| ------- | ---------------------------------------- | ------ | --------------------------------------- |
| `games` | Finished games in one room | 5 | Head-to-head and score-based rooms |
| `win` | One finished game, won (or quest closed) | 1 | Quests, plus rooms with a real opponent |
| `ai` | Enemies defeated in one room | 10 | Quests — the rooms with enemies in them |
`static/weekly-challenge.json` still ships and still **wins**: a non-empty `Challenges` array
there pins the week to that hand-authored rotation and skips generation entirely, which is
how a debug or event week gets served without a code change. Empty (as shipped), it supplies
only what generation doesn't own — `CompletedRequired`, `FallbackGiftName` and
`ChallengeThemeString`, plus the `Gift` block used as a fallback if the catalog can't be read.
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.
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 field notes describe both the
generated rotation and the pinned file, since they are the same shape on the wire.
### Top level
| Field | Example | Notes |
| ---------------------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `ChallengeMapId` | `17` | Id of the rotation as a whole ("map" of challenges). Echoed back on `updateProgress`; bump it when you publish a new week. |
| `CompletedRequired` | `false` | _(inferred)_ All-or-nothing: `true` makes the `Gift` need every challenge, `false` the three-of-five threshold below. |
| `StartAt` / `EndAt` | `2026-03-25T21:00:00` | The window, 7 days apart, **no timezone suffix** — unlike `ServerTime`. Treat as UTC. |
| `ServerTime` | `2026-03-31T14:42:54.2754728Z` | .NET round-trip timestamp (7-digit fraction, `Z`). The client dates the countdown off this, so it is **frozen** — see below. |
| `Challenges` | array | The week's challenges, rendered in order. |
| `Gift` | object | The reward preview for finishing the set. |
| `FallbackGiftName` | `"4-Star Box"` | Shown when the client can't resolve `Gift` into a name. |
| `ChallengeThemeString` | a designer quote | Free text carried through from the captured rotation; a theme note, not a rendered UI string as far as we can tell. |
| Field | Example | Notes |
| ---------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ChallengeMapId` | `1347` | Id of the rotation as a whole ("map" of challenges). Echoed back on `updateProgress`. Generated as `1000 + weekIndex`; the four-digit floor keeps it clear of hand-authored ids (17, 19), which would otherwise read a player's old rows as progress against a different set. |
| `CompletedRequired` | `false` | _(inferred)_ All-or-nothing: `true` makes the `Gift` need every challenge, `false` the three-of-five threshold below. |
| `StartAt` / `EndAt` | `2026-03-25T21:00:00` | The window, 7 days apart, **no timezone suffix** — unlike `ServerTime`. Treat as UTC. |
| `ServerTime` | `2026-03-31T14:42:54.2754728Z` | .NET round-trip timestamp (7-digit fraction, `Z`). The client dates the countdown off this. Generated rotations send the REAL clock — 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` sits _inside_ `StartAt`…`EndAt`, 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 (the captured week: Mar 31 inside Mar 25 → Apr 1). If
you edit the window, move `ServerTime` inside the new one too, or the challenges may render
as already over.
**The clock:** a generated rotation's window is genuinely the current week, so `ServerTime`
is simply now and the countdown the client draws is real — the challenges expire on Wednesday
at 21:00 UTC and the next week's set replaces them.
That is the thing generation fixes. A **pinned** rotation is static, so its `ServerTime` has
to be _frozen_ inside `StartAt`…`EndAt` — a day before the end is the captured shape (Mar 31
inside Mar 25 → Apr 1) — or the client renders the week as already over. Move the window on a
pinned rotation and you must move `ServerTime` into it too.
### 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`. |
| Field | Notes |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ChallengeId` | Unique within the rotation, not sequential (`37, 38, 44, 49, 63`). Posted back on `updateProgress`. Generated ids are the index of the (room, kind) pair in the generator's candidate list, so id 12 is always the same challenge — append rooms, never insert. |
| `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` as generated — `getCurrent` overwrites it per caller from `challenge_status`, along with `Config`. |
`^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
@@ -293,21 +321,29 @@ of avatar-item guids, `AvatarItemType`, `ConsumableItemDesc`, `EquipmentPrefabNa
**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)").
**Weekly rewards are equipment**, so a generated week rolls one from sf3 — every item there
carrying an `EquipmentModificationGuid` (187 of its 1161), drawn with the week's own seed.
Drawing from the live catalog rather than a copied list is what lets the grant path resolve
the pick back to the entry selling it, so the player receives a properly named item; the
block a generated week emits carries that entry's `GiftDropId` and rarity.
`EquipmentModificationGuid` is sometimes 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`) — and sometimes a plain guid; sf3 carries both forms
and they are matched as opaque strings, never converted. The reward is identified by prefab +
that guid, _not_ by `GiftDropId`: the captured 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.
The block carries no display strings (and the captured one carries 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 pinned rotation sets them; neither is present in the captured one
or in a generated block.
**`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
@@ -319,8 +355,8 @@ consolation tier with no code change; a name that doesn't parse falls back to 4
There is no claim endpoint and the client never asks: the reward is handed out from the
`updateProgress` call that reaches the threshold. Every completing report on the **live**
rotation re-reads the caller's completions and, once enough of `weekly-challenge.json`'s
challenges are there, grants the `Gift` the way a purchase grants a drop — the item into
rotation re-reads the caller's completions and, once enough of the week's own challenges are
there, grants the `Gift` the way a purchase grants a drop — the item into
`inventory`/`equipment`/`consumable`, plus a gift box (message
`Weekly challenge complete!`) the player finds in `GET /api/avatar/v2/gifts`.
@@ -370,29 +406,41 @@ the block empty and naming the tier.
### 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
challenge) into `challenge_status`, and `getCurrent` reads them back to stamp each
challenge's `Complete` **and `Config`**. 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:
**The client does the evaluating, and `Config` is its scratchpad.** It walks the rule tree
locally and posts that tree back with its own progress written into the nodes — `cc` on a
counter is the running count, `c` marks a satisfied node — so the posted tree is per-player
state, not a copy of the catalog, and it is stored. `getCurrent` then serves the static
challenge with the stored `Config` and `Complete` overwritten onto it; serving the pristine
authored tree instead (what this used to do) threw away partial progress on every login. The
server still evaluates none of the tree.
The response is the four posted fields, except `Complete` and `Config` are the **stored**
values rather than the posted ones, 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 report carrying no `Config` keeps the stored tree.** `config` itself doesn't latch —
it's a tally, so the newest tree wins — but a report without one is missing data, not a
reset, and must not blank the progress.
- **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.
the same id in a later week would otherwise start out already complete, and half-counted.
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
with every `Complete` false and every `Config` as authored, rather than a 401, since the
rotation is public and a failure on this route can stall the client's load. A challenge the
caller has never reported keeps its authored `Config` too (a stored `NULL`), since a client
handed a null tree has nothing to evaluate. 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
request an isolate serves, so mutating it would leak one player's progress to the next
caller.
## Game rewards (`reward_status`)
@@ -0,0 +1,16 @@
-- Store the `Config` rule tree the client posts with each weekly-challenge progress report.
--
-- Migration 0009 kept only the completion flag, on the grounds that the tree is the
-- challenge's definition (static/weekly-challenge.json) and therefore identical for every
-- player. That is only true of the tree the SERVER publishes: the client posts it back with
-- its own progress written into the nodes — `cc` on a counter is the running count, `c`
-- marks a satisfied node — so the posted copy is per-player state, and dropping it threw
-- away the only record of how far along a player was. The client does the evaluating; this
-- is where the partial progress it reports has to live between sessions.
--
-- Nullable, and NULL is meaningful: no report has been stored for that challenge yet (or a
-- report arrived without a `Config`), so `/api/challenge/v2/getCurrent` serves the static
-- tree for it unchanged. Kept in sync with CHALLENGE_STATUS_SCHEMA_DDL in
-- src/challenge-db.ts.
ALTER TABLE challenge_status ADD COLUMN config TEXT;
@@ -1,22 +0,0 @@
-- Game-reward selections — the three-choice reward the client shows after a
-- challenge or level-up. `/api/gamerewards/v1/request` mints one and pushes it to the
-- player over the notifications hub; `/api/gamerewards/v1/select` consumes it.
--
-- The three offered drop ids are recorded so `select` can verify the player is
-- claiming something they were actually offered, and `consumed` makes the selection
-- single-use. Owned by the `econ` worker; generated from src/rewards-db.ts
-- (SCHEMA_DDL) — keep in sync.
CREATE TABLE IF NOT EXISTS reward_selection (
reward_selection_id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
message TEXT NOT NULL DEFAULT '',
gift_context INTEGER NOT NULL DEFAULT 0,
reward_type INTEGER NOT NULL DEFAULT 0,
gift_drop_1_id INTEGER NOT NULL,
gift_drop_2_id INTEGER NOT NULL,
gift_drop_3_id INTEGER NOT NULL,
consumed INTEGER NOT NULL DEFAULT 0,
created_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_reward_selection_account ON reward_selection (account_id);
-22
View File
@@ -1,22 +0,0 @@
-- Per-player objective progress — the daily/weekly challenge checklist. The client
-- reports progress with `/api/objectives/v1/updateobjective` and reads it back from
-- `/api/objectives/v1/myprogress`.
--
-- An objective is keyed by (account, group, index) — the client's own identifiers —
-- so updates upsert on that triple. `has_claimed_reward` latches on first completion
-- so a reward can't be paid twice. `group`/`index` are SQL keywords, hence the
-- `group_id`/`idx` column names. Owned by the `econ` worker; generated from
-- src/objectives-db.ts (SCHEMA_DDL) — keep in sync.
CREATE TABLE IF NOT EXISTS objective (
account_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
idx INTEGER NOT NULL,
progress REAL NOT NULL DEFAULT 0,
visual_progress REAL NOT NULL DEFAULT 0,
is_completed INTEGER NOT NULL DEFAULT 0,
is_rewarded INTEGER NOT NULL DEFAULT 0,
has_claimed_reward INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (account_id, group_id, idx)
);
CREATE INDEX IF NOT EXISTS idx_objective_account ON objective (account_id);
@@ -1,16 +0,0 @@
-- A player's objective *groups* — the daily/weekly sets their objectives belong to.
-- The client clears a group when it's finished with it (`/api/objectives/v1/cleargroup`),
-- which marks it completed and stamps the clear time; `myprogress` reads the groups
-- back alongside the objectives themselves.
--
-- Keyed by (account, group), the client's own identifier. `group` is a SQL keyword,
-- hence `group_id`. Owned by the `econ` worker; generated from src/objectives-db.ts
-- (SCHEMA_DDL) — keep in sync.
CREATE TABLE IF NOT EXISTS objective_group (
account_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
is_completed INTEGER NOT NULL DEFAULT 0,
cleared_at TEXT,
PRIMARY KEY (account_id, group_id)
);
+63 -33
View File
@@ -1,36 +1,46 @@
/**
* Weekly-challenge progress on the shared `recflare` D1 database — one row per
* (account, challenge), written by `POST /api/challenge/v2/updateProgress` and read back
* by `GET /api/challenge/v2/getCurrent` to stamp each challenge's per-player `Complete`.
* by `GET /api/challenge/v2/getCurrent` to stamp each challenge's per-player state.
*
* Only the completion flag is stored, not the `Config` rule tree the client posts with it.
* That tree is the challenge's DEFINITION (it comes from static/weekly-challenge.json and
* is identical for everyone), decorated with the client's running count in `cc`; the
* server evaluates none of it, so persisting a per-player copy would only be a second,
* staler copy of the catalog. See .agents/weekly-challenge-config/SKILL.md for the grammar.
* The CLIENT owns the evaluating: it walks the challenge's rule tree locally and posts the
* tree back with its own progress written into the nodes — `cc` on a counter is the running
* count, `c` marks a satisfied node (see .agents/skills/weekly-challenge-config/SKILL.md for
* the grammar). So the posted `Config` is not the catalog's copy of the definition, it is
* per-player STATE, and it is stored here alongside the completion flag; the server still
* evaluates none of it. `getCurrent` serves the static challenge with the stored `Config`
* and `Complete` overwritten onto it, which is how partial progress survives a session:
* without it a player who had two of three kills started over on every login.
*
* Completion LATCHES within a rotation: the client reports progress repeatedly, and a
* report that arrives with the challenge no longer complete (a fresh session, a reordered
* retry) must not un-finish something already finished. A report carrying a different
* `ChallengeMapId` is a new rotation and REPLACES the row instead — challenge ids are only
* unique within a rotation, so a challenge that returns in a later week would otherwise
* start out already complete on the old week's row.
* retry) must not un-finish something already finished. `config` does NOT latch — it is the
* running tally, so the newest report wins — but a report that carries none leaves the
* stored tree alone rather than blanking it. A report carrying a different `ChallengeMapId`
* is a new rotation and REPLACES the row instead — challenge ids are only unique within a
* rotation, so a challenge that returns in a later week would otherwise start out already
* complete, and half-counted, on the old week's row.
*
* Finishing enough of a rotation's challenges earns its `Gift`, which is handed out from the
* same `updateProgress` call that reaches the threshold. That payout is gated by a
* second table here, `challenge_gift` — one row per (account, rotation), claimed once.
*
* The `econ` worker owns both tables and their migrations
* (apps/econ/migrations/0009_challenge_status.sql, 0011_challenge_gift.sql).
* (apps/econ/migrations/0009_challenge_status.sql, 0011_challenge_gift.sql,
* 0014_challenge_status_config.sql).
*/
/** Schema DDL (mirror of migrations 0009_challenge_status.sql) — also builds the table in tests. */
/**
* Schema DDL (mirror of migrations 0009_challenge_status.sql + 0014_challenge_status_config.sql)
* — also builds the table in tests.
*/
export const CHALLENGE_STATUS_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS challenge_status (
account_id INTEGER NOT NULL,
challenge_id INTEGER NOT NULL,
challenge_map_id INTEGER NOT NULL,
complete INTEGER NOT NULL,
config TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (account_id, challenge_id)
)`,
@@ -41,70 +51,90 @@ export interface ChallengeProgress {
challengeMapId: number
challengeId: number
complete: boolean
/** The client-evaluated rule tree, or null when the report carried none. */
config: string | null
}
/** What a stored row holds for one challenge, as `getCurrent` overwrites it onto the catalog. */
export interface ChallengeStatus {
complete: boolean
/** The last tree the client posted; null means it never posted one — serve the static tree. */
config: string | null
}
/**
* Record a progress report and return the completion the row now holds — which is what the
* Record a progress report and return the state the row now holds — which is what the
* response must echo, since it isn't always what was posted: within a rotation `complete`
* only ever goes false → true (see the latching note above), so a `false` report against a
* finished challenge answers `true`.
* finished challenge answers `true`, and a report with no `Config` answers the tree already
* stored.
*
* SQLite evaluates every `DO UPDATE SET` expression against the pre-update row, so the
* `CASE` can compare the stored `challenge_map_id` with the incoming one while the same
* `CASE`s can compare the stored `challenge_map_id` with the incoming one while the same
* statement overwrites it.
*/
export async function recordChallengeProgress(
db: D1Database,
accountId: number,
progress: ChallengeProgress
): Promise<boolean> {
): Promise<ChallengeStatus> {
const row = await db
.prepare(
`INSERT INTO challenge_status (account_id, challenge_id, challenge_map_id, complete, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5)
`INSERT INTO challenge_status (account_id, challenge_id, challenge_map_id, complete, config, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT (account_id, challenge_id) DO UPDATE SET
complete = CASE
WHEN challenge_status.challenge_map_id = excluded.challenge_map_id
THEN MAX(challenge_status.complete, excluded.complete)
ELSE excluded.complete
END,
config = CASE
WHEN challenge_status.challenge_map_id = excluded.challenge_map_id
THEN COALESCE(excluded.config, challenge_status.config)
ELSE excluded.config
END,
challenge_map_id = excluded.challenge_map_id,
updated_at = excluded.updated_at
RETURNING complete`
RETURNING complete, config`
)
.bind(
accountId,
progress.challengeId,
progress.challengeMapId,
progress.complete ? 1 : 0,
progress.config,
new Date().toISOString()
)
.first<{ complete: number }>()
return row?.complete === 1
.first<{ complete: number; config: string | null }>()
return { complete: row?.complete === 1, config: row?.config ?? null }
}
/**
* The ids of the challenges a player has finished in one rotation. Scoped to the rotation
* so a stale row from an earlier week — same challenge id, different `challenge_map_id` —
* doesn't show up pre-completed before the client has reported anything against it.
* What a player has stored for one rotation's challenges, keyed by challenge id. Scoped to
* the rotation so a stale row from an earlier week — same challenge id, different
* `challenge_map_id` — doesn't show up pre-completed, or half-counted, before the client has
* reported anything against it.
*
* Also what earning the rotation's `Gift` is decided from: it is due once ENOUGH of the
* challenges in static/weekly-challenge.json appear here — three of the five a week
* publishes, not all of them (see `CHALLENGES_REQUIRED_FOR_GIFT` in econ.app.ts).
* Read by `getCurrent` to overwrite the static rotation, and by the gift path: the `Gift` is
* due once ENOUGH of the week's own challenges are complete here — three of the five a
* rotation publishes, not all of them (see `CHALLENGES_REQUIRED_FOR_GIFT` in econ.app.ts).
* The rotation itself is generated per week by src/challenge-rotation.ts.
*/
export async function getCompletedChallengeIds(
export async function getChallengeStatuses(
db: D1Database,
accountId: number,
challengeMapId: number
): Promise<Set<number>> {
): Promise<Map<number, ChallengeStatus>> {
const { results } = await db
.prepare(
`SELECT challenge_id FROM challenge_status
WHERE account_id = ?1 AND challenge_map_id = ?2 AND complete = 1`
`SELECT challenge_id, complete, config FROM challenge_status
WHERE account_id = ?1 AND challenge_map_id = ?2`
)
.bind(accountId, challengeMapId)
.all<{ challenge_id: number }>()
return new Set(results.map((r) => r.challenge_id))
.all<{ challenge_id: number; complete: number; config: string | null }>()
return new Map(
results.map((r) => [r.challenge_id, { complete: r.complete === 1, config: r.config }])
)
}
/** Schema DDL (mirror of migrations 0011_challenge_gift.sql) — also builds the table in tests. */
+658
View File
@@ -0,0 +1,658 @@
/**
* The weekly challenge rotation, generated from the calendar week rather than authored.
*
* A week's rotation is a pure function of which week it is: the same five challenges, the
* same window and the same gift for every player, recomputed identically by every isolate
* and every request. That is not a nicety — `challenge_status` rows are scoped by
* `ChallengeMapId` and the gift threshold counts completions against `Challenges`, so two
* callers who disagreed about what this week holds would disagree about who has finished it.
* Everything here therefore hangs off {@link rotationIndex} and a seeded PRNG; nothing calls
* `Math.random()` or reads the clock except to work out which week it is.
*
* The client evaluates the rule trees and the server never does (see
* .agents/skills/weekly-challenge-config/SKILL.md), so a generated tree is a specification
* handed to a client that fails SILENTLY when it's malformed. Everything emitted here is
* therefore built from the three idioms that are pinned by captured live data or by a
* rotation this server has already served — no lib-only node types, no invented fields.
*
* `static/weekly-challenge.json` still ships, and still wins: a non-empty `Challenges` array
* there PINS the week to that hand-authored rotation and skips generation entirely, which is
* how a debug or event rotation gets served without a code change. When it is empty the file
* supplies only the parts generation doesn't own — the fallback gift name, the theme string,
* and `CompletedRequired`.
*/
import weeklyChallenge from '../static/weekly-challenge.json'
/** Rec Room's weekly reset: Wednesday 21:00 UTC, the boundary both captured rotations sit on. */
const ROTATION_EPOCH_MS = Date.UTC(2020, 0, 1, 21, 0, 0)
const WEEK_MS = 7 * 24 * 60 * 60 * 1000
/**
* Where generated `ChallengeMapId`s start. Hand-authored rotations have used small ids (the
* captured 17, this repo's 19), and a generated id that collided with one would let a
* player's stored rows from that rotation read as progress against a completely different
* set of challenges. A four-digit floor keeps the two id spaces from ever meeting.
*/
const CHALLENGE_MAP_ID_BASE = 1000
/** How many challenges a week publishes. Five is the captured rotation's size, and what the three-of-five gift threshold is written against. */
const CHALLENGES_PER_ROTATION = 5
/**
* How many challenges of one kind a week may hold. Five slots over three kinds with a cap of
* two guarantees every kind appears, so no week is five variations of "finish some games".
*/
const MAX_PER_KIND = 2
/** `ct:6` event ids — `ChallengeEventTypes`. Only the two the captured/served trees use. */
const EVENT_GAME_END = 2
const EVENT_ELIMINATED_AI = 5
/** The targets each kind counts to. Fixed rather than rolled: a week should vary in WHAT it asks, not in how much. */
const GAMES_TARGET = 5
const AI_TARGET = 10
/** What a challenge asks for. Each maps to one proven `Config` idiom and one line of copy. */
type ChallengeKind = 'games' | 'win' | 'ai'
/**
* A room the generator may name, keyed by the scene(s) its games run in.
*
* `scenes` is what `ct:7` matches, and one scene can belong to several rooms (Soccer and
* Stadium are one scene; Dodgeball, Gym and DodgeballVR are another) — `shares` records the
* rooms a challenge naming this one also completes in, which is a property of the game data,
* not something the tree can narrow. Entries are one per scene, so picking by room key also
* keeps a week from naming one scene twice.
*
* `link` is the `^Token` the client resolves into a tappable room name; `null` where the room
* name would make a doubtful token (Charades spans two scenes and starts with a digit) and
* the copy falls back to plain text, which the captured rotation also does.
*/
interface ChallengeRoom {
/** Stable key — also the slug fragment in a generated `Name`. */
key: string
/** Plain display name, used when `link` is null. */
name: string
/** The `^Token` room link, or null to write the name plainly. */
link: string | null
/** `UnitySceneId`s this room's games run in, straight from apps/rooms/static/ImportRooms.json. */
scenes: string[]
/** The kinds this room can be asked for — quests have enemies to defeat, hangouts have no games at all. */
kinds: ChallengeKind[]
/** True for the quest rooms, whose "win" is completing the quest rather than beating other players. */
quest?: boolean
/** Other rooms on the same scene, which a challenge naming this one also completes in. */
shares?: string
}
/**
* The rooms in play. Every scene id here resolves in `apps/rooms/static/ImportRooms.json` —
* a challenge naming a scene this server hosts no room for can never be completed by anyone,
* and nothing server-side would report that.
*
* `kinds` is deliberately conservative. `win` reads the `won` session variable, which is
* pinned by the captured rotation for quests and is meaningful in a head-to-head game, so
* rooms where "winning" is vague (bowling, disc golf, charades, Stunt Runner) only ever ask
* for completed games. `ai` is quests only: they are the rooms with enemies in them.
*/
const CHALLENGE_ROOMS: ChallengeRoom[] = [
// Quests — win the quest, or thin out its enemies.
{
key: 'GoldenTrophy',
name: 'Quest for the Golden Trophy',
link: '^GoldenTrophy',
scenes: ['91e16e35-f48f-4700-ab8a-a1b79e50e51b'],
kinds: ['win', 'ai'],
quest: true,
},
{
key: 'Jumbotron',
name: 'The Rise of Jumbotron',
link: '^TheRiseofJumbotron',
scenes: ['acc06e66-c2d0-4361-b0cd-46246a4c455c'],
kinds: ['win', 'ai'],
quest: true,
},
{
key: 'CrimsonCauldron',
name: 'Curse of the Crimson Cauldron',
link: '^CrimsonCauldron',
scenes: ['949fa41f-4347-45c0-b7ac-489129174045'],
kinds: ['win', 'ai'],
quest: true,
},
{
key: 'IsleOfLostSkulls',
name: 'The Isle of Lost Skulls',
link: '^IsleOfLostSkulls',
scenes: ['7e01cfe0-820a-406f-b1b3-0a5bf575235c'],
kinds: ['win', 'ai'],
quest: true,
},
{
key: 'Crescendo',
name: 'Crescendo of the Blood Moon',
link: '^Crescendo',
scenes: ['49cb8993-a956-43e2-86f4-1318f279b22a'],
kinds: ['win', 'ai'],
quest: true,
},
// Head-to-head rooms — finish games, or win one.
{
key: 'Clearcut',
name: 'Paintball: Clear Cut',
link: '^Paintball.Clearcut',
scenes: ['380d18b5-de9c-49f3-80f7-f4a95c1de161'],
kinds: ['games', 'win'],
shares: 'PaintballVR/Clearcut, Clearcut/Home',
},
{
key: 'River',
name: 'Paintball: River',
link: '^Paintball.River',
scenes: ['e122fe98-e7db-49e8-a1b1-105424b6e1f0'],
kinds: ['games', 'win'],
shares: 'PaintballVR/River, River/Home',
},
{
key: 'Homestead',
name: 'Paintball: Homestead',
link: '^Paintball.Homestead',
scenes: ['a785267d-c579-42ea-be43-fec1992d1ca7'],
kinds: ['games', 'win'],
shares: 'PaintballVR/Homestead, Homestead/Home',
},
{
key: 'Quarry',
name: 'Paintball: Quarry',
link: '^Paintball.Quarry',
scenes: ['ff4c6427-7079-4f59-b22a-69b089420827'],
kinds: ['games', 'win'],
shares: 'PaintballVR/Quarry, Quarry/Home',
},
{
key: 'Spillway',
name: 'Paintball: Spillway',
link: '^Paintball.Spillway',
scenes: ['58763055-2dfb-4814-80b8-16fac5c85709'],
kinds: ['games', 'win'],
shares: 'PaintballVR/Spillway, Spillway/Home',
},
{
key: 'Dodgeball',
name: 'Dodgeball',
link: '^Dodgeball',
scenes: ['3d474b26-26f7-45e9-9a36-9b02847d5e6f'],
kinds: ['games', 'win'],
shares: 'Gym/Home, DodgeballVR/Home',
},
{
key: 'Soccer',
name: 'Soccer',
link: '^Soccer',
scenes: ['6d5eea4b-f069-4ed0-9916-0e2f07df0d03'],
kinds: ['games', 'win'],
shares: 'Stadium/Home',
},
{
key: 'Hangar',
name: 'Laser Tag: Hangar',
link: '^LaserTag.Hangar',
scenes: ['239e676c-f12f-489f-bf3a-d4c383d692c3'],
kinds: ['games', 'win'],
shares: 'Hangar/Home',
},
{
key: 'CyberJunkCity',
name: 'Laser Tag: CyberJunk City',
link: '^LaserTag.CyberJunkCity',
scenes: ['9d6456ce-6264-48b4-808d-2d96b3d91038'],
kinds: ['games', 'win'],
shares: 'LaserTagCyberJunk/Home, CyberJunkCity/Home',
},
{
key: 'Paddleball',
name: 'Paddleball',
link: '^Paddleball',
scenes: ['d89f74fa-d51e-477a-a425-025a891dd499'],
kinds: ['games', 'win'],
},
{
key: 'FrontierSolos',
name: 'Rec Royale: Solos',
link: '^RecRoyaleSolos',
scenes: ['b010171f-4875-4e89-baba-61e878cd41e1'],
kinds: ['games', 'win'],
},
{
key: 'FrontierSquads',
name: 'Rec Royale: Squads',
link: '^RecRoyaleSquads',
scenes: ['253fa009-6e65-4c90-91a1-7137a56a267f'],
kinds: ['games', 'win'],
},
// Rooms where finishing is the whole ask — "winning" one of these isn't a thing the
// `won` variable is known to report.
{
key: 'Bowling',
name: 'Bowling',
link: '^Bowling',
scenes: ['ae929543-9a07-41d5-8ee9-dbbee8c36800'],
kinds: ['games'],
shares: 'BowlingAlley/Home',
},
{
key: 'DiscGolfLake',
name: 'Disc Golf: Lake',
link: '^DiscGolfLake',
scenes: ['f6f7256c-e438-4299-b99e-d20bef8cf7e0'],
kinds: ['games'],
shares: 'Lake/Home',
},
{
key: 'DiscGolfPropulsion',
name: 'Disc Golf: Propulsion',
link: '^DiscGolfPropulsion',
scenes: ['d9378c9f-80bc-46fb-ad1e-1bed8a674f55'],
kinds: ['games'],
shares: 'PropulsionTestRange/Home',
},
{
key: 'Charades',
name: 'Charades',
link: null,
// Both charades scenes, as the captured rotation's own charades challenge does.
scenes: ['a673712c-877f-4749-b69a-4a4c6310d545', '4078dfed-24bb-4db7-863f-578ba48d726b'],
kinds: ['games'],
shares: '3DCharades/InkSpaceHome, Legacy3DCharades/Home',
},
{
key: 'StuntRunner',
name: 'Stunt Runner',
link: '^StuntRunner',
scenes: ['b7281665-a715-4051-826b-8e08e69c6172'],
kinds: ['games'],
},
]
/** One challenge as the rotation serves it — `Complete` is stamped per caller by `getCurrent`. */
export interface RotationChallenge {
ChallengeId: number
Name: string
Config: string
Description: string
Tooltip: string
Complete: boolean
}
/**
* The rotation's reward block. Same item vocabulary as a storefront gift drop, but with
* `Context`/`Rarity` spelled `GiftContext`/`GiftRarity` — the two shapes are not
* interchangeable, see `toChallengeGiftDrop` in econ.app.ts.
*/
export interface ChallengeGiftBlock {
GiftDropId: number
AvatarItemDesc: string
AvatarItemType: number
ConsumableItemDesc: string
EquipmentPrefabName: string
EquipmentModificationGuid: string
StorefrontType: number
Xp: number
Level: number
GiftContext: number
GiftRarity: number
/**
* Display strings, OPTIONAL because neither the captured rotation nor a generated block
* carries them — the reward's name is resolved from the catalog entry selling the same
* item, falling back to `FallbackGiftName`. A pinned rotation can set them to name its
* reward outright.
*/
FriendlyName?: string
Tooltip?: string
}
/** A week's whole rotation — the body `GET /api/challenge/v2/getCurrent` serves. */
export interface WeeklyChallengeRotation {
ChallengeMapId: number
CompletedRequired: boolean
StartAt: string
EndAt: string
ServerTime: string
Challenges: RotationChallenge[]
Gift: ChallengeGiftBlock
FallbackGiftName: string
/**
* What the week is themed on — the FriendlyName of the item its `Gift` hands over, set
* by {@link withWeeklyGift} once the catalog has named the roll. The static file's value
* is only a placeholder: a generated week's reward isn't known until it is rolled. A
* PINNED rotation keeps whatever string it ships.
*/
ChallengeThemeString: string
}
/** An equipment item the weekly gift can be drawn from — one sf3 entry, trimmed to what a gift needs. */
export interface EquipmentGift {
GiftDropId: number
EquipmentPrefabName: string
EquipmentModificationGuid: string
Rarity: number
/** The catalog's display name for the item — what the week is themed on. */
FriendlyName: string
}
/** Whether the shipped file pins the week, in which case nothing here is generated. */
function pinnedRotation(): WeeklyChallengeRotation | null {
return weeklyChallenge.Challenges.length > 0 ? (weeklyChallenge as WeeklyChallengeRotation) : null
}
/**
* Which week it is: whole weeks since the epoch, so the value ticks over at Wednesday 21:00
* UTC and every caller in the same week gets the same number.
*/
export function rotationIndex(now: Date): number {
return Math.floor((now.getTime() - ROTATION_EPOCH_MS) / WEEK_MS)
}
/**
* This week's `ChallengeMapId` — the identity of the rotation, and what makes a stored
* completion belong to one week rather than another. Cheap on purpose: `updateProgress` asks
* only this to decide whether a report is against the live week.
*/
export function rotationMapId(now: Date): number {
return pinnedRotation()?.ChallengeMapId ?? CHALLENGE_MAP_ID_BASE + rotationIndex(now)
}
/** The week's window, as the client's `StartAt`/`EndAt` want it: UTC, but written without a zone. */
function rotationWindow(index: number): { StartAt: string; EndAt: string } {
const start = ROTATION_EPOCH_MS + index * WEEK_MS
return {
StartAt: toLocalIsoString(new Date(start)),
EndAt: toLocalIsoString(new Date(start + WEEK_MS)),
}
}
/** `2026-08-19T21:00:00` — ISO 8601 with the milliseconds and the `Z` cut off, which is the shape the client's window fields take. */
function toLocalIsoString(at: Date): string {
return at.toISOString().slice(0, 19)
}
/**
* `2026-08-25T14:42:54.2754728Z` — .NET's round-trip format, seven fractional digits. The
* client dates its countdown off this, and because a generated window is genuinely the
* current one, this is the real clock rather than the frozen timestamp a static file needs.
*/
function toDotNetString(at: Date): string {
return `${at.toISOString().slice(0, -1)}0000Z`
}
/** mulberry32 — a small deterministic PRNG. Same seed, same week, same rotation, everywhere. */
function mulberry32(seed: number): () => number {
let a = seed >>> 0
return () => {
a = (a + 0x6d2b79f5) >>> 0
let t = a
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
/**
* Spread a week index into a seed. Adjacent weeks are adjacent integers, and feeding those
* straight in makes consecutive rotations correlate; a multiply by a large odd constant
* (Knuth's) scatters them.
*/
function seedFor(mapId: number, salt: number): number {
return Math.imul(mapId ^ salt, 2654435761) >>> 0
}
/** In-place Fisher-Yates against a seeded stream — the only place ordering comes from. */
function shuffle<T>(items: T[], random: () => number): T[] {
const out = [...items]
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1))
const a = out[i] as T
const b = out[j] as T
out[i] = b
out[j] = a
}
return out
}
/** A `ct:7` scene allow-list node. */
function sceneNode(scenes: string[]) {
return { ct: 7, vs: scenes.map((l) => ({ l })) }
}
/**
* The rule tree for one (kind, room), as an object — stringified into `Config` by the caller.
* Each branch is one of the three idioms in the skill doc, with the field order the captured
* trees use:
*
* - `games` — a counter over finished games in the room (`ct:1` + `GameEnd` + scene).
* - `win` — one finished game in the room that the player won (`ct:0` + `GameEnd` + `won` + scene).
* - `ai` — a counter over enemies defeated in the room (`ct:1` + `EliminatedAI` + scene).
*/
function configFor(kind: ChallengeKind, room: ChallengeRoom): unknown {
const scene = sceneNode(room.scenes)
switch (kind) {
case 'games':
return {
ct: 1,
ipc: false,
ctc: [{ ct: 0, ipc: false, wc: [{ ct: 6, vs: [EVENT_GAME_END] }, scene] }],
t: GAMES_TARGET,
}
case 'win':
return {
ct: 0,
ipc: false,
wc: [{ ct: 6, vs: [EVENT_GAME_END] }, { ct: 9, vs: [true], v: 'won' }, scene],
}
case 'ai':
return {
ct: 1,
ipc: false,
ctc: [{ ct: 0, ipc: false, wc: [{ ct: 6, vs: [EVENT_ELIMINATED_AI] }, scene] }],
t: AI_TARGET,
}
}
}
/**
* The copy for one (kind, room). Generated from the same two inputs as the tree, which is
* the point: the client renders these strings and evaluates the tree independently, so
* hand-written copy is free to drift into describing a challenge that doesn't exist.
*/
function copyFor(
kind: ChallengeKind,
room: ChallengeRoom
): { Description: string; Tooltip: string } {
const where = room.link ?? room.name
switch (kind) {
case 'games':
return {
Description: `Complete ${GAMES_TARGET} games in ${where}`,
Tooltip: `Play ${GAMES_TARGET} games of ${room.name} through to the end. Winning is optional.`,
}
case 'win':
return room.quest === true
? {
Description: `Complete the ${where} quest`,
Tooltip: `See ${room.name} through to a win.`,
}
: {
Description: `Win a game in ${where}`,
Tooltip: `Come out on top of a game of ${room.name}.`,
}
case 'ai':
return {
Description: `Defeat ${AI_TARGET} enemies in ${where}`,
Tooltip: `Take out ${AI_TARGET} enemies in ${room.name}. They don't have to be in one run.`,
}
}
}
/** The internal slug — never displayed, but it's what a log line or a D1 row is read against. */
function nameFor(kind: ChallengeKind, room: ChallengeRoom): string {
switch (kind) {
case 'games':
return `Complete${GAMES_TARGET}Games${room.key}`
case 'win':
return `Win${room.key}`
case 'ai':
return `Defeat${AI_TARGET}AI${room.key}`
}
}
/** One thing the generator may publish: a room crossed with a kind that room supports. */
interface Candidate {
challengeId: number
kind: ChallengeKind
room: ChallengeRoom
}
/**
* Every (room, kind) pair, in a fixed order — the index in this list IS the challenge id.
*
* Deriving the id from the pair rather than from the position in a week keeps ids meaningful
* across weeks: id 12 is always "win in Dodgeball", so a `challenge_status` row that outlives
* its rotation is at worst stale, never a different challenge wearing the same id. Ids are
* only required to be unique within a rotation, which distinct pairs trivially are.
*
* Appending to `CHALLENGE_ROOMS` is safe; INSERTING into the middle renumbers everything
* after it, so add rooms at the end.
*/
const CANDIDATES: Candidate[] = CHALLENGE_ROOMS.flatMap((room) =>
room.kinds.map((kind) => ({ challengeId: 0, kind, room }))
).map((candidate, index) => ({ ...candidate, challengeId: index + 1 }))
/**
* Pick the week's challenges: shuffle every candidate, then take the first five that keep
* one room out of two slots and one kind out of three. The relaxation pass exists so the
* constraints can never under-deliver a rotation — a short week would quietly lower the gift
* threshold, since it clamps to what's published.
*/
function pickChallenges(random: () => number): RotationChallenge[] {
const shuffled = shuffle(CANDIDATES, random)
const picked: Candidate[] = []
const rooms = new Set<string>()
const kinds = new Map<ChallengeKind, number>()
for (const pass of [0, 1]) {
for (const candidate of shuffled) {
if (picked.length === CHALLENGES_PER_ROTATION) break
if (rooms.has(candidate.room.key)) continue
if (pass === 0 && (kinds.get(candidate.kind) ?? 0) >= MAX_PER_KIND) continue
picked.push(candidate)
rooms.add(candidate.room.key)
kinds.set(candidate.kind, (kinds.get(candidate.kind) ?? 0) + 1)
}
}
return picked.map(({ challengeId, kind, room }) => ({
ChallengeId: challengeId,
Name: nameFor(kind, room),
Config: JSON.stringify(configFor(kind, room)),
...copyFor(kind, room),
Complete: false,
}))
}
/**
* The week's gift: one equipment item, drawn from the pool with the week's own seed.
*
* Weekly rewards are equipment — the captured rotation's is a camera skin — so the pool is
* every sf3 item carrying an `EquipmentModificationGuid`. Drawing from the live catalog
* rather than a copied list is what lets `toChallengeGiftDrop` resolve the pick back to the
* entry selling it and hand the player a properly named item.
*
* Null when the pool is empty (the catalog didn't load), and the caller keeps the static
* file's block so the reward preview is still something rather than nothing.
*/
function pickWeeklyGift(
mapId: number,
pool: EquipmentGift[]
): { gift: ChallengeGiftBlock; friendlyName: string } | null {
if (pool.length === 0) return null
const random = mulberry32(seedFor(mapId, 0x9e3779b9))
const gift = pool[Math.floor(random() * pool.length)] as EquipmentGift
// The name comes back alongside rather than on the block: the block is the wire shape,
// whose display strings are optional and left unset here so `toChallengeGiftDrop` keeps
// resolving them from the catalog entry that sells the item.
return {
friendlyName: gift.FriendlyName,
gift: {
GiftDropId: gift.GiftDropId,
AvatarItemDesc: '',
AvatarItemType: 0,
ConsumableItemDesc: '',
EquipmentPrefabName: gift.EquipmentPrefabName,
EquipmentModificationGuid: gift.EquipmentModificationGuid,
StorefrontType: 0,
Xp: 0,
Level: 0,
GiftContext: 0,
GiftRarity: gift.Rarity,
},
}
}
/**
* Memoised generation. The rotation is identical for every caller in a week, so it is built
* once per isolate per week rather than per request; `ServerTime` is the one field that
* moves, and it is written fresh on the way out.
*
* The cached object is never handed out directly for that reason, and callers that
* personalise it (`getCurrent` stamping per-player state) rebuild rather than mutate.
*/
let cached: WeeklyChallengeRotation | null = null
/**
* This week's rotation, with the static file's `Gift` as a placeholder — callers that can
* read the catalog replace it with {@link pickWeeklyGift}. Pure apart from `now`: same week
* in, same rotation out.
*/
export function buildRotation(now: Date): WeeklyChallengeRotation {
const pinned = pinnedRotation()
if (pinned !== null) return pinned
const index = rotationIndex(now)
const mapId = CHALLENGE_MAP_ID_BASE + index
if (cached === null || cached.ChallengeMapId !== mapId) {
cached = {
ChallengeMapId: mapId,
CompletedRequired: weeklyChallenge.CompletedRequired,
...rotationWindow(index),
ServerTime: '',
Challenges: pickChallenges(mulberry32(seedFor(mapId, 0))),
Gift: weeklyChallenge.Gift as ChallengeGiftBlock,
FallbackGiftName: weeklyChallenge.FallbackGiftName,
ChallengeThemeString: weeklyChallenge.ChallengeThemeString,
}
}
return { ...cached, ServerTime: toDotNetString(now) }
}
/**
* Put the week's own reward on a rotation. Separate from {@link buildRotation} because the
* pool comes from the storefront catalog, which is an async read the cheap paths
* (`updateProgress` deciding whether a report is against the live week) have no reason to pay.
*
* A PINNED rotation is returned untouched: it ships its own `Gift`, and overwriting that with
* a rolled one would make the pin a half-pin.
*/
export function withWeeklyGift(
rotation: WeeklyChallengeRotation,
pool: EquipmentGift[]
): WeeklyChallengeRotation {
if (pinnedRotation() !== null) return rotation
const picked = pickWeeklyGift(rotation.ChallengeMapId, pool)
if (picked === null) return rotation
// The week is themed on its reward: `ChallengeThemeString` is the item's catalog name,
// which is the same string `toChallengeGiftDrop` resolves for the grant, so the heading
// and the thing handed over read as one. The static file's value is a placeholder — it
// can't name an item that is rolled per week.
return { ...rotation, Gift: picked.gift, ChallengeThemeString: picked.friendlyName }
}
+211 -420
View File
@@ -33,7 +33,6 @@ import defaultAvatarItems from '../static/default-avatar-items.json'
import defaultAvatar from '../static/default-avatar.json'
import defaultBaseAvatarItems from '../static/default-base-avatar-items.json'
import myProgress from '../static/my-progress.json'
import weeklyChallenge from '../static/weekly-challenge.json'
import { getAvatar, setAvatar } from './avatar-db'
import {
ALL_PLATFORMS,
@@ -45,11 +44,8 @@ import {
isSpendable,
spendCurrency,
} from './balance-db'
import {
claimChallengeGift,
getCompletedChallengeIds,
recordChallengeProgress,
} from './challenge-db'
import { claimChallengeGift, getChallengeStatuses, recordChallengeProgress } from './challenge-db'
import { buildRotation, rotationMapId, withWeeklyGift } from './challenge-rotation'
import {
consumeConsumable,
countConsumable,
@@ -58,12 +54,6 @@ import {
} from './consumables-db'
import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db'
import { getInventory, grantItem, toAvatarItemV4 } from './inventory-db'
import {
clearObjectiveGroup,
getObjectiveGroups,
getObjectives,
updateObjective,
} from './objectives-db'
import {
AUTHED,
AvatarItemV4Dto,
@@ -94,7 +84,6 @@ import {
jsonBody,
JsonObject,
MakerAiFreeTrialEligibilityResponse,
ObjectiveGroupDto,
OpaqueJsonBody,
OPTIONAL_AUTHED,
ReferralProgressResponse,
@@ -102,35 +91,29 @@ import {
RRPlusSignUpBonus,
SaveOutfitRequest,
SaveOutfitV4Response,
SelectGameRewardRequest,
SubscriptionResponse,
UNAUTHORIZED_RESPONSE,
UpdateObjectiveRequest,
UpdateObjectiveResponse,
} from './openapi'
import { claimReward } from './reward-db'
import {
consumeRewardSelection,
createRewardSelection,
getRewardSelection,
rollRewardDrops,
tokenRewardDrop,
} from './rewards-db'
import type { Context } from 'hono'
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
import type {
BalanceResponsePayload,
GiftPackagePayload,
PurchaseBalanceModificationPayload,
RewardSelectionPayload,
} from '../../notify/src/notification-payloads'
import type { Avatar } from './avatar-db'
import type {
ChallengeGiftBlock,
EquipmentGift,
WeeklyChallengeRotation,
} from './challenge-rotation'
import type { ConsumeResult } from './consumables-db'
import type { App } from './context'
import type { Equipment } from './equipment-db'
import type { AvatarItem } from './inventory-db'
import type { GameRewardDrop } from './rewards-db'
/**
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on
@@ -166,32 +149,6 @@ function unauthorized(c: Context<App>) {
return c.body(null, 401)
}
/**
* Push a notification to a player over the websocket hub. Rewards are *delivered*
* this way — the HTTP response carries none of it — but a hub that's down shouldn't
* fail the request that already committed, so a delivery failure is logged, not thrown.
*/
async function pushToPlayer(
c: Context<App>,
playerId: number,
notificationType: NotificationType,
data: Record<string, unknown>
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId,
notificationType,
data
)
} catch (err) {
logger.error('failed to push notification', {
playerId,
notificationType,
error: err instanceof Error ? err.message : String(err),
})
}
}
/**
* 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
@@ -728,6 +685,43 @@ async function loadRollCatalog(c: Context<App>): Promise<StoreItem[]> {
return storefront?.StoreItems ?? []
}
/**
* The equipment a weekly challenge gift can be drawn from: every roll-catalog item carrying
* an `EquipmentModificationGuid`. Weekly rewards are equipment — the captured rotation's is a
* camera skin — and in sf3 that guid is exactly what marks an item as equipment (187 of its
* 1161, all with a prefab, none with an avatar item or consumable attached).
*
* `GiftDropId` comes off `PurchasableItemId`, which every sf3 equipment entry agrees with.
*/
function toEquipmentGiftPool(catalog: StoreItem[]): EquipmentGift[] {
return catalog
.filter((item) => item.GiftDrop.EquipmentModificationGuid !== '')
.map((item) => ({
GiftDropId: item.PurchasableItemId,
EquipmentPrefabName: item.GiftDrop.EquipmentPrefabName,
EquipmentModificationGuid: item.GiftDrop.EquipmentModificationGuid,
Rarity: item.GiftDrop.Rarity,
// Carried so the rotation can theme the week on the item it rolled; the grant path
// resolves the same name from this entry when it hands the item over.
FriendlyName: item.GiftDrop.FriendlyName,
}))
}
/**
* The same pool, memoised for the life of the isolate. `getCurrent` needs it on every call
* just to show the week's reward, and sf3 is a megabyte and a half of JSON to fetch and parse
* — but it is a bundled asset, so it cannot change under a running isolate and a deploy
* builds new ones. A failed read is deliberately NOT cached: it would pin an empty pool (and
* so the static fallback gift) until the next deploy.
*/
let cachedGiftPool: EquipmentGift[] | null = null
async function loadEquipmentGiftPool(c: Context<App>): Promise<EquipmentGift[]> {
if (cachedGiftPool !== null) return cachedGiftPool
const pool = toEquipmentGiftPool(await loadRollCatalog(c))
if (pool.length > 0) cachedGiftPool = pool
return pool
}
/**
* Whether the player already owns what a drop carries — the question a query drop's "an
* item that you don't have" turns on, and the one that decides whether the weekly gift
@@ -1234,25 +1228,24 @@ const GIFT_CONTEXT_GAME_REWARDS = 50
const DEFAULT_GAME_REWARD_MESSAGE = 'Reward earned!'
/**
* The gift box a CHOSEN reward selection hands over. The selection's drop is Rec Room's
* `GiftDrop` wire shape (what the client was offered); this is the subset `grantGiftDrop`
* needs to wrap it. Every drop is a token drop for now, so there is nothing to grant into
* the inventory — the tokens are credited by the caller and the box is what the player
* opens. `Xp` is the reward's, so the box carries the same amount banked in `progression`.
* The gift-drop a claimed game reward hands over: XP in a box, no item. Every item field is
* empty on purpose — this is not a purchase and not a roll, so `grantGiftDrop` grants
* nothing into the inventory and only creates the box. The XP is banked in `progression`;
* the copy here is what the box and its notification display.
*/
function toSelectedRewardDrop(drop: GameRewardDrop): StoreGiftDrop {
function toGameRewardDrop(): StoreGiftDrop {
return {
FriendlyName: drop.FriendlyName,
Tooltip: drop.Tooltip,
ConsumableItemDesc: drop.ConsumableItemDesc,
AvatarItemDesc: drop.AvatarItemDesc,
AvatarItemType: drop.AvatarItemType,
EquipmentPrefabName: drop.EquipmentPrefabName,
EquipmentModificationGuid: drop.EquipmentModificationGuid,
Rarity: drop.Rarity,
Context: drop.Context,
Currency: drop.Currency,
CurrencyType: drop.CurrencyType,
FriendlyName: '',
Tooltip: '',
ConsumableItemDesc: '',
AvatarItemDesc: '',
AvatarItemType: null,
EquipmentPrefabName: '',
EquipmentModificationGuid: '',
Rarity: 0,
Context: GIFT_CONTEXT_GAME_REWARDS,
Currency: 0,
CurrencyType: 0,
Xp: GAME_REWARD_XP,
}
}
@@ -1338,30 +1331,6 @@ async function grantLevelUpGifts(
}
}
/**
* The rotation's reward, as static/weekly-challenge.json writes it. Same item vocabulary as
* a storefront `GiftDrop` but with `Context`/`Rarity` spelled `GiftContext`/`GiftRarity`,
* so it has to be translated before the grant path can read it (see
* {@link toChallengeGiftDrop}).
*
* `FriendlyName`/`Tooltip` are OPTIONAL because the captured rotation has neither — the
* client resolves the reward's name from the item itself, falling back to
* `FallbackGiftName`. A rotation we publish can carry them to name the granted item
* properly without a code change.
*/
interface ChallengeGift {
AvatarItemDesc: string
AvatarItemType: number
ConsumableItemDesc: string
EquipmentPrefabName: string
EquipmentModificationGuid: string
GiftContext: number
GiftRarity: number
Xp: number
FriendlyName?: string
Tooltip?: string
}
/** The message on the gift box the weekly reward arrives in. */
const CHALLENGE_GIFT_MESSAGE = 'Weekly challenge complete!'
@@ -1383,8 +1352,8 @@ const DEFAULT_FALLBACK_STARS = 4
* it is what the client renders when the gift resolves to a box rather than a named item —
* so a rotation can retune the tier by renaming it, with no code change.
*/
function fallbackGiftRarity(): number {
const stars = Number(/^(\d+)-star/i.exec(weeklyChallenge.FallbackGiftName)?.[1])
function fallbackGiftRarity(rotation: WeeklyChallengeRotation): number {
const stars = Number(/^(\d+)-star/i.exec(rotation.FallbackGiftName)?.[1])
return STAR_RARITY[stars - 1] ?? STAR_RARITY[DEFAULT_FALLBACK_STARS - 1] ?? 0
}
@@ -1399,8 +1368,11 @@ function fallbackGiftRarity(): number {
* selling the same item, so the granted item reads as itself — "Camera Skin (Comic)" rather
* than the name of the box it might have arrived in.
*/
function toChallengeGiftDrop(catalog: StoreItem[]): StoreGiftDrop {
const gift = weeklyChallenge.Gift as ChallengeGift
function toChallengeGiftDrop(
rotation: WeeklyChallengeRotation,
catalog: StoreItem[]
): StoreGiftDrop {
const gift: ChallengeGiftBlock = rotation.Gift
const sold = catalog.find(
({ GiftDrop: drop }) =>
(gift.EquipmentModificationGuid !== '' &&
@@ -1408,7 +1380,7 @@ function toChallengeGiftDrop(catalog: StoreItem[]): StoreGiftDrop {
(gift.AvatarItemDesc !== '' && drop.AvatarItemDesc === gift.AvatarItemDesc)
)?.GiftDrop
return {
FriendlyName: gift.FriendlyName ?? sold?.FriendlyName ?? weeklyChallenge.FallbackGiftName,
FriendlyName: gift.FriendlyName ?? sold?.FriendlyName ?? rotation.FallbackGiftName,
Tooltip: gift.Tooltip ?? sold?.Tooltip ?? '',
ConsumableItemDesc: gift.ConsumableItemDesc,
AvatarItemDesc: gift.AvatarItemDesc,
@@ -1429,17 +1401,17 @@ function toChallengeGiftDrop(catalog: StoreItem[]): StoreGiftDrop {
* it. Handed over instead of the rotation's item when that item would be a duplicate, which
* is what the fallback name is for — the reward reads "the Camera Skin, or a 4-Star Box".
*/
function toChallengeFallbackDrop(): StoreGiftDrop {
function toChallengeFallbackDrop(rotation: WeeklyChallengeRotation): StoreGiftDrop {
return {
FriendlyName: weeklyChallenge.FallbackGiftName,
FriendlyName: rotation.FallbackGiftName,
Tooltip: '',
ConsumableItemDesc: '',
AvatarItemDesc: '',
AvatarItemType: null,
EquipmentPrefabName: '',
EquipmentModificationGuid: '',
Rarity: fallbackGiftRarity(),
Context: (weeklyChallenge.Gift as ChallengeGift).GiftContext,
Rarity: fallbackGiftRarity(rotation),
Context: rotation.Gift.GiftContext,
Currency: 0,
CurrencyType: 0,
IsQuery: true,
@@ -1459,11 +1431,9 @@ const CHALLENGES_REQUIRED_FOR_GIFT = 3
* all-or-nothing when it's true — the reading its name and the partial default suggest —
* and a rotation shorter than the threshold can only ever ask for what it publishes.
*/
function challengesRequiredForGift(): number {
const published = weeklyChallenge.Challenges.length
return weeklyChallenge.CompletedRequired
? published
: Math.min(CHALLENGES_REQUIRED_FOR_GIFT, published)
function challengesRequiredForGift(rotation: WeeklyChallengeRotation): number {
const published = rotation.Challenges.length
return rotation.CompletedRequired ? published : Math.min(CHALLENGES_REQUIRED_FOR_GIFT, published)
}
/**
@@ -1489,25 +1459,27 @@ function challengesRequiredForGift(): number {
* would otherwise meet without playing.
*/
async function awardChallengeGift(c: Context<App>, accountId: number): Promise<void> {
const rotation = buildRotation(new Date())
try {
if (weeklyChallenge.Challenges.length === 0) return
const complete = await getCompletedChallengeIds(
c.env.DB,
accountId,
weeklyChallenge.ChallengeMapId
)
const done = weeklyChallenge.Challenges.filter((ch) => complete.has(ch.ChallengeId)).length
if (done < challengesRequiredForGift()) return
if (rotation.Challenges.length === 0) return
const statuses = await getChallengeStatuses(c.env.DB, accountId, rotation.ChallengeMapId)
const done = rotation.Challenges.filter(
(ch) => statuses.get(ch.ChallengeId)?.complete === true
).length
if (done < challengesRequiredForGift(rotation)) return
// Claim first: this is what stops the next report paying out a second time.
const claimed = await claimChallengeGift(c.env.DB, accountId, weeklyChallenge.ChallengeMapId)
const claimed = await claimChallengeGift(c.env.DB, accountId, rotation.ChallengeMapId)
if (!claimed) return
// Only now is the catalog worth reading: it names the week's reward and is what the
// grant path rolls a duplicate's replacement from.
const catalog = await loadRollCatalog(c)
const reward = toChallengeGiftDrop(catalog)
const week = withWeeklyGift(rotation, toEquipmentGiftPool(catalog))
const reward = toChallengeGiftDrop(week, catalog)
const duplicate = await ownsGiftDrop(c.env.DB, accountId, reward)
const granted = await grantGiftDrop(
c,
accountId,
duplicate ? toChallengeFallbackDrop() : reward,
duplicate ? toChallengeFallbackDrop(week) : reward,
CHALLENGE_GIFT_MESSAGE,
{ rollCatalog: catalog }
)
@@ -1518,7 +1490,7 @@ async function awardChallengeGift(c: Context<App>, accountId: number): Promise<v
await pushGiftReceived(c, accountId, granted, CHALLENGE_GIFT_MESSAGE, COACH_ACCOUNT_ID)
logger.info('weekly challenge gift granted', {
accountId,
challengeMapId: weeklyChallenge.ChallengeMapId,
challengeMapId: rotation.ChallengeMapId,
giftId: granted.id,
fallbackRoll: duplicate,
challengesComplete: done,
@@ -1526,7 +1498,7 @@ async function awardChallengeGift(c: Context<App>, accountId: number): Promise<v
} catch (err) {
logger.error('failed to grant weekly challenge gift', {
accountId,
challengeMapId: weeklyChallenge.ChallengeMapId,
challengeMapId: rotation.ChallengeMapId,
error: err instanceof Error ? err.message : String(err),
})
}
@@ -1651,121 +1623,63 @@ const app = new Hono<App>({ strict: false })
}
)
// The player's objectives progress. Their own recorded objectives once they've made
// any (the client reports them through `updateobjective`); the bundled default set
// otherwise, including for a signed-out caller — the client needs a well-formed
// checklist to render either way.
// The player's objectives progress. Serves a static JSON file verbatim with
// no auth — same default for everyone until there's a DB binding to track
// per-player progress.
.get(
'/api/objectives/v1/myprogress',
describeRoute({
tags: ['Econ'],
summary: 'Objectives progress',
description: [
'The players own recorded objectives, or the bundled default set when they have',
'reported none yet. A signed-out caller gets the default rather than a 401 — the',
'client needs a well-formed checklist either way.',
].join(' '),
security: OPTIONAL_AUTHED,
responses: { 200: json(JsonObject, 'The players objectives, or the bundled default') },
description:
'Serves the bundled static progress verbatim (no per-player store yet). No auth.',
responses: { 200: json(JsonObject, 'The bundled objectives-progress default') },
}),
async (c) => {
const id = await authedId(c)
if (id === null) return c.json(myProgress)
const [objectives, groups] = await Promise.all([
getObjectives(c.env.DB, id),
getObjectiveGroups(c.env.DB, id),
])
if (objectives.length === 0 && groups.length === 0) return c.json(myProgress)
return c.json({
Objectives: objectives,
// Fall back to the default groups until the player has cleared one of their own.
ObjectiveGroups: groups.length === 0 ? myProgress.ObjectiveGroups : groups,
})
}
(c) => c.json(myProgress)
)
// The client clearing an objective group — it's done with that set (its dailies
// rolled over, say). Auth-gated. Marks the group completed, stamps the clear time,
// and returns the group as the client reads it. Accepts GET or POST since the client
// may use either, so `Group` is taken from the JSON body or the query string.
// Clears a group of objectives. No per-player progress to clear yet, so this
// is a no-op that returns an empty array (a 404 here breaks the client). Accepts
// GET or POST since the client may use either.
.on(
['GET', 'POST'],
'/api/objectives/v1/cleargroup',
describeRoute({
tags: ['Econ'],
summary: 'Clear an objectives group',
description:
'Marks the group completed and stamps `ClearedAt`. Accepts GET or POST; `Group` comes from the JSON body or the query string.',
security: AUTHED,
responses: {
200: json(ObjectiveGroupDto, 'The cleared group'),
401: UNAUTHORIZED_RESPONSE,
},
summary: 'Clear an objectives group (no-op)',
description: 'No per-player progress to clear yet → []. Accepts GET or POST.',
responses: { 200: json(JsonArray, 'Always empty for now') },
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => ({}))) as Record<string, unknown>
const group = Number(body.Group ?? c.req.query('Group')) || 0
return c.json(await clearObjectiveGroup(c.env.DB, id, group))
}
(c) => c.json([])
)
// The client reporting progress on an objective as it plays. Auth-gated; the body is
// the whole objective as the client now sees it (Index/Group identify it within the
// player's set), and it reads back the state of the GROUP that objective belongs to —
// camelCase here, unlike the PascalCase body it posted.
//
// The completion flag the client posts is `HasClaimedReward` — the same spelling
// `myprogress` serves. `IsRewarded` is accepted as well because the DTO carries that
// name internally, but the client never sends it.
// Report one objective's progress. The client posts the whole objective as it now
// sees it (Index/Group identify it within `myprogress`) and reads back the state of
// the GROUP that objective belongs to — camelCase here, unlike the PascalCase body it
// posted. Stubbed: with no objectives store yet we persist nothing, echo the group
// back and never complete it, so the reward-claim flow isn't triggered. `clearedAt`
// is the clear time, which for a group we didn't clear is just now.
.post(
'/api/objectives/v1/updateobjective',
describeRoute({
tags: ['Econ'],
summary: 'Report objective progress',
description: [
'Upserts the objective on (account, group, index) and answers the state of its group.',
'`has_claimed_reward` latches on first completion so a reward cant be paid twice.',
'Stubbed: with no objectives store we persist nothing and never complete a group.',
'Echoes `Group` back as camelCase `group` with `isCompleted: false` so the client',
'gets a well-formed body.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(UpdateObjectiveRequest, 'The objective as the client now sees it'),
responses: {
200: json(UpdateObjectiveResponse, 'The state of the group the objective belongs to'),
400: { description: 'Body was not JSON' },
401: UNAUTHORIZED_RESPONSE,
},
responses: { 200: json(UpdateObjectiveResponse, 'The echoed group, never completed') },
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.body(null, 400)
const num = (v: unknown): number => (typeof v === 'number' ? v : 0)
const bool = (v: unknown): boolean => v === true
const group = num(body.Group)
await updateObjective(c.env.DB, id, {
Group: group,
Index: num(body.Index),
Progress: num(body.Progress),
VisualProgress: num(body.VisualProgress),
IsCompleted: bool(body.IsCompleted),
IsRewarded: bool(body.HasClaimedReward ?? body.IsRewarded),
})
// Read the group back rather than echoing the request: the client re-renders the
// checklist from this, so a group the player already cleared must come back cleared.
const stored = (await getObjectiveGroups(c.env.DB, id)).find((g) => g.Group === group)
const body = await c.req
.json<{ Group?: string | number }>()
.catch(() => ({}) as Record<string, never>)
return c.json({
group,
isCompleted: stored?.IsCompleted ?? false,
clearedAt: stored?.ClearedAt ?? new Date().toISOString(),
group: Number(body.Group) || 0,
isCompleted: false,
clearedAt: new Date().toISOString(),
})
}
)
@@ -2159,11 +2073,17 @@ const app = new Hono<App>({ strict: false })
}
)
// Favourite/un-favourite owned equipment. [Authorize]. The client PUTs the entries
// it wants changed (one request can carry several) and reads nothing back. Only
// Favourite/un-favourite owned equipment. [Authorize]. The client sends the entries it
// wants changed (one request can carry several) and reads nothing back. Only
// `Favorited` is written — the rest of each entry is the client echoing what it was
// served, and a guid the caller doesn't own matches no row and is dropped.
.put(
//
// PUT or POST: the client uses both spellings for this one call, with an identical body
// either way, so they are the same route rather than two handlers. A 404 on the POST
// leaves the star drawn on the item the client already redrew, and the favourite
// silently doesn't stick.
.on(
['PUT', 'POST'],
'/api/equipment/v1/update',
describeRoute({
tags: ['Equipment'],
@@ -2171,7 +2091,8 @@ const app = new Hono<App>({ strict: false })
description: [
'Applies the posted `Favorited` flags to the callers owned equipment, matched by',
'`ModificationGuid`. Everything else in each entry is ignored, and a guid the caller',
'doesnt own is silently skipped. Empty body on success.',
'doesnt own is silently skipped. Empty body on success. Accepts PUT or POST — the',
'client uses both, with the same body.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(EquipmentUpdateRequest, 'The entries to update'),
@@ -2968,60 +2889,76 @@ const app = new Hono<App>({ strict: false })
(c) => c.json(adCarouselItems)
)
// Current weekly challenge. The rotation itself is the bundled static JSON (its format
// is documented in the README) but each challenge's `Complete` is per-player, so the
// caller's rows from `challenge_status` are stamped over the static `false`s.
// Auth is OPTIONAL: without a valid bearer the static catalog is served unchanged
// rather than 401, since the rotation is public information and a 404/401 on this
// route can stall the client's load orchestration.
// Current weekly challenge. The rotation is GENERATED from the calendar week (see
// challenge-rotation.ts — the same five challenges, window and gift for everyone, derived
// from the week index; static/weekly-challenge.json pins it instead when it carries
// challenges), but each challenge's state is per-player, so the caller's rows from
// `challenge_status` are stamped over the week's: `Complete` over the published `false`,
// and `Config` over the published rule tree — the client evaluates that tree locally and
// reports it back with its running counts written into it (`cc`/`c`), so serving the
// pristine tree back is what makes partial progress reset every session.
// Auth is OPTIONAL: without a valid bearer the week is served unstamped rather than 401,
// since the rotation is public information and a 404/401 on this route can stall the
// client's load orchestration.
.get(
'/api/challenge/v2/getCurrent',
describeRoute({
tags: ['Econ'],
summary: 'Current weekly challenge',
description: [
'The bundled static rotation, with each challenges `Complete` stamped from the',
'callers progress rows. Auth is optional — unauthenticated callers get the static',
'catalog with every `Complete` false.',
'This weeks rotation — generated from the calendar week — with each challenges',
'`Complete` and `Config` stamped from the callers progress rows, the stored `Config`',
'carrying the clients running counts. Auth is optional: unauthenticated callers get',
'the week unstamped, every `Complete` false and every `Config` as published.',
].join(' '),
security: OPTIONAL_AUTHED,
responses: { 200: json(JsonObject, 'The current weekly challenge') },
}),
async (c) => {
const rotation = withWeeklyGift(buildRotation(new Date()), await loadEquipmentGiftPool(c))
const id = await authedId(c)
if (id === null) return c.json(weeklyChallenge)
const complete = await getCompletedChallengeIds(c.env.DB, id, weeklyChallenge.ChallengeMapId)
if (complete.size === 0) return c.json(weeklyChallenge)
// Rebuild rather than mutate: the static import is module state shared by every
// request this isolate serves, so stamping it in place would leak one player's
// completions to the next caller.
if (id === null) return c.json(rotation)
const statuses = await getChallengeStatuses(c.env.DB, id, rotation.ChallengeMapId)
if (statuses.size === 0) return c.json(rotation)
// Rebuild rather than mutate: the generated rotation is cached module state shared
// by every request this isolate serves, so stamping it in place would leak one
// player's progress to the next caller.
return c.json({
...weeklyChallenge,
Challenges: weeklyChallenge.Challenges.map((challenge) => ({
...challenge,
Complete: complete.has(challenge.ChallengeId),
})),
...rotation,
Challenges: rotation.Challenges.map((challenge) => {
const status = statuses.get(challenge.ChallengeId)
if (status === undefined) return challenge
// A row with no stored tree (never reported one) keeps the authored `Config`;
// overwriting it with null would hand the client a challenge it can't evaluate.
return {
...challenge,
Complete: status.complete,
Config: status.config ?? challenge.Config,
}
}),
})
}
)
// Report progress on a weekly challenge. [Authorize]. The client evaluates the
// challenge's rule tree locally and posts ChallengeMapId/ChallengeId, that tree in
// `Config`, and whether it now considers the challenge `Complete`. Only the
// completion is persisted (keyed by account + challenge); `Config` is the catalog's
// own definition plus the client's running count, so storing it would duplicate
// static data. Echoes the identifying fields back with the completion the row now
// holds — which is not always what was posted, since completion latches within a
// rotation.
// `Config`, and whether it now considers the challenge `Complete`. Both are persisted
// (keyed by account + challenge): the posted tree is the catalog's definition with the
// client's running counts written into it, so it is this player's progress, and
// `getCurrent` serves it back in place of the authored tree. Echoes the identifying
// fields back with the state the row now holds — which is not always what was posted,
// since completion latches within a rotation and a report with no `Config` keeps the
// stored tree.
.post(
'/api/challenge/v2/updateProgress',
describeRoute({
tags: ['Econ'],
summary: 'Report weekly-challenge progress',
description: [
'Persists the reported completion into `challenge_status`, keyed by account +',
'challenge. `Config` is accepted and echoed but not stored. Completion latches within',
'a rotation, so the echoed `Complete` is the stored value, not the posted one.',
'Persists the reported completion and rule tree into `challenge_status`, keyed by',
'account + challenge, so `getCurrent` can serve the players own progress back.',
'Completion latches within a rotation and a report carrying no `Config` keeps the',
'stored tree, so the echoed fields are the stored values, not the posted ones.',
].join(' '),
security: AUTHED,
requestBody: jsonBody(ChallengeProgressRequest, 'Challenge ids + the evaluated rule tree'),
@@ -3043,14 +2980,16 @@ const app = new Hono<App>({ strict: false })
.catch(() => ({}) as Record<string, never>)
const challengeMapId = Number(body.ChallengeMapId) || 0
const challengeId = Number(body.ChallengeId) || 0
const config = typeof body.Config === 'string' ? body.Config : null
// Nothing to key a row on — echo the body back rather than writing a (0, 0) row.
const complete =
const stored =
challengeId === 0
? parseBool(body.Complete)
? { complete: parseBool(body.Complete), config }
: await recordChallengeProgress(c.env.DB, id, {
challengeMapId,
challengeId,
complete: parseBool(body.Complete),
config,
})
// This report may have been the last one of the set. Only a completing report on
// the LIVE rotation can be — an old rotation's set can no longer be finished, and
@@ -3058,14 +2997,14 @@ const app = new Hono<App>({ strict: false })
// The response is unchanged whether or not a gift was won: the client learns about
// the box from `GET /api/avatar/v2/gifts`, and adding a field here would be
// inventing response shape the client never sent us.
if (complete && challengeId !== 0 && challengeMapId === weeklyChallenge.ChallengeMapId) {
if (stored.complete && challengeId !== 0 && challengeMapId === rotationMapId(new Date())) {
await awardChallengeGift(c, id)
}
return c.json({
ChallengeMapId: challengeMapId,
ChallengeId: challengeId,
Config: typeof body.Config === 'string' ? body.Config : '',
Complete: complete,
Config: stored.config ?? '',
Complete: stored.complete,
})
}
)
@@ -3081,18 +3020,15 @@ const app = new Hono<App>({ strict: false })
// 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.
//
// What a claim hands over is a CHOICE, not a payout. The reference offers three drops and
// lets the player pick one, so an owed reward mints a `reward_selection` and pushes the
// three options as `RewardSelectionReceived`; nothing is paid until the player picks with
// `v1/select`. The HTTP response therefore carries none of it — it is the `{ error,
// success, value }` envelope the reference answers this flow with. An on-cooldown ask
// mints nothing, pushes nothing, and pays nothing.
// A claim pays GAME_REWARD_XP into `progression` and hands over a gift box carrying that
// XP, announced with the same GiftPackageReceivedImmediate frame the weekly gift uses
// the client posted the message to show, so the box wears it. An on-cooldown ask changes
// nothing and pays nothing.
//
// The cooldown key is the type and the activity AS THE CLIENT SPELLS THEM (strings), which
// is what `reward_status` stores. The frame's `RewardType`/`GiftContext` are numeric in the
// client's decoder and there is no captured mapping from those names to their ids, so they
// carry the numeric form when the client sends one and fall back to `GameRewards` (50)
// otherwise — the same context the gift boxes on this worker already use.
// The response stays `[]` either way. It is what the client already accepts, and the box
// is how a reward is delivered, so there is no captured shape to put the payout in the
// reference answers its own (different, selection-based) flow with a success envelope,
// not a list of rewards.
//
// `giftContext` (the activity, e.g. `Soccer`) is part of the cooldown key: the first
// activity of the day is per ACTIVITY, so a player who moves from Soccer to Paintball is
@@ -3105,16 +3041,15 @@ const app = new Hono<App>({ strict: false })
summary: 'Request a game reward',
description: [
'Claims one reward of `rewardType` in `giftContext` per hour per player, recorded in',
'`reward_status`. An owed claim mints a three-drop `reward_selection` and pushes it as',
'`RewardSelectionReceived`; the player picks one with `/api/gamerewards/v1/select`.',
'The cooldown is per (type, activity), so a different activity is owed another reward',
'while the same one is not; an ask with no `giftContext` keys on the empty context.',
'The choices ride on the hub, so a claim and an on-cooldown ask answer the same envelope.',
'`reward_status`. The cooldown is per (type, activity), so a different activity is',
'owed another reward while the same one is not; an ask with no `giftContext` keys on',
'the empty context. The reward rides in a gift box, so a claim and a rejected',
'(on-cooldown) ask both answer `[]`.',
].join(' '),
security: AUTHED,
requestBody: form(GameRewardRequest, 'The reward type and its display message'),
responses: {
200: json(ConsumeEnvelope, 'Success envelope — the choices are pushed, not returned'),
200: json(JsonArray, 'The rewards granted — always [] while the payload is stubbed'),
401: UNAUTHORIZED_RESPONSE,
},
}),
@@ -3124,181 +3059,37 @@ const app = new Hono<App>({ strict: false })
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const rewardType = typeof body.rewardType === 'string' ? body.rewardType : ''
// No type, nothing to gate: don't write a row keyed on an empty string.
if (rewardType === '') return c.json({ error: '', success: true, value: null })
if (rewardType === '') return c.json([])
const giftContext = typeof body.giftContext === 'string' ? body.giftContext : ''
const claimed = await claimReward(c.env.DB, id, rewardType, giftContext)
// On cooldown: nothing was claimed, so no selection is minted and nothing is announced.
if (claimed === null) return c.json({ error: '', success: true, value: null })
// 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 numeric forms the hub frame carries (see the note above).
const contextId = Number.parseInt(giftContext, 10) || GIFT_CONTEXT_GAME_REWARDS
const rewardTypeId = Number.parseInt(rewardType, 10) || 0
const drops = rollRewardDrops(contextId)
const selection = await createRewardSelection(c.env.DB, id, {
message,
giftContext: contextId,
rewardType: rewardTypeId,
dropIds: drops.map((d) => d.GiftDropId),
})
// `satisfies` rather than an annotation: the hub takes a Record<string, unknown> and an
// interface has no implicit index signature, but every key is still checked against the
// shape the client's decoder parses.
const payload = {
RewardSelectionId: selection.RewardSelectionId,
RewardType: rewardTypeId,
Message: message,
GiftContext: contextId,
GiftDrop1: drops[0],
GiftDrop2: drops[1],
GiftDrop3: drops[2],
// The reference sends the third drop twice — once plain, once under the
// subscriber key. With no subscriber-only drop pool the two are the same drop.
Subscriber_GiftDrop3: drops[2],
CreatedAt: selection.CreatedAt,
} satisfies RewardSelectionPayload
await pushToPlayer(c, id, NotificationType.RewardSelectionReceived, payload)
logger.info('game reward selection offered', {
// Bank the XP first: it is the reward, and the box is the wrapper the client shows.
// A failure here must not leave a box promising XP that was never credited.
const { progression, levelsGained } = await addXp(c.env.DB, id, GAME_REWARD_XP)
const granted = await grantGiftDrop(c, id, toGameRewardDrop(), message)
await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID)
// Every grant moves the bar, whether or not it crossed a level.
await pushProgressionUpdate(c, id, progression)
// …and every level crossed is worth a box of its own tier.
await grantLevelUpGifts(c, id, { progression, levelsGained })
logger.info('game reward claimed', {
accountId: id,
rewardType,
giftContext,
grantCount: claimed,
message,
rewardSelectionId: selection.RewardSelectionId,
dropIds: selection.GiftDropIds,
})
return c.json({ error: '', success: true, value: null })
}
)
// Claim one of the three rewards a selection offered. [Authorize]. The selection must be
// the caller's, unconsumed, and must actually contain the claimed drop — otherwise 403, so
// a player can't mint a reward they were never offered or redeem one twice.
//
// This is where a game reward is finally PAID: the chosen drop's tokens are credited, the
// reward's XP goes into `progression`, and the drop is wrapped in a gift box so the client
// has something to open. The box is announced with GiftPackageRewardSelectionReceived (32)
// — the gift-package frame for a box that came from a selection, as opposed to the
// Immediate (31) one a weekly gift or a direct grant uses.
//
// Every drop is a token drop for now, and a token drop's id is the NEGATIVE of its amount,
// which is how the claim rebuilds it without a catalog lookup.
.post(
'/api/gamerewards/v1/select',
describeRoute({
tags: ['Econ'],
summary: 'Claim one of an offered reward selection',
description: [
'Consumes the `reward_selection` minted by `/api/gamerewards/v1/request` and pays the',
'chosen drop: its tokens are credited, `GAME_REWARD_XP` is banked, and a gift box is',
'created and announced as `GiftPackageRewardSelectionReceived`. 403 when the selection',
'isnt the callers, is already consumed, or never offered the claimed drop — the',
'consume is conditional, so two racing claims mean the second one loses.',
].join(' '),
security: AUTHED,
requestBody: form(SelectGameRewardRequest, 'The selection and the drop being claimed'),
responses: {
200: json(JsonObject, 'The claimed gift-drop'),
400: { description: '`giftDropId` was missing' },
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the callers selection, already consumed, or not offered' },
},
}),
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 int = (name: string): number => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
const v = key === undefined ? undefined : body[key]
return typeof v === 'string' ? Number.parseInt(v, 10) || 0 : 0
}
const rewardSelectionId = int('rewardSelectionId')
const giftDropId = int('giftDropId')
if (giftDropId === 0) return c.json({ error: 'giftDropId is required' }, 400)
const selection =
rewardSelectionId <= 0 ? null : await getRewardSelection(c.env.DB, rewardSelectionId)
if (
selection === null ||
selection.AccountId !== id ||
selection.Consumed ||
!selection.GiftDropIds.includes(giftDropId)
) {
return c.body(null, 403)
}
// Consume conditionally: two racing claims mean the second one loses.
if (!(await consumeRewardSelection(c.env.DB, selection.RewardSelectionId))) {
return c.body(null, 403)
}
const drop = tokenRewardDrop(-giftDropId, selection.GiftContext)
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
// Credit BEFORE the box: the box is only the "you got something" panel, and opening one
// grants nothing (see /api/avatar/v2/gifts/consume). A box promising tokens that were
// never credited would read as a reward that silently paid nothing.
await ensureStartingBalances(c.env.DB, id, startingTokens)
const balance = await creditCurrency(
c.env.DB,
id,
CurrencyType.RecCenterTokens,
drop.Currency,
startingTokens
)
// The frame carries the RESULTING total, never the payout — see the balance-bucket
// note in CLAUDE.md.
await pushBalanceUpdate(c, id, CurrencyType.RecCenterTokens, balance)
// The reward's XP is the same GAME_REWARD_XP the flow was always worth; the tokens are
// what the player CHOSE on top of it.
const { progression, levelsGained } = await addXp(c.env.DB, id, GAME_REWARD_XP)
const granted = await grantGiftDrop(c, id, toSelectedRewardDrop(drop), selection.Message)
const payload = {
Id: granted.id,
FromPlayerId: COACH_ACCOUNT_ID,
ConsumableItemDesc: drop.ConsumableItemDesc,
AvatarItemType: drop.AvatarItemType,
AvatarItemDesc: drop.AvatarItemDesc,
EquipmentPrefabName: drop.EquipmentPrefabName,
EquipmentModificationGuid: drop.EquipmentModificationGuid,
CurrencyType: drop.CurrencyType,
Currency: drop.Currency,
Xp: GAME_REWARD_XP,
GiftContext: selection.GiftContext,
GiftRarity: drop.Rarity,
Message: selection.Message,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: ALL_PLATFORMS,
} satisfies GiftPackagePayload
await pushToPlayer(c, id, NotificationType.GiftPackageRewardSelectionReceived, payload)
// Every grant moves the bar, whether or not it crossed a level.
await pushProgressionUpdate(c, id, progression)
// …and every level crossed is worth a box of its own tier.
await grantLevelUpGifts(c, id, { progression, levelsGained })
logger.info('game reward selected', {
accountId: id,
rewardSelectionId: selection.RewardSelectionId,
giftDropId,
tokens: drop.Currency,
balance,
xp: GAME_REWARD_XP,
level: progression.Level,
levelsGained,
levelXp: progression.XP,
giftId: granted.id,
})
return c.json(drop)
return c.json([])
}
)
+3 -3
View File
@@ -36,7 +36,7 @@ export const EQUIPMENT_SCHEMA_DDL: string[] = [
* inconsistency to tidy up: a drop is a flat record holding avatar, consumable and
* equipment fields side by side, so it needs the prefix to disambiguate, while this
* record is all equipment. Confirmed against the live endpoint, and the entries the
* client PUTs back to `/api/equipment/v1/update` use the same unprefixed names.
* client sends back to `/api/equipment/v1/update` use the same unprefixed names.
*/
export interface Equipment extends Record<string, unknown> {
ModificationGuid: string
@@ -46,7 +46,7 @@ export interface Equipment extends Record<string, unknown> {
Rarity: number
/** Always -1 (all platforms) — we don't gate equipment per platform. */
PlatformMask: number
/** Player-set favourite flag, toggled by `PUT /api/equipment/v1/update`. */
/** Player-set favourite flag, toggled by `PUT`/`POST /api/equipment/v1/update`. */
Favorited: boolean
}
@@ -73,7 +73,7 @@ export async function grantEquipment(
.run()
}
/** One entry of the `PUT /api/equipment/v1/update` body. */
/** One entry of the `PUT`/`POST /api/equipment/v1/update` body. */
export interface EquipmentFavoriteUpdate {
ModificationGuid: string
Favorited: boolean
-187
View File
@@ -1,187 +0,0 @@
/**
* Per-player objective progress — the daily/weekly challenge checklist the client
* shows. The client reports progress as it plays (`/api/objectives/v1/updateobjective`)
* and reads it back on load (`/api/objectives/v1/myprogress`).
*
* An objective is identified by its (group, index) within a player's set, so updates
* upsert on that triple rather than allocating ids. `has_claimed_reward` is latched
* the first time an objective completes — the reference awards progression XP at that
* moment, and the flag is what stops it being awarded twice.
*/
/** Schema DDL (mirror of migrations/0015_objective.sql and 0016_objective_group.sql). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS objective (
account_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
idx INTEGER NOT NULL,
progress REAL NOT NULL DEFAULT 0,
visual_progress REAL NOT NULL DEFAULT 0,
is_completed INTEGER NOT NULL DEFAULT 0,
is_rewarded INTEGER NOT NULL DEFAULT 0,
has_claimed_reward INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (account_id, group_id, idx)
)`,
`CREATE INDEX IF NOT EXISTS idx_objective_account ON objective (account_id)`,
// A player's objective *groups* — the daily/weekly sets. The client clears a group
// once it's done with it (`cleargroup`), which stamps `cleared_at`.
`CREATE TABLE IF NOT EXISTS objective_group (
account_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
is_completed INTEGER NOT NULL DEFAULT 0,
cleared_at TEXT,
PRIMARY KEY (account_id, group_id)
)`,
]
/** One objective's progress, as the client reads it back from `myprogress`. */
export interface Objective {
Group: number
Index: number
Progress: number
VisualProgress: number
IsCompleted: boolean
HasClaimedReward: boolean
}
/** What the client posts when it makes progress on an objective. */
export interface ObjectiveUpdate {
Group: number
Index: number
Progress: number
VisualProgress: number
IsCompleted: boolean
IsRewarded: boolean
}
/**
* Record progress on an objective. Upserts on (account, group, index).
* `has_claimed_reward` latches on the first completion and never unlatches, so an
* objective that completes twice (or is replayed by the client) only ever pays out
* once. Returns true when this call is the one that completed it.
*/
export async function updateObjective(
db: D1Database,
accountId: number,
update: ObjectiveUpdate
): Promise<boolean> {
const existing = await db
.prepare(
`SELECT is_completed, has_claimed_reward FROM objective
WHERE account_id = ?1 AND group_id = ?2 AND idx = ?3`
)
.bind(accountId, update.Group, update.Index)
.first<{ is_completed: number; has_claimed_reward: number }>()
const wasCompleted = existing?.is_completed === 1
const newlyCompleted = update.IsCompleted && !wasCompleted
const hasClaimedReward = existing?.has_claimed_reward === 1 || newlyCompleted
await db
.prepare(
`INSERT INTO objective
(account_id, group_id, idx, progress, visual_progress,
is_completed, is_rewarded, has_claimed_reward)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(account_id, group_id, idx) DO UPDATE SET
progress = ?4,
visual_progress = ?5,
is_completed = ?6,
is_rewarded = ?7,
has_claimed_reward = ?8`
)
.bind(
accountId,
update.Group,
update.Index,
update.Progress,
update.VisualProgress,
update.IsCompleted ? 1 : 0,
update.IsRewarded ? 1 : 0,
hasClaimedReward ? 1 : 0
)
.run()
return newlyCompleted
}
/** An objective group's state, as `myprogress` and `cleargroup` report it. */
export interface ObjectiveGroup {
Group: number
IsCompleted: boolean
ClearedAt: string
}
/**
* Clear an objective group — the client saying it's finished with that set (its
* dailies rolled over, say). Stamps the clear time and marks the group completed,
* returning the group as the client reads it back.
*
* The group's individual objectives are deliberately left in place: the client still
* renders what was achieved, and `updateobjective` overwrites them by (group, index)
* when the next set is issued.
*/
export async function clearObjectiveGroup(
db: D1Database,
accountId: number,
group: number
): Promise<ObjectiveGroup> {
const clearedAt = new Date().toISOString()
await db
.prepare(
`INSERT INTO objective_group (account_id, group_id, is_completed, cleared_at)
VALUES (?1, ?2, 1, ?3)
ON CONFLICT(account_id, group_id) DO UPDATE SET is_completed = 1, cleared_at = ?3`
)
.bind(accountId, group, clearedAt)
.run()
return { Group: group, IsCompleted: true, ClearedAt: clearedAt }
}
/** A player's objective groups, or an empty list when they've cleared none. */
export async function getObjectiveGroups(
db: D1Database,
accountId: number
): Promise<ObjectiveGroup[]> {
const { results } = await db
.prepare(
`SELECT group_id, is_completed, cleared_at FROM objective_group
WHERE account_id = ?1 ORDER BY group_id`
)
.bind(accountId)
.all<{ group_id: number; is_completed: number; cleared_at: string | null }>()
return results.map((r) => ({
Group: r.group_id,
IsCompleted: r.is_completed === 1,
ClearedAt: r.cleared_at ?? '',
}))
}
/** A player's objectives, or an empty list when they've made no progress yet. */
export async function getObjectives(db: D1Database, accountId: number): Promise<Objective[]> {
const { results } = await db
.prepare(
`SELECT group_id, idx, progress, visual_progress, is_completed, has_claimed_reward
FROM objective WHERE account_id = ?1
ORDER BY group_id, idx`
)
.bind(accountId)
.all<{
group_id: number
idx: number
progress: number
visual_progress: number
is_completed: number
has_claimed_reward: number
}>()
return results.map((r) => ({
Group: r.group_id,
Index: r.idx,
Progress: r.progress,
VisualProgress: r.visual_progress,
IsCompleted: r.is_completed === 1,
HasClaimedReward: r.has_claimed_reward === 1,
}))
}
+9 -29
View File
@@ -269,7 +269,9 @@ export const MakerAiFreeTrialEligibilityResponse = z
export const ChallengeProgressResponse = z.object({
ChallengeMapId: z.int(),
ChallengeId: z.int(),
Config: z.string().describe('Echoed back verbatim; not stored'),
Config: z
.string()
.describe('The STORED rule tree — a report carrying none keeps (and echoes) the last one'),
Complete: z
.boolean()
.describe('The STORED completion — latches true within a rotation, so it may differ'),
@@ -281,21 +283,9 @@ export const ChallengeProgressResponse = z.object({
* `ObjectiveGroups` entries `myprogress` serves — three spellings of the same group.
*/
export const UpdateObjectiveResponse = z.object({
group: z.int().describe('The group the reported objective belongs to'),
isCompleted: z.boolean().describe('Whether that group has been cleared'),
clearedAt: z.string().describe('When the group was cleared'),
})
/**
* An objective GROUP as the client reads it back — the PascalCase shape served inside
* `myprogress` and returned by `cleargroup`. Note `updateobjective` answers the same
* three facts in camelCase (`UpdateObjectiveResponse`); the client parses both, so the
* two spellings are deliberate rather than an inconsistency to clean up.
*/
export const ObjectiveGroupDto = z.object({
Group: z.int().describe('The clients own group identifier'),
IsCompleted: z.boolean(),
ClearedAt: z.string().nullable().describe('ISO-8601; null before the group is cleared'),
group: z.int().describe('Echoed back from the request'),
isCompleted: z.boolean().describe('Always false — no objectives store yet'),
clearedAt: z.string().describe('When the group was cleared — now, since nothing persists'),
})
/**
@@ -496,7 +486,9 @@ export const ChallengeProgressRequest = z.object({
Config: z
.string()
.optional()
.describe('The client-evaluated rule tree, with its running count in `cc`; not stored'),
.describe(
'The client-evaluated rule tree, with its running count in `cc`; stored as the players progress'
),
Complete: z
.union([z.string(), z.boolean()])
.optional()
@@ -515,18 +507,6 @@ export const GameRewardRequest = z.object({
.describe('The activity it came from, e.g. `Soccer` — part of the cooldown key'),
})
/**
* `POST /api/gamerewards/v1/select` form body — which of the three offered drops the
* player picked. Both ids are read case-insensitively: the client's casing for these is
* not pinned down, and a mis-cased field would silently read as 0 and 403 the claim.
*/
export const SelectGameRewardRequest = z.object({
rewardSelectionId: z.string().describe('The `reward_selection` being claimed against'),
giftDropId: z
.string()
.describe('The chosen drop; must be one of the three the selection offered'),
})
/**
* `POST /api/objectives/v1/updateobjective` JSON body — one objective's state as the
* client now sees it. `Index`/`Group` identify it within `myprogress`; the rest is the
-201
View File
@@ -1,201 +0,0 @@
/**
* Game-reward selections — the three-choice reward the client shows after a
* challenge/level-up. `/api/gamerewards/v1/request` mints a selection and pushes it
* to the player over the notifications hub (the HTTP response carries nothing); the
* player then picks one with `/api/gamerewards/v1/select`, which consumes it.
*
* The three offered drops are recorded so `select` can verify the player is claiming
* a drop they were actually offered, and `consumed` makes a selection single-use — a
* player can't redeem the same reward twice.
*
* There's no reward-drop catalog (avatar items, consumables) yet, so every offered
* drop is a token choice. That's the reference's own fallback path when it runs out
* of drops: a token drop's id is the negative of its amount, which is how `select`
* reconstructs it without a catalog lookup.
*/
/** Schema DDL (mirror of migrations/0014_reward_selection.sql). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS reward_selection (
reward_selection_id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
message TEXT NOT NULL DEFAULT '',
gift_context INTEGER NOT NULL DEFAULT 0,
reward_type INTEGER NOT NULL DEFAULT 0,
gift_drop_1_id INTEGER NOT NULL,
gift_drop_2_id INTEGER NOT NULL,
gift_drop_3_id INTEGER NOT NULL,
consumed INTEGER NOT NULL DEFAULT 0,
created_at TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_reward_selection_account ON reward_selection (account_id)`,
]
/** One of the three rewards a player is offered (Rec Room's `GiftDrop` wire shape). */
export interface GameRewardDrop {
GiftDropId: number
FriendlyName: string
Tooltip: string
ConsumableItemDesc: string
AvatarItemDesc: string
AvatarItemType: number
EquipmentPrefabName: string
EquipmentModificationGuid: string
IsQuery: boolean
Unique: boolean
SubscribersOnly: boolean
Rarity: number
CurrencyType: number
Currency: number
Context: number
ItemSetId: number
ItemSetFriendlyName: string
}
/** The token amounts a reward choice can be worth. */
const TOKEN_AMOUNTS = [10, 25, 50, 100, 250, 500]
/**
* A token reward choice. The drop id is the *negative* of the amount, which is how a
* token drop is told apart from a catalog drop (positive id) and how `select` rebuilds
* it — the reference does the same.
*/
export function tokenRewardDrop(amount: number, context: number): GameRewardDrop {
return {
GiftDropId: -amount,
FriendlyName: `${amount} Tokens!`,
Tooltip: 'Winner!',
ConsumableItemDesc: '',
AvatarItemDesc: '',
AvatarItemType: 0,
EquipmentPrefabName: '',
EquipmentModificationGuid: '',
IsQuery: false,
Unique: false,
SubscribersOnly: false,
Rarity: 0,
CurrencyType: 2, // RecCenterTokens
Currency: amount,
Context: context,
ItemSetId: 1,
ItemSetFriendlyName: '',
}
}
/** Three distinct token choices for a reward selection. */
export function rollRewardDrops(context: number): GameRewardDrop[] {
const amounts = [...TOKEN_AMOUNTS]
const picked: number[] = []
for (let i = 0; i < 3; i++) {
const [amount] = amounts.splice(Math.floor(Math.random() * amounts.length), 1)
picked.push(amount)
}
return picked.map((amount) => tokenRewardDrop(amount, context))
}
/** A stored reward selection — the three drops offered to a player, and whether they picked. */
export interface RewardSelection {
RewardSelectionId: number
AccountId: number
Message: string
GiftContext: number
RewardType: number
GiftDropIds: number[]
Consumed: boolean
CreatedAt: string
}
/** Record a reward selection (the three drops a player was offered). */
export async function createRewardSelection(
db: D1Database,
accountId: number,
input: { message: string; giftContext: number; rewardType: number; dropIds: number[] }
): Promise<RewardSelection> {
const createdAt = new Date().toISOString()
const row = await db
.prepare(
`INSERT INTO reward_selection
(account_id, message, gift_context, reward_type,
gift_drop_1_id, gift_drop_2_id, gift_drop_3_id, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
RETURNING reward_selection_id`
)
.bind(
accountId,
input.message,
input.giftContext,
input.rewardType,
input.dropIds[0],
input.dropIds[1],
input.dropIds[2],
createdAt
)
.first<{ reward_selection_id: number }>()
return {
RewardSelectionId: row?.reward_selection_id ?? 0,
AccountId: accountId,
Message: input.message,
GiftContext: input.giftContext,
RewardType: input.rewardType,
GiftDropIds: input.dropIds,
Consumed: false,
CreatedAt: createdAt,
}
}
/** Look up a reward selection by id, or null when there's no such row. */
export async function getRewardSelection(
db: D1Database,
rewardSelectionId: number
): Promise<RewardSelection | null> {
const row = await db
.prepare(
`SELECT reward_selection_id, account_id, message, gift_context, reward_type,
gift_drop_1_id, gift_drop_2_id, gift_drop_3_id, consumed, created_at
FROM reward_selection WHERE reward_selection_id = ?1`
)
.bind(rewardSelectionId)
.first<{
reward_selection_id: number
account_id: number
message: string
gift_context: number
reward_type: number
gift_drop_1_id: number
gift_drop_2_id: number
gift_drop_3_id: number
consumed: number
created_at: string | null
}>()
if (row === null) return null
return {
RewardSelectionId: row.reward_selection_id,
AccountId: row.account_id,
Message: row.message,
GiftContext: row.gift_context,
RewardType: row.reward_type,
GiftDropIds: [row.gift_drop_1_id, row.gift_drop_2_id, row.gift_drop_3_id],
Consumed: row.consumed === 1,
CreatedAt: row.created_at ?? '',
}
}
/**
* Mark a selection consumed. Returns false when it was already consumed — the
* conditional update is what makes a reward single-use even if the client sends the
* same claim twice.
*/
export async function consumeRewardSelection(
db: D1Database,
rewardSelectionId: number
): Promise<boolean> {
const result = await db
.prepare(
'UPDATE reward_selection SET consumed = 1 WHERE reward_selection_id = ?1 AND consumed = 0'
)
.bind(rewardSelectionId)
.run()
return (result.meta.changes ?? 0) > 0
}
+334 -409
View File
@@ -19,9 +19,6 @@ import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventio
// The notification-type ids the hub carries, from the worker that owns them — asserting
// against the enum rather than a copied number is what keeps these frames honest.
import { NotificationType } from '../../../../notify/src/notification-types'
// The live weekly rotation, so the challenge tests exercise whatever it currently holds
// instead of hard-coded ids from a rotation that has since been replaced.
import weeklyChallenge from '../../../static/weekly-challenge.json'
import { SCHEMA_DDL } from '../../avatar-db'
import {
BALANCE_SCHEMA_DDL,
@@ -31,12 +28,14 @@ import {
spendCurrency,
} from '../../balance-db'
import { CHALLENGE_GIFT_SCHEMA_DDL, CHALLENGE_STATUS_SCHEMA_DDL } from '../../challenge-db'
// The live weekly rotation, generated the same way the worker generates it, so the challenge
// tests exercise whatever this week actually holds instead of ids from a rotation that has
// since rolled over.
import { buildRotation, rotationIndex, withWeeklyGift } from '../../challenge-rotation'
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db'
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
import { SCHEMA_DDL as OBJECTIVES_SCHEMA_DDL } from '../../objectives-db'
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
import { SCHEMA_DDL as REWARDS_SCHEMA_DDL } from '../../rewards-db'
import type { Env } from '../../context'
@@ -46,8 +45,11 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com'
/** This week's rotation — the same one the worker builds for these requests. */
const weekly = buildRotation(new Date())
/** The first challenge of the live rotation — the progress tests report against it. */
const CURRENT_CHALLENGE = weeklyChallenge.Challenges[0]
const CURRENT_CHALLENGE = weekly.Challenges[0]
// Build the accounts table and seed the test player (the default token's sub, 42)
// so avatar reads/writes have a row to attach to.
@@ -67,10 +69,6 @@ beforeAll(async () => {
for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Reward selections (owned by this worker) — game rewards record what was offered.
for (const stmt of REWARDS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Objectives (owned by this worker) — per-player challenge progress.
for (const stmt of OBJECTIVES_SCHEMA_DDL) await env.DB.prepare(stmt).run()
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
.run()
@@ -387,143 +385,43 @@ describe('econ endpoints', () => {
expect(Array.isArray(body.ObjectiveGroups)).toBe(true)
})
test('POST /api/objectives/v1/updateobjective records progress; myprogress reads it back', async () => {
type Progress = {
Objectives: Array<{
Group: number
Index: number
Progress: number
VisualProgress: number
IsCompleted: boolean
HasClaimedReward: boolean
}>
ObjectiveGroups: unknown[]
}
const update = async (body: unknown, sub = '4242'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/api/objectives/v1/updateobjective`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const progress = async (sub = '4242'): Promise<Progress> => {
const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/myprogress`, {
headers: await bearer(sub),
})
test('objectives/v1/cleargroup returns [] for GET and POST (no auth)', async () => {
for (const method of ['GET', 'POST'] as const) {
const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/cleargroup`, { method })
expect(res.status).toBe(200)
return (await res.json()) as Progress
expect(await res.json()).toEqual([])
}
// Partial progress on one objective.
const res = await update({
Group: 0,
Index: 2,
Progress: 0.5,
VisualProgress: 0.5,
IsCompleted: false,
IsRewarded: false,
})
expect(res.status).toBe(200)
const mid = await progress()
expect(mid.Objectives).toEqual([
{
Group: 0,
Index: 2,
Progress: 0.5,
VisualProgress: 0.5,
IsCompleted: false,
HasClaimedReward: false,
},
])
// The default groups still ride along.
expect(mid.ObjectiveGroups.length).toBeGreaterThan(0)
// Completing it latches HasClaimedReward — the reward can only be paid once.
await update({
Group: 0,
Index: 2,
Progress: 1,
VisualProgress: 1,
IsCompleted: true,
IsRewarded: false,
})
const done = await progress()
expect(done.Objectives[0]).toMatchObject({ IsCompleted: true, HasClaimedReward: true })
// A second objective is tracked separately, keyed by (group, index).
await update({
Group: 1,
Index: 0,
Progress: 0.25,
VisualProgress: 0.25,
IsCompleted: false,
IsRewarded: false,
})
expect((await progress()).Objectives.map((o) => [o.Group, o.Index])).toEqual([
[0, 2],
[1, 0],
])
// Another player's progress is their own; a signed-out reader gets the default set.
expect((await progress('4243')).Objectives.length).toBeGreaterThanOrEqual(0)
const anon = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/myprogress`)
expect(anon.status).toBe(200)
// Auth-gated.
const noToken = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/updateobjective`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Group: 0, Index: 0 }),
})
expect(noToken.status).toBe(401)
})
test('POST /api/objectives/v1/cleargroup clears the group; myprogress reports it', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/cleargroup`, {
test('POST /api/objectives/v1/updateobjective echoes the group, never completed', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/updateobjective`, {
method: 'POST',
headers: { ...(await bearer('4444')), 'Content-Type': 'application/json' },
body: JSON.stringify({ Group: 1 }),
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
Index: 2,
Group: 3,
Progress: 1,
VisualProgress: 0,
IsCompleted: true,
HasClaimedReward: false,
}),
})
expect(res.status).toBe(200)
const cleared = (await res.json()) as {
Group: number
IsCompleted: boolean
ClearedAt: string
}
expect(cleared).toMatchObject({ Group: 1, IsCompleted: true })
expect(typeof cleared.ClearedAt).toBe('string')
// The cleared group comes back on the player's progress.
const progress = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/myprogress`, {
headers: await bearer('4444'),
})
const body = (await progress.json()) as { ObjectiveGroups: Array<{ Group: number }> }
expect(body.ObjectiveGroups.map((g) => g.Group)).toEqual([1])
// Auth-gated.
const anon = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/cleargroup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Group: 1 }),
})
expect(anon.status).toBe(401)
const body = (await res.json()) as { group: number; isCompleted: boolean; clearedAt: string }
expect(body.group).toBe(3)
expect(body.isCompleted).toBe(false)
expect(Number.isNaN(Date.parse(body.clearedAt))).toBe(false)
})
test('POST /api/objectives/v1/updateobjective is auth-gated and 400s on a non-JSON body', async () => {
// No token: the objective belongs to a player, so there is nobody to record it against.
const anon = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/updateobjective`, {
method: 'POST',
body: 'not json',
})
expect(anon.status).toBe(401)
// Authenticated but unparseable: nothing to upsert, so this is the client's error.
test('POST /api/objectives/v1/updateobjective tolerates a non-JSON body', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/updateobjective`, {
method: 'POST',
headers: await bearer('4646'),
body: 'not json',
})
expect(res.status).toBe(400)
expect(res.status).toBe(200)
const body = (await res.json()) as { group: number; isCompleted: boolean }
expect(body.group).toBe(0)
expect(body.isCompleted).toBe(false)
})
test('GET /api/checklist/v1|v2/current 401s without a token, serves the NUX list with one', async () => {
@@ -1196,9 +1094,9 @@ describe('econ endpoints', () => {
expect(await unlocked()).toHaveLength(1)
// Favouriting sticks.
const update = async (favorited: boolean) =>
const update = async (favorited: boolean, method: 'PUT' | 'POST' = 'PUT') =>
exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
method: 'PUT',
method,
headers: { ...(await bearer('31')), 'Content-Type': 'application/json' },
body: JSON.stringify([
{ PrefabName: '[DiscGolfDisc]', ModificationGuid: guid, Favorited: favorited },
@@ -1215,22 +1113,75 @@ describe('econ endpoints', () => {
expect((await update(false)).status).toBe(200)
after = await unlocked()
expect(after[0].Favorited).toBe(false)
// The client sends this as a POST too, with the same body — same effect.
expect((await update(true, 'POST')).status).toBe(200)
expect((await unlocked())[0]?.Favorited).toBe(true)
expect((await update(false, 'POST')).status).toBe(200)
expect((await unlocked())[0]?.Favorited).toBe(false)
})
test('PUT /api/equipment/v1/update 401s without a token, 400s on a non-array body', async () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: '[]',
})
expect(anon.status).toBe(401)
test('POST /api/equipment/v1/update favourites from the clients own body', async () => {
// The body verbatim as the client sends it — a full echo of the entry it was served,
// of which only `Favorited` is read.
const post = async (favorited: boolean, sub = '33') =>
exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify([
{
PrefabName: '[ShareCamera]',
ModificationGuid: 'g5u0weNLmkCLeUXFUVn74Q',
FriendlyName: 'Camera Skin (Comic)',
Tooltip: 'ShareCamera Comic Debug: 2121',
Rarity: 5,
Favorited: favorited,
},
]),
})
const bad = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
method: 'PUT',
headers: { ...(await bearer('32')), 'Content-Type': 'application/json' },
body: '{}',
// Nothing owned yet: the guid matches no row, so this is a silent no-op, not an error.
expect((await post(true)).status).toBe(200)
await grantEquipment(env.DB, 33, {
PrefabName: '[ShareCamera]',
ModificationGuid: 'g5u0weNLmkCLeUXFUVn74Q',
FriendlyName: 'Camera Skin (Comic)',
Tooltip: 'ShareCamera Comic Debug: 2121',
Rarity: 5,
PlatformMask: -1,
Favorited: false,
})
expect(bad.status).toBe(400)
const owned = async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`, {
headers: await bearer('33'),
})
return (await res.json()) as Array<{ ModificationGuid: string; Favorited: boolean }>
}
expect((await owned())[0]?.Favorited).toBe(false)
expect((await post(true)).status).toBe(200)
expect((await owned())[0]?.Favorited).toBe(true)
expect((await post(false)).status).toBe(200)
expect((await owned())[0]?.Favorited).toBe(false)
})
test('equipment/v1/update 401s without a token, 400s on a non-array body (PUT and POST)', async () => {
for (const method of ['PUT', 'POST'] as const) {
const anon = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
method,
headers: { 'Content-Type': 'application/json' },
body: '[]',
})
expect(anon.status).toBe(401)
const bad = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
method,
headers: { ...(await bearer('32')), 'Content-Type': 'application/json' },
body: '{}',
})
expect(bad.status).toBe(400)
}
})
test('POST /api/storefronts/v2/buyItem 409s when the sent price no longer matches', async () => {
@@ -1940,10 +1891,111 @@ describe('econ endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
expect(res.status).toBe(200)
const body = (await res.json()) as { ChallengeMapId: number; Challenges: unknown[] }
expect(body).toHaveProperty('ChallengeMapId')
expect(Array.isArray(body.Challenges)).toBe(true)
expect(body.ChallengeMapId).toBe(weekly.ChallengeMapId)
expect(body.Challenges).toHaveLength(weekly.Challenges.length)
})
test('the rotation is a pure function of the week', async () => {
const at = new Date('2026-08-25T12:00:00Z')
// Same instant, same rotation — and any instant in the same week, too. Two players
// served different challenges for one `ChallengeMapId` would disagree about who has
// finished the week.
expect(buildRotation(at)).toEqual(buildRotation(at))
const laterSameWeek = new Date('2026-08-26T20:59:59Z')
expect(rotationIndex(laterSameWeek)).toBe(rotationIndex(at))
expect(buildRotation(laterSameWeek).Challenges).toEqual(buildRotation(at).Challenges)
// …and the week rolls at Wednesday 21:00 UTC, one map id at a time.
const nextWeek = new Date('2026-08-26T21:00:00Z')
expect(rotationIndex(nextWeek)).toBe(rotationIndex(at) + 1)
expect(buildRotation(nextWeek).ChallengeMapId).toBe(buildRotation(at).ChallengeMapId + 1)
expect(buildRotation(nextWeek).StartAt).toBe(buildRotation(at).EndAt)
})
test('the week is themed on the name of the item it rolls', async () => {
// `ChallengeThemeString` is the reward's catalog FriendlyName. The static file ships it
// empty on purpose — a generated week's gift isn't known until it is rolled — so the
// theming happens where the pick does.
const pool = [
{
GiftDropId: 11,
EquipmentPrefabName: '[ShareCamera]',
EquipmentModificationGuid: 'guid-a',
Rarity: 30,
FriendlyName: 'Camera Skin (Comic)',
},
{
GiftDropId: 12,
EquipmentPrefabName: '[Boombox]',
EquipmentModificationGuid: 'guid-b',
Rarity: 20,
FriendlyName: 'Boombox (Neon)',
},
]
const at = new Date('2026-08-25T12:00:00Z')
const themed = withWeeklyGift(buildRotation(at), pool)
const rolled = pool.find((p) => p.GiftDropId === themed.Gift.GiftDropId)
expect(rolled).toBeDefined()
expect(themed.ChallengeThemeString).toBe(rolled!.FriendlyName)
// An empty pool (the catalog didn't load) leaves the rotation as it was rather than
// theming the week on nothing.
expect(withWeeklyGift(buildRotation(at), []).ChallengeThemeString).toBe(
buildRotation(at).ChallengeThemeString
)
// And over the live catalog the route serves a real name, not the placeholder.
const served = (await (
await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
).json()) as { ChallengeThemeString: string }
expect(served.ChallengeThemeString).not.toBe('')
})
test('every generated week is five valid, distinct challenges', async () => {
// Walk two years of rotations: the pool, the constraints and the tree builders all have
// to hold for every week, not just this one.
for (let week = 0; week < 104; week++) {
const at = new Date(Date.UTC(2026, 0, 7, 21, 0, 0) + week * 7 * 24 * 60 * 60 * 1000)
const rotation = buildRotation(at)
const where = `week ${week}`
expect(rotation.Challenges, where).toHaveLength(5)
// Ids have to be unique within a rotation — `challenge_status` is keyed by them.
const ids = rotation.Challenges.map((ch) => ch.ChallengeId)
expect(new Set(ids).size, where).toBe(ids.length)
// One room per week: five ways to say "play Paintball" is not a rotation.
const scenes = rotation.Challenges.flatMap((ch) => sceneIdsOf(ch.Config))
expect(new Set(scenes).size, where).toBe(scenes.length)
for (const challenge of rotation.Challenges) {
// A malformed tree fails SILENTLY in the client — the challenge just never
// completes — so the shape is asserted here rather than discovered in game.
const tree = JSON.parse(challenge.Config) as { ct: number; t?: number }
expect([0, 1], `${where} ${challenge.Name}`).toContain(tree.ct)
if (tree.ct === 1) expect(tree.t, `${where} ${challenge.Name}`).toBeGreaterThan(0)
expect(sceneIdsOf(challenge.Config).length, `${where} ${challenge.Name}`).toBeGreaterThan(0)
// The copy is generated from the same inputs as the tree, so it can't drift — but a
// counter still has to say out loud how far it counts.
if (tree.ct === 1) expect(challenge.Description, where).toContain(String(tree.t))
expect(challenge.Tooltip.length, `${where} ${challenge.Name}`).toBeGreaterThan(0)
expect(challenge.Complete, `${where} ${challenge.Name}`).toBe(false)
}
}
})
/** Every `ct:7` scene id in a rule tree, however deep the tree nests them. */
function sceneIdsOf(config: string): string[] {
const scenes: string[] = []
const walk = (node: unknown): void => {
if (Array.isArray(node)) return node.forEach(walk)
if (node === null || typeof node !== 'object') return
const record = node as { ct?: number; vs?: Array<{ l?: string }> }
if (record.ct === 7)
for (const value of record.vs ?? []) if (value.l !== undefined) scenes.push(value.l)
for (const value of Object.values(record)) walk(value)
}
walk(JSON.parse(config))
return scenes
}
test('GET /api/storefronts/v1/adcarouselitems returns the carousel items', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v1/adcarouselitems`)
expect(res.status).toBe(200)
@@ -1966,7 +2018,7 @@ describe('econ endpoints', () => {
method: 'POST',
headers: { ...(await bearer('70')), 'Content-Type': 'application/json' },
body: JSON.stringify({
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
ChallengeMapId: String(weekly.ChallengeMapId),
ChallengeId: String(challenge.ChallengeId),
Config: challenge.Config,
// .NET's bool.ToString() — the capitalized string, which `Boolean("False")`
@@ -1976,7 +2028,7 @@ describe('econ endpoints', () => {
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
ChallengeMapId: weeklyChallenge.ChallengeMapId,
ChallengeMapId: weekly.ChallengeMapId,
ChallengeId: challenge.ChallengeId,
Config: challenge.Config,
Complete: false,
@@ -1999,7 +2051,7 @@ describe('econ endpoints', () => {
method: 'POST',
headers: { ...bearerHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
ChallengeMapId: String(weekly.ChallengeMapId),
ChallengeId: completedId,
Complete: 'True',
}),
@@ -2048,24 +2100,74 @@ describe('econ endpoints', () => {
expect(await completeOf(await post('18', 'True'))).toBe(true)
})
test('the reported Config is stored and served back over the static rule tree', async () => {
const challenge = CURRENT_CHALLENGE
const bearerHeaders = await bearer('78')
const headers = { ...bearerHeaders, 'Content-Type': 'application/json' }
// The client posts the catalog's tree with its own running count written into it —
// `cc` on the counter — which is the progress that has to survive the session.
const inProgress = challenge.Config.replace(/}$/, ',"cc":1}')
expect(inProgress).not.toBe(challenge.Config)
const post = (body: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
method: 'POST',
headers,
body: JSON.stringify({
ChallengeMapId: String(weekly.ChallengeMapId),
ChallengeId: String(challenge.ChallengeId),
...body,
}),
})
const reported = await post({ Config: inProgress, Complete: 'False' })
expect(await reported.json()).toEqual({
ChallengeMapId: weekly.ChallengeMapId,
ChallengeId: challenge.ChallengeId,
Config: inProgress,
Complete: false,
})
const configOf = async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`, {
headers: bearerHeaders,
})
const body = (await res.json()) as {
Challenges: Array<{ ChallengeId: number; Config: string }>
}
return body.Challenges.find((ch) => ch.ChallengeId === challenge.ChallengeId)?.Config
}
expect(await configOf()).toBe(inProgress)
// A report carrying no tree is not a reset — the stored progress stays, and is echoed.
const noConfig = await post({ Complete: 'False' })
expect(((await noConfig.json()) as { Config: string }).Config).toBe(inProgress)
expect(await configOf()).toBe(inProgress)
// Challenges this player never reported keep the authored tree, and so does everyone else.
const anon = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
const anonBody = (await anon.json()) as { Challenges: Array<{ Config: string }> }
expect(anonBody.Challenges.map((ch) => ch.Config)).toEqual(
weekly.Challenges.map((ch) => ch.Config)
)
})
/**
* How many of the rotation's challenges earn the gift — three, unless the rotation
* publishes fewer or declares itself all-or-nothing (`CHALLENGES_REQUIRED_FOR_GIFT`).
*/
const REQUIRED_FOR_GIFT = weeklyChallenge.CompletedRequired
? weeklyChallenge.Challenges.length
: Math.min(3, weeklyChallenge.Challenges.length)
const REQUIRED_FOR_GIFT = weekly.CompletedRequired
? weekly.Challenges.length
: Math.min(3, weekly.Challenges.length)
/** Report the live rotation's challenges complete, for one player. */
async function finishTheRotation(sub: string) {
const headers = { ...(await bearer(sub)), 'Content-Type': 'application/json' }
const ids = weeklyChallenge.Challenges.map((challenge) => challenge.ChallengeId)
const ids = weekly.Challenges.map((challenge) => challenge.ChallengeId)
const report = (challengeId: number) =>
exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
method: 'POST',
headers,
body: JSON.stringify({
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
ChallengeMapId: String(weekly.ChallengeMapId),
ChallengeId: String(challengeId),
Complete: 'True',
}),
@@ -2073,6 +2175,16 @@ describe('econ endpoints', () => {
return { ids, report }
}
/**
* The reward this week advertises, read back from the route that shows it to the client.
* The gift is rolled from the storefront catalog rather than authored, so the assertion
* that matters is that the box a player receives carries what the rotation promised.
*/
async function advertisedGift() {
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
return ((await res.json()) as { Gift: Record<string, string & number> }).Gift
}
/** A player's unopened gift boxes, as the client reads them back. */
async function giftBoxes(sub: string) {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
@@ -2089,7 +2201,8 @@ describe('econ endpoints', () => {
}
test('completing enough of the rotation grants its gift, once', async () => {
// The live rotation, so this follows whatever static/weekly-challenge.json holds.
// The live rotation, so this follows whatever this week generated.
const gift = await advertisedGift()
const { ids, report } = await finishTheRotation('74')
// The threshold can't ask for more than the week publishes: a five-challenge week asks
// for three, and a rotation of three or fewer asks for all of them.
@@ -2106,7 +2219,7 @@ describe('econ endpoints', () => {
const won = await giftBoxes('74')
expect(won).toHaveLength(1)
expect(won[0]?.Message).toBe('Weekly challenge complete!')
expect(won[0]?.EquipmentModificationGuid).toBe(weeklyChallenge.Gift.EquipmentModificationGuid)
expect(won[0]?.EquipmentModificationGuid).toBe(gift.EquipmentModificationGuid)
// The client is told the moment the set is finished, rather than finding the box the
// next time it reads the gifts list. `Immediate` (31), from Coach (1).
@@ -2119,10 +2232,10 @@ describe('econ endpoints', () => {
FromGiftDropId: 0,
FromPlayerId: 1,
ConsumableItemDesc: '',
AvatarItemDesc: weeklyChallenge.Gift.AvatarItemDesc,
AvatarItemType: weeklyChallenge.Gift.AvatarItemType,
EquipmentPrefabName: weeklyChallenge.Gift.EquipmentPrefabName,
EquipmentModificationGuid: weeklyChallenge.Gift.EquipmentModificationGuid,
AvatarItemDesc: gift.AvatarItemDesc,
AvatarItemType: gift.AvatarItemType,
EquipmentPrefabName: gift.EquipmentPrefabName,
EquipmentModificationGuid: gift.EquipmentModificationGuid,
CurrencyType: 0,
Currency: 0,
Xp: 0,
@@ -2130,9 +2243,10 @@ describe('econ endpoints', () => {
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: -2,
GiftContext: weeklyChallenge.Gift.GiftContext,
// The catalog's rarity for the item, not the block's `GiftRarity` of 0.
GiftRarity: 5,
GiftContext: gift.GiftContext,
// The catalog's rarity for the item — which is also what the generated block carries,
// since the week's gift is drawn from the catalog itself.
GiftRarity: gift.GiftRarity,
Message: 'Weekly challenge complete!',
})
@@ -2141,9 +2255,7 @@ describe('econ endpoints', () => {
headers: await bearer('74'),
})
const owned = (await unlocked.json()) as Array<{ ModificationGuid: string }>
expect(owned.map((e) => e.ModificationGuid)).toContain(
weeklyChallenge.Gift.EquipmentModificationGuid
)
expect(owned.map((e) => e.ModificationGuid)).toContain(gift.EquipmentModificationGuid)
// Finishing the REST of the set, and re-reporting what's already done (which the client
// keeps doing), must not mint a second reward.
@@ -2153,10 +2265,11 @@ describe('econ endpoints', () => {
test('a player who already owns the rotations gift rolls the fallback box instead', async () => {
// Own the reward up front — the case the rotation's `FallbackGiftName` exists for.
const gift = await advertisedGift()
await grantEquipment(env.DB, 75, {
ModificationGuid: weeklyChallenge.Gift.EquipmentModificationGuid,
PrefabName: weeklyChallenge.Gift.EquipmentPrefabName,
FriendlyName: 'Camera Skin (Comic)',
ModificationGuid: gift.EquipmentModificationGuid,
PrefabName: gift.EquipmentPrefabName,
FriendlyName: 'The weeks reward, already owned',
Tooltip: '',
Rarity: 5,
PlatformMask: -1,
@@ -2175,9 +2288,7 @@ describe('econ endpoints', () => {
// Something they don't have, at the tier `FallbackGiftName` names ("4-Star Box" → 30),
// rather than a second copy of the gift.
const rolled = won[0]
expect(rolled?.EquipmentModificationGuid).not.toBe(
weeklyChallenge.Gift.EquipmentModificationGuid
)
expect(rolled?.EquipmentModificationGuid).not.toBe(gift.EquipmentModificationGuid)
expect(rolled?.GiftRarity).toBe(30)
expect(
(rolled?.AvatarItemDesc ?? '') !== '' || (rolled?.EquipmentModificationGuid ?? '') !== ''
@@ -2317,12 +2428,12 @@ describe('econ endpoints', () => {
.bind(rewardType, giftContext)
.first<{ granted_at: string; grant_count: number }>()
// A claim answers the envelope — the three choices ride out on the hub, not in the body.
// A claim answers the empty list the client accepts — the reward rides in a gift box.
const first = await request(
'rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day'
)
expect(first.status).toBe(200)
expect(await first.json()).toEqual({ error: '', success: true, value: null })
expect(await first.json()).toEqual([])
const claimed = await statusOf('FirstActivityOfDay')
expect(claimed?.grant_count).toBe(1)
@@ -2371,7 +2482,7 @@ describe('econ endpoints', () => {
expect((await statusOf('FirstActivityOfDay'))?.grant_count).toBe(2)
})
test('a game reward offers three choices, and picking one pays it', async () => {
test('a claimed game reward pays XP into a gift box, and announces it', async () => {
const request = async (body: string) =>
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST',
@@ -2381,24 +2492,6 @@ describe('econ endpoints', () => {
},
body,
})
const select = async (fields: Record<string, string>, sub = '82'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/select`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields).toString(),
})
/** The selection most recently offered to 82, and the three drops it holds. */
const latestSelection = async () =>
env.DB.prepare(
`SELECT reward_selection_id, gift_drop_1_id, gift_drop_2_id, gift_drop_3_id
FROM reward_selection WHERE account_id = 82
ORDER BY reward_selection_id DESC LIMIT 1`
).first<{
reward_selection_id: number
gift_drop_1_id: number
gift_drop_2_id: number
gift_drop_3_id: number
}>()
/** Age the cooldown so the next ask is eligible again. */
const passAnHour = () =>
env.DB.prepare(
@@ -2409,110 +2502,45 @@ describe('econ endpoints', () => {
await drainFrames()
expect((await getProgression(env.DB, 82)).XP).toBe(0)
// Asking mints a selection and announces the three choices. Nothing is paid yet: no
// XP, no box, no tokens — the player hasn't picked.
const res = await request('rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day')
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ error: '', success: true, value: null })
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 0 })
expect(await giftBoxes('82')).toHaveLength(0)
expect(await res.json()).toEqual([])
const offerFrames = await drainFrames()
expect(offerFrames.map((f) => f.notificationType)).toEqual([
NotificationType.RewardSelectionReceived,
])
const offer = offerFrames[0]!
expect(offer.accountId).toBe(82)
expect(offer.payload).toMatchObject({
Message: 'First Game of the Day',
// No numeric giftContext was sent, so the frame falls back to GiftContext.GameRewards.
GiftContext: 50,
})
// Three distinct token choices, plus the subscriber duplicate of the third.
const drops = [
offer.payload.GiftDrop1,
offer.payload.GiftDrop2,
offer.payload.GiftDrop3,
] as Array<{ GiftDropId: number; Currency: number }>
expect(new Set(drops.map((d) => d.GiftDropId)).size).toBe(3)
expect(offer.payload.Subscriber_GiftDrop3).toEqual(offer.payload.GiftDrop3)
// An on-cooldown ask offers nothing at all — no second selection, no frame.
const before = await latestSelection()
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
expect((await latestSelection())?.reward_selection_id).toBe(before?.reward_selection_id)
expect(await drainFrames()).toEqual([])
// Picking one pays it: the tokens are credited, the XP banked, and a box created.
const chosen = before!.gift_drop_2_id
const tokensBefore = await getBalance(
env.DB,
82,
CurrencyType.RecCenterTokens,
DEFAULT_STARTING_TOKENS
)
const claim = await select({
rewardSelectionId: String(before!.reward_selection_id),
giftDropId: String(chosen),
})
expect(claim.status).toBe(200)
expect(await claim.json()).toMatchObject({
GiftDropId: chosen,
CurrencyType: CurrencyType.RecCenterTokens,
Currency: -chosen,
FriendlyName: `${-chosen} Tokens!`,
})
// A token drop's id is the negative of its amount, so that is what lands on the balance.
expect(
await getBalance(env.DB, 82, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(tokensBefore + -chosen)
// 5 XP is deliberately less than the 10 the first level costs, so one reward moves the
// 5 XP is deliberately less than the 10 the first level costs, so one action moves the
// bar without levelling anyone up.
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
// One box, carrying the message the client asked to show and no item — a game reward
// is tokens and XP, not an item.
const boxes = await giftBoxes('82')
expect(boxes).toHaveLength(1)
expect(boxes[0]).toMatchObject({
// One box: the XP reward itself, carrying the message the client asked to show and no
// item — a game reward is not an item.
const first = await giftBoxes('82')
expect(first).toHaveLength(1)
expect(first[0]).toMatchObject({
Xp: 5,
Message: 'First Game of the Day',
AvatarItemDesc: '',
EquipmentModificationGuid: '',
ConsumableItemDesc: '',
})
// The balance, the box, then the bar — no level-up box, since no level was crossed.
const paid = await drainFrames()
expect(paid.map((f) => f.notificationType)).toEqual([
NotificationType.StorefrontBalanceUpdate,
NotificationType.GiftPackageRewardSelectionReceived,
// The box, then the bar — no level-up box, since no level was crossed.
const frames = await drainFrames()
expect(frames.map((f) => f.notificationType)).toEqual([
NotificationType.GiftPackageReceivedImmediate,
NotificationType.PlayerProgressionLevelUpdate,
])
// The balance frame carries the RESULTING total, never the payout.
expect(paid[0]?.payload).toMatchObject({
Balance: tokensBefore + -chosen,
CurrencyType: CurrencyType.RecCenterTokens,
})
expect(paid[1]?.payload).toMatchObject({
Id: boxes[0]?.Id,
expect(frames[0]?.accountId).toBe(82)
expect(frames[0]?.payload).toMatchObject({
Id: first[0]?.Id,
FromPlayerId: 1,
Xp: 5,
Currency: -chosen,
// GiftContext.GameRewards — the box came from gameplay, not a purchase.
GiftContext: 50,
Message: 'First Game of the Day',
})
expect(paid[2]?.payload).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
expect(frames[1]?.payload).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
// The selection is single-use: the same claim again is refused, and pays nothing more.
expect(
(
await select({
rewardSelectionId: String(before!.reward_selection_id),
giftDropId: String(chosen),
})
).status
).toBe(403)
// An on-cooldown ask pays nothing: no more boxes, no frames, no more XP.
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
expect(await giftBoxes('82')).toHaveLength(1)
expect(await drainFrames()).toEqual([])
@@ -2521,17 +2549,6 @@ describe('econ endpoints', () => {
// is the pacing the smaller grant buys.
await passAnHour()
expect((await request('rewardType=FirstActivityOfDay&Message=Second')).status).toBe(200)
const second = await latestSelection()
expect(second?.reward_selection_id).not.toBe(before?.reward_selection_id)
await drainFrames()
expect(
(
await select({
rewardSelectionId: String(second!.reward_selection_id),
giftDropId: String(second!.gift_drop_1_id),
})
).status
).toBe(200)
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 2, XP: 0 })
// …and level 2 pays 2-Star Clothing per the published table: an AVATAR ITEM, never an
@@ -2551,13 +2568,32 @@ describe('econ endpoints', () => {
// v4 serves the camelCase DTO, unlike the PascalCase records on the gift box.
const owned = (await items.json()) as Array<{ avatarItemDesc: string }>
expect(owned.map((i) => i.avatarItemDesc)).toContain(clothingBox?.AvatarItemDesc)
// The level-up box rides the Immediate channel, after the selection's own payout frames.
expect((await drainFrames()).map((f) => f.notificationType)).toEqual([
NotificationType.StorefrontBalanceUpdate,
NotificationType.GiftPackageRewardSelectionReceived,
NotificationType.GiftPackageReceivedImmediate,
NotificationType.PlayerProgressionLevelUpdate,
NotificationType.GiftPackageReceivedImmediate,
])
// Two more rewards reach level 3, which the table pays as a CONSUMABLE rather than
// clothing — rolled without a rarity, since the table names none for them.
for (const message of ['Third', 'Fourth']) {
await passAnHour()
expect((await request(`rewardType=FirstActivityOfDay&Message=${message}`)).status).toBe(200)
}
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 3, XP: 0 })
const afterLevel3 = await giftBoxes('82')
const consumableBox = afterLevel3[afterLevel3.length - 1]
expect(consumableBox?.Message).toBe('Level 3!')
expect(consumableBox?.ConsumableItemDesc).not.toBe('')
expect(consumableBox?.AvatarItemDesc).toBe('')
expect(consumableBox?.EquipmentModificationGuid).toBe('')
const consumables = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, {
headers: await bearer('82'),
})
const held = (await consumables.json()) as Array<{ ConsumableItemDesc: string }>
expect(held.map((cons) => cons.ConsumableItemDesc)).toContain(consumableBox?.ConsumableItemDesc)
})
test('POST /api/gamerewards/v1/request is 401 without a token, and ignores a typeless ask', async () => {
@@ -2578,122 +2614,11 @@ describe('econ endpoints', () => {
body: 'Message=First%20Game%20of%20the%20Day',
})
expect(typeless.status).toBe(200)
expect(await typeless.json()).toEqual({ error: '', success: true, value: null })
expect(await typeless.json()).toEqual([])
const rows = await env.DB.prepare(
'SELECT COUNT(*) AS count FROM reward_status WHERE account_id = 81'
).first<{ count: number }>()
expect(rows?.count).toBe(0)
// …and nothing was offered either: no cooldown row means no selection to pick from.
const offered = await env.DB.prepare(
'SELECT COUNT(*) AS count FROM reward_selection WHERE account_id = 81'
).first<{ count: number }>()
expect(offered?.count).toBe(0)
})
test('POST /api/gamerewards/v1/request mints a three-choice selection', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
rewardType: 'PostGameActivity',
Message: 'nice work',
giftContext: '4',
}).toString(),
})
expect(res.status).toBe(200)
// The HTTP body carries nothing — the choices go out over the websocket hub.
expect(await res.json()).toEqual({ error: '', success: true, value: null })
// The selection is recorded, with three distinct token choices for this player.
const row = await env.DB.prepare(
`SELECT account_id, message, gift_context, consumed,
gift_drop_1_id, gift_drop_2_id, gift_drop_3_id
FROM reward_selection ORDER BY reward_selection_id DESC LIMIT 1`
).first<{
account_id: number
message: string
gift_context: number
consumed: number
gift_drop_1_id: number
gift_drop_2_id: number
gift_drop_3_id: number
}>()
expect(row).toMatchObject({
account_id: 42,
message: 'nice work',
gift_context: 4,
consumed: 0,
})
const ids = [row!.gift_drop_1_id, row!.gift_drop_2_id, row!.gift_drop_3_id]
// Token drops carry the negative of their amount as their id.
expect(new Set(ids).size).toBe(3)
expect(ids.every((id) => id < 0)).toBe(true)
expect(
(await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, { method: 'POST' }))
.status
).toBe(401)
})
test('POST /api/gamerewards/v1/select claims a drop once, and only if offered', async () => {
await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST',
headers: { ...(await bearer('77')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
rewardType: 'LevelUp',
Message: 'level up',
giftContext: '7',
}).toString(),
})
const sel = await env.DB.prepare(
`SELECT reward_selection_id, gift_drop_1_id FROM reward_selection
WHERE account_id = 77 ORDER BY reward_selection_id DESC LIMIT 1`
).first<{ reward_selection_id: number; gift_drop_1_id: number }>()
const selectionId = sel!.reward_selection_id
const offeredId = sel!.gift_drop_1_id
const select = async (fields: Record<string, string>, sub = '77'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/select`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields).toString(),
})
// A drop that wasn't offered is refused, as is another player's selection.
expect(
(await select({ rewardSelectionId: String(selectionId), giftDropId: '-999' })).status
).toBe(403)
expect(
(
await select(
{ rewardSelectionId: String(selectionId), giftDropId: String(offeredId) },
'42'
)
).status
).toBe(403)
// Claiming an offered drop returns it — a token drop worth its id's magnitude.
const res = await select({
rewardSelectionId: String(selectionId),
giftDropId: String(offeredId),
})
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({
GiftDropId: offeredId,
CurrencyType: 2,
Currency: -offeredId,
Context: 7,
FriendlyName: `${-offeredId} Tokens!`,
})
// The selection is single-use: claiming again is refused.
expect(
(await select({ rewardSelectionId: String(selectionId), giftDropId: String(offeredId) }))
.status
).toBe(403)
// A missing drop id is the client's error, not a refusal.
expect((await select({ rewardSelectionId: String(selectionId) })).status).toBe(400)
})
test('GET /api/roomkeys/v1/mine returns []', async () => {
@@ -2937,8 +2862,8 @@ describe('econ endpoints', () => {
'POST /api/checklist/v1/complete',
'POST /api/checklist/v2/complete',
'POST /api/consumables/v1/consume',
'POST /api/equipment/v1/update',
'POST /api/gamerewards/v1/request',
'POST /api/gamerewards/v1/select',
'POST /api/items/bulkpurchase',
'POST /api/objectives/v1/cleargroup',
'POST /api/objectives/v1/updateobjective',
+1 -18
View File
@@ -4,24 +4,7 @@
"StartAt": "2026-08-19T21:00:00",
"EndAt": "2026-09-26T21:00:00",
"ServerTime": "2026-08-25T14:42:54.2754728Z",
"Challenges": [
{
"ChallengeId": 1,
"Name": "Debug2AIGoldenTrophy",
"Config": "{\"ct\":1,\"ipc\":false,\"ctc\":[{\"ct\":0,\"ipc\":false,\"wc\":[{\"ct\":6,\"vs\":[5]},{\"ct\":7,\"vs\":[{\"l\":\"91e16e35-f48f-4700-ab8a-a1b79e50e51b\"}]}]}],\"t\":2}",
"Description": "Defeat 2 enemies in ^GoldenTrophy",
"Tooltip": "Go to ^GoldenTrophy and take out 2 goblins.",
"Complete": false
},
{
"ChallengeId": 2,
"Name": "Debug2AIJumbotron",
"Config": "{\"ct\":1,\"ipc\":false,\"ctc\":[{\"ct\":0,\"ipc\":false,\"wc\":[{\"ct\":6,\"vs\":[5]},{\"ct\":7,\"vs\":[{\"l\":\"acc06e66-c2d0-4361-b0cd-46246a4c455c\"}]}]}],\"t\":2}",
"Description": "Defeat 2 enemies in ^TheRiseOfJumbotron",
"Tooltip": "Go to ^TheRiseOfJumbotron and take out 2 enemies.",
"Complete": false
}
],
"Challenges": [],
"Gift": {
"GiftDropId": 3994,
"AvatarItemDesc": "",
+118 -9
View File
@@ -4,14 +4,40 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { logger, withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { GetNearbyScoresBody, GetRanksBody, json, jsonBody, LeaderboardRows } from './openapi'
import {
CheckAndSetStatBody,
CheckAndSetStatResponse,
GetNearbyScoresBody,
GetPlayerRankBody,
GetRanksBody,
json,
jsonBody,
LeaderboardRows,
PlayerRank,
} from './openapi'
import type { App } from './context'
/**
* Leaderboard Worker. Nothing scores anything here yet — the routes answer the shape the
* client parses, with no rows in them.
* client parses, with no rows, no rank and no stored stats behind them.
*/
/**
* The rank a player who isn't on the board gets. Nothing is scored here, so every caller is
* unranked — but "unranked" has to be said in the client's own vocabulary, and `Rank` is
* 1-based: a 0 would render as first place and a negative one may not render at all. A
* number far past the end of any real board reads as last, which is what an unscored player
* is, and is recognisable in a log or a screenshot as a sentinel rather than a real standing.
*/
const UNRANKED = 99999
/**
* The score behind {@link UNRANKED}. Zero rather than a second sentinel: no stat has ever
* been stored, and 0 is what "no score" means in the client's own units.
*/
const NO_SCORE = 0
const app = new Hono<App>()
.use(
'*',
@@ -121,6 +147,86 @@ const app = new Hono<App>()
}
)
// One player's standing, rather than a page of the board — what the client asks when it
// needs to show "you: #17" next to a leaderboard. The body names the player and the board
// (`RoomId` + `StatChannel` + `FilterType`, where FilterType is Global 0 / Friends 1).
//
// The answer is three fields — `{ PlayerId, Score, Rank }` — and notably does NOT echo the
// board back, so the client pairs the answer with its question itself. `PlayerId` is
// therefore the one field read out of the body: answering with a different player's id
// would be answering a question nobody asked.
//
// Nothing is scored here, so every caller is unranked and gets {@link UNRANKED} with a
// zero score. A body that can't be read still gets an answer — a board that fails to draw
// is worse than one that draws the player as unranked — so `PlayerId` falls back to 0.
.post(
'/leaderboard/GetPlayerRank',
describeRoute({
tags: ['Leaderboard'],
summary: 'One players rank',
description: [
'What the client asks when it needs a single players standing rather than a page of',
'the board — the body names the player and the board (`RoomId` + `StatChannel` +',
'`FilterType`: Global 0, Friends 1).',
'',
'Nothing is scored or stored on this server yet, so the answer is always the same:',
`\`Rank\` ${UNRANKED}, a sentinel meaning unranked (ranks are 1-based, so a 0 would`,
'render as first place), and `Score` 0.',
'',
'`PlayerId` is echoed from the request and is the only field read out of it — the',
'response carries no board selectors, so the client matches the answer to its own',
'question. An unreadable body is answered rather than rejected, with a `PlayerId` of 0.',
].join(' '),
requestBody: jsonBody(GetPlayerRankBody, 'The player and the board being asked about'),
responses: { 200: json(PlayerRank, 'The players standing — always unranked') },
}),
async (c) => {
const body = await c.req
.json<{ PlayerId?: number }>()
.catch(() => ({}) as { PlayerId?: number })
logger.info('GetPlayerRank', { body })
return c.json({ PlayerId: body.PlayerId ?? 0, Score: NO_SCORE, Rank: UNRANKED })
}
)
// A stat write: the client posts the value it wants stored for a room's stat channel,
// along with `CurrentStatValue` — what it believes is stored now, null when it believes
// nothing is. That pairing makes it a compare-and-set rather than a plain write, which is
// how a room's high-score board avoids being walked backwards by a stale client. There is
// no `PlayerId`: the stat belongs to whoever is calling.
//
// Nothing is stored yet, so the write is accepted and dropped. The answer is a BARE `0` —
// not an envelope, not `{ value: 0 }` — which is what the live service returns and so what
// the client's parser expects. The body is logged, not read.
.post(
'/leaderboard/CheckAndSetStat',
describeRoute({
tags: ['Leaderboard'],
summary: 'Write a players stat',
description: [
'A compare-and-set on one of a rooms tracked stats: `StatValue` is what the client',
'wants stored, `CurrentStatValue` what it believes is stored now (null when it believes',
'nothing is). No `PlayerId` — the stat belongs to the caller.',
'',
'Nothing is stored on this server yet, so the write is accepted and dropped. The',
'response is the BARE number `0`, not an envelope and not a `{ value }` wrapper — what',
'the live service answers, and what the clients parser expects.',
'',
'The body is IGNORED and logged, which is how these shapes get recovered from a live',
'client.',
].join(' '),
requestBody: jsonBody(CheckAndSetStatBody, 'The stat, the room and the value to store'),
responses: { 200: json(CheckAndSetStatResponse, 'Always the bare number 0') },
}),
async (c) => {
const body = await c.req.text().catch(() => '<unreadable>')
logger.info('CheckAndSetStat', { body })
return c.json(0)
}
)
// 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(
@@ -136,14 +242,17 @@ app.get(
'Leaderboards for recflare, a private-server reimplementation of the Rec Room',
'backend — the boards a room keeps for the stats it tracks.',
'',
'NOTHING IS SCORED HERE YET. Both reads answer `{ "Rows": [] }`, which is a complete',
'answer rather than an error: an empty list means "this leaderboard has no scores"',
'and the client renders a blank board. The `Rows` key is always present — a bare',
'`{}` trips the clients parser.',
'NOTHING IS SCORED HERE YET, and every route answers accordingly rather than',
'failing: the two board reads answer `{ "Rows": [] }`, an empty list being a',
'complete answer meaning "this leaderboard has no scores" (the `Rows` key is always',
'present — a bare `{}` trips the clients parser); `GetPlayerRank` answers a rank of',
'99999, the sentinel for unranked, with a score of 0; and `CheckAndSetStat` accepts',
'a stat write, drops it, and answers the bare number `0`.',
'',
'Neither route reads its request body. Both log it verbatim instead, which is how',
'the shapes below get recovered from a live client; `GetNearbyScores` body is',
'still unknown for exactly that reason. No route needs a token today.',
'Only `GetPlayerRank` reads anything out of its request body, and only the',
'`PlayerId` it echoes back. Every route logs the body verbatim, which is how these',
'shapes get recovered from a live client; `GetNearbyScores` body is still unknown',
'for exactly that reason. No route needs a token today.',
].join('\n'),
},
servers: [{ url: 'https://leaderboard.recflare.net', description: 'Production' }],
+64 -4
View File
@@ -9,8 +9,9 @@ import type { OpenAPIV3_1 } from 'openapi-types'
* IMPORTANT: these are DESCRIPTIVE ONLY. They are passed to `describeRoute` to generate
* the spec and are never wired into `hono-openapi`'s `validator()`. Same rationale as the
* auth/accounts/econ/match workers: a reverse-engineered protocol, lenient handlers, no
* runtime validation. Here it matters more than usual — the handlers do not parse their
* bodies at all yet, so a body that contradicts the schema below is still answered.
* runtime validation. Here it matters more than usual — three of the four handlers do not
* parse their bodies at all, and the fourth reads one field, so a body that contradicts the
* schema below is still answered.
*
* Do NOT add `.meta({ id })` to these schemas — with this hono-openapi + zod v4 setup a
* meta'd schema used in a response emits a `$ref` the framework doesn't always hoist into
@@ -34,7 +35,7 @@ function toOpenApiSchema(schema: z.ZodType): OpenAPIV3_1.SchemaObject {
return jsonSchema as OpenAPIV3_1.SchemaObject
}
/** An `application/json` request body — what the client posts to both reads. */
/** An `application/json` request body — every leaderboard route takes one. */
export function jsonBody(schema: z.ZodType, description: string): OpenAPIV3_1.RequestBodyObject {
return { description, content: { 'application/json': { schema: toOpenApiSchema(schema) } } }
}
@@ -58,6 +59,32 @@ export const LeaderboardRows = z.object({
.describe('The boards rows. Always empty — nothing is scored or stored yet.'),
})
/**
* `POST /leaderboard/GetPlayerRank` — one player's standing on one board, e.g.
* `{"PlayerId":205,"Score":4200,"Rank":17}`.
*
* Three fields only: none of the board selectors the request names are echoed back, so the
* client matches the answer to the question by having asked it. Nothing is scored here yet,
* so `Rank` is a constant sentinel and `Score` is zero — see the route for why that pairing
* rather than a rank of 0, which would read as "first place".
*/
export const PlayerRank = z.object({
PlayerId: z.int().describe('Echoed from the request — whose rank this is'),
Score: z.int().describe('The stat value behind the rank. Always 0 — nothing is scored yet'),
Rank: z.int().describe('1-based position on the board. Always 99999 — an unranked sentinel'),
})
/**
* `POST /leaderboard/CheckAndSetStat` — a BARE JSON number, not an envelope and not a
* `{ value }` wrapper. The whole body is `0`.
*
* What the number means beyond "not an error" hasn't been recovered from the client; `0` is
* what the live service answers, so it is what this answers.
*/
export const CheckAndSetStatResponse = z
.literal(0)
.describe('Always the bare number 0 — the result code the live service returns')
// ---- Request schemas -------------------------------------------------------
/**
@@ -74,10 +101,43 @@ export const GetRanksBody = z.object({
PlayerId: z.int().describe('The player reading the board'),
StatChannel: z.int().describe('Which of the rooms tracked stats to rank on'),
RoomId: z.int().describe('The room whose board is being read'),
FilterType: z.int().describe('Client-side filter selector; its members arent known yet'),
FilterType: z.int().describe('Who the board counts: 0 Global, 1 Friends'),
SortAscending: z.boolean().describe('false ranks highest-first, the usual leaderboard'),
})
/**
* The body the client posts to `GetPlayerRank`, e.g.
* `{"PlayerId":205,"StatChannel":2,"RoomId":14,"FilterType":0,"SortAscending":false}`.
*
* The same board selectors {@link GetRanksBody} carries, minus the slice — this asks about
* one player rather than a page. Only `PlayerId` is read today, to echo it back.
*/
export const GetPlayerRankBody = z.object({
PlayerId: z.int().describe('The player whose rank is being asked for'),
StatChannel: z.int().describe('Which of the rooms tracked stats to rank on'),
RoomId: z.int().describe('The room whose board is being read'),
FilterType: z.int().describe('Who the board counts: 0 Global, 1 Friends'),
SortAscending: z.boolean().describe('false ranks highest-first, the usual leaderboard'),
})
/**
* The body the client posts to `CheckAndSetStat`, e.g.
* `{"StatChannel":2,"RoomId":14,"StatValue":1,"CurrentStatValue":null}`.
*
* A compare-and-set: `CurrentStatValue` is what the client believes is already stored (null
* when it believes nothing is), and `StatValue` is what it wants stored. There is no
* `PlayerId` — the stat belongs to whoever is calling.
*/
export const CheckAndSetStatBody = z.object({
StatChannel: z.int().describe('Which of the rooms tracked stats is being written'),
RoomId: z.int().describe('The room the stat belongs to'),
StatValue: z.number().describe('The value to store'),
CurrentStatValue: z
.number()
.nullable()
.describe('What the client believes is stored now; null when it believes nothing is'),
})
/**
* The body posted to `GetNearbyScores`. Its shape has NOT been recovered from the client —
* the handler logs the raw text precisely so it can be — so this documents an open object
@@ -33,13 +33,53 @@ it('answers GetRanks with an empty row list', async () => {
expect(await res.json()).toEqual({ Rows: [] })
})
it('answers GetPlayerRank with the unranked sentinel and the callers own id', async () => {
const res = await SELF.fetch('https://example.com/leaderboard/GetPlayerRank', {
method: 'POST',
body: JSON.stringify({
PlayerId: 205,
StatChannel: 2,
RoomId: 14,
FilterType: 0,
SortAscending: false,
}),
})
expect(res.status).toBe(200)
// Three fields, no board selectors: the client pairs the answer with its own question.
// Rank is 1-based, so the sentinel has to be a big number rather than 0 — which would
// render the unranked caller as first place.
expect(await res.json()).toEqual({ PlayerId: 205, Score: 0, Rank: 99999 })
})
it('answers GetPlayerRank even when the body is unreadable', async () => {
const res = await SELF.fetch('https://example.com/leaderboard/GetPlayerRank', {
method: 'POST',
body: 'not json',
})
// A board that fails to draw is worse than one that draws the player as unranked.
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ PlayerId: 0, Score: 0, Rank: 99999 })
})
it('answers CheckAndSetStat with a bare 0', async () => {
const res = await SELF.fetch('https://example.com/leaderboard/CheckAndSetStat', {
method: 'POST',
body: JSON.stringify({ StatChannel: 2, RoomId: 14, StatValue: 1, CurrentStatValue: null }),
})
expect(res.status).toBe(200)
// The whole body is the number — not an envelope, not `{ value: 0 }`.
expect(await res.text()).toBe('0')
})
it('serves an openapi spec with no dangling refs', async () => {
const res = await SELF.fetch('https://example.com/openapi.json')
expect(res.status).toBe(200)
const spec = (await res.json()) as Record<string, unknown>
expect(Object.keys(spec.paths as object).sort()).toEqual([
'/',
'/leaderboard/CheckAndSetStat',
'/leaderboard/GetNearbyScores',
'/leaderboard/GetPlayerRank',
'/leaderboard/GetRanks',
])
expect(JSON.stringify(spec).match(/\$ref/g)).toBeNull()
+2 -1
View File
@@ -200,8 +200,9 @@ const CuratedListFields = {
Description: z.string().nullable(),
ImageName: z
.string()
.nullable()
.describe(
'Must be a STRING — the client reads it straight into a string field. `DefaultRoomImage.jpg` where nothing set one; empty or null renders a blank tile.'
'A STRING on any list the client draws a tile for — it reads this straight into a string field, and empty or null renders that tile blank. `DefaultRoomImage.jpg` where nothing set one. Null only on a list with no tile to draw, like the `RoomGenreTags` capture, whose items are genre names rather than rooms.'
),
Type: z.int().describe('The `ListEntityType` — what the `ItemIds` are'),
ItemIds: z
+39 -23
View File
@@ -9,6 +9,8 @@ import {
SUBROOM_SCHEMA_DDL,
} from '@repo/domain'
import curatedLists from '../../../static/curated-lists.json'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
@@ -240,40 +242,54 @@ it('serves one curated list object, not a collection', async () => {
})
it('serves every capture in static/curated-lists.json by name', async () => {
// One array holds every list, and each entry must be reachable by the keys the client
// asks with. `ImageName` must be a string even when empty — the client parses it into
// one — while `Description` may be null. The ids the client caches against have to be
// unique and reach it with their digits intact.
const names = [
'Discovery.PageSource.PlayExplore',
'Discovery.PageSource.PlayLibrary',
'RoomCategories.MoodPlaylists.AlgoEndpoint.FeelingLucky',
]
// Driven by the file itself, so a capture added to it is a capture this covers: each
// entry must be reachable by the three keys the client asks with, and come back as
// itself. The ids the client caches against have to be unique and reach it with their
// digits intact.
expect(curatedLists.length).toBeGreaterThan(0)
const seen = new Set<string>()
for (const name of names) {
const res = await SELF.fetch(`${ORIGIN}/curatedlists?creatorAccountId=1&type=7&name=${name}`)
for (const capture of curatedLists) {
const res = await SELF.fetch(
`${ORIGIN}/curatedlists?creatorAccountId=${capture.CreatorAccountId}` +
`&type=${capture.Type}&name=${capture.Name}`
)
expect(res.status).toBe(200)
const body = await res.text()
// Never a quoted id: the client's field is a number.
expect(body).toMatch(/"ListId":\d+,/)
const list = JSON.parse(body) as {
ListId: number
Type: number
Name: string
ItemIds: string[]
Description: string | null
ImageName: string
}
expect(list.Name).toBe(name)
expect(list.Type).toBe(7)
expect(list.ItemIds.length).toBeGreaterThan(0)
expect(typeof list.ImageName).toBe('string')
const { ListId: _id, ...rest } = JSON.parse(body) as Record<string, unknown>
const { ListId: _captured, ...expected } = capture as Record<string, unknown>
expect(rest).toEqual(expected)
expect((capture.ItemIds as string[]).length).toBeGreaterThan(0)
const id = /"ListId":(\d+),/.exec(body)?.[1]
expect(id).toBe(capture.ListId)
expect(seen.has(id!)).toBe(false)
seen.add(id!)
}
})
it('serves the RoomGenreTags capture with its tag names', async () => {
// Genre NAMES, not room or section ids — and the one capture with a null `ImageName`,
// since the client draws no tile for it. Served under type 5, where the other captures
// are type 7.
const res = await SELF.fetch(
`${ORIGIN}/curatedlists?creatorAccountId=1&type=5&name=RoomGenreTags`
)
expect(res.status).toBe(200)
const body = await res.text()
expect(body).toContain('"ListId":1')
expect(JSON.parse(body)).toEqual({
ListId: 1,
CreatorAccountId: 1,
Name: 'RoomGenreTags',
Description: '',
ImageName: null,
Type: 5,
ItemIds: ['quest', 'battle', 'roleplay', 'horror', 'hangout', 'casual', 'explore'],
CreatedAt: '2026-01-01T00:00:00Z',
})
})
it('matches the name case-insensitively and prefers it over the type', async () => {
const canonical = await (
await SELF.fetch(
+10
View File
@@ -54,5 +54,15 @@
],
"Accessibility": 1,
"CreatedAt": "2024-05-22T05:37:43.7726633Z"
},
{
"ListId": "1",
"CreatorAccountId": 1,
"Name": "RoomGenreTags",
"Description": "",
"ImageName": null,
"Type": 5,
"ItemIds": ["quest", "battle", "roleplay", "horror", "hangout", "casual", "explore"],
"CreatedAt": "2026-01-01T00:00:00Z"
}
]
+13
View File
@@ -32,6 +32,7 @@ import {
MatchmakingErrorCode,
MessageType,
MOST_ACTIVE_CLUBHOUSE_LIMIT,
presenceGeoFromCf,
recordRoomVisit,
refreshInstanceFullness,
RoomInstanceType,
@@ -523,6 +524,10 @@ async function enterRoom(c: Context<App>, id: number, roomInstance: RoomInstance
// Carry the session lock recorded at login forward, so matchmake doesn't wipe it
// and the heartbeat can keep verifying against it.
loginLock: prev?.loginLock,
// Where this matchmake came from, coarsened at the edge (see presenceGeoFromCf).
// Falls back to the row's last known cell when the request carried no geolocation,
// so a player only leaves the globe when they leave the server.
geo: presenceGeoFromCf(c.req.raw.cf) ?? prev?.geo,
})
// Count the visit. Every matchmake route funnels through here with the instance the
@@ -1285,6 +1290,7 @@ const app = new Hono<App>()
const presence = await getPresence<RoomInstance>(c.env.DB, id)
if (presence) {
presence.loginLock = loginLock
presence.geo = presenceGeoFromCf(c.req.raw.cf) ?? presence.geo
await setPresence(c.env.DB, presence)
} else {
// No live presence yet — seed a lobby row (roomInstance null) holding the
@@ -1299,6 +1305,7 @@ const app = new Hono<App>()
platform: account?.platform ?? 0,
appVersion: (await callerVersion(c)) ?? GAME_VERSION,
loginLock,
geo: presenceGeoFromCf(c.req.raw.cf) ?? undefined,
})
}
}
@@ -1574,6 +1581,11 @@ const app = new Hono<App>()
const versionChanged = version !== null && presence.appVersion !== version
if (versionChanged) presence.appVersion = version
// The heartbeat is the only call a parked player keeps making, so it's what
// keeps their location current — someone who moves house or switches to mobile
// data re-pins on the next refresh instead of at their next matchmake.
presence.geo = presenceGeoFromCf(c.req.raw.cf) ?? presence.geo
// Otherwise the heartbeat's only side effect is refreshing the TTL, and only
// once it's within PRESENCE_REFRESH_THRESHOLD (s) of lapsing — a still player is
// refreshed periodically rather than re-written on every beat. `expiresAt` is
@@ -1611,6 +1623,7 @@ const app = new Hono<App>()
const presence = await getPresence<RoomInstance>(c.env.DB, id)
if (presence && !Number.isNaN(sv)) {
presence.statusVisibility = sv
presence.geo = presenceGeoFromCf(c.req.raw.cf) ?? presence.geo
await setPresence(c.env.DB, presence)
}
}
@@ -0,0 +1,19 @@
-- Which of a room's tags is its PRIMARY GENRE.
--
-- Generated from packages/domain/src/rooms-db.ts (ROOM_SCHEMA_DDL) — keep in sync.
--
-- The 2023 client picked a genre by toggling one of five "main" tags
-- (`pvp`/`quest`/`game`/`hangout`/`art`) as radio buttons, which the tag row alone could
-- express: whichever of the five was present was the genre. The 2025 client instead posts
-- `primaryGenreTag=<tag>` to `PUT /rooms/{id}/tags` over a genre vocabulary it reads from
-- the `RoomGenreTags` curated list, and it renders the chosen one differently from the
-- room's other tags — so "is this tag the genre" is now a fact about the ROW, separate
-- from whether the tag is there at all. A room can carry `puzzle` and `social` as ordinary
-- tags with only `social` flagged.
--
-- A real column rather than a `type` value: `type` is the client's tag-CATEGORY int
-- (0 user, 1 beta, 2 auto-derived like `rro`) which is echoed back as stored, and the
-- primary genre is orthogonal to it — the flagged tag is still a plain Type 0 user tag.
--
-- Defaults to 0, so every existing row reads as "not the genre", which is what they were.
ALTER TABLE room_tag ADD COLUMN is_primary_genre INTEGER NOT NULL DEFAULT 0;
+38 -4
View File
@@ -132,10 +132,20 @@ export const RoomRoleDto = z.object({
InvitedRole: z.int(),
})
/** A tag on a room. `Type` 0 = set by the owner, 2 = auto-derived (e.g. `rro`). */
/**
* A tag on a room. `Type` 0 = set by the owner, 2 = auto-derived (e.g. `rro`).
*
* `IsPrimaryGenre` marks the one tag that is the room's genre, and is PRESENT ONLY on
* that tag — the key is absent on the others rather than sent as false. It is orthogonal
* to `Type`: the flagged tag is an ordinary owner-set tag that happens to be the genre.
*/
export const RoomTagDto = z.object({
Tag: z.string(),
Type: z.int().describe('0 = owner-set, 2 = auto'),
Type: z.int().describe('0 = owner-set, 1 = client-derived (`autoTag`), 2 = server-derived'),
IsPrimaryGenre: z
.literal(true)
.optional()
.describe('Present only on the rooms primary genre tag; absent, never false, on the rest'),
})
/**
@@ -524,9 +534,33 @@ export const NameRequest = z.object({
name: z.string().describe('Non-empty, and not already taken by another room'),
})
/** `PUT /rooms/{roomId}/tags` — a toggle, not a set. */
/**
* `PUT /rooms/{roomId}/tags` — one route, two bodies, told apart by their FIELDS.
*
* A lone `tag` is the 2023 toggle: added when absent, removed when present. A `tag`
* alongside anything else is part of a whole-state save, where nothing toggles — `tag`
* repeats and is the complete user-tag set, `autoTag` adds a derived (Type 1) tag, and
* `primaryGenreTag` flags the genre. They compose into one write.
*/
export const TagRequest = z.object({
tag: z.string().describe('Added when absent, removed when present'),
tag: z
.union([z.string(), z.array(z.string())])
.optional()
.describe(
'Alone: toggled (added when absent, removed when present). Alongside any other field, or repeated: the COMPLETE set of user (Type 0) tags — an omitted one is removed'
),
autoTag: z
.union([z.string(), z.array(z.string())])
.optional()
.describe(
'A derived tag to add at Type 1 (`limitsv2`, `beta`). Repeatable and additive — never removes one'
),
primaryGenreTag: z
.string()
.optional()
.describe(
'Set as the rooms primary genre. Added as a Type 0 tag if the room lacks it; every other tag keeps its place and loses the flag'
),
})
/** `PUT /rooms/{roomId}/image`. */
+70 -18
View File
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import {
Accessibility,
applyRoomTagEdit,
areFriends,
autocompleteRoomSearch,
banPlayerFromRoom,
@@ -51,7 +52,6 @@ import {
setSubRoomPermissions,
toggleCheer,
toggleFavorite,
toggleRoomTag,
unbanPlayerFromRoom,
updateRoomFields,
} from '@repo/domain'
@@ -1687,25 +1687,54 @@ const app = new Hono<App>()
}
)
// Toggle a tag on a room. Auth-gated (401) and owner/co-owner-only (403). Body is
// the `tag` form field. There's no delete/patch endpoint, so this call toggles: it
// adds the tag (Type 0) if absent and removes it if present. The "main" tags
// (#pvp/#quest/#game/#hangout/#art) are radio buttons — setting one clears the
// others. Returns the `{ success, error, value }` envelope with the updated
// room as `value`; business failures are 200 with success:false.
// Change a room's tags. Auth-gated (401) and owner/co-owner-only (403). Returns the
// `{ success, error, value }` envelope with the updated room as `value`; business
// failures are 200 with success:false.
//
// TWO BODIES reach this one path — Rec Room reshaped the request rather than minting a
// second route, so the fields, not the URL, say which one this is:
//
// - `tag=<name>` ALONE is the 2023 toggle. There is no delete/patch counterpart, so
// the same call adds the tag (Type 0) when absent and removes it when present, and
// the five "main" tags (pvp/quest/game/hangout/art) act as radio buttons.
// - Anything else is the whole-state save both clients send from room settings:
// `autoTag=limitsv2&tag=roleplay&tag=social&tag=sports&primaryGenreTag=roleplay`.
// `tag` repeats and is the complete set of USER tags, `autoTag` adds a derived one
// (Type 1), and `primaryGenreTag` flags the genre. All three compose into one write.
//
// The discriminator is deliberately "is there more than a lone `tag`": a save that
// happens to carry one selected tag must not TOGGLE it back off, which is what made
// this worth spelling out rather than counting `tag` alone.
.put(
'/rooms/:roomId{[0-9]+}/tags',
describeRoute({
tags: ['Room settings'],
summary: 'Toggle a tag on a room',
description: [
'Owner or co-owner only (403 otherwise). There is no delete/patch counterpart, so',
'this call TOGGLES: it adds the tag (Type 0) when absent and removes it when',
'present. The “main” tags (`pvp`/`quest`/`game`/`hangout`/`art`) behave as radio',
'buttons — setting one clears the others. Answers the lowercase envelope with the',
'updated room, which the client re-renders from, and pushes a `RoomUpdate` to the',
'owner for their other sessions.',
].join(' '),
'Owner or co-owner only (403 otherwise). Two bodies reach this one path, and the',
'FIELDS say which — not the URL.',
'',
'**A lone `tag=<name>` TOGGLES** (the 2023 form): there is no delete/patch',
'counterpart, so the same call adds the tag (Type 0) when absent and removes it when',
'present, and the “main” tags (`pvp`/`quest`/`game`/`hangout`/`art`) behave as radio',
'buttons among themselves.',
'',
'**Anything else is a whole-state save** — the form room settings posts, e.g.',
'`autoTag=limitsv2&tag=roleplay&tag=social&tag=sports&primaryGenreTag=roleplay`.',
'Nothing toggles here; the three fields compose into one write:',
'',
'- `tag` repeats and is the COMPLETE set of user (Type 0) tags — one the body omits',
'is removed. Derived tags are not the clients to send and are left alone.',
'- `autoTag` repeats and adds a derived tag at **Type 1** (`limitsv2`, `beta`). It is',
'additive: it never removes one, since the client posts what it wants rather than the',
'full set. A tag already on the room is re-categorised rather than duplicated.',
'- `primaryGenreTag` flags the rooms genre. The tag is added as a Type 0 tag when',
'the room lacks it and left as it stands when it has it; `IsPrimaryGenre: true` moves',
'onto it, and every OTHER tag loses the flag but KEEPS its place.',
'',
'Answers the lowercase envelope with the updated room, which the client re-renders',
'from, and pushes a `RoomUpdate` to the owner for their other sessions.',
].join('\n'),
security: AUTHED,
parameters: [roomIdParam],
requestBody: form(TagRequest, 'The tag to toggle'),
@@ -1726,11 +1755,34 @@ const app = new Hono<App>()
// already returned 401 for a missing/invalid token).
if (!canManageRoom(room, accountId)) return c.body(null, 403)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const tag = typeof body.tag === 'string' ? body.tag.trim() : ''
if (tag === '') return roomEnvelope(c, null, 'You must provide a tag!')
// `all: true` because `tag` REPEATS on the whole-state save; without it Hono keeps
// only the last value and a three-tag save would land as one tag.
const body: Record<string, unknown> = await c.req.parseBody({ all: true }).catch(() => ({}))
// An empty value is the same nothing as an absent field. There is no "clear the
// genre" request, so a blank `primaryGenreTag` is a malformed post rather than an
// instruction to unset — and a blank `tag` can't name what to toggle.
const values = (name: string): string[] =>
(Array.isArray(body[name]) ? body[name] : [body[name]])
.filter((v): v is string => typeof v === 'string')
.map((v) => v.trim())
.filter((v) => v !== '')
const updated = await toggleRoomTag(c.env.DB, roomId, room, tag)
const tags = values('tag')
const autoTags = values('autoTag')
const primaryGenre = values('primaryGenreTag')[0]
if (tags.length === 0 && autoTags.length === 0 && primaryGenre === undefined) {
return roomEnvelope(c, null, 'You must provide a tag!')
}
// A LONE tag is the 2023 toggle; a tag alongside anything else — another tag, an
// auto tag, a genre — is part of a whole-state save, where nothing toggles.
const isToggle = tags.length === 1 && autoTags.length === 0 && primaryGenre === undefined
const updated = await applyRoomTagEdit(c.env.DB, roomId, room, {
toggle: isToggle ? tags[0] : undefined,
tags: isToggle ? undefined : tags.length > 0 ? tags : undefined,
autoTags,
primaryGenre,
})
// This one DOES answer the updated room, so the caller's own client redraws from
// the response; the push is for their other sessions, as on every mutation below.
await pushRoomUpdate(c, accountId, updated)
+175
View File
@@ -2584,6 +2584,181 @@ describe('rooms endpoints', () => {
expect(tagsIn(byCoOwner)).toContain('spooky')
})
it('PUT /rooms/:id/tags sets the primary genre from primaryGenreTag', async () => {
type TagResult = {
success: boolean
error: string
value: { Tags?: Array<{ Tag: string; Type: number; IsPrimaryGenre?: boolean }> } | null
}
const tags = async (res: Response) => ((await res.json()) as TagResult).value?.Tags ?? []
const genre = (list: Awaited<ReturnType<typeof tags>>) =>
list.filter((t) => t.IsPrimaryGenre).map((t) => t.Tag)
// Same gates as the toggle body — the second shape doesn't open a second door.
expect((await putForm('/rooms/4/tags', { primaryGenreTag: 'social' })).status).toBe(401)
expect((await putForm('/rooms/4/tags', { primaryGenreTag: 'social' }, '999')).status).toBe(403)
// A blank value is a malformed post, not "unset the genre".
expect(
await (await putForm('/rooms/4/tags', { primaryGenreTag: ' ' }, '1')).json()
).toMatchObject({ success: false, error: 'You must provide a tag!' })
// Room 4 is seeded with the auto-derived `rro` (Type 2); add an ordinary tag too, so
// the genre can be seen not to disturb either of them.
await putForm('/rooms/4/tags', { tag: 'puzzle' }, '1')
// The genre tag is ADDED when the room lacks it — a Type 0 tag, flagged.
const set = await tags(await putForm('/rooms/4/tags', { primaryGenreTag: 'social' }, '1'))
expect(set).toContainEqual({ Tag: 'social', Type: 0, IsPrimaryGenre: true })
// …and the key is absent on the others rather than false.
expect(set).toContainEqual({ Tag: 'puzzle', Type: 0 })
// Choosing another genre MOVES the flag. Unlike the old five-way radio it does not
// remove the tag it displaced: `social` stays on the room, just no longer the genre.
const moved = await tags(await putForm('/rooms/4/tags', { primaryGenreTag: 'horror' }, '1'))
expect(genre(moved)).toEqual(['horror'])
expect(moved.map((t) => t.Tag).sort()).toEqual(['horror', 'puzzle', 'rro', 'social'])
// A tag the room already carries keeps its Type and simply becomes the genre — the
// seeded `rro` is Type 2 and stays Type 2.
const promoted = await tags(await putForm('/rooms/4/tags', { primaryGenreTag: 'rro' }, '1'))
expect(promoted).toContainEqual({ Tag: 'rro', Type: 2, IsPrimaryGenre: true })
expect(genre(promoted)).toEqual(['rro'])
// Stored on the row, so a cold read says the same thing.
expect(
await env.DB.prepare(
'SELECT tag, is_primary_genre FROM room_tag WHERE room_id = 4 AND is_primary_genre = 1'
).all<{ tag: string; is_primary_genre: number }>()
).toMatchObject({ results: [{ tag: 'rro', is_primary_genre: 1 }] })
const read = (await (await SELF.fetch(`${ORIGIN}/rooms/4`)).json()) as {
Tags: Array<{ Tag: string; IsPrimaryGenre?: boolean }>
}
expect(read.Tags.filter((t) => t.IsPrimaryGenre).map((t) => t.Tag)).toEqual(['rro'])
// The toggle body still works on the same room, and toggling the flagged tag off
// takes the genre with it — the room's genre WAS that tag.
const toggledOff = await tags(await putForm('/rooms/4/tags', { tag: 'rro' }, '1'))
expect(toggledOff.map((t) => t.Tag)).not.toContain('rro')
expect(genre(toggledOff)).toEqual([])
// …while toggling an unrelated tag leaves a genre alone.
await putForm('/rooms/4/tags', { primaryGenreTag: 'social' }, '1')
const other = await tags(await putForm('/rooms/4/tags', { tag: 'campfire' }, '1'))
expect(genre(other)).toEqual(['social'])
// A `tag` alongside the genre is a whole-state save, NOT a toggle: `campfire` stays
// rather than being toggled back off. (The set semantics themselves are next.)
const both = await tags(
await putForm('/rooms/4/tags', { tag: 'campfire', primaryGenreTag: 'puzzle' }, '1')
)
expect(genre(both)).toEqual(['puzzle'])
expect(both.map((t) => t.Tag)).toContain('campfire')
// Put room 4 back the way it was seeded — its `rro` tag is what the rro feeds count.
await env.DB.batch([
env.DB.prepare('DELETE FROM room_tag WHERE room_id = 4'),
env.DB.prepare(
"INSERT INTO room_tag (room_id, tag, type, is_primary_genre) VALUES (4, 'rro', 2, 0)"
),
])
})
it('PUT /rooms/:id/tags takes the whole-state save: repeated tag, autoTag, genre', async () => {
type Tag = { Tag: string; Type: number; IsPrimaryGenre?: boolean }
// A room of this test's own: the whole-state save REPLACES the user tags, and doing
// that to a seeded room would strip tags the feeds above are asserted on.
const ROOM = 9700
await env.DB.prepare('INSERT INTO room (data) VALUES (?1)')
.bind(
JSON.stringify({
RoomId: ROOM,
Name: 'TagSaveRoom',
CreatorAccountId: 1,
IsDorm: false,
Accessibility: 1,
SubRooms: [],
})
)
.run()
const save = async (query: string, sub = '1') => {
const res = await SELF.fetch(`${ORIGIN}/rooms/${ROOM}/tags`, {
method: 'PUT',
headers: {
...(await bearer(sub)),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: query,
})
expect(res.status).toBe(200)
const body = (await res.json()) as { success: boolean; value: { Tags?: Tag[] } | null }
expect(body.success).toBe(true)
return [...(body.value?.Tags ?? [])].sort((a, b) => a.Tag.localeCompare(b.Tag))
}
// The form room settings posts. `tag` repeats — without `all: true` on the parse only
// the last would arrive, and a three-tag save would land as one.
const saved = await save(
'autoTag=limitsv2&tag=roleplay&tag=social&tag=sports&primaryGenreTag=roleplay'
)
expect(saved).toEqual([
{ Tag: 'limitsv2', Type: 1 },
{ Tag: 'roleplay', Type: 0, IsPrimaryGenre: true },
{ Tag: 'social', Type: 0 },
{ Tag: 'sports', Type: 0 },
])
// Idempotent — the same save twice is the same room. This is why a lone `tag` toggles
// but a `tag` in company does not: toggling here would clear the room on every save.
expect(
await save('autoTag=limitsv2&tag=roleplay&tag=social&tag=sports&primaryGenreTag=roleplay')
).toEqual(saved)
// `tag` is the COMPLETE user set: dropping one removes it. The auto tag is NOT the
// client's to send here and survives a save that never mentions it.
expect(await save('tag=roleplay&primaryGenreTag=roleplay')).toEqual([
{ Tag: 'limitsv2', Type: 1 },
{ Tag: 'roleplay', Type: 0, IsPrimaryGenre: true },
])
// autoTag is additive and repeatable, and re-categorises a tag the room already has
// rather than duplicating it — `tag` is the table's key, so there is one row per name.
expect(await save('autoTag=beta&autoTag=roleplay')).toEqual([
{ Tag: 'beta', Type: 1 },
{ Tag: 'limitsv2', Type: 1 },
// Was a Type 0 user tag; posting it as an auto tag moves its category, and the
// genre flag rides along with the row.
{ Tag: 'roleplay', Type: 1, IsPrimaryGenre: true },
])
// An autoTag alone is a valid request — it names no `tag`, and must not be refused
// for it.
const bare = await save('autoTag=limitsv2')
expect(bare.map((t) => t.Tag)).toContain('limitsv2')
// A save that names no user tags at all clears them, leaving the derived ones.
expect((await save('tag=&autoTag=limitsv2')).map((t) => t.Tag)).toEqual([
'beta',
'limitsv2',
'roleplay',
])
// Same gates as every other body.
expect(
(
await SELF.fetch(`${ORIGIN}/rooms/${ROOM}/tags`, {
method: 'PUT',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'autoTag=limitsv2',
})
).status
).toBe(401)
await env.DB.batch([
env.DB.prepare('DELETE FROM room_tag WHERE room_id = ?1').bind(ROOM),
env.DB.prepare('DELETE FROM room WHERE room_id = ?1').bind(ROOM),
])
})
// Tags live in `room_tag`, not in the room blob (migration 0013). These pin the
// invariant that makes that safe: the table is the only copy, and the DTO is rebuilt
// from it on read.
+1
View File
@@ -19,6 +19,7 @@
"@repo/domain": "workspace:*",
"@repo/hono-helpers": "workspace:*",
"@scalar/api-reference": "1.63.0",
"cobe": "2.0.1",
"hono": "4.12.27",
"react": "19.2.7",
"react-dom": "19.2.7",
+400 -4
View File
@@ -1,3 +1,4 @@
import createGlobe from 'cobe'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Accessibility } from '@repo/domain/src/enums'
@@ -13,6 +14,7 @@ import {
SOURCE_REPO,
} from '../links'
import type { COBEOptions, Globe, Marker } from 'cobe'
import type { ReactNode } from 'react'
/**
@@ -777,6 +779,7 @@ function HomePage({
<Stage slides={feed.slides} offerSignup={offerSignup} navigate={navigate} />
<div className="shell home">
<About slides={feed.slides} error={feed.error} />
<PlayersWorldwide />
</div>
</main>
)
@@ -943,6 +946,399 @@ function About({ slides, error }: { slides: Slide[] | null; error: string }) {
)
}
/* ---- Who's playing, and where ------------------------------------------- */
/** One pin from `/server-status/locations`: a grid cell and how many players are in it. */
interface Pin {
lat: number
lon: number
/** ISO 3166-1 alpha-2, or `XX` when the edge couldn't name a country. */
country: string
players: number
}
/**
* The whole answer from `/server-status/locations`. `players` is everyone online and
* `located` only those with a pin a player the edge couldn't place is counted in the
* first and missing from the second, so the section can say so rather than quietly
* showing a smaller number than the rest of the page.
*/
interface WorldPresence {
players: number
located: number
pins: Pin[]
}
/** How often the globe re-asks who's online. */
const GLOBE_POLL_MS = 30_000
/**
* Poll `www` for where the online players are. `presence === null` means the first
* answer hasn't landed yet.
*
* Same-origin, so unlike the photo feed this doesn't wait on the config `www` serves
* it itself. Polling stops while the tab is hidden and asks again on the way back, so a
* page left open in a background tab overnight isn't a few thousand requests. A failed
* poll keeps the last good answer on screen: a globe that empties out because one
* request timed out reads as "everyone left", which is worse than being 30s stale.
*/
function useWorldPresence(): { presence: WorldPresence | null; error: string } {
const [presence, setPresence] = useState<WorldPresence | null>(null)
const [error, setError] = useState('')
useEffect(() => {
let live = true
let timer: ReturnType<typeof setTimeout> | undefined
// Function declarations, not consts: `schedule` names `poll` and `poll` names
// `schedule`, and hoisting is what lets them be written in reading order.
function schedule() {
clearTimeout(timer)
if (!live || document.hidden) return
timer = setTimeout(poll, GLOBE_POLL_MS)
}
function poll() {
call<WorldPresence>('/server-status/locations')
.then((next) => {
if (!live) return
setPresence(next)
setError('')
})
.catch((e: unknown) => {
if (live) setError(e instanceof Error ? e.message : String(e))
})
.finally(schedule)
}
// Coming back to a tab that was away: answer now, rather than after a timer that
// was deliberately never armed while it was hidden.
const onVisibility = () => {
if (!document.hidden) poll()
}
document.addEventListener('visibilitychange', onVisibility)
poll()
return () => {
live = false
clearTimeout(timer)
document.removeEventListener('visibilitychange', onVisibility)
}
}, [])
return { presence, error }
}
/** Radians the globe turns per frame when nobody is steering it. */
const GLOBE_SPIN_PER_FRAME = 0.0028
/** Radians per pixel of drag — cobe's own demo figure, and it feels right. */
const GLOBE_DRAG_PER_PX = 1 / 200
/**
* Where the spin starts. Arbitrary the globe turns continuously, so this only decides
* which face the first second shows; nudge it if that first face keeps landing on ocean.
*/
const GLOBE_START_PHI = 4.1
/** A pin's dot size, from the smallest that reads to one that still isn't a blob. */
const PIN_MIN_SIZE = 0.028
const PIN_MAX_SIZE = 0.075
/**
* Pins cobe markers. Sized by head-count against the busiest cell so a crowd reads as
* one, on a square root because area is what the eye compares: scaling the radius
* linearly makes four players look sixteen times the size of one.
*/
function pinMarkers(pins: Pin[]): Marker[] {
const busiest = pins.reduce((n, pin) => Math.max(n, pin.players), 1)
return pins.map((pin) => ({
location: [pin.lat, pin.lon],
size: PIN_MIN_SIZE + (PIN_MAX_SIZE - PIN_MIN_SIZE) * Math.sqrt(pin.players / busiest),
}))
}
/** cobe wants colours as 01 RGB triples, so the palette is repeated here in its terms. */
const GLOBE_THEME = {
// Warm dark: the surface the screenshots are lit against (--surface-hi / --line).
dark: {
dark: 1,
baseColor: [0.21, 0.17, 0.13],
glowColor: [0.31, 0.24, 0.17],
markerColor: [1, 0.44, 0.004], // --accent #FE7101
mapBrightness: 5.4,
},
light: {
dark: 0,
baseColor: [0.86, 0.84, 0.81],
glowColor: [1, 0.99, 0.97],
markerColor: [0.88, 0.37, 0], // --accent #E05F00
mapBrightness: 2.2,
},
} as const
/**
* The globe itself: a dotted earth with a pin per populated cell, spinning slowly and
* draggable.
*
* Drawn by `cobe`, a ~13KB WebGL globe that takes markers as plain lat/lon and does the
* projection no three.js, no map tiles and no network of its own, which is what makes
* it affordable on a page whose point is the hero photo above it.
*
* Purely the picture: every number it shows lives in the list beside it too, so a
* browser with no WebGL (or a reader who isn't looking at pixels) loses nothing. That's
* also why the canvas is aria-hidden rather than labelled.
*/
function PlayerGlobe({ pins }: { pins: Pin[] }) {
const canvas = useRef<HTMLCanvasElement>(null)
const box = useRef<HTMLDivElement>(null)
const [failed, setFailed] = useState(false)
const [theme, setTheme] = useState<'dark' | 'light'>(() =>
typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: light)').matches
? 'light'
: 'dark'
)
// New markers are handed to the running globe rather than rebuilding it, so a poll
// doesn't restart the spin. The flag is what keeps the buffer upload to the frames
// where something actually changed instead of all sixty a second.
const markers = useRef<Marker[]>(pinMarkers(pins))
const markersChanged = useRef(true)
useEffect(() => {
markers.current = pinMarkers(pins)
markersChanged.current = true
}, [pins])
// How far the pointer has dragged the globe, in radians. A ref, not state: it changes
// on every pointermove and the animation loop is the only thing that reads it, so
// re-rendering React for it would be sixty wasted renders a second.
const nudge = useRef(0)
const dragFrom = useRef<number | null>(null)
// The site follows the system theme with no toggle of its own (see styles.css), so
// this listens for the same switch the CSS does and rebuilds the globe in the other
// palette — cobe takes its colours at creation.
useEffect(() => {
if (typeof matchMedia !== 'function') return
const query = matchMedia('(prefers-color-scheme: light)')
const onChange = () => setTheme(query.matches ? 'light' : 'dark')
query.addEventListener('change', onChange)
return () => query.removeEventListener('change', onChange)
}, [])
useEffect(() => {
const surface = canvas.current
const frame = box.current
if (!surface || !frame) return
let globe: Globe | null = null
let request = 0
let phi = GLOBE_START_PHI
let size = 0
let sizeChanged = false
// The auto-spin is decoration, and a globe that never stops moving is exactly what
// this setting is for. The pins (and the drag) still work.
const still =
typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches
function draw() {
if (!globe) return
// Only the parts that changed: cobe reallocates the drawing buffer whenever it's
// handed a width, which would clear the canvas on every single frame.
const next: Partial<COBEOptions> = {}
if (sizeChanged) {
next.width = size
next.height = size
sizeChanged = false
}
if (markersChanged.current) {
next.markers = markers.current
markersChanged.current = false
}
// A hand on the globe stops the drift, and it picks back up from wherever it was
// let go rather than snapping to where it would have got to.
if (!still && dragFrom.current === null) phi += GLOBE_SPIN_PER_FRAME
next.phi = phi + nudge.current
globe.update(next)
request = requestAnimationFrame(draw)
}
function begin() {
// Nothing to draw into yet — the observer calls back again once there is.
if (globe || size === 0) return
try {
globe = createGlobe(surface!, {
devicePixelRatio: Math.min(devicePixelRatio || 1, 2),
width: size,
height: size,
phi,
// Tilted a little north: most of the pins are, and a globe seen dead-on from
// the equator reads as a flat circle.
theta: 0.22,
diffuse: 1.2,
mapSamples: 14000,
markers: markers.current,
...GLOBE_THEME[theme],
// The palette is readonly (`as const`), which the option type isn't.
baseColor: [...GLOBE_THEME[theme].baseColor],
glowColor: [...GLOBE_THEME[theme].glowColor],
markerColor: [...GLOBE_THEME[theme].markerColor],
})
} catch {
// No WebGL, or a context the browser refused to give. The list beside this
// carries every number the globe was going to show, so drop the canvas and
// leave the section otherwise intact.
setFailed(true)
return
}
markersChanged.current = false
request = requestAnimationFrame(draw)
}
// Square, and sized from the layout rather than from a constant, so the globe fills
// its column at every breakpoint instead of being letterboxed on one of them.
const observer = new ResizeObserver(() => {
const width = Math.round(frame.clientWidth)
if (width === 0 || width === size) return
size = width
sizeChanged = true
begin()
})
observer.observe(frame)
return () => {
observer.disconnect()
cancelAnimationFrame(request)
globe?.destroy()
}
}, [theme])
if (failed) return null
return (
<div className="globe-frame" ref={box}>
<canvas
className="globe-canvas"
ref={canvas}
// Decorative: `PlayersWorldwide` states the head-count in words and lists every
// country beside it, so there is nothing here for a screen reader to miss.
aria-hidden="true"
onPointerDown={(e) => {
dragFrom.current = e.clientX
e.currentTarget.setPointerCapture(e.pointerId)
}}
onPointerMove={(e) => {
if (dragFrom.current === null) return
nudge.current += (e.clientX - dragFrom.current) * GLOBE_DRAG_PER_PX
dragFrom.current = e.clientX
}}
onPointerUp={(e) => {
dragFrom.current = null
e.currentTarget.releasePointerCapture(e.pointerId)
}}
onPointerCancel={() => {
dragFrom.current = null
}}
/>
</div>
)
}
/** Country codes to names, once — building an Intl formatter per row is not free. */
const countryNames =
typeof Intl.DisplayNames === 'function' ? new Intl.DisplayNames(['en'], { type: 'region' }) : null
/** A country code as something to read. `XX` is the edge declining to name one. */
function countryName(code: string): string {
if (code === 'XX') return 'Somewhere else'
return countryNames?.of(code) ?? code
}
/** Players per country, busiest first — the pins in a cell-by-cell list's stead. */
function byCountry(pins: Pin[]): Array<{ country: string; players: number }> {
const totals = new Map<string, number>()
for (const pin of pins) totals.set(pin.country, (totals.get(pin.country) ?? 0) + pin.players)
return [...totals]
.map(([country, players]) => ({ country, players }))
.sort(
(a, b) =>
b.players - a.players || countryName(a.country).localeCompare(countryName(b.country))
)
}
/** How many countries to name before the rest become "and n more". */
const COUNTRY_ROWS = 6
/**
* "People are playing this right now, from all over" the claim the rest of the page
* makes in words, shown instead.
*
* The globe is the illustration and the list is the content: everything the pins say is
* written out beside them, which is what lets the canvas be decorative (and lets the
* whole thing degrade to a list where WebGL isn't available).
*/
function PlayersWorldwide() {
const { presence, error } = useWorldPresence()
const pins = presence?.pins ?? []
const countries = byCountry(pins)
return (
<section className="globe" aria-labelledby="globe-title">
<div className="globe-copy">
<h2 className="about-title" id="globe-title">
Somebody is playing right now
</h2>
{presence === null ? (
<p className="about-lede">
{error ? "Can't reach the servers to ask who's online." : 'Counting whos on…'}
</p>
) : presence.located === 0 ? (
<p className="about-lede">
{presence.players > 0
? `${presence.players.toLocaleString()} online — nobody placed on the map yet.`
: 'Nobody is online this second. The servers are up; be the first one on.'}
</p>
) : (
<>
<p className="globe-count">
<strong>{presence.located.toLocaleString()}</strong>{' '}
{presence.located === 1 ? 'player' : 'players'} in {countries.length}{' '}
{countries.length === 1 ? 'country' : 'countries'}, right now.
</p>
<ul className="globe-list">
{countries.slice(0, COUNTRY_ROWS).map((row) => (
<li key={row.country}>
<span>{countryName(row.country)}</span>
<span className="globe-tally">{row.players.toLocaleString()}</span>
</li>
))}
{countries.length > COUNTRY_ROWS && (
<li className="globe-more">
<span>and {countries.length - COUNTRY_ROWS} more</span>
</li>
)}
</ul>
{/* The head-count and the map can disagree say which, rather than
letting the smaller number look like the answer. */}
{presence.players > presence.located && (
<p className="globe-note">
{presence.players - presence.located} more online from somewhere we couldn&apos;t
place.
</p>
)}
</>
)}
{/* Not a disclaimer in the footer: people see a map of themselves and want to
know how precise it is, so it says so where they're looking. */}
<p className="globe-note">
Pins are rounded to about 55km before anyone stores them, and nobody&apos;s address is
kept see the <a href="/privacy">privacy policy</a>.
</p>
</div>
<PlayerGlobe pins={pins} />
</section>
)
}
/**
* The sign-in page sign in, plus create-account when the server says signup is open
* (it needs a Turnstile keypair; see SiteConfig). Redirects to the account page once a
@@ -1382,10 +1778,10 @@ function BlobUpload({
<span className="badge beta">Beta</span>
</p>
<p className="muted blob-upload-caveat">
New and lightly tested. Nothing here checks the file the server stores whatever it
is and the game finds out on load. This server runs the {CLIENT_BUILD_DATE} build, so
scene data from a room built on anything newer may not load at all. Download the save
above and keep it before replacing it.
New and lightly tested. Nothing here checks the file the server stores whatever it is and
the game finds out on load. This server runs the {CLIENT_BUILD_DATE} build, so scene data
from a room built on anything newer may not load at all. Download the save above and keep it
before replacing it.
</p>
<label className="blob-upload-file">
Scene data file
+126
View File
@@ -403,6 +403,118 @@ body {
background: var(--error);
}
/* ---- Who's playing, and where ------------------------------------------- */
/*
* The same split as .about, mirrored: the numbers on the left, the globe on the right,
* under the photo that's already on that side. The globe is the illustration and the
* list is the content everything the pins say is written out beside them, so the
* section still reads with no WebGL (PlayerGlobe renders nothing at all in that case,
* and the copy column simply takes the width).
*/
.globe {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 380px);
gap: 32px 48px;
align-items: center;
padding: 8px 0 56px;
}
.globe-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 14px;
min-width: 0;
}
/* The one number the section exists to say. Tabular figures so a poll landing on a
different count doesn't shift the words after it. */
.globe-count {
margin: 0;
font-size: 1.05rem;
color: var(--muted);
font-variant-numeric: tabular-nums;
}
.globe-count strong {
font-family: var(--display);
font-weight: 700;
font-size: 1.6rem;
letter-spacing: -0.02em;
color: var(--text);
margin-right: 2px;
}
/* Country, then tally, on rules rather than in a box: this sits directly under the
lede and a bordered card here would read as a second, competing surface. */
.globe-list {
list-style: none;
margin: 2px 0 0;
padding: 0;
width: 100%;
max-width: 340px;
}
.globe-list li {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
padding: 7px 0;
border-bottom: 1px solid var(--line);
font-size: 0.95rem;
}
.globe-list li:last-child {
border-bottom: none;
}
.globe-tally {
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--text);
}
.globe-more {
color: var(--muted);
font-style: italic;
}
.globe-note {
margin: 0;
font-size: 0.8rem;
color: var(--muted);
}
.globe-note a {
color: inherit;
}
/* Square and sized from the layout: PlayerGlobe measures this box and hands cobe the
width, so the canvas has to be told its own size in CSS rather than inheriting one
from the drawing buffer. */
.globe-frame {
position: relative;
width: 100%;
aspect-ratio: 1;
justify-self: end;
}
.globe-canvas {
width: 100%;
height: 100%;
/* The globe is draggable, so it says so and never steals a page scroll on touch,
which `touch-action: none` on a full-width element would. */
cursor: grab;
touch-action: pan-y;
contain: layout paint;
}
.globe-canvas:active {
cursor: grabbing;
}
.cta {
display: inline-block;
font-family: var(--body);
@@ -1098,6 +1210,20 @@ button[type='submit']:disabled {
gap: 26px;
padding-top: 40px;
}
/* One column too but the globe goes first: stacked, it's the thing worth
scrolling to, and a list of countries above it reads as a table of nothing. */
.globe {
grid-template-columns: 1fr;
gap: 24px;
padding-bottom: 40px;
}
.globe-frame {
order: -1;
justify-self: center;
max-width: 340px;
}
}
@media (max-width: 620px) {
+3 -1
View File
@@ -34,7 +34,7 @@ import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL, SOURCE_REPO } from './links'
*/
/** Last substantive revision, shown in the header. Bump when the text changes. */
const EFFECTIVE_DATE = '26 July 2026'
const EFFECTIVE_DATE = '25 August 2026'
/** The palette and type of the main site, inlined — this page loads no stylesheet. */
const STYLES = `
@@ -246,6 +246,7 @@ export function privacyPage(): string {
<li>Your relationships with other players friends, invites and blocks and your interactions with rooms, such as favourites and cheers.</li>
<li>Your in-game economy: token balance, inventory, outfits and gifts received.</li>
<li>Your presence which room instance you are currently in so friends can find you and join. Presence records expire automatically on their own.</li>
<li>An approximate location, worked out by our hosting provider from the IP address your game connects on, and stored on that presence record <em>instead of</em> the address. It is rounded to roughly 55 kilometres before it is stored, so it identifies a region, not a place and it disappears with the presence record when you stop playing.</li>
<li>Your player settings and preferences.</li>
</ul>
@@ -254,6 +255,7 @@ export function privacyPage(): string {
<li><strong>To run the game.</strong> Nearly everything above exists so the world can be reassembled the next time you log in your avatar, your rooms, your inventory, your photos, your conversations.</li>
<li><strong>To sign you in.</strong> Your platform identity, password hash and session tokens are what prove an account is yours and stop anyone else using it.</li>
<li><strong>To let players find each other.</strong> Presence, friend lists and public feeds including the photo slideshow on this website's front page, which shows public in-game photos along with the username of the player who took each one.</li>
<li><strong>To show that people are playing.</strong> This website's front page has a globe of where the players who are online right now are. It is drawn from the approximate locations above, counted per region before it leaves the server so it shows how many players are in an area, never who they are, and no address or individual location is ever sent to the page.</li>
<li><strong>To keep the server usable.</strong> IP addresses, device identifiers and logs are used to investigate abuse, ban evasion and bugs, and to limit how many accounts can be created from one place. This is the only reason we keep them.</li>
<li><strong>To contact you, if you asked us to.</strong> An email address you add is used for account recovery and account notices, nothing else.</li>
</ul>
+68 -1
View File
@@ -1,13 +1,18 @@
import { adminSecretsStore, env, SELF } from 'cloudflare:test'
import { beforeAll, expect, it } from 'vitest'
import { PRESENCE_SCHEMA_DDL, PRESENCE_TTL_SECONDS } from '@repo/domain/src/presence-db'
import {
PRESENCE_SCHEMA_DDL,
PRESENCE_TTL_SECONDS,
presenceGeoFromCf,
} from '@repo/domain/src/presence-db'
import { DOCUMENTED_SERVICES } from '../../docs'
import { DISCORD_INVITE, ISSUES_URL, PRIVACY_EMAIL } from '../../links'
import { turnstileKeys } from '../../turnstile'
import { postAuthForm, readAuthError } from '../../upstream'
import type { PresenceGeo } from '@repo/domain/src/presence-db'
import type { Env } from '../../context'
declare module 'cloudflare:test' {
@@ -260,6 +265,68 @@ it('serves a public head-count of the players actually online', async () => {
expect(await res.json()).toEqual({ status: 'online', players: 2 })
})
// The globe on the front page. Two things are pinned here that a rendering bug wouldn't
// catch: that the response carries COUNTS per grid cell and no per-player row (the whole
// reason locations are stored coarsened in the first place), and that `players` and
// `located` are allowed to disagree — a player the edge couldn't place is online without
// being on the map, and the page says so rather than showing the smaller number.
it('serves player locations as counts per grid cell, never per player', async () => {
const now = Math.floor(Date.now() / 1000)
await env.DB.prepare('DELETE FROM presence').run()
const write = (accountId: number, expiresAt: number, geo: PresenceGeo | null) =>
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(JSON.stringify({ accountId, roomInstance: null, expiresAt, geo: geo ?? undefined }))
.run()
const live = now + PRESENCE_TTL_SECONDS
// Two players in one cell, one in another, one online but unplaceable, one lapsed.
await write(1, live, { lat: 34, lon: -118.5, country: 'US' })
await write(2, live, { lat: 34, lon: -118.5, country: 'US' })
await write(3, live, { lat: 51.5, lon: 0, country: 'GB' })
await write(4, live, null)
await write(5, now - 1, { lat: 34, lon: -118.5, country: 'US' })
const res = await SELF.fetch('https://example.com/server-status/locations', {
headers: { origin: 'https://s.example' },
})
expect(res.status).toBe(200)
// Public like the head-count beside it.
expect(res.headers.get('access-control-allow-origin')).toBe('*')
expect(await res.json()).toEqual({
// Everyone unexpired, including the player with no location…
players: 4,
// …who is the reason these two differ.
located: 3,
// Busiest cell first, and the two in one cell are ONE pin — not two rows that
// happen to share coordinates, which would be a per-player list in disguise.
pins: [
{ lat: 34, lon: -118.5, country: 'US', players: 2 },
{ lat: 51.5, lon: 0, country: 'GB', players: 1 },
],
})
})
// The blur is applied on the way IN, so the database itself never holds a fine
// coordinate — pinned because doing it at read time would look identical from the
// outside and be worth much less.
it('snaps a location to the grid and refuses to name a pseudo-country', () => {
expect(presenceGeoFromCf({ latitude: '34.0522', longitude: '-118.2437', country: 'US' })).toEqual(
{ lat: 34, lon: -118, country: 'US' }
)
// Cleanly on the grid, not 34.900000000000006 — two spellings of one cell would
// group into two pins sitting on top of each other.
expect(presenceGeoFromCf({ latitude: '34.8', longitude: '0.1', country: 'gb' })).toEqual({
lat: 35,
lon: 0,
country: 'GB',
})
// `T1` is Tor, not a country.
expect(presenceGeoFromCf({ latitude: '0', longitude: '0', country: 'T1' })?.country).toBe('XX')
// No `cf` at all is the ordinary local-dev case, and must not become a pin at (0, 0).
expect(presenceGeoFromCf(undefined)).toBeNull()
expect(presenceGeoFromCf({ country: 'US' })).toBeNull()
})
it('serves the aggregated docs page with a source per documented service', async () => {
const res = await SELF.fetch('https://example.com/docs')
expect(res.status).toBe(200)
+22 -1
View File
@@ -1,7 +1,7 @@
import { Hono } from 'hono'
import { useWorkersLogger } from 'workers-tagged-logger'
import { countOnlinePlayers } from '@repo/domain/src/presence-db'
import { countOnlinePlayers, countOnlinePlayersByLocation } from '@repo/domain/src/presence-db'
import { logger, withDefaultCors, withOnError } from '@repo/hono-helpers'
import { authUnreachable } from './auth-messages'
@@ -97,6 +97,27 @@ const app = new Hono<App>()
})
})
// Where those players are, for the globe on the homepage: one pin per populated grid
// cell with a head-count, and nothing else. Same open CORS as the head-count above.
//
// No address ever reaches this worker, let alone the browser. A player's location is
// resolved by the EDGE from the IP their own game client's request arrived on, snapped
// to a ~55km grid before it is stored, and grouped into counts by the database — so
// the finest thing that exists to serve is "n players somewhere in this cell", and
// there is no per-player row to leak even if this route were made to return more.
//
// `players` is everyone online and `located` only those with a pin, because they can
// differ (a player the edge can't place, or one whose row predates geo) and a globe
// showing eight pins under a headline reading twelve looks broken rather than partial.
.get('/server-status/locations', withDefaultCors(), async (c) => {
const pins = await countOnlinePlayersByLocation(c.env.DB)
return c.json({
players: await countOnlinePlayers(c.env.DB),
located: pins.reduce((n, pin) => n + pin.players, 0),
pins,
})
})
// ---- Signup -------------------------------------------------------------
// Create an account from the website, behind a Turnstile bot check. The check is what
+11 -2
View File
@@ -30,7 +30,9 @@
// SPA `not_found_handling`, a top-level *navigation* to a non-asset path (browsers
// send `Sec-Fetch-Mode: navigate`) is served index.html WITHOUT invoking the Worker,
// so a browser hitting `/docs` got the homepage. Keep this in sync with the Worker's
// routes (`/api/*`, `/docs*`, `/privacy` and `/server-status`).
// routes (`/api/*`, `/docs*`, `/privacy` and `/server-status*`) including SUB-routes:
// `/server-status` matches that exact path only, so `/server-status/locations` (the
// globe's pins) needed its own pattern or it silently served the homepage instead.
// `/docs/scalar.standalone.js` is
// deliberately excluded so it's served directly as the static asset it is.
//
@@ -40,7 +42,14 @@
"assets": {
"binding": "ASSETS",
"not_found_handling": "single-page-application",
"run_worker_first": ["/api/*", "/docs", "/docs/openapi/*", "/privacy", "/server-status"]
"run_worker_first": [
"/api/*",
"/docs",
"/docs/openapi/*",
"/privacy",
"/server-status",
"/server-status/*"
]
},
// The Turnstile keypair guarding web signup, out of the same account-level Secrets
// Store every other worker binds for JWT_SECRET values live there, never in this
+8 -3
View File
@@ -63,10 +63,15 @@ export const CURATED_LIST_SCHEMA_DDL: string[] = [
/**
* One curated list as the client parses it, whether it came out of D1 or out of a static
* capture. `Description` may be null but `ImageName` must be a STRING the client reads it
* straight into a string field and `ItemIds` are strings even where they stand for
* capture. `Description` may be null, and `ItemIds` are strings even where they stand for
* numeric ids, which is what the working captures carry.
*
* `ImageName` is a STRING on every list the client draws a TILE for it reads it straight
* into a string field, and empty or null renders that tile blank. It is nullable only
* because one capture (`RoomGenreTags`, whose `ItemIds` are genre names rather than rooms)
* is served with a null, having no tile to draw. A stored list always has a string; don't
* reach for the null on anything the client renders as a row.
*
* `ListId` is a string HERE ONLY and never reaches the client as one: the `lists` worker's
* `serializeCuratedList` puts the digits back on the wire unquoted, because the client's
* field is a number and a quoted id fails its parser. It stays a string even though a
@@ -78,7 +83,7 @@ export interface CuratedList {
CreatorAccountId: number
Name: string
Description: string | null
ImageName: string
ImageName: string | null
Type: number
ItemIds: string[]
Accessibility?: number
+122
View File
@@ -69,6 +69,82 @@ export const PRESENCE_SCHEMA_DDL: string[] = [
`CREATE INDEX IF NOT EXISTS idx_presence_expires ON presence (expires_at)`,
]
/**
* How coarse a stored player location is, in degrees about 55km at the equator.
*
* Coordinates are snapped to this grid BEFORE they are written, so nothing finer than a
* grid cell ever reaches the database. The only thing that reads them is the globe on
* the website, where the whole earth renders a few hundred pixels across and a cell is
* comfortably under one pixel so the blur costs the picture nothing, and a leak of the
* presence table still can't put anybody in a particular town.
*/
export const GEO_GRID_DEGREES = 0.5
/**
* A live player's approximate location, derived from the IP their request arrived on and
* stored INSTEAD of it presence never holds an address, and nothing downstream can
* recover one from this.
*
* Cloudflare resolves the address at the edge and hands us the result on `request.cf`, so
* there's no third-party lookup and no IP for our own code to handle. What arrives is
* already city-grade at best; {@link presenceGeoFromCf} snaps it to GEO_GRID_DEGREES on
* top of that.
*/
export interface PresenceGeo {
/** Latitude, snapped to the grid. */
lat: number
/** Longitude, snapped to the grid. */
lon: number
/** ISO 3166-1 alpha-2, uppercased — `XX` when the edge didn't name a real country. */
country: string
}
/**
* The part of `request.cf` a location is read from the whole of the contract with the
* edge, in one place. Cloudflare sends all three as strings.
*/
export interface GeoProperties {
latitude?: string | null
longitude?: string | null
country?: string | null
}
/**
* The location to stamp on a presence row, or null when the request carries none.
*
* Null is the ordinary case in local dev (miniflare sets no `cf`) and for any address the
* edge can't place, so callers carry the row's previous location forward rather than
* blanking it a player who keeps heartbeating shouldn't drop off the globe because one
* request arrived without geolocation.
*/
export function presenceGeoFromCf(properties: unknown): PresenceGeo | null {
// `unknown` rather than {@link GeoProperties}, because callers pass `c.req.raw.cf`,
// which Hono types as the union of the incoming and OUTGOING `cf` shapes — and the
// outgoing one has no geolocation on it at all, so nothing narrower accepts the
// argument every caller actually has. Every read below already tolerates a miss.
const cf = properties as GeoProperties | undefined | null
const lat = Number.parseFloat(String(cf?.latitude ?? ''))
const lon = Number.parseFloat(String(cf?.longitude ?? ''))
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null
// `country` is alpha-2 for a real country and something else for the pseudo-countries
// (`T1` is Tor); the shape test is what sorts them, so a new one can't leak through.
const country = typeof cf?.country === 'string' ? cf.country.toUpperCase() : ''
return {
lat: snapToGeoGrid(lat),
lon: snapToGeoGrid(lon),
country: /^[A-Z]{2}$/.test(country) ? country : 'XX',
}
}
/** Round a coordinate onto the GEO_GRID_DEGREES grid. */
function snapToGeoGrid(value: number): number {
// Re-rounded through toFixed because binary floats don't land on clean multiples
// (34.9 / 0.5 * 0.5 is 34.900000000000006), and two spellings of one cell would GROUP
// BY into two pins sitting on top of each other. Adding 0 normalises -0 to 0, which
// would otherwise be a third.
return Number((Math.round(value / GEO_GRID_DEGREES) * GEO_GRID_DEGREES + 0).toFixed(4))
}
/**
* The presence a caller writes the room instance the player is in plus the
* status fields the heartbeat echoes. Generic over the room-instance shape so each
@@ -89,6 +165,12 @@ export interface PresenceInput<TRoomInstance = unknown> {
* matchmake supplies one.
*/
loginLock?: string
/**
* Roughly where the player is, from the IP the write arrived on (see
* {@link PresenceGeo}). Absent when the request carried no geolocation, and only ever
* read in aggregate the website's globe counts players per grid cell.
*/
geo?: PresenceGeo
}
/** A stored presence row — the input plus its absolute expiry (epoch seconds). */
@@ -190,6 +272,46 @@ export async function countOnlinePlayers(db: D1Database, now = nowSeconds()): Pr
return row?.n ?? 0
}
/**
* Where the players who are online right now are, one entry per populated
* GEO_GRID_DEGREES cell the pins behind the website's globe.
*
* Aggregated in SQL rather than by reading the rows out, so what leaves the database is
* already a count per cell: no caller ever holds a list of individual players and their
* locations, which is the point (see {@link PresenceGeo}). Rows written before geo
* existed, and players the edge couldn't place, have no `$.geo` and are simply left out
* so the totals here can be lower than {@link countOnlinePlayers}, and callers should
* report both rather than passing this sum off as the player count.
*
* Grouped on the JSON path rather than a generated column: `presence` is bounded by
* account count and this is a once-per-poll read, so the scan is cheaper than a schema
* change on a table another worker owns.
*/
export async function countOnlinePlayersByLocation(
db: D1Database,
now = nowSeconds()
): Promise<PresenceLocation[]> {
const { results } = await db
.prepare(
`SELECT json_extract(data, '$.geo.lat') AS lat,
json_extract(data, '$.geo.lon') AS lon,
json_extract(data, '$.geo.country') AS country,
COUNT(*) AS players
FROM presence
WHERE expires_at > ?1 AND json_extract(data, '$.geo.lat') IS NOT NULL
GROUP BY lat, lon, country
ORDER BY players DESC, country, lat, lon`
)
.bind(now)
.all<PresenceLocation>()
return results
}
/** One populated grid cell — a pin on the globe, and how many players are in it. */
export interface PresenceLocation extends PresenceGeo {
players: number
}
/**
* Live head-count per ROOM, keyed by room id the players standing in any of a
* room's instances right now. One grouped query rather than a count per room, so
+160 -36
View File
@@ -63,10 +63,16 @@ export const ROOM_SCHEMA_DDL: string[] = [
// feed (a discovery category row, a `#tag` search) select in SQL instead of parsing
// every room blob to ask. `type` is the client's tag-category int — 0 user, 2 the
// auto-derived ones like `rro` — echoed back as stored.
//
// `is_primary_genre` (migrations/0015_room_tag_primary_genre.sql) flags the ONE tag
// that is the room's genre, which the 2025 client sets with `primaryGenreTag=` and
// draws differently from the rest. It is orthogonal to `type`: the flagged tag is
// still an ordinary Type 0 user tag, and a room carries other tags alongside it.
`CREATE TABLE IF NOT EXISTS room_tag (
room_id INTEGER NOT NULL,
tag TEXT NOT NULL,
type INTEGER NOT NULL DEFAULT 0,
is_primary_genre INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (room_id, tag)
)`,
`CREATE INDEX IF NOT EXISTS idx_room_tag_tag ON room_tag (tag)`,
@@ -451,47 +457,150 @@ export async function setRoomRole(
}
/**
* Mutually-exclusive "main" room tags. The UI presents these as radio buttons, so
* setting one clears any other main tag. Compared case-insensitively.
* Mutually-exclusive "main" room tags. The 2023 UI presents these as radio buttons, so
* toggling one on clears any other main tag. Compared case-insensitively.
*
* Only the TOGGLE body obeys this the newer whole-set body says outright which tags the
* room has, and its genre is the `IsPrimaryGenre` flag rather than membership of this set.
*/
const MAIN_TAGS = new Set(['pvp', 'quest', 'game', 'hangout', 'art'])
/**
* Add a user tag (`Type: 0`) to a room's tags, or remove it when it's already there
* (case-insensitive). The caller supplies the already-loaded room (owner-checked) to avoid
* a re-read. Returns the updated room.
* A tag's `Type` — the client's tag CATEGORY, echoed back as stored.
*
* Only `room_tag` is written the room blob no longer carries tags at all, so the room row
* is left alone. The whole resulting set is written rather than a single insert/delete, so
* the radio-button behaviour below stays one atomic batch.
* `user` is what a player types or picks. `auto` is what the client derives about the room
* and posts as `autoTag` (`limitsv2`, `beta`). `derived` is this server's own (`rro`).
* A tag's category is orthogonal to whether it is the room's primary genre.
*/
export async function toggleRoomTag(
export const RoomTagType = {
user: 0,
auto: 1,
derived: 2,
} as const
/**
* The tag changes ONE `PUT /rooms/{id}/tags` request asks for. Every field is optional and
* they compose: a single request may replace the user tags, add a derived one and move the
* genre, and it is applied as one write.
*/
export interface RoomTagEdit {
/**
* The 2023 single-tag TOGGLE: the tag is added when the room lacks it and removed when
* it has it, and adding one of {@link MAIN_TAGS} clears the others.
*/
toggle?: string
/**
* The whole set of USER tags, replacing every `Type: 0` tag the room carries. The
* derived tags (`auto`, `derived`) are not the client's to send and are left alone.
*/
tags?: string[]
/**
* Tags to ensure present at `Type: 1`. Additive nothing here removes an auto tag,
* since the client posts the ones it wants rather than the full set. A tag already on
* the room is re-categorised rather than duplicated.
*/
autoTags?: string[]
/**
* The tag to flag as the room's genre. Added (as a user tag) when the room lacks it;
* every other tag keeps its place and loses the flag.
*/
primaryGenre?: string
}
/** A tag's name, lowercased — every comparison in here is case-insensitive. */
const tagKey = (t: RoomTag): string => String(t?.Tag).toLowerCase()
/**
* Apply one request's worth of tag changes to a room and store the result. The caller
* supplies the already-loaded (owner-checked) room, so nothing is re-read.
*
* The changes are composed into ONE set and written once: a request naming tags, an auto
* tag and a genre is a single state for the room, and applying it in three writes would
* let a reader (or a failure) land between them.
*
* Only `room_tag` is written the room blob carries no tags at all, so the room row is
* left alone.
*/
export async function applyRoomTagEdit(
db: D1Database,
roomId: number,
room: Room,
tag: string
edit: RoomTagEdit
): Promise<Room> {
const tags = Array.isArray(room.Tags) ? (room.Tags as RoomTag[]) : []
const lower = tag.toLowerCase()
const tagLower = (t: RoomTag): string => String(t?.Tag).toLowerCase()
const existing = tags.findIndex((t) => tagLower(t) === lower)
const current = Array.isArray(room.Tags) ? (room.Tags as RoomTag[]) : []
// Copies throughout: the room handed in is answered to the client, and the steps below
// mutate what they build.
let next: RoomTag[] = current.map((t) => ({ ...t }))
// The client has no delete/patch endpoint — the same call toggles a tag: remove
// it if already present, add it otherwise. Adding a main tag is a radio pick, so
// it also clears any other main tag already set.
let nextTags: RoomTag[]
if (existing !== -1) {
nextTags = tags.filter((_, i) => i !== existing)
} else if (MAIN_TAGS.has(lower)) {
nextTags = [...tags.filter((t) => !MAIN_TAGS.has(tagLower(t))), { Tag: tag, Type: 0 }]
} else {
nextTags = [...tags, { Tag: tag, Type: 0 }]
if (edit.tags !== undefined) {
// A SET, not a merge. The posted list is exactly the room's user tags afterwards; a
// tag already there keeps its row (and its genre flag, until the genre step below
// says otherwise) rather than being deleted and re-added.
const posted = new Set(edit.tags.map((t) => t.toLowerCase()))
next = [
...next.filter((t) => t.Type !== RoomTagType.user && !posted.has(tagKey(t))),
...edit.tags.map(
(tag) =>
next.find((t) => tagKey(t) === tag.toLowerCase()) ?? { Tag: tag, Type: RoomTagType.user }
),
]
} else if (edit.toggle !== undefined) {
// The 2023 client has no delete/patch endpoint, so the same call toggles: remove the
// tag if present, add it otherwise. Adding a main tag is a radio pick, so it also
// clears any other main tag. Removing the flagged tag takes the genre with it, which
// is right — the room's genre WAS that tag.
const lower = edit.toggle.toLowerCase()
const existing = next.findIndex((t) => tagKey(t) === lower)
if (existing !== -1) {
next = next.filter((_, i) => i !== existing)
} else {
const kept = MAIN_TAGS.has(lower) ? next.filter((t) => !MAIN_TAGS.has(tagKey(t))) : next
next = [...kept, { Tag: edit.toggle, Type: RoomTagType.user }]
}
}
await setRoomTags(db, roomId, nextTags)
// Reflect what was just stored, lowercased the way the table holds it, so the caller
// answers the client with the tags a re-read would give it.
return { ...room, Tags: nextTags.map((t) => ({ Tag: t.Tag.toLowerCase(), Type: t.Type })) }
for (const auto of edit.autoTags ?? []) {
const existing = next.find((t) => tagKey(t) === auto.toLowerCase())
// A tag the room already carries is re-categorised in place rather than duplicated —
// `tag` is the table's key, so there is only ever one row per name anyway.
if (existing) existing.Type = RoomTagType.auto
else next.push({ Tag: auto, Type: RoomTagType.auto })
}
if (edit.primaryGenre !== undefined) {
const lower = edit.primaryGenre.toLowerCase()
for (const tag of next) delete tag.IsPrimaryGenre
const chosen = next.find((t) => tagKey(t) === lower)
// A tag the room already carries keeps its category and simply becomes the genre;
// one it doesn't is added as an ordinary user tag.
if (chosen) chosen.IsPrimaryGenre = true
else next.push({ Tag: edit.primaryGenre, Type: RoomTagType.user, IsPrimaryGenre: true })
}
return storeRoomTags(db, roomId, room, next)
}
/**
* Write a room's whole tag set and answer the room carrying it, lowercased the way the
* table holds it so the caller replies with exactly what a re-read would give, without
* paying for the re-read. `IsPrimaryGenre` survives only where it was set, and stays
* absent (not false) everywhere else.
*/
async function storeRoomTags(
db: D1Database,
roomId: number,
room: Room,
tags: RoomTag[]
): Promise<Room> {
await setRoomTags(db, roomId, tags)
return {
...room,
Tags: tags.map((t) => {
const stored: RoomTag = { Tag: t.Tag.toLowerCase(), Type: t.Type }
if (t.IsPrimaryGenre) stored.IsPrimaryGenre = true
return stored
}),
}
}
/** Find a subroom (by SubRoomId) inside an already-hydrated room's `SubRooms`, or undefined. */
@@ -1136,16 +1245,31 @@ async function attachCurrentSaves(
// place a tag is stored — and a tag lookup is an indexed query rather than a scan that
// parses every room to ask.
/** One of a room's tags, as the client's room DTO carries it. */
/**
* One of a room's tags, as the client's room DTO carries it.
*
* `IsPrimaryGenre` is PRESENT ONLY on the one tag that is the room's genre the key is
* left off the others rather than sent as false, which is the shape the client sends and
* reads back. At most one tag in an array carries it; see {@link setPrimaryGenreTag}.
*/
export interface RoomTag {
Tag: string
Type: number
IsPrimaryGenre?: boolean
}
interface RoomTagRow {
room_id: number
tag: string
type: number
is_primary_genre: number
}
/** Project a stored tag row, adding `IsPrimaryGenre` only when the row is flagged. */
function toRoomTag(row: RoomTagRow): RoomTag {
const tag: RoomTag = { Tag: row.tag, Type: row.type }
if (row.is_primary_genre) tag.IsPrimaryGenre = true
return tag
}
/** Group tag rows by RoomId, preserving the order they arrived in (alphabetical by tag). */
@@ -1153,7 +1277,7 @@ function groupTags(rows: RoomTagRow[]): Map<number, RoomTag[]> {
const byRoom = new Map<number, RoomTag[]>()
for (const row of rows) {
const list = byRoom.get(row.room_id) ?? []
list.push({ Tag: row.tag, Type: row.type })
list.push(toRoomTag(row))
byRoom.set(row.room_id, list)
}
return byRoom
@@ -1183,7 +1307,7 @@ async function tagsByRoom(db: D1Database, roomIds: number[]): Promise<Map<number
if (ids.length > MAX_BOUND_PARAMS) {
const { results } = await db
.prepare('SELECT room_id, tag, type FROM room_tag ORDER BY tag')
.prepare('SELECT room_id, tag, type, is_primary_genre FROM room_tag ORDER BY tag')
.all<RoomTagRow>()
const wanted = new Set(ids)
return groupTags(results.filter((row) => wanted.has(row.room_id)))
@@ -1192,7 +1316,7 @@ async function tagsByRoom(db: D1Database, roomIds: number[]): Promise<Map<number
const placeholders = ids.map((_, i) => `?${i + 1}`).join(',')
const { results } = await db
.prepare(
`SELECT room_id, tag, type FROM room_tag
`SELECT room_id, tag, type, is_primary_genre FROM room_tag
WHERE room_id IN (${placeholders}) ORDER BY tag`
)
.bind(...ids)
@@ -1234,14 +1358,14 @@ async function parseAllWithTags(db: D1Database, rows: RoomRow[]): Promise<Room[]
*/
export async function setRoomTags(db: D1Database, roomId: number, tags: RoomTag[]): Promise<void> {
const statements = [db.prepare('DELETE FROM room_tag WHERE room_id = ?1').bind(roomId)]
for (const { Tag, Type } of tags) {
for (const { Tag, Type, IsPrimaryGenre } of tags) {
statements.push(
db
.prepare(
`INSERT INTO room_tag (room_id, tag, type) VALUES (?1, ?2, ?3)
ON CONFLICT (room_id, tag) DO UPDATE SET type = ?3`
`INSERT INTO room_tag (room_id, tag, type, is_primary_genre) VALUES (?1, ?2, ?3, ?4)
ON CONFLICT (room_id, tag) DO UPDATE SET type = ?3, is_primary_genre = ?4`
)
.bind(roomId, String(Tag).toLowerCase(), Number(Type) || 0)
.bind(roomId, String(Tag).toLowerCase(), Number(Type) || 0, IsPrimaryGenre ? 1 : 0)
)
}
await db.batch(statements)
+8
View File
@@ -1237,6 +1237,9 @@ importers:
'@scalar/api-reference':
specifier: 1.63.0
version: 1.63.0(tailwindcss@4.3.3)(typescript@6.0.3)(zod@4.4.3)
cobe:
specifier: ^2.0.1
version: 2.0.1
hono:
specifier: 4.12.27
version: 4.12.27
@@ -3307,6 +3310,9 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
cobe@2.0.1:
resolution: {integrity: sha512-aaa6vcIlaC8C1SF50LDH0Anybo/EAXnrxqe+bwvr4+YUtZydqjeBjTTD7ziCCkbRrRGSns3I3F6cZsf3W+L+ag==}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -6253,6 +6259,8 @@ snapshots:
clsx@2.1.1: {}
cobe@2.0.1: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4