23 KiB
name, description
| name | description |
|---|---|
| weekly-challenge-config | 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 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.
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.
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
Not a nested object. In the file it looks like:
"Config": "{\"ct\":0,\"ipc\":false,\"wc\":[...]}"
Author the tree as an object and stringify it into the field — don't hand-escape:
bun -e 'const t={ct:0,ipc:false,wc:[{ct:6,vs:[2]}]}; console.log(JSON.stringify(JSON.stringify(t)))'
To read this week's back (the generated rotation, or the pinned file if one is in place):
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
rotation actually uses.
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 |
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.
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.
Fields
| 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) |
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.
m values (lib): 0 Count, 1 UniqueToolCount, 2 UniqueAttackerCount,
3 UniqueDefenderCount, 4 GroupedToolMaxCount, 5 UniqueGameCount.
Event types — ct: 6 vs values
(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:
| 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 |
{"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.
Session variables (v, vk)
(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:
cat > /tmp/scene.ts <<'EOF'
// path is resolved against the cwd, so run this from the repo root
const rooms = await Bun.file('apps/rooms/static/ImportRooms.json').json()
const want = new Set(process.argv.slice(2))
const byScene = new Map<string, string[]>()
for (const r of rooms as any[])
for (const s of r.SubRooms ?? [])
byScene.set(s.UnitySceneId, [...(byScene.get(s.UnitySceneId) ?? []), `${r.Name}/${s.Name}`])
for (const [id, names] of byScene) if (!want.size || want.has(id)) console.log(id, names.join(', '))
EOF
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.
The idioms
One-shot quest (captured)
No t — one qualifying session is the whole goal.
// "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.
// "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:
// "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 a counter is the current count (…,"t":5,"cc":1), and c ("c":true)
marks a node it now considers satisfied.
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
none of it: the counting is the client's. Don't author cc/c, and don't try to read
progress out of the tree you author.
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:
BoolVarEqualsChallengeandRequiredGestureChallengeserializect: 0in the lib.RequiredObjectChallengedeclaresChallengeTypeas a getter-only auto-property with no initializer and theVarEqualssubclasses never override it, so it defaults to0. The captured rotation proves the real value is9for thewonpredicate. Don't take a lib-generatedctfor those two at face value.inis emitted where the captured data omits it.BoolVarEqualsChallenge's constructor forcesInclusive = true, so the lib would write{"ct":9,"in":true,"vs":[true],"v":"won"}where the live rotation sent noin.ipcis written twice when true —ChallengeBase.Serializeadds it, thenChallenge.Serializeadds it again, which throws on the duplicate dictionary key. The lib only works withIgnorePreviousCompletions = false, which is all the captured data uses.ChallengeCountChallengeBuilder.ResetConditionadds the node to itself — its parameter shadows thechallengefield. SetResetConditionsdirectly instead.SpawnableToolTypesre-rolls per build, soct:5and anyt_tcomparison is pinned to one client version.
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.
- A new room — add an entry with its
UnitySceneId(s) fromapps/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 inshares. Setkindsconservatively:winreads thewonvariable, so only rooms where winning is a real outcome;aiis quests. Append, never insert —ChallengeIdis the candidate's index, so inserting renumbers every challenge after it. - A new kind — add it to
ChallengeKindand give it a branch in all three ofconfigFor(the tree),copyFor(the strings) andnameFor(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. - 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.
- Pick the idiom: one-shot (
ct:0root, add thewonpredicate if winning is required), counted (ct:1root witht), or buffered/streak (ct:2root,rcon the child). - Resolve the scenes from the table above, and check the bolded shared rooms — decide whether the extra rooms it lets in are acceptable.
- Pick the right event type for
ct:6—2(GameEnd) only if you really are counting finished games. - Build the tree as an object, stringify it twice into
Config. - Give the entry a
ChallengeIdunique within the rotation (they aren't sequential), and write the real goal inDescription—Nameis an internal slug that is not authoritative (captured id63is namedComplete3SpillwayGamesbut itsConfigand description are Clearcut). KeepDescription/Tooltipin step withConfig: the client renders the strings and evaluates the tree independently, so a mismatch ships a challenge that advances somewhere the text never mentions. - Leave
Complete: false;getCurrentstamps it per caller. - Set a
ChallengeMapIdthat no recent week has used — a new map id is what resets stored completions, and generated weeks are1000 + weekIndex, so stay well clear of that range. - Keep
ServerTimeinsideStartAt…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 that every tree in this week's rotation parses, pinned or generated:
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 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
The node-type, event-type, field, variable and scene-constant tables above are derived from
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/skills/daily-objectives-config/SKILL.md— the other objective system, onGET api/config/v2. Different grammar entirely: a flat{type, score}enum, not a tree.