diff --git a/.agents/skills/weekly-challenge-config/SKILL.md b/.agents/skills/weekly-challenge-config/SKILL.md index 5e3b770..ea9d855 100644 --- a/.agents/skills/weekly-challenge-config/SKILL.md +++ b/.agents/skills/weekly-challenge-config/SKILL.md @@ -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":""}]}` | 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":}` for a constant or `{"t":1,"vk":""}` +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. diff --git a/apps/econ/README.md b/apps/econ/README.md index c33b69f..4d5f801 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -248,10 +248,11 @@ rotation to differ. | `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` (Mar 31) sits _inside_ `StartAt`…`EndAt` (Mar 25 → Apr 1), -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. If you edit the window, move -`ServerTime` inside the new one too, or the challenges may render as already over. +**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. ### A challenge entry @@ -279,10 +280,10 @@ scene allow-list (`ct: 7`, subroom `UnitySceneId`s) or a session variable (`ct: `won`). The server never evaluates any of it — the client does, and posts the tree back with its own count written in. -**Reading or writing one? See `.agents/weekly-challenge-config/SKILL.md`** — the full -grammar, the two idioms the file uses, how to resolve a scene guid to a room, the shared -scenes that make a challenge complete in more rooms than you meant (`Soccer` and `Stadium` -are one scene), and an authoring checklist. +**Reading or writing one? See `.agents/skills/weekly-challenge-config/SKILL.md`** — the full +node-type enum, the event types and session variables a predicate can match, the named scene +constants (including the shared scenes that make a challenge complete in more rooms than you +meant — `Soccer` and `Stadium` are one scene), the three idioms, and an authoring checklist. ### The `Gift` block diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index d19be2a..3f20a9b 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -1985,9 +1985,9 @@ 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. const { ids, report } = await finishTheRotation('74') - // The whole point of the threshold: the gift lands before the set is finished (the - // published week is five challenges for three). - expect(REQUIRED_FOR_GIFT).toBeLessThan(ids.length) + // 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. + expect(REQUIRED_FOR_GIFT).toBeLessThanOrEqual(ids.length) for (const id of ids.slice(0, REQUIRED_FOR_GIFT - 1)) { expect((await report(id)).status).toBe(200) } diff --git a/apps/econ/static/weekly-challenge.json b/apps/econ/static/weekly-challenge.json index 236f40d..4242ba7 100644 --- a/apps/econ/static/weekly-challenge.json +++ b/apps/econ/static/weekly-challenge.json @@ -1,48 +1,24 @@ { - "ChallengeMapId": 17, + "ChallengeMapId": 19, "CompletedRequired": false, - "StartAt": "2026-03-25T21:00:00", - "EndAt": "2026-04-01T21:00:00", - "ServerTime": "2026-03-31T14:42:54.2754728Z", + "StartAt": "2026-08-19T21:00:00", + "EndAt": "2026-09-26T21:00:00", + "ServerTime": "2026-08-25T14:42:54.2754728Z", "Challenges": [ { - "ChallengeId": 37, - "Name": "CompleteJT", - "Config": "{\"ct\":1,\"c\":true,\"ipc\":false,\"ctc\":[{\"ct\":0,\"ipc\":false,\"wc\":[{\"ct\":6,\"vs\":[2]},{\"ct\":7,\"vs\":[{\"l\":\"6d5eea4b-f069-4ed0-9916-0e2f07df0d03\"},{\"l\":\"4078dfed-24bb-4db7-863f-578ba48d726b\"}]}]}],\"t\":1,\"cc\":1}", - "Description": "Complete ^TheRiseOfJumbotron quest", - "Tooltip": "Vanquish Jumbotron in the Rise of Jumbotron quest!", + "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": 38, - "Name": "CompleteBloodMoon", - "Config": "{\"ct\":0,\"ipc\":false,\"wc\":[{\"ct\":6,\"vs\":[2]},{\"ct\":9,\"vs\":[true],\"v\":\"won\"},{\"ct\":7,\"vs\":[{\"l\":\"49cb8993-a956-43e2-86f4-1318f279b22a\"}]}]}", - "Description": "Complete the ^Crescendo quest", - "Tooltip": "Vanquish Dracula in the Crescendo of the Blood Moon quest!", - "Complete": false - }, - { - "ChallengeId": 44, - "Name": "Complete10PaintballGames", - "Config": "{\"ct\":1,\"ipc\":false,\"ctc\":[{\"ct\":0,\"ipc\":false,\"wc\":[{\"ct\":6,\"vs\":[2]},{\"ct\":7,\"vs\":[{\"l\":\"e122fe98-e7db-49e8-a1b1-105424b6e1f0\"},{\"l\":\"a785267d-c579-42ea-be43-fec1992d1ca7\"},{\"l\":\"ff4c6427-7079-4f59-b22a-69b089420827\"},{\"l\":\"380d18b5-de9c-49f3-80f7-f4a95c1de161\"},{\"l\":\"58763055-2dfb-4814-80b8-16fac5c85709\"},{\"l\":\"65ddbb48-5a01-4e3e-972d-e5c7419e2bc3\"}]}]}],\"t\":10}", - "Description": "Complete 10 games in ^Paintball", - "Tooltip": "Go to ^Paintball and finish 10 games across any maps.", - "Complete": false - }, - { - "ChallengeId": 49, - "Name": "Complete5Charades", - "Config": "{\"ct\":1,\"ipc\":false,\"ctc\":[{\"ct\":0,\"ipc\":false,\"wc\":[{\"ct\":6,\"vs\":[2]},{\"ct\":7,\"vs\":[{\"l\":\"a673712c-877f-4749-b69a-4a4c6310d545\"},{\"l\":\"4078dfed-24bb-4db7-863f-578ba48d726b\"}]}]}],\"t\":5}", - "Description": "Complete 5 Charades games", - "Tooltip": "Finish 5 games in ^3DCharades.", - "Complete": false - }, - { - "ChallengeId": 63, - "Name": "Complete3SpillwayGames", - "Config": "{\"ct\":1,\"ipc\":false,\"ctc\":[{\"ct\":0,\"ipc\":false,\"wc\":[{\"ct\":6,\"vs\":[2]},{\"ct\":7,\"vs\":[{\"l\":\"380d18b5-de9c-49f3-80f7-f4a95c1de161\"}]}]}],\"t\":3}", - "Description": "Complete 3 games of Paintball: Clear Cut", - "Tooltip": "Go to ^Paintball.Clearcut and finish 3 games.", + "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 } ],