[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
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,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`)
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
`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
@@ -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
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).
@@ -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
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
+81 -45
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`.
+3 -3
View File
@@ -116,9 +116,9 @@ export async function recordChallengeProgress(
* reported anything against it.
*
* 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 —
* three of the five a week publishes, not all of them (see `CHALLENGES_REQUIRED_FOR_GIFT`
* in econ.app.ts).
* 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 getChallengeStatuses(
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 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,
@@ -46,6 +45,7 @@ import {
spendCurrency,
} from './balance-db'
import { claimChallengeGift, getChallengeStatuses, recordChallengeProgress } from './challenge-db'
import { buildRotation, rotationMapId, withWeeklyGift } from './challenge-rotation'
import {
consumeConsumable,
countConsumable,
@@ -105,6 +105,11 @@ import type {
PurchaseBalanceModificationPayload,
} 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'
@@ -680,6 +685,40 @@ 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,
}))
}
/**
* 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
@@ -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. */
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 —
* 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
}
@@ -1350,8 +1365,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 !== '' &&
@@ -1359,7 +1377,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,
@@ -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
* 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,
@@ -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 —
* 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)
}
/**
@@ -1440,23 +1456,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 statuses = await getChallengeStatuses(c.env.DB, accountId, weeklyChallenge.ChallengeMapId)
const done = weeklyChallenge.Challenges.filter(
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()) return
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 }
)
@@ -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)
logger.info('weekly challenge gift granted', {
accountId,
challengeMapId: weeklyChallenge.ChallengeMapId,
challengeMapId: rotation.ChallengeMapId,
giftId: granted.id,
fallbackRoll: duplicate,
challengesComplete: done,
@@ -1475,7 +1495,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),
})
}
@@ -2859,40 +2879,43 @@ 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 state is per-player, so the caller's
// rows from `challenge_status` are stamped over the static ones: `Complete` over the
// static `false`, and `Config` over the static 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 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` and `Config` stamped',
'from the callers progress rows the stored `Config` carries the clients running',
'counts. Auth is optional unauthenticated callers get the static catalog with every',
'`Complete` false and every `Config` as authored.',
'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 statuses = await getChallengeStatuses(c.env.DB, id, weeklyChallenge.ChallengeMapId)
if (statuses.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
// progress 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) => {
...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`;
@@ -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 box from `GET /api/avatar/v2/gifts`, and adding a field here would be
// inventing response shape the client never sent us.
if (
stored.complete &&
challengeId !== 0 &&
challengeMapId === weeklyChallenge.ChallengeMapId
) {
if (stored.complete && challengeId !== 0 && challengeMapId === rotationMapId(new Date())) {
await awardChallengeGift(c, id)
}
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
// 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,6 +28,10 @@ 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 } 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'
@@ -44,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.
@@ -1834,10 +1838,72 @@ 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('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)
@@ -1860,7 +1926,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")`
@@ -1870,7 +1936,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,
@@ -1893,7 +1959,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',
}),
@@ -1944,7 +2010,7 @@ describe('econ endpoints', () => {
test('the reported Config is stored and served back over the static rule tree', async () => {
const challenge = CURRENT_CHALLENGE
const bearerHeaders = await bearer('74')
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.
@@ -1955,14 +2021,14 @@ describe('econ endpoints', () => {
method: 'POST',
headers,
body: JSON.stringify({
ChallengeMapId: String(weeklyChallenge.ChallengeMapId),
ChallengeMapId: String(weekly.ChallengeMapId),
ChallengeId: String(challenge.ChallengeId),
...body,
}),
})
const reported = await post({ Config: inProgress, Complete: 'False' })
expect(await reported.json()).toEqual({
ChallengeMapId: weeklyChallenge.ChallengeMapId,
ChallengeMapId: weekly.ChallengeMapId,
ChallengeId: challenge.ChallengeId,
Config: inProgress,
Complete: false,
@@ -1988,7 +2054,7 @@ describe('econ endpoints', () => {
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(
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
* 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',
}),
@@ -2017,6 +2083,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`, {
@@ -2033,7 +2109,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.
@@ -2050,7 +2127,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).
@@ -2063,10 +2140,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,
@@ -2074,9 +2151,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!',
})
@@ -2085,9 +2163,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.
@@ -2097,10 +2173,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,
@@ -2119,9 +2196,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 ?? '') !== ''
+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": "",