[econ] auto challenges

This commit is contained in:
Devin Zuczek
2026-08-25 16:18:32 -04:00
parent 7c3f2a36cd
commit 7275176734
7 changed files with 980 additions and 193 deletions
+55 -17
View File
@@ -1,12 +1,21 @@
--- ---
name: weekly-challenge-config 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 # The weekly-challenge `Config` rule tree
Reference for reading and writing the `Config` field of a challenge in Reference for reading and writing the `Config` field of a weekly challenge (served by
`apps/econ/static/weekly-challenge.json` (served by `GET /api/challenge/v2/getCurrent`). `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 **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. 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)))' 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 ```sh
bun -e 'const c=require("./apps/econ/static/weekly-challenge.json"); bun -e 'const {buildRotation}=await import("./apps/econ/src/challenge-rotation.ts");
for (const x of c.Challenges) console.log(x.ChallengeId, x.Description, "\n ", JSON.parse(x.Config))' 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 ## Node types (`ct`) — the full enum
**(lib)** `ChallengeTypes`. Every node carries one. Bold rows are the ones the captured **(lib)** `ChallengeTypes`. Every node carries one. Bold rows are the ones the captured
@@ -293,7 +305,7 @@ 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`) it: **`cc`** on a counter is the current count (`…,"t":5,"cc":1`), and **`c`** (`"c":true`)
marks a node it now considers satisfied. marks a node it now considers satisfied.
Neither belongs in `weekly-challenge.json` — they are progress, not definition. The server Neither belongs in an authored tree — they are progress, not definition. The server
stores the posted tree per player (`challenge_status.config`; see 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 `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 tree, which is how a half-finished challenge survives a session — but it still evaluates
@@ -324,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 - **`SpawnableToolTypes` re-rolls per build**, so `ct:5` and any `t_t` comparison is pinned
to one client version. 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), 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). counted (`ct:1` root with `t`), or buffered/streak (`ct:2` root, `rc` on the child).
@@ -340,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 client renders the strings and evaluates the tree independently, so a mismatch ships a
challenge that advances somewhere the text never mentions. challenge that advances somewhere the text never mentions.
6. Leave `Complete: false`; `getCurrent` stamps it per caller. 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, 7. Set a `ChallengeMapId` that no recent week has used — a new map id is what resets stored
and a new map id is what resets stored completions. 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. 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 ```sh
bun -e 'const c=require("./apps/econ/static/weekly-challenge.json"); bun -e 'const {buildRotation}=await import("./apps/econ/src/challenge-rotation.ts");
c.Challenges.forEach(x => JSON.parse(x.Config)); console.log("ok", c.Challenges.length)' 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 Then `bun vitest run apps/econ``src/test/integration/api.test.ts` builds the same rotation
asserts `getCurrent` against it. Note the gift threshold follows the rotation size and asserts `getCurrent` against it, and walks two years of generated weeks checking every
(`CHALLENGES_REQUIRED_FOR_GIFT` clamps to what you publish), so a rotation of three or fewer tree. Note the gift threshold follows the rotation size (`CHALLENGES_REQUIRED_FOR_GIFT`
asks for all of them. clamps to what you publish), so a pinned rotation of three or fewer asks for all of them.
## Credits ## Credits
+69 -33
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 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). 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` 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 and `Config` stamped in — see Progress below). The server never evaluates the rules: the
the rule tree in each challenge's `Config`, watches its own gameplay, and posts the tree client reads the rule tree in each challenge's `Config`, watches its own gameplay, and posts
back to `/api/challenge/v2/updateProgress` with its verdict. So this file is the entire the tree back to `/api/challenge/v2/updateProgress` with its verdict. So a rotation is the
definition of a week's challenges — ids, display strings, matching rules and the reward entire definition of a week's challenges — ids, display strings, matching rules and the
preview. 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. 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 Field meanings marked _(inferred)_ are read from how the values line up with the strings the
the client renders; the rest are pinned by the data itself. The file itself is edited client renders; the rest are pinned by the data itself. The field notes describe both the
freely as rotations change — the examples here are the captured week, so expect the shipped generated rotation and the pinned file, since they are the same shape on the wire.
rotation to differ.
### Top level ### Top level
| Field | Example | Notes | | 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. | | `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. | | `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. | | `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. | | `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. | | `Challenges` | array | The week's challenges, rendered in order. |
| `Gift` | object | The reward preview for finishing the set. | | `Gift` | object | The reward preview for finishing the set. |
| `FallbackGiftName` | `"4-Star Box"` | Shown when the client can't resolve `Gift` into a name. | | `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. | | `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 **The clock:** a generated rotation's window is genuinely the current week, so `ServerTime`
end, and the file is static — so the client always sees an active rotation with a ~1-day is simply now and the countdown the client draws is real — the challenges expire on Wednesday
countdown rather than an expired one (the captured week: Mar 31 inside Mar 25 → Apr 1). If at 21:00 UTC and the next week's set replaces them.
you edit the window, move `ServerTime` inside the new one too, or the challenges may render
as already over. 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 ### A challenge entry
| Field | Notes | | Field | Notes |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ChallengeId` | Unique within the rotation, not sequential (`37, 38, 44, 49, 63`). Posted back on `updateProgress`. | | `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. | | `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. | | `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"`. | | `Description` | The one-line goal, e.g. `"Complete 10 games in ^Paintball"`. |
| `Tooltip` | The longer hint under it. | | `Tooltip` | The longer hint under it. |
| `Complete` | Per-player state, so always `false` in the file — `getCurrent` overwrites it per caller from `challenge_status`. | | `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` 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 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 **renamed**: a storefront's `Context`/`Rarity` are `GiftContext`/`GiftRarity` here. Don't
feed one shape to the other's reader. feed one shape to the other's reader.
`EquipmentModificationGuid` is the Rec Room packed guid — 22-char URL-safe base64 of the 16 **Weekly rewards are equipment**, so a generated week rolls one from sf3 — every item there
guid bytes in .NET little-endian order, padding stripped (`g5u0weNLmkCLeUXFUVn74Q` → carrying an `EquipmentModificationGuid` (187 of its 1161), drawn with the week's own seed.
`c1b49b83-4be3-409a-8b79-45c55159fbe1`). The reward is identified by prefab + that guid, Drawing from the live catalog rather than a copied list is what lets the grant path resolve
_not_ by `GiftDropId`: this block's `GiftDropId` is `3994`, while the same skin sells in the pick back to the entry selling it, so the player receives a properly named item; the
`sf3.json` as `2121` ("Camera Skin (Comic)"). 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 **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 block is translated into a storefront gift-drop first (`toChallengeGiftDrop`); the renamed
`GiftContext`/`GiftRarity` are exactly what that translation is for. `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 The block carries no display strings (and the captured one carries a `GiftRarity` of `0` for
rarity `5`, so both are taken from the catalog entry selling the same item (matched on an item that sells at rarity `5`), so both are taken from the catalog entry selling the same
equipment guid / avatar desc) — the reward reads as "Camera Skin (Comic)", not as the box it item, matched on equipment guid / avatar desc — the reward reads as "Camera Skin (Comic)",
might have arrived in. An explicit `FriendlyName`/`Tooltip` on the block wins over the not as the box it might have arrived in. An explicit `FriendlyName`/`Tooltip` on the block
catalog if a rotation we publish sets them; neither is present in the captured one. 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 **`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 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 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** `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 rotation re-reads the caller's completions and, once enough of the week's own challenges are
challenges are there, grants the `Gift` the way a purchase grants a drop — the item into there, grants the `Gift` the way a purchase grants a drop — the item into
`inventory`/`equipment`/`consumable`, plus a gift box (message `inventory`/`equipment`/`consumable`, plus a gift box (message
`Weekly challenge complete!`) the player finds in `GET /api/avatar/v2/gifts`. `Weekly challenge complete!`) the player finds in `GET /api/avatar/v2/gifts`.
+3 -3
View File
@@ -116,9 +116,9 @@ export async function recordChallengeProgress(
* reported anything against it. * reported anything against it.
* *
* Read by `getCurrent` to overwrite the static rotation, and by the gift path: the `Gift` is * Read by `getCurrent` to overwrite the static rotation, and by the gift path: the `Gift` is
* due once ENOUGH of the challenges in static/weekly-challenge.json are complete here — * due once ENOUGH of the week's own challenges are complete here — three of the five a
* three of the five a week publishes, not all of them (see `CHALLENGES_REQUIRED_FOR_GIFT` * rotation publishes, not all of them (see `CHALLENGES_REQUIRED_FOR_GIFT` in econ.app.ts).
* in econ.app.ts). * The rotation itself is generated per week by src/challenge-rotation.ts.
*/ */
export async function getChallengeStatuses( export async function getChallengeStatuses(
db: D1Database, db: D1Database,
+636
View File
@@ -0,0 +1,636 @@
/**
* 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
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
}
/** 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[]): ChallengeGiftBlock | null {
if (pool.length === 0) return null
const random = mulberry32(seedFor(mapId, 0x9e3779b9))
const gift = pool[Math.floor(random() * pool.length)] as EquipmentGift
return {
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 gift = pickWeeklyGift(rotation.ChallengeMapId, pool)
return gift === null ? rotation : { ...rotation, Gift: gift }
}
+93 -74
View File
@@ -33,7 +33,6 @@ import defaultAvatarItems from '../static/default-avatar-items.json'
import defaultAvatar from '../static/default-avatar.json' import defaultAvatar from '../static/default-avatar.json'
import defaultBaseAvatarItems from '../static/default-base-avatar-items.json' import defaultBaseAvatarItems from '../static/default-base-avatar-items.json'
import myProgress from '../static/my-progress.json' import myProgress from '../static/my-progress.json'
import weeklyChallenge from '../static/weekly-challenge.json'
import { getAvatar, setAvatar } from './avatar-db' import { getAvatar, setAvatar } from './avatar-db'
import { import {
ALL_PLATFORMS, ALL_PLATFORMS,
@@ -46,6 +45,7 @@ import {
spendCurrency, spendCurrency,
} from './balance-db' } from './balance-db'
import { claimChallengeGift, getChallengeStatuses, recordChallengeProgress } from './challenge-db' import { claimChallengeGift, getChallengeStatuses, recordChallengeProgress } from './challenge-db'
import { buildRotation, rotationMapId, withWeeklyGift } from './challenge-rotation'
import { import {
consumeConsumable, consumeConsumable,
countConsumable, countConsumable,
@@ -105,6 +105,11 @@ import type {
PurchaseBalanceModificationPayload, PurchaseBalanceModificationPayload,
} from '../../notify/src/notification-payloads' } from '../../notify/src/notification-payloads'
import type { Avatar } from './avatar-db' import type { Avatar } from './avatar-db'
import type {
ChallengeGiftBlock,
EquipmentGift,
WeeklyChallengeRotation,
} from './challenge-rotation'
import type { ConsumeResult } from './consumables-db' import type { ConsumeResult } from './consumables-db'
import type { App } from './context' import type { App } from './context'
import type { Equipment } from './equipment-db' import type { Equipment } from './equipment-db'
@@ -680,6 +685,40 @@ async function loadRollCatalog(c: Context<App>): Promise<StoreItem[]> {
return storefront?.StoreItems ?? [] 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,
}))
}
/**
* 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 * 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 * item that you don't have" turns on, and the one that decides whether the weekly gift
@@ -1289,30 +1328,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. */ /** The message on the gift box the weekly reward arrives in. */
const CHALLENGE_GIFT_MESSAGE = 'Weekly challenge complete!' const CHALLENGE_GIFT_MESSAGE = 'Weekly challenge complete!'
@@ -1334,8 +1349,8 @@ const DEFAULT_FALLBACK_STARS = 4
* it is what the client renders when the gift resolves to a box rather than a named item — * 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. * so a rotation can retune the tier by renaming it, with no code change.
*/ */
function fallbackGiftRarity(): number { function fallbackGiftRarity(rotation: WeeklyChallengeRotation): number {
const stars = Number(/^(\d+)-star/i.exec(weeklyChallenge.FallbackGiftName)?.[1]) const stars = Number(/^(\d+)-star/i.exec(rotation.FallbackGiftName)?.[1])
return STAR_RARITY[stars - 1] ?? STAR_RARITY[DEFAULT_FALLBACK_STARS - 1] ?? 0 return STAR_RARITY[stars - 1] ?? STAR_RARITY[DEFAULT_FALLBACK_STARS - 1] ?? 0
} }
@@ -1350,8 +1365,11 @@ function fallbackGiftRarity(): number {
* selling the same item, so the granted item reads as itself — "Camera Skin (Comic)" rather * 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. * than the name of the box it might have arrived in.
*/ */
function toChallengeGiftDrop(catalog: StoreItem[]): StoreGiftDrop { function toChallengeGiftDrop(
const gift = weeklyChallenge.Gift as ChallengeGift rotation: WeeklyChallengeRotation,
catalog: StoreItem[]
): StoreGiftDrop {
const gift: ChallengeGiftBlock = rotation.Gift
const sold = catalog.find( const sold = catalog.find(
({ GiftDrop: drop }) => ({ GiftDrop: drop }) =>
(gift.EquipmentModificationGuid !== '' && (gift.EquipmentModificationGuid !== '' &&
@@ -1359,7 +1377,7 @@ function toChallengeGiftDrop(catalog: StoreItem[]): StoreGiftDrop {
(gift.AvatarItemDesc !== '' && drop.AvatarItemDesc === gift.AvatarItemDesc) (gift.AvatarItemDesc !== '' && drop.AvatarItemDesc === gift.AvatarItemDesc)
)?.GiftDrop )?.GiftDrop
return { return {
FriendlyName: gift.FriendlyName ?? sold?.FriendlyName ?? weeklyChallenge.FallbackGiftName, FriendlyName: gift.FriendlyName ?? sold?.FriendlyName ?? rotation.FallbackGiftName,
Tooltip: gift.Tooltip ?? sold?.Tooltip ?? '', Tooltip: gift.Tooltip ?? sold?.Tooltip ?? '',
ConsumableItemDesc: gift.ConsumableItemDesc, ConsumableItemDesc: gift.ConsumableItemDesc,
AvatarItemDesc: gift.AvatarItemDesc, AvatarItemDesc: gift.AvatarItemDesc,
@@ -1380,17 +1398,17 @@ function toChallengeGiftDrop(catalog: StoreItem[]): StoreGiftDrop {
* it. Handed over instead of the rotation's item when that item would be a duplicate, which * 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". * 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 { return {
FriendlyName: weeklyChallenge.FallbackGiftName, FriendlyName: rotation.FallbackGiftName,
Tooltip: '', Tooltip: '',
ConsumableItemDesc: '', ConsumableItemDesc: '',
AvatarItemDesc: '', AvatarItemDesc: '',
AvatarItemType: null, AvatarItemType: null,
EquipmentPrefabName: '', EquipmentPrefabName: '',
EquipmentModificationGuid: '', EquipmentModificationGuid: '',
Rarity: fallbackGiftRarity(), Rarity: fallbackGiftRarity(rotation),
Context: (weeklyChallenge.Gift as ChallengeGift).GiftContext, Context: rotation.Gift.GiftContext,
Currency: 0, Currency: 0,
CurrencyType: 0, CurrencyType: 0,
IsQuery: true, IsQuery: true,
@@ -1410,11 +1428,9 @@ const CHALLENGES_REQUIRED_FOR_GIFT = 3
* all-or-nothing when it's true — the reading its name and the partial default suggest — * 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. * and a rotation shorter than the threshold can only ever ask for what it publishes.
*/ */
function challengesRequiredForGift(): number { function challengesRequiredForGift(rotation: WeeklyChallengeRotation): number {
const published = weeklyChallenge.Challenges.length const published = rotation.Challenges.length
return weeklyChallenge.CompletedRequired return rotation.CompletedRequired ? published : Math.min(CHALLENGES_REQUIRED_FOR_GIFT, published)
? published
: Math.min(CHALLENGES_REQUIRED_FOR_GIFT, published)
} }
/** /**
@@ -1440,23 +1456,27 @@ function challengesRequiredForGift(): number {
* would otherwise meet without playing. * would otherwise meet without playing.
*/ */
async function awardChallengeGift(c: Context<App>, accountId: number): Promise<void> { async function awardChallengeGift(c: Context<App>, accountId: number): Promise<void> {
const rotation = buildRotation(new Date())
try { try {
if (weeklyChallenge.Challenges.length === 0) return if (rotation.Challenges.length === 0) return
const statuses = await getChallengeStatuses(c.env.DB, accountId, weeklyChallenge.ChallengeMapId) const statuses = await getChallengeStatuses(c.env.DB, accountId, rotation.ChallengeMapId)
const done = weeklyChallenge.Challenges.filter( const done = rotation.Challenges.filter(
(ch) => statuses.get(ch.ChallengeId)?.complete === true (ch) => statuses.get(ch.ChallengeId)?.complete === true
).length ).length
if (done < challengesRequiredForGift()) return if (done < challengesRequiredForGift(rotation)) return
// Claim first: this is what stops the next report paying out a second time. // 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 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 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 duplicate = await ownsGiftDrop(c.env.DB, accountId, reward)
const granted = await grantGiftDrop( const granted = await grantGiftDrop(
c, c,
accountId, accountId,
duplicate ? toChallengeFallbackDrop() : reward, duplicate ? toChallengeFallbackDrop(week) : reward,
CHALLENGE_GIFT_MESSAGE, CHALLENGE_GIFT_MESSAGE,
{ rollCatalog: catalog } { rollCatalog: catalog }
) )
@@ -1467,7 +1487,7 @@ async function awardChallengeGift(c: Context<App>, accountId: number): Promise<v
await pushGiftReceived(c, accountId, granted, CHALLENGE_GIFT_MESSAGE, COACH_ACCOUNT_ID) await pushGiftReceived(c, accountId, granted, CHALLENGE_GIFT_MESSAGE, COACH_ACCOUNT_ID)
logger.info('weekly challenge gift granted', { logger.info('weekly challenge gift granted', {
accountId, accountId,
challengeMapId: weeklyChallenge.ChallengeMapId, challengeMapId: rotation.ChallengeMapId,
giftId: granted.id, giftId: granted.id,
fallbackRoll: duplicate, fallbackRoll: duplicate,
challengesComplete: done, challengesComplete: done,
@@ -1475,7 +1495,7 @@ async function awardChallengeGift(c: Context<App>, accountId: number): Promise<v
} catch (err) { } catch (err) {
logger.error('failed to grant weekly challenge gift', { logger.error('failed to grant weekly challenge gift', {
accountId, accountId,
challengeMapId: weeklyChallenge.ChallengeMapId, challengeMapId: rotation.ChallengeMapId,
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
}) })
} }
@@ -2859,40 +2879,43 @@ const app = new Hono<App>({ strict: false })
(c) => c.json(adCarouselItems) (c) => c.json(adCarouselItems)
) )
// Current weekly challenge. The rotation itself is the bundled static JSON (its format // Current weekly challenge. The rotation is GENERATED from the calendar week (see
// is documented in the README) but each challenge's state is per-player, so the caller's // challenge-rotation.ts — the same five challenges, window and gift for everyone, derived
// rows from `challenge_status` are stamped over the static ones: `Complete` over the // from the week index; static/weekly-challenge.json pins it instead when it carries
// static `false`, and `Config` over the static rule tree — the client evaluates that tree // challenges), but each challenge's state is per-player, so the caller's rows from
// locally and reports it back with its running counts written into it (`cc`/`c`), so // `challenge_status` are stamped over the week's: `Complete` over the published `false`,
// serving the pristine tree back is what makes partial progress reset every session. // and `Config` over the published rule tree — the client evaluates that tree locally and
// Auth is OPTIONAL: without a valid bearer the static catalog is served unchanged // reports it back with its running counts written into it (`cc`/`c`), so serving the
// rather than 401, since the rotation is public information and a 404/401 on this // pristine tree back is what makes partial progress reset every session.
// route can stall the client's load orchestration. // 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( .get(
'/api/challenge/v2/getCurrent', '/api/challenge/v2/getCurrent',
describeRoute({ describeRoute({
tags: ['Econ'], tags: ['Econ'],
summary: 'Current weekly challenge', summary: 'Current weekly challenge',
description: [ description: [
'The bundled static rotation, with each challenges `Complete` and `Config` stamped', 'This weeks rotation — generated from the calendar week — with each challenges',
'from the callers progress rows the stored `Config` carries the clients running', '`Complete` and `Config` stamped from the callers progress rows, the stored `Config`',
'counts. Auth is optional unauthenticated callers get the static catalog with every', 'carrying the clients running counts. Auth is optional: unauthenticated callers get',
'`Complete` false and every `Config` as authored.', 'the week unstamped, every `Complete` false and every `Config` as published.',
].join(' '), ].join(' '),
security: OPTIONAL_AUTHED, security: OPTIONAL_AUTHED,
responses: { 200: json(JsonObject, 'The current weekly challenge') }, responses: { 200: json(JsonObject, 'The current weekly challenge') },
}), }),
async (c) => { async (c) => {
const rotation = withWeeklyGift(buildRotation(new Date()), await loadEquipmentGiftPool(c))
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.json(weeklyChallenge) if (id === null) return c.json(rotation)
const statuses = await getChallengeStatuses(c.env.DB, id, weeklyChallenge.ChallengeMapId) const statuses = await getChallengeStatuses(c.env.DB, id, rotation.ChallengeMapId)
if (statuses.size === 0) return c.json(weeklyChallenge) if (statuses.size === 0) return c.json(rotation)
// Rebuild rather than mutate: the static import is module state shared by every // Rebuild rather than mutate: the generated rotation is cached module state shared
// request this isolate serves, so stamping it in place would leak one player's // by every request this isolate serves, so stamping it in place would leak one
// progress to the next caller. // player's progress to the next caller.
return c.json({ return c.json({
...weeklyChallenge, ...rotation,
Challenges: weeklyChallenge.Challenges.map((challenge) => { Challenges: rotation.Challenges.map((challenge) => {
const status = statuses.get(challenge.ChallengeId) const status = statuses.get(challenge.ChallengeId)
if (status === undefined) return challenge if (status === undefined) return challenge
// A row with no stored tree (never reported one) keeps the authored `Config`; // A row with no stored tree (never reported one) keeps the authored `Config`;
@@ -2964,11 +2987,7 @@ const app = new Hono<App>({ strict: false })
// The response is unchanged whether or not a gift was won: the client learns about // 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 // the box from `GET /api/avatar/v2/gifts`, and adding a field here would be
// inventing response shape the client never sent us. // inventing response shape the client never sent us.
if ( if (stored.complete && challengeId !== 0 && challengeMapId === rotationMapId(new Date())) {
stored.complete &&
challengeId !== 0 &&
challengeMapId === weeklyChallenge.ChallengeMapId
) {
await awardChallengeGift(c, id) await awardChallengeGift(c, id)
} }
return c.json({ return c.json({
+111 -36
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 // 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. // against the enum rather than a copied number is what keeps these frames honest.
import { NotificationType } from '../../../../notify/src/notification-types' 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 { SCHEMA_DDL } from '../../avatar-db'
import { import {
BALANCE_SCHEMA_DDL, BALANCE_SCHEMA_DDL,
@@ -31,6 +28,10 @@ import {
spendCurrency, spendCurrency,
} from '../../balance-db' } from '../../balance-db'
import { CHALLENGE_GIFT_SCHEMA_DDL, CHALLENGE_STATUS_SCHEMA_DDL } from '../../challenge-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 } from '../../challenge-rotation'
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db' import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db' import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db'
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db' import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
@@ -44,8 +45,11 @@ declare module 'cloudflare:test' {
const ORIGIN = 'https://example.com' 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. */ /** 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) // 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. // so avatar reads/writes have a row to attach to.
@@ -1834,10 +1838,72 @@ describe('econ endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`) const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
expect(res.status).toBe(200) expect(res.status).toBe(200)
const body = (await res.json()) as { ChallengeMapId: number; Challenges: unknown[] } const body = (await res.json()) as { ChallengeMapId: number; Challenges: unknown[] }
expect(body).toHaveProperty('ChallengeMapId') expect(body.ChallengeMapId).toBe(weekly.ChallengeMapId)
expect(Array.isArray(body.Challenges)).toBe(true) 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('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 () => { test('GET /api/storefronts/v1/adcarouselitems returns the carousel items', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v1/adcarouselitems`) const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v1/adcarouselitems`)
expect(res.status).toBe(200) expect(res.status).toBe(200)
@@ -1860,7 +1926,7 @@ describe('econ endpoints', () => {
method: 'POST', method: 'POST',
headers: { ...(await bearer('70')), 'Content-Type': 'application/json' }, headers: { ...(await bearer('70')), 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
ChallengeMapId: String(weeklyChallenge.ChallengeMapId), ChallengeMapId: String(weekly.ChallengeMapId),
ChallengeId: String(challenge.ChallengeId), ChallengeId: String(challenge.ChallengeId),
Config: challenge.Config, Config: challenge.Config,
// .NET's bool.ToString() — the capitalized string, which `Boolean("False")` // .NET's bool.ToString() — the capitalized string, which `Boolean("False")`
@@ -1870,7 +1936,7 @@ describe('econ endpoints', () => {
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual({ expect(await res.json()).toEqual({
ChallengeMapId: weeklyChallenge.ChallengeMapId, ChallengeMapId: weekly.ChallengeMapId,
ChallengeId: challenge.ChallengeId, ChallengeId: challenge.ChallengeId,
Config: challenge.Config, Config: challenge.Config,
Complete: false, Complete: false,
@@ -1893,7 +1959,7 @@ describe('econ endpoints', () => {
method: 'POST', method: 'POST',
headers: { ...bearerHeaders, 'Content-Type': 'application/json' }, headers: { ...bearerHeaders, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
ChallengeMapId: String(weeklyChallenge.ChallengeMapId), ChallengeMapId: String(weekly.ChallengeMapId),
ChallengeId: completedId, ChallengeId: completedId,
Complete: 'True', Complete: 'True',
}), }),
@@ -1944,7 +2010,7 @@ describe('econ endpoints', () => {
test('the reported Config is stored and served back over the static rule tree', async () => { test('the reported Config is stored and served back over the static rule tree', async () => {
const challenge = CURRENT_CHALLENGE const challenge = CURRENT_CHALLENGE
const bearerHeaders = await bearer('74') const bearerHeaders = await bearer('78')
const headers = { ...bearerHeaders, 'Content-Type': 'application/json' } const headers = { ...bearerHeaders, 'Content-Type': 'application/json' }
// The client posts the catalog's tree with its own running count written into it — // 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. // `cc` on the counter — which is the progress that has to survive the session.
@@ -1955,14 +2021,14 @@ describe('econ endpoints', () => {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify({ body: JSON.stringify({
ChallengeMapId: String(weeklyChallenge.ChallengeMapId), ChallengeMapId: String(weekly.ChallengeMapId),
ChallengeId: String(challenge.ChallengeId), ChallengeId: String(challenge.ChallengeId),
...body, ...body,
}), }),
}) })
const reported = await post({ Config: inProgress, Complete: 'False' }) const reported = await post({ Config: inProgress, Complete: 'False' })
expect(await reported.json()).toEqual({ expect(await reported.json()).toEqual({
ChallengeMapId: weeklyChallenge.ChallengeMapId, ChallengeMapId: weekly.ChallengeMapId,
ChallengeId: challenge.ChallengeId, ChallengeId: challenge.ChallengeId,
Config: inProgress, Config: inProgress,
Complete: false, Complete: false,
@@ -1988,7 +2054,7 @@ describe('econ endpoints', () => {
const anon = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`) const anon = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
const anonBody = (await anon.json()) as { Challenges: Array<{ Config: string }> } const anonBody = (await anon.json()) as { Challenges: Array<{ Config: string }> }
expect(anonBody.Challenges.map((ch) => ch.Config)).toEqual( expect(anonBody.Challenges.map((ch) => ch.Config)).toEqual(
weeklyChallenge.Challenges.map((ch) => ch.Config) weekly.Challenges.map((ch) => ch.Config)
) )
}) })
@@ -1996,20 +2062,20 @@ describe('econ endpoints', () => {
* How many of the rotation's challenges earn the gift — three, unless the rotation * 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`). * publishes fewer or declares itself all-or-nothing (`CHALLENGES_REQUIRED_FOR_GIFT`).
*/ */
const REQUIRED_FOR_GIFT = weeklyChallenge.CompletedRequired const REQUIRED_FOR_GIFT = weekly.CompletedRequired
? weeklyChallenge.Challenges.length ? weekly.Challenges.length
: Math.min(3, weeklyChallenge.Challenges.length) : Math.min(3, weekly.Challenges.length)
/** Report the live rotation's challenges complete, for one player. */ /** Report the live rotation's challenges complete, for one player. */
async function finishTheRotation(sub: string) { async function finishTheRotation(sub: string) {
const headers = { ...(await bearer(sub)), 'Content-Type': 'application/json' } 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) => const report = (challengeId: number) =>
exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, { exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, {
method: 'POST', method: 'POST',
headers, headers,
body: JSON.stringify({ body: JSON.stringify({
ChallengeMapId: String(weeklyChallenge.ChallengeMapId), ChallengeMapId: String(weekly.ChallengeMapId),
ChallengeId: String(challengeId), ChallengeId: String(challengeId),
Complete: 'True', Complete: 'True',
}), }),
@@ -2017,6 +2083,16 @@ describe('econ endpoints', () => {
return { ids, report } 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. */ /** A player's unopened gift boxes, as the client reads them back. */
async function giftBoxes(sub: string) { async function giftBoxes(sub: string) {
const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, { const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, {
@@ -2033,7 +2109,8 @@ describe('econ endpoints', () => {
} }
test('completing enough of the rotation grants its gift, once', async () => { 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') const { ids, report } = await finishTheRotation('74')
// The threshold can't ask for more than the week publishes: a five-challenge week asks // 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. // for three, and a rotation of three or fewer asks for all of them.
@@ -2050,7 +2127,7 @@ describe('econ endpoints', () => {
const won = await giftBoxes('74') const won = await giftBoxes('74')
expect(won).toHaveLength(1) expect(won).toHaveLength(1)
expect(won[0]?.Message).toBe('Weekly challenge complete!') 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 // 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). // next time it reads the gifts list. `Immediate` (31), from Coach (1).
@@ -2063,10 +2140,10 @@ describe('econ endpoints', () => {
FromGiftDropId: 0, FromGiftDropId: 0,
FromPlayerId: 1, FromPlayerId: 1,
ConsumableItemDesc: '', ConsumableItemDesc: '',
AvatarItemDesc: weeklyChallenge.Gift.AvatarItemDesc, AvatarItemDesc: gift.AvatarItemDesc,
AvatarItemType: weeklyChallenge.Gift.AvatarItemType, AvatarItemType: gift.AvatarItemType,
EquipmentPrefabName: weeklyChallenge.Gift.EquipmentPrefabName, EquipmentPrefabName: gift.EquipmentPrefabName,
EquipmentModificationGuid: weeklyChallenge.Gift.EquipmentModificationGuid, EquipmentModificationGuid: gift.EquipmentModificationGuid,
CurrencyType: 0, CurrencyType: 0,
Currency: 0, Currency: 0,
Xp: 0, Xp: 0,
@@ -2074,9 +2151,10 @@ describe('econ endpoints', () => {
Platform: -1, Platform: -1,
PlatformsToSpawnOn: -1, PlatformsToSpawnOn: -1,
BalanceType: -2, BalanceType: -2,
GiftContext: weeklyChallenge.Gift.GiftContext, GiftContext: gift.GiftContext,
// The catalog's rarity for the item, not the block's `GiftRarity` of 0. // The catalog's rarity for the item — which is also what the generated block carries,
GiftRarity: 5, // since the week's gift is drawn from the catalog itself.
GiftRarity: gift.GiftRarity,
Message: 'Weekly challenge complete!', Message: 'Weekly challenge complete!',
}) })
@@ -2085,9 +2163,7 @@ describe('econ endpoints', () => {
headers: await bearer('74'), headers: await bearer('74'),
}) })
const owned = (await unlocked.json()) as Array<{ ModificationGuid: string }> const owned = (await unlocked.json()) as Array<{ ModificationGuid: string }>
expect(owned.map((e) => e.ModificationGuid)).toContain( expect(owned.map((e) => e.ModificationGuid)).toContain(gift.EquipmentModificationGuid)
weeklyChallenge.Gift.EquipmentModificationGuid
)
// Finishing the REST of the set, and re-reporting what's already done (which the client // Finishing the REST of the set, and re-reporting what's already done (which the client
// keeps doing), must not mint a second reward. // keeps doing), must not mint a second reward.
@@ -2097,10 +2173,11 @@ describe('econ endpoints', () => {
test('a player who already owns the rotations gift rolls the fallback box instead', async () => { 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. // Own the reward up front — the case the rotation's `FallbackGiftName` exists for.
const gift = await advertisedGift()
await grantEquipment(env.DB, 75, { await grantEquipment(env.DB, 75, {
ModificationGuid: weeklyChallenge.Gift.EquipmentModificationGuid, ModificationGuid: gift.EquipmentModificationGuid,
PrefabName: weeklyChallenge.Gift.EquipmentPrefabName, PrefabName: gift.EquipmentPrefabName,
FriendlyName: 'Camera Skin (Comic)', FriendlyName: 'The weeks reward, already owned',
Tooltip: '', Tooltip: '',
Rarity: 5, Rarity: 5,
PlatformMask: -1, PlatformMask: -1,
@@ -2119,9 +2196,7 @@ describe('econ endpoints', () => {
// Something they don't have, at the tier `FallbackGiftName` names ("4-Star Box" → 30), // Something they don't have, at the tier `FallbackGiftName` names ("4-Star Box" → 30),
// rather than a second copy of the gift. // rather than a second copy of the gift.
const rolled = won[0] const rolled = won[0]
expect(rolled?.EquipmentModificationGuid).not.toBe( expect(rolled?.EquipmentModificationGuid).not.toBe(gift.EquipmentModificationGuid)
weeklyChallenge.Gift.EquipmentModificationGuid
)
expect(rolled?.GiftRarity).toBe(30) expect(rolled?.GiftRarity).toBe(30)
expect( expect(
(rolled?.AvatarItemDesc ?? '') !== '' || (rolled?.EquipmentModificationGuid ?? '') !== '' (rolled?.AvatarItemDesc ?? '') !== '' || (rolled?.EquipmentModificationGuid ?? '') !== ''
+1 -18
View File
@@ -4,24 +4,7 @@
"StartAt": "2026-08-19T21:00:00", "StartAt": "2026-08-19T21:00:00",
"EndAt": "2026-09-26T21:00:00", "EndAt": "2026-09-26T21:00:00",
"ServerTime": "2026-08-25T14:42:54.2754728Z", "ServerTime": "2026-08-25T14:42:54.2754728Z",
"Challenges": [ "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
}
],
"Gift": { "Gift": {
"GiftDropId": 3994, "GiftDropId": 3994,
"AvatarItemDesc": "", "AvatarItemDesc": "",