diff --git a/.agents/skills/weekly-challenge-config/SKILL.md b/.agents/skills/weekly-challenge-config/SKILL.md index 4c52959..922b7b2 100644 --- a/.agents/skills/weekly-challenge-config/SKILL.md +++ b/.agents/skills/weekly-challenge-config/SKILL.md @@ -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 diff --git a/apps/econ/README.md b/apps/econ/README.md index 0d100bc..b1eff35 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -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`. diff --git a/apps/econ/src/challenge-db.ts b/apps/econ/src/challenge-db.ts index e765b9e..1b37f53 100644 --- a/apps/econ/src/challenge-db.ts +++ b/apps/econ/src/challenge-db.ts @@ -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, diff --git a/apps/econ/src/challenge-rotation.ts b/apps/econ/src/challenge-rotation.ts new file mode 100644 index 0000000..16757b4 --- /dev/null +++ b/apps/econ/src/challenge-rotation.ts @@ -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(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() + const kinds = new Map() + 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 } +} diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 8d6b843..8335792 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -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): Promise { 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): Promise { + 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, accountId: number): Promise { + 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, accountId: number): Promise, accountId: number): Promise({ 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 challenge’s `Complete` and `Config` stamped', - 'from the caller’s progress rows — the stored `Config` carries the client’s running', - 'counts. Auth is optional — unauthenticated callers get the static catalog with every', - '`Complete` false and every `Config` as authored.', + 'This week’s rotation — generated from the calendar week — with each challenge’s', + '`Complete` and `Config` stamped from the caller’s progress rows, the stored `Config`', + 'carrying the client’s 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({ 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({ diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 9e3c627..9c300ac 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -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 }).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 rotation’s 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 week’s 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 ?? '') !== '' diff --git a/apps/econ/static/weekly-challenge.json b/apps/econ/static/weekly-challenge.json index 4242ba7..577e4e6 100644 --- a/apps/econ/static/weekly-challenge.json +++ b/apps/econ/static/weekly-challenge.json @@ -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": "",