9.2 KiB
name, description
| name | description |
|---|---|
| weekly-challenge-config | Read and author the `Config` rule tree in apps/econ/static/weekly-challenge.json — node types, scene-id predicates, and the shared-scene traps |
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).
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.
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.
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 one back:
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
Each node carries a numeric type in ct. Two composite kinds appear:
- Match (
ct: 0) —wcis a list of predicates that must all hold for one game result (AND). - Counter (
ct: 1) —ctcholds the child node to count,tis the target count.
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.
Predicate leaves
Leaves carry vs, a list of accepted values matched as OR.
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. |
The two idioms
Every challenge in the captured rotation is one of these.
// "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
]}
// "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
]}
]}
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.
Scene ids, not room ids
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.
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:
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 — from a room name
to the guid to put in vs.
Shared scenes to watch for
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.
| 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 |
Regenerate this list with the script above and no arguments.
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.
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.
Authoring a new challenge
- Pick the idiom: one-shot (
ct: 0root, add thewonpredicate if winning is required) or counted (ct: 1root witht). - Resolve the scenes with the script above, and check the shared-scene table — decide whether the extra rooms it lets in are acceptable.
- 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). - Leave
Complete: false;getCurrentstamps it per caller. - Bump
ChallengeMapIdif this is a new rotation — ids only need to be unique within one, and a new map id is what resets stored completions. - Keep
ServerTimeinsideStartAt…EndAt, or the client renders the rotation as expired.
Sanity check the file parses and every tree parses:
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.
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, onGET api/config/v2