[econ] debugging challenges

This commit is contained in:
Devin Zuczek
2026-08-25 14:44:08 -04:00
parent 5cb2cbf967
commit f6d92ec1e1
4 changed files with 298 additions and 135 deletions
+272 -86
View File
@@ -1,6 +1,6 @@
---
name: weekly-challenge-config
description: Read and author the `Config` rule tree in apps/econ/static/weekly-challenge.json — node types, scene-id predicates, and the shared-scene traps
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
---
# The weekly-challenge `Config` rule tree
@@ -13,9 +13,21 @@ gameplay, and posts the tree back to `/api/challenge/v2/updateProgress` with its
So the tree is a _specification handed to the client_, and a malformed one fails silently —
the challenge just never completes. Nothing server-side will tell you.
Everything here was read off one captured live rotation, not a spec. Meanings marked
_(inferred)_ are read from how values line up with the strings the client renders; the rest
are pinned by the data.
## Provenance
Two independent sources, and it matters which one a fact came from:
- **`CoffeeMan240/RecRoom.ChallengeLib`** — https://github.com/CoffeeMan240/RecRoom.ChallengeLib,
a .NET builder library reverse-engineered from the 20200306 client with data types from
20210813. It names every node type, field and enum below. Facts from it are marked **(lib)**
and are *names*, not observations: the library is a reimplementation, so a name can be right
about intent and still diverge from what the 20230414 client this server targets actually
reads. See "Where the lib and the live data disagree".
- **One captured live rotation** — the shipped `ChallengeMapId: 17`. Facts from it are marked
**(captured)** and are pinned by data actually served to a real client.
Where both agree the fact is solid. Where only the lib has it, treat the name as a strong
hypothesis and test in-game before shipping a rotation that depends on it.
## `Config` is an escaped JSON string
@@ -38,63 +50,173 @@ 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))'
```
## Node types
## Node types (`ct`) — the full enum
Each node carries a numeric type in `ct`. Two composite kinds appear:
**(lib)** `ChallengeTypes`. Every node carries one. Bold rows are the ones the captured
rotation actually uses.
- **Match** (`ct: 0`) — `wc` is a list of predicates that must _all_ hold for one game
result (AND).
- **Counter** (`ct: 1`) — `ctc` holds the child node to count, `t` is the target count.
| `ct` | Name | Kind | Extra fields |
| ---- | ---- | ---- | ------------ |
| **`0`** | `Challenge` | Composite — the plain AND node | `wc`, `rc` |
| **`1`** | `ChallengeCountChallenge` | Composite — count to a target | `ctc`, `t`, + `wc`/`rc` |
| **`2`** | `TimedBufferChallenge` | Composite — count within a rolling window | `ccc`, `t`, `i`, `pm`, `n`, `pb`, `m`, + `wc`/`rc` |
| `3` | `DynamicFloatArithmeticChallenge` | Leaf — compare two float resolvers | `op`, `rA`, `rB` |
| `4` | `DynamicIntArithmeticChallenge` | Leaf — compare two int resolvers | `op`, `rA`, `rB` |
| `5` | `RequiredToolChallenge` | Leaf — **removed** mid-2020; see note | `vs` |
| **`6`** | `RequiredEventTypeChallenge` | Leaf — which gameplay event | `vs` (ChallengeEventTypes) |
| **`7`** | `RequiredRoomSceneLocationChallenge` | Leaf — scene allow-list | `vs` (`[{"l": guid}]`) |
| `8` | `RequiredEnemyTypeChallenge` | Leaf — which enemy | `vs` (EnemyTypes) |
| **`9`** | `BoolVarEqualsChallenge` | Leaf — a bool session var equals | `v`, `vs` |
| `10` | — | **unnamed in the enum**; do not use | — |
| `11` | `DiscGolfFinishUnderParChallenge` | Leaf — no fields at all | — |
| `12` | `RequiredGameModeActivityChallenge` | Leaf — legacy game mode | `vs` (LegacyGameModeType) |
| `13` | `CompleteGameWithoutChallenge` | Macro over `ct:2` | as `ct:2` |
| `14` | `RequiredGestureChallenge` | Leaf — a gesture var (`v: "hg"`) | `v`, `vs` (PlayerGesture) |
| `15` | `HitstreakChallenge` | Macro over `ct:1` | as `ct:1` |
| `16` | `HitstreakCountChallenge` | Macro over `ct:2` | as `ct:2` |
Which slot a node uses (`wc` vs `ctc`) tells you what its children are; a node never has
both. `ipc` is `false` on every composite node in the reference data — purpose unknown, but
the client echoes it back, so keep emitting it.
`ct: 5` is obsolete — the lib marks it `LEGACYRequiredToolChallenge`, removed from the game
around mid-late 2020. Post-2020 clients express "used tool X" as a `ct:0` with a
`PickedUpTool` event predicate plus a `ct:4` comparing the `t_t` var. Its value enum
(`SpawnableToolTypes`) **re-rolls every game build**, so any tool-typed challenge is
build-specific.
## Predicate leaves
The macros (`13`, `15`, `16`) serialize as their base type plus preset children — they are
authoring conveniences, not distinct client behavior. But they emit their own `ct`, so the
client must know the id: don't invent macro ids.
Leaves carry `vs`, a list of accepted values matched as OR.
## Fields
| `ct` | Shape | Meaning |
| ---- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `6` | `{"ct":6,"vs":[2]}` | _(inferred)_ The kind of event being matched — a finished game/session. Present in **every** leaf group and always `[2]`; nothing observed varying it, so treat it as required boilerplate. |
| `7` | `{"ct":7,"vs":[{"l":"<guid>"}]}` | Scene allow-list: each `l` is a subroom's `UnitySceneId` (see `apps/rooms`). Matches if the game happened in any of them. |
| `9` | `{"ct":9,"vs":[true],"v":"won"}` | A named session variable (`v`) equals one of `vs` — here, the player won. |
| Field | Meaning |
| ----- | ------- |
| `ct` | Node type, above. **(lib + captured)** |
| `ipc` | `IgnorePreviousCompletions` **(lib)** — the captured data sets it `false` on every composite. Keep emitting it. |
| `wc` | `WithConditions` — predicates that must **all** hold (AND). **(lib + captured)** |
| `rc` | `ResetConditions` — matching any of these **resets progress to zero**. This is what makes streaks. **(lib)** |
| `ctc` | `ChallengesToCount``ct:1`'s children; each match increments toward `t`. **(lib + captured)** |
| `ccc` | `ChallengesToCount` for `ct:2` — same idea, different slot name. **(lib)** |
| `t` | Target count. **(lib + captured)** |
| `vs` | Accepted values, matched as OR. **(lib + captured)** |
| `v` | Var key for `ct:9`/`ct:14`. **(lib + captured)** |
| `in` | `Inclusive` — omitted when false. **(lib)**; the captured `won` predicate omits it. |
| `ex` | `ExcludesIncludesNull` — omitted when false. **(lib)** |
| `i` | `ct:2` window length in seconds, as a **2-dp string** (`"-1.00"`). `-1` = no window. **(lib)** |
| `pm` | `ct:2` progress mode: `0` Complete, `1` Count. **(lib)** |
| `n` | `ct:2` notification counts — milestones the client announces en route to `t`. **(lib)** |
| `pb` | `ct:2` `PersistBuffer` — carry the buffer across games. **(lib)** |
| `m` | `ct:2` count method, omitted when `0`. **(lib)** |
| `op` | `ct:3`/`ct:4` comparison: `0` GT, `1` LT, `2` EQ, `3` GTE, `4` LTE. **(lib)** |
| `rA`, `rB` | `ct:3`/`ct:4` operands, each a num resolver. **(lib)** |
| `cc`, `c` | **Client-side progress — never author these.** See below. **(captured)** |
## The two idioms
A num resolver (`rA`/`rB`) is `{"t":0,"c":<const>}` for a constant or `{"t":1,"vk":"<var>"}`
for a session variable. Note `t` means *resolver type* here, not target.
Every challenge in the captured rotation is one of these.
`m` values **(lib)**: `0` Count, `1` UniqueToolCount, `2` UniqueAttackerCount,
`3` UniqueDefenderCount, `4` GroupedToolMaxCount, `5` UniqueGameCount.
```jsonc
// "Complete ^TheRiseOfJumbotron quest" — one winning session in one scene
{ "ct": 0, "ipc": false, "wc": [
{ "ct": 6, "vs": [2] },
{ "ct": 9, "vs": [true], "v": "won" },
{ "ct": 7, "vs": [{ "l": "acc06e66-…" }] } // TheRiseofJumbotron / Home
]}
## Event types — `ct: 6` `vs` values
// "Complete 5 Charades games" — count matching sessions to a target
{ "ct": 1, "ipc": false, "t": 5, "ctc": [
{ "ct": 0, "ipc": false, "wc": [
{ "ct": 6, "vs": [2] },
{ "ct": 7, "vs": [{ "l": "a673712c-…" }, { "l": "4078dfed-…" }] } // 3DCharades + Legacy3DCharades
]}
]}
```
**(lib)** `ChallengeEventTypes`. The captured rotation only ever used `2`, which the old
version of this doc guessed was opaque boilerplate. It is not — it is `GameEnd`:
The quest challenges have **no `t`** (one qualifying session is the whole goal) and the
counted ones have **no `won` predicate** (finishing counts, winning is irrelevant). A "one
map only" challenge is the counted shape with a single-entry scene list.
| id | Name | | id | Name |
| -- | ---- | - | -- | ---- |
| `0` | `None` | | `7` | `Score` |
| `1` | `GameStart` | | `8` | `ShieldBlock` |
| **`2`** | **`GameEnd`** | | `9` | `ActivityLoad` |
| `3` | `LocalPlayerEliminated` | | `10` | `FlagCaptured` |
| `4` | `ElminatedOtherPlayer` _(sic)_ | | `11` | `FlagReturned` |
| `5` | `EliminatedAI` | | `12` | `Gesture` |
| `6` | `PickedUpTool` | | `13` | `PlayerJoined` |
## Scene ids, not room ids
`{"ct":6,"vs":[2]}` appears in every captured leaf group because every captured challenge
counts *finished games*, not because the field is fixed. Counting anything else — hits,
scores, revives — means changing this value.
`ct: 7` matches `UnitySceneId`, so one guid can name several rooms — a screens room, its VR
twin, and the standalone base room all share a scene. The captured "Complete 10 games in
^Paintball" lists six guids, which are the subrooms of _both_ `Paintball` and `PaintballVR`,
each of which is also a standalone base room (`River`, `Clearcut`, …). One list covers every
way in.
## Session variables (`v`, `vk`)
Resolve a guid against `SubRooms[].UnitySceneId` in `apps/rooms/static/ImportRooms.json`
(same data as `apps/rooms/migrations/0002_import_rooms.sql`). Run from the repo root:
**(lib)** The vars an event publishes, keyed by the event that carries them.
| Key | Type | On | Meaning |
| --- | ---- | -- | ------- |
| `gid` | string | any game event | Game id |
| `gameMode` | int | any game event | `LegacyGameModeType` |
| `numTeammates` | int | any game event | Size of your team |
| `t_score` | float | any game event | Your team's score |
| `jip` | bool | GameStart | Joined in progress |
| `isSpectator` | bool | GameStart | Spectating |
| `te` | float | GameEnd | Time elapsed |
| **`won`** | bool | GameEnd | Did the player win (used in quests) |
| `ev_score` | float | Score | Current score |
| `e_vid` | int | enemy events | Enemy photon view id |
| `e_t` | int | enemy events | `EnemyTypes` |
| `dp_vid` / `dp_pid` | int | player events | Defender photon view id / RecNet id |
| `ap_vid` / `ap_pid` | int | player events | Attacker photon view id / RecNet id |
| `bodyPart` | int | ElminatedOtherPlayer | `BodyPart`: `-1` None, `0` Head, `1` Torso, `2` LeftHand, `3` RightHand, `4` Mouth |
| `t_vid` / `t_t` | int | tool events | Tool photon view id / `SpawnableToolTypes` |
| `strokeCount` / `par` | int | DiscGolf Score | Strokes taken / hole par |
So the captured `{"ct":9,"vs":[true],"v":"won"}` is `GameEnded.Won` — a quest win.
## Named scene constants — `ct: 7` `vs` values
**(lib)** `RoomSceneLocations`, cross-checked against `apps/rooms/static/ImportRooms.json`.
`ct:7` matches `UnitySceneId`, so **one guid can name several rooms**. Bolded rooms share
their scene — a challenge naming that guid completes in every room listed.
The lib names every scene present in our room data (36 of its 40 resolve; the other four are
marked below), so this table is a complete index in both directions.
| Constant | Scene id | Rooms in `ImportRooms.json` |
| -------- | -------- | --------------------------- |
| `DORM_ROOM` | `76d98498-60a1-430c-ab76-b54a29b7a163` | DormRoom/Home |
| `REC_CENTER` | `cbad71af-0831-44d8-b8ef-69edafa841f6` | RecCenter/Home |
| `LEGACY_CHARADES` | `4078dfed-24bb-4db7-863f-578ba48d726b` | Legacy3DCharades/Home |
| `LAKE` | `f6f7256c-e438-4299-b99e-d20bef8cf7e0` | **DiscGolfLake/Home**, **Lake/Home** |
| `PROPULSION` | `d9378c9f-80bc-46fb-ad1e-1bed8a674f55` | **DiscGolfPropulsion/Home**, **PropulsionTestRange/Home** |
| `DODGEBALL` | `3d474b26-26f7-45e9-9a36-9b02847d5e6f` | **Dodgeball/Home**, **Gym/Home**, **DodgeballVR/Home** |
| `THE_LOUNGE` | `a067557f-ca32-43e6-b6e5-daaec60b4f5a` | Lounge/Home |
| `PADDLEBALL` | `d89f74fa-d51e-477a-a425-025a891dd499` | Paddleball/Home |
| `RIVER` | `e122fe98-e7db-49e8-a1b1-105424b6e1f0` | **Paintball/River**, **PaintballVR/River**, **River/Home** |
| `HOMESTEAD` | `a785267d-c579-42ea-be43-fec1992d1ca7` | **Paintball/Homestead**, **PaintballVR/Homestead**, **Homestead/Home** |
| `QUARRY` | `ff4c6427-7079-4f59-b22a-69b089420827` | **Paintball/Quarry**, **PaintballVR/Quarry**, **Quarry/Home** |
| `CLEAR_CUT` | `380d18b5-de9c-49f3-80f7-f4a95c1de161` | **Paintball/Clearcut**, **PaintballVR/Clearcut**, **Clearcut/Home** |
| `SPILLWAY` | `58763055-2dfb-4814-80b8-16fac5c85709` | **Paintball/Spillway**, **PaintballVR/Spillway**, **Spillway/Home** |
| `QUEST_FOR_THE_GOLDEN_TROPHY` | `91e16e35-f48f-4700-ab8a-a1b79e50e51b` | GoldenTrophy/Home |
| `ORIENTATION` | `c79709d8-a31b-48aa-9eb8-cc31ba9505e8` | Orientation/Home |
| `THE_RISE_OF_JUMBOTRON` | `acc06e66-c2d0-4361-b0cd-46246a4c455c` | TheRiseofJumbotron/Home |
| `CURSE_OF_THE_CRIMSON_CAULDRON` | `949fa41f-4347-45c0-b7ac-489129174045` | CrimsonCauldron/Home |
| `THE_ISLE_OF_LOST_SKULLS` | `7e01cfe0-820a-406f-b1b3-0a5bf575235c` | IsleOfLostSkulls/Home |
| `SOCCER` | `6d5eea4b-f069-4ed0-9916-0e2f07df0d03` | **Soccer/Home**, **Stadium/Home** |
| `PERFORMANCE_HALL` | `9932f88f-3929-43a0-a012-a40b5128e346` | PerformanceHall/Home |
| `PSVR_ROOM_CALIBRATION` | `f5fbd9c9-e853-4036-9d48-5f68e861af04` | _not in ImportRooms.json_ |
| `PARK` | `0a864c86-5a71-4e18-8041-8124e4dc9d98` | Park/Home |
| `WAREHOUSE` | `239e676c-f12f-489f-bf3a-d4c383d692c3` | **LaserTag/Hangar**, **Hangar/Home** |
| `CYBERJUNK_CITY` | `9d6456ce-6264-48b4-808d-2d96b3d91038` | **LaserTag/CyberJunkCity**, **LaserTagCyberJunk/Home**, **CyberJunkCity/Home** |
| `MAKER_ROOM` | `a75f7547-79eb-47c6-8986-6767abcb4f92` | MakerRoom/Home |
| `FRONTIER_SOLOS` | `b010171f-4875-4e89-baba-61e878cd41e1` | RecRoyaleSolos/Home |
| `FRONTIER_SQUADS` | `253fa009-6e65-4c90-91a1-7137a56a267f` | RecRoyaleSquads/Home |
| `CRESCENDO_OF_THE_BLOOD_MOON` | `49cb8993-a956-43e2-86f4-1318f279b22a` | Crescendo/Home |
| `BOWLING_ALLEY` | `ae929543-9a07-41d5-8ee9-dbbee8c36800` | **Bowling/Home**, **BowlingAlley/Home** |
| `ANIMATION_RECORDING_STUDIO` | `a95c349c-0f96-4c2d-a4c8-4969ffa8ea44` | _not in ImportRooms.json_ |
| `STUNTRUNNER` | `b7281665-a715-4051-826b-8e08e69c6172` | StuntRunner/StuntRunner |
| `STUNTRUNNER_THE_MAIN_EVENT` | `3a636bd2-f896-424c-9225-c184522c0d87` | StuntRunner/TheMainEvent |
| `STUNTRUNNER_BASE_ROOM` | `882e9b96-7115-4b03-86f6-c0c9d8e22e00` | StuntRunnerBaseRoom/Home |
| `REGISTRATION` | `cf61556d-68fd-4288-9ae5-7a512621e569` | Registration/Home |
| `AR_ROOM` | `bf268f5f-b55b-41af-8628-32fa4b5d70b6` | ARRoom/Home |
| `DRIVEIN` | `65ddbb48-5a01-4e3e-972d-e5c7419e2bc3` | **Paintball/Drive-in**, **PaintballVR/Drive-in**, **DriveIn/Home** |
| `CHARADES_THE_INK_SPACE` | `a673712c-877f-4749-b69a-4a4c6310d545` | 3DCharades/InkSpaceHome |
| `THE_INK_SPACE_BASE_ROOM` | `1fa06e3c-c307-4c11-a91b-1fabcddb8a96` | TheInkSpace/Home |
| `FRONTIER_UGC` | `a16bfd31-ffb9-46ac-a199-362c163130c0` | _not in ImportRooms.json_ |
The lib also defines `INVALID`, which serializes as `Guid.Empty` and is not a real scene.
Two shared scenes are genuine surprises rather than a deliberate screens/VR/base-room trio:
**`Soccer/Home` and `Stadium/Home` are the same scene**, and **`Dodgeball` shares its scene
with the plain `Gym`**. Decide whether the extra rooms are acceptable before shipping.
Regenerate the room column from the repo root:
```sh
cat > /tmp/scene.ts <<'EOF'
@@ -111,61 +233,114 @@ bun run /tmp/scene.ts 380d18b5-de9c-49f3-80f7-f4a95c1de161
# → 380d18b5-… Paintball/Clearcut, PaintballVR/Clearcut, Clearcut/Home
```
With no arguments it dumps every scene, which is how you go the other way — from a room name
to the guid to put in `vs`.
With no arguments it dumps every scene, which is how you go the other way.
### Shared scenes to watch for
## The idioms
These guids resolve to more than one room, so a challenge naming one also completes in the
others. Most are a deliberate screens/VR/base-room trio, but two are genuine surprises:
**`Soccer/Home` and `Stadium/Home` are the same scene**, so a soccer challenge also completes
in the Stadium, and `Dodgeball` shares its scene with the plain `Gym`.
### One-shot quest **(captured)**
| Scene id | Rooms |
| ----------- | ------------------------------------------------------------------ |
| `6d5eea4b…` | Soccer/Home, **Stadium/Home** |
| `3d474b26…` | Dodgeball/Home, **Gym/Home**, DodgeballVR/Home |
| `ae929543…` | Bowling/Home, BowlingAlley/Home |
| `f6f7256c…` | DiscGolfLake/Home, Lake/Home |
| `d9378c9f…` | DiscGolfPropulsion/Home, PropulsionTestRange/Home |
| `239e676c…` | LaserTag/Hangar, Hangar/Home |
| `9d6456ce…` | LaserTag/CyberJunkCity, LaserTagCyberJunk/Home, CyberJunkCity/Home |
| `e122fe98…` | Paintball/River, PaintballVR/River, River/Home |
| `a785267d…` | Paintball/Homestead, PaintballVR/Homestead, Homestead/Home |
| `ff4c6427…` | Paintball/Quarry, PaintballVR/Quarry, Quarry/Home |
| `380d18b5…` | Paintball/Clearcut, PaintballVR/Clearcut, Clearcut/Home |
| `58763055…` | Paintball/Spillway, PaintballVR/Spillway, Spillway/Home |
| `65ddbb48…` | Paintball/Drive-in, PaintballVR/Drive-in, DriveIn/Home |
No `t` — one qualifying session is the whole goal.
Regenerate this list with the script above and no arguments.
```jsonc
// "Complete ^TheRiseOfJumbotron quest"
{ "ct": 0, "ipc": false, "wc": [
{ "ct": 6, "vs": [2] }, // GameEnd
{ "ct": 9, "vs": [true], "v": "won" }, // …and won
{ "ct": 7, "vs": [{ "l": "acc06e66-…" }] } // THE_RISE_OF_JUMBOTRON
]}
```
### Counted sessions **(captured)**
No `won` predicate — finishing counts, winning is irrelevant. A "one map only" challenge is
this with a single-entry scene list.
```jsonc
// "Complete 5 Charades games"
{ "ct": 1, "ipc": false, "ctc": [
{ "ct": 0, "ipc": false, "wc": [
{ "ct": 6, "vs": [2] },
{ "ct": 7, "vs": [{ "l": "a673712c-…" }, { "l": "4078dfed-…" }] } // both Charades scenes
]}
], "t": 5 }
```
### Streaks and buffers **(lib)**
`rc` resets the count, which is how "N in a row without dying" is expressed. Wrapping that
in a `ct:2` counts how many streaks you land:
```jsonc
// "Get 20 three-kill streaks in the Golden Trophy quest", announced at 5/10/15
{ "ct": 2, "ipc": false,
"wc": [{ "ct": 7, "ipc": false, "vs": [{ "l": "91e16e35-…" }] }], // GOLDEN_TROPHY
"ccc": [{ "ct": 1, "ipc": false, "t": 3,
"ctc": [{ "ct": 6, "ipc": false, "vs": [5] }], // EliminatedAI
"rc": [{ "ct": 6, "ipc": false, "vs": [3] }] }],// …reset on your own death
"i": "-1.00", // no time window
"t": 20, "pm": 1, // count mode, target 20
"n": [5, 10, 15], // milestone notifications
"pb": true } // buffer survives across games
```
`pm: 1` (Count) is what makes `t` a tally; `pm: 0` (Complete) treats the buffer as a
one-shot. `i: "-1.00"` disables the time window — a positive value makes it "N within
X seconds".
## Progress fields (`cc`, `c`) — client-side only
On `updateProgress` the client posts the same tree back with its own progress written into
it: **`cc`** on the counter node is the current count (`…,"t":5,"cc":1`), and **`c`**
(`"c":true`) marks a node it now considers satisfied.
it: **`cc`** on a counter is the current count (`…,"t":5,"cc":1`), and **`c`** (`"c":true`)
marks a node it now considers satisfied.
Neither belongs in `weekly-challenge.json` — they are progress, not definition. The server
echoes the posted `Config` back untouched and never persists it (`challenge_status` stores
only the completion flag; see `apps/econ/src/challenge-db.ts`), so the running count lives
only in the client. Don't add `cc`/`c` to an authored tree, and don't try to read progress
out of one.
only in the client. Don't author `cc`/`c`, and don't try to read progress out of one.
This is also the cheapest way to decode an unfamiliar tree: serve it, play the activity, and
watch which node grows a `cc`.
## Where the lib and the live data disagree
The library is a 2020/2021 reimplementation, not the 20230414 client. Known divergences,
all worth checking before trusting a lib-only field:
- **`BoolVarEqualsChallenge` and `RequiredGestureChallenge` serialize `ct: 0` in the lib.**
`RequiredObjectChallenge` declares `ChallengeType` as a getter-only auto-property with no
initializer and the `VarEquals` subclasses never override it, so it defaults to `0`. The
captured rotation proves the real value is `9` for the `won` predicate. Don't take a
lib-generated `ct` for those two at face value.
- **`in` is emitted where the captured data omits it.** `BoolVarEqualsChallenge`'s
constructor forces `Inclusive = true`, so the lib would write
`{"ct":9,"in":true,"vs":[true],"v":"won"}` where the live rotation sent no `in`.
- **`ipc` is written twice when true** — `ChallengeBase.Serialize` adds it, then
`Challenge.Serialize` adds it again, which throws on the duplicate dictionary key. The lib
only works with `IgnorePreviousCompletions = false`, which is all the captured data uses.
- **`ChallengeCountChallengeBuilder.ResetCondition` adds the node to itself** — its parameter
shadows the `challenge` field. Set `ResetConditions` directly instead.
- **`SpawnableToolTypes` re-rolls per build**, so `ct:5` and any `t_t` comparison is pinned
to one client version.
## Authoring a new challenge
1. Pick the idiom: one-shot (`ct: 0` root, add the `won` predicate if winning is required)
or counted (`ct: 1` root with `t`).
2. Resolve the scenes with the script above, and check the shared-scene table — decide
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).
2. Resolve the scenes from the table above, and check the bolded shared rooms — decide
whether the extra rooms it lets in are acceptable.
3. Build the tree as an object, stringify it twice into `Config`.
4. Give the entry a `ChallengeId` unique **within the rotation** (they aren't sequential),
3. Pick the right event type for `ct:6``2` (GameEnd) only if you really are counting
finished games.
4. Build the tree as an object, stringify it twice into `Config`.
5. Give the entry a `ChallengeId` unique **within the rotation** (they aren't sequential),
and write the real goal in `Description``Name` is an internal slug that is not
authoritative (captured id `63` is named `Complete3SpillwayGames` but its `Config` and
description are Clearcut).
5. Leave `Complete: false`; `getCurrent` stamps it per caller.
6. Bump `ChallengeMapId` if this is a new rotation — ids only need to be unique within one,
description are Clearcut). **Keep `Description`/`Tooltip` in step with `Config`:** the
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. Keep `ServerTime` inside `StartAt``EndAt`, or the client renders the rotation as expired.
8. Keep `ServerTime` inside `StartAt``EndAt`, or the client renders the rotation as expired.
Sanity check the file parses and every tree parses:
@@ -174,10 +349,21 @@ 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)'
```
Then `bun turbo -F econ test``src/test/integration/api.test.ts` imports the file and
asserts `getCurrent` against it.
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.
## Credits
The node-type, event-type, field, variable and scene-constant tables above are derived from
**[CoffeeMan240/RecRoom.ChallengeLib](https://github.com/CoffeeMan240/RecRoom.ChallengeLib)**,
a .NET challenge-builder library that reverse-engineered this format from the 20200306 client
(data types from 20210813). Without it the `ct` values were opaque integers. Thanks to
CoffeeMan240 for publishing it.
## Related
- `apps/econ/README.md` — the rest of the weekly-challenge file (top level, `Gift`, progress)
- `.agents/daily-objectives/SKILL.md` — the other objective system, on `GET api/config/v2`
- `.agents/skills/daily-objectives-config/SKILL.md` — the other objective system, on
`GET api/config/v2`. Different grammar entirely: a flat `{type, score}` enum, not a tree.