From 7c3f2a36cd6a7d3f30642d5884a61ab8262d45aa Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 25 Aug 2026 15:28:16 -0400 Subject: [PATCH] [econ] fix challenges not persisting --- .../skills/weekly-challenge-config/SKILL.md | 8 +- apps/econ/README.md | 38 +++++--- .../0014_challenge_status_config.sql | 16 ++++ apps/econ/src/challenge-db.ts | 96 ++++++++++++------- apps/econ/src/econ.app.ts | 87 ++++++++++------- apps/econ/src/openapi.ts | 8 +- apps/econ/src/test/integration/api.test.ts | 50 ++++++++++ 7 files changed, 215 insertions(+), 88 deletions(-) create mode 100644 apps/econ/migrations/0014_challenge_status_config.sql diff --git a/.agents/skills/weekly-challenge-config/SKILL.md b/.agents/skills/weekly-challenge-config/SKILL.md index ea9d855..4c52959 100644 --- a/.agents/skills/weekly-challenge-config/SKILL.md +++ b/.agents/skills/weekly-challenge-config/SKILL.md @@ -294,9 +294,11 @@ it: **`cc`** on a counter is the current count (`…,"t":5,"cc":1`), and **`c`** 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 author `cc`/`c`, and don't try to read progress out of one. +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`. diff --git a/apps/econ/README.md b/apps/econ/README.md index 4d5f801..0d100bc 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -370,29 +370,41 @@ the block empty and naming the tier. ### Progress (`challenge_status`) `POST /api/challenge/v2/updateProgress` (auth-gated) upserts one row per (account, -challenge) into `challenge_status`, and `getCurrent` reads them back to stamp `Complete`. -The body is `{ ChallengeMapId, ChallengeId, Config, Complete }` with the ids as **strings** -and `Complete` as .NET's `"True"`/`"False"` — capitalized, so `Boolean(body.Complete)` reads +challenge) into `challenge_status`, and `getCurrent` reads them back to stamp each +challenge's `Complete` **and `Config`**. The body is +`{ ChallengeMapId, ChallengeId, Config, Complete }` with the ids as **strings** and +`Complete` as .NET's `"True"`/`"False"` — capitalized, so `Boolean(body.Complete)` reads "not complete" as complete (`parseBool` handles both spellings and a real JSON `true`). -Only the completion is stored. `Config` is the catalog's own rule tree plus the client's -running count, so a per-player copy would just be a staler duplicate of static data — it is -echoed back untouched but never persisted. The response is the four posted fields, except -`Complete` is the **stored** value rather than the posted one, because: +**The client does the evaluating, and `Config` is its scratchpad.** It walks the rule tree +locally and posts that tree back with its own progress written into the nodes — `cc` on a +counter is the running count, `c` marks a satisfied node — so the posted tree is per-player +state, not a copy of the catalog, and it is stored. `getCurrent` then serves the static +challenge with the stored `Config` and `Complete` overwritten onto it; serving the pristine +authored tree instead (what this used to do) threw away partial progress on every login. The +server still evaluates none of the tree. + +The response is the four posted fields, except `Complete` and `Config` are the **stored** +values rather than the posted ones, because: - **Completion latches within a rotation.** The client reports repeatedly, and a later report saying "not complete" (a fresh session, a retry arriving out of order) must not un-finish something already finished. +- **A report carrying no `Config` keeps the stored tree.** `config` itself doesn't latch — + it's a tally, so the newest tree wins — but a report without one is missing data, not a + reset, and must not blank the progress. - **A new rotation resets the row.** Challenge ids are only unique within a rotation, so - the same id in a later week would otherwise start out already complete. A report whose - `ChallengeMapId` differs from the stored one replaces the row instead of latching; reads - are scoped to the rotation for the same reason. + the same id in a later week would otherwise start out already complete, and half-counted. + A report whose `ChallengeMapId` differs from the stored one replaces the row instead of + latching; reads are scoped to the rotation for the same reason. `getCurrent`'s auth is **optional** — an unauthenticated caller gets the static rotation -with every `Complete` false rather than a 401, since the rotation is public and a failure -on this route can stall the client's load. The overlay rebuilds the response object rather +with every `Complete` false and every `Config` as authored, rather than a 401, since the +rotation is public and a failure on this route can stall the client's load. A challenge the +caller has never reported keeps its authored `Config` too (a stored `NULL`), since a client +handed a null tree has nothing to evaluate. The overlay rebuilds the response object rather than stamping the imported JSON in place: that import is module state shared across every -request an isolate serves, so mutating it would leak one player's completions to the next +request an isolate serves, so mutating it would leak one player's progress to the next caller. ## Game rewards (`reward_status`) diff --git a/apps/econ/migrations/0014_challenge_status_config.sql b/apps/econ/migrations/0014_challenge_status_config.sql new file mode 100644 index 0000000..aa5f63b --- /dev/null +++ b/apps/econ/migrations/0014_challenge_status_config.sql @@ -0,0 +1,16 @@ +-- Store the `Config` rule tree the client posts with each weekly-challenge progress report. +-- +-- Migration 0009 kept only the completion flag, on the grounds that the tree is the +-- challenge's definition (static/weekly-challenge.json) and therefore identical for every +-- player. That is only true of the tree the SERVER publishes: the client posts it back with +-- its own progress written into the nodes — `cc` on a counter is the running count, `c` +-- marks a satisfied node — so the posted copy is per-player state, and dropping it threw +-- away the only record of how far along a player was. The client does the evaluating; this +-- is where the partial progress it reports has to live between sessions. +-- +-- Nullable, and NULL is meaningful: no report has been stored for that challenge yet (or a +-- report arrived without a `Config`), so `/api/challenge/v2/getCurrent` serves the static +-- tree for it unchanged. Kept in sync with CHALLENGE_STATUS_SCHEMA_DDL in +-- src/challenge-db.ts. + +ALTER TABLE challenge_status ADD COLUMN config TEXT; diff --git a/apps/econ/src/challenge-db.ts b/apps/econ/src/challenge-db.ts index f9626ce..e765b9e 100644 --- a/apps/econ/src/challenge-db.ts +++ b/apps/econ/src/challenge-db.ts @@ -1,36 +1,46 @@ /** * Weekly-challenge progress on the shared `recflare` D1 database — one row per * (account, challenge), written by `POST /api/challenge/v2/updateProgress` and read back - * by `GET /api/challenge/v2/getCurrent` to stamp each challenge's per-player `Complete`. + * by `GET /api/challenge/v2/getCurrent` to stamp each challenge's per-player state. * - * Only the completion flag is stored, not the `Config` rule tree the client posts with it. - * That tree is the challenge's DEFINITION (it comes from static/weekly-challenge.json and - * is identical for everyone), decorated with the client's running count in `cc`; the - * server evaluates none of it, so persisting a per-player copy would only be a second, - * staler copy of the catalog. See .agents/weekly-challenge-config/SKILL.md for the grammar. + * The CLIENT owns the evaluating: it walks the challenge's rule tree locally and posts the + * tree back with its own progress written into the nodes — `cc` on a counter is the running + * count, `c` marks a satisfied node (see .agents/skills/weekly-challenge-config/SKILL.md for + * the grammar). So the posted `Config` is not the catalog's copy of the definition, it is + * per-player STATE, and it is stored here alongside the completion flag; the server still + * evaluates none of it. `getCurrent` serves the static challenge with the stored `Config` + * and `Complete` overwritten onto it, which is how partial progress survives a session: + * without it a player who had two of three kills started over on every login. * * Completion LATCHES within a rotation: the client reports progress repeatedly, and a * report that arrives with the challenge no longer complete (a fresh session, a reordered - * retry) must not un-finish something already finished. A report carrying a different - * `ChallengeMapId` is a new rotation and REPLACES the row instead — challenge ids are only - * unique within a rotation, so a challenge that returns in a later week would otherwise - * start out already complete on the old week's row. + * retry) must not un-finish something already finished. `config` does NOT latch — it is the + * running tally, so the newest report wins — but a report that carries none leaves the + * stored tree alone rather than blanking it. A report carrying a different `ChallengeMapId` + * is a new rotation and REPLACES the row instead — challenge ids are only unique within a + * rotation, so a challenge that returns in a later week would otherwise start out already + * complete, and half-counted, on the old week's row. * * Finishing enough of a rotation's challenges earns its `Gift`, which is handed out from the * same `updateProgress` call that reaches the threshold. That payout is gated by a * second table here, `challenge_gift` — one row per (account, rotation), claimed once. * * The `econ` worker owns both tables and their migrations - * (apps/econ/migrations/0009_challenge_status.sql, 0011_challenge_gift.sql). + * (apps/econ/migrations/0009_challenge_status.sql, 0011_challenge_gift.sql, + * 0014_challenge_status_config.sql). */ -/** Schema DDL (mirror of migrations 0009_challenge_status.sql) — also builds the table in tests. */ +/** + * Schema DDL (mirror of migrations 0009_challenge_status.sql + 0014_challenge_status_config.sql) + * — also builds the table in tests. + */ export const CHALLENGE_STATUS_SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS challenge_status ( account_id INTEGER NOT NULL, challenge_id INTEGER NOT NULL, challenge_map_id INTEGER NOT NULL, complete INTEGER NOT NULL, + config TEXT, updated_at TEXT NOT NULL, PRIMARY KEY (account_id, challenge_id) )`, @@ -41,70 +51,90 @@ export interface ChallengeProgress { challengeMapId: number challengeId: number complete: boolean + /** The client-evaluated rule tree, or null when the report carried none. */ + config: string | null +} + +/** What a stored row holds for one challenge, as `getCurrent` overwrites it onto the catalog. */ +export interface ChallengeStatus { + complete: boolean + /** The last tree the client posted; null means it never posted one — serve the static tree. */ + config: string | null } /** - * Record a progress report and return the completion the row now holds — which is what the + * Record a progress report and return the state the row now holds — which is what the * response must echo, since it isn't always what was posted: within a rotation `complete` * only ever goes false → true (see the latching note above), so a `false` report against a - * finished challenge answers `true`. + * finished challenge answers `true`, and a report with no `Config` answers the tree already + * stored. * * SQLite evaluates every `DO UPDATE SET` expression against the pre-update row, so the - * `CASE` can compare the stored `challenge_map_id` with the incoming one while the same + * `CASE`s can compare the stored `challenge_map_id` with the incoming one while the same * statement overwrites it. */ export async function recordChallengeProgress( db: D1Database, accountId: number, progress: ChallengeProgress -): Promise { +): Promise { const row = await db .prepare( - `INSERT INTO challenge_status (account_id, challenge_id, challenge_map_id, complete, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5) + `INSERT INTO challenge_status (account_id, challenge_id, challenge_map_id, complete, config, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) ON CONFLICT (account_id, challenge_id) DO UPDATE SET complete = CASE WHEN challenge_status.challenge_map_id = excluded.challenge_map_id THEN MAX(challenge_status.complete, excluded.complete) ELSE excluded.complete END, + config = CASE + WHEN challenge_status.challenge_map_id = excluded.challenge_map_id + THEN COALESCE(excluded.config, challenge_status.config) + ELSE excluded.config + END, challenge_map_id = excluded.challenge_map_id, updated_at = excluded.updated_at - RETURNING complete` + RETURNING complete, config` ) .bind( accountId, progress.challengeId, progress.challengeMapId, progress.complete ? 1 : 0, + progress.config, new Date().toISOString() ) - .first<{ complete: number }>() - return row?.complete === 1 + .first<{ complete: number; config: string | null }>() + return { complete: row?.complete === 1, config: row?.config ?? null } } /** - * The ids of the challenges a player has finished in one rotation. Scoped to the rotation - * so a stale row from an earlier week — same challenge id, different `challenge_map_id` — - * doesn't show up pre-completed before the client has reported anything against it. + * What a player has stored for one rotation's challenges, keyed by challenge id. Scoped to + * the rotation so a stale row from an earlier week — same challenge id, different + * `challenge_map_id` — doesn't show up pre-completed, or half-counted, before the client has + * reported anything against it. * - * Also what earning the rotation's `Gift` is decided from: it is due once ENOUGH of the - * challenges in static/weekly-challenge.json appear here — three of the five a week - * publishes, not all of them (see `CHALLENGES_REQUIRED_FOR_GIFT` in econ.app.ts). + * 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). */ -export async function getCompletedChallengeIds( +export async function getChallengeStatuses( db: D1Database, accountId: number, challengeMapId: number -): Promise> { +): Promise> { const { results } = await db .prepare( - `SELECT challenge_id FROM challenge_status - WHERE account_id = ?1 AND challenge_map_id = ?2 AND complete = 1` + `SELECT challenge_id, complete, config FROM challenge_status + WHERE account_id = ?1 AND challenge_map_id = ?2` ) .bind(accountId, challengeMapId) - .all<{ challenge_id: number }>() - return new Set(results.map((r) => r.challenge_id)) + .all<{ challenge_id: number; complete: number; config: string | null }>() + return new Map( + results.map((r) => [r.challenge_id, { complete: r.complete === 1, config: r.config }]) + ) } /** Schema DDL (mirror of migrations 0011_challenge_gift.sql) — also builds the table in tests. */ diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 97157fd..8d6b843 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -45,11 +45,7 @@ import { isSpendable, spendCurrency, } from './balance-db' -import { - claimChallengeGift, - getCompletedChallengeIds, - recordChallengeProgress, -} from './challenge-db' +import { claimChallengeGift, getChallengeStatuses, recordChallengeProgress } from './challenge-db' import { consumeConsumable, countConsumable, @@ -1446,12 +1442,10 @@ function challengesRequiredForGift(): number { async function awardChallengeGift(c: Context, accountId: number): Promise { try { if (weeklyChallenge.Challenges.length === 0) return - const complete = await getCompletedChallengeIds( - c.env.DB, - accountId, - weeklyChallenge.ChallengeMapId - ) - const done = weeklyChallenge.Challenges.filter((ch) => complete.has(ch.ChallengeId)).length + const statuses = await getChallengeStatuses(c.env.DB, accountId, weeklyChallenge.ChallengeMapId) + const done = weeklyChallenge.Challenges.filter( + (ch) => statuses.get(ch.ChallengeId)?.complete === true + ).length if (done < challengesRequiredForGift()) 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) @@ -2866,8 +2860,11 @@ const app = new Hono({ strict: false }) ) // Current weekly challenge. The rotation itself is the bundled static JSON (its format - // is documented in the README) but each challenge's `Complete` is per-player, so the - // caller's rows from `challenge_status` are stamped over the static `false`s. + // 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. @@ -2877,9 +2874,10 @@ const app = new Hono({ strict: false }) tags: ['Econ'], summary: 'Current weekly challenge', description: [ - 'The bundled static rotation, with each challenge’s `Complete` stamped from the', - 'caller’s progress rows. Auth is optional — unauthenticated callers get the static', - 'catalog with every `Complete` false.', + '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.', ].join(' '), security: OPTIONAL_AUTHED, responses: { 200: json(JsonObject, 'The current weekly challenge') }, @@ -2887,38 +2885,47 @@ const app = new Hono({ strict: false }) async (c) => { const id = await authedId(c) if (id === null) return c.json(weeklyChallenge) - const complete = await getCompletedChallengeIds(c.env.DB, id, weeklyChallenge.ChallengeMapId) - if (complete.size === 0) 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 - // completions to the next caller. + // progress to the next caller. return c.json({ ...weeklyChallenge, - Challenges: weeklyChallenge.Challenges.map((challenge) => ({ - ...challenge, - Complete: complete.has(challenge.ChallengeId), - })), + Challenges: weeklyChallenge.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`; + // overwriting it with null would hand the client a challenge it can't evaluate. + return { + ...challenge, + Complete: status.complete, + Config: status.config ?? challenge.Config, + } + }), }) } ) // Report progress on a weekly challenge. [Authorize]. The client evaluates the // challenge's rule tree locally and posts ChallengeMapId/ChallengeId, that tree in - // `Config`, and whether it now considers the challenge `Complete`. Only the - // completion is persisted (keyed by account + challenge); `Config` is the catalog's - // own definition plus the client's running count, so storing it would duplicate - // static data. Echoes the identifying fields back with the completion the row now - // holds — which is not always what was posted, since completion latches within a - // rotation. + // `Config`, and whether it now considers the challenge `Complete`. Both are persisted + // (keyed by account + challenge): the posted tree is the catalog's definition with the + // client's running counts written into it, so it is this player's progress, and + // `getCurrent` serves it back in place of the authored tree. Echoes the identifying + // fields back with the state the row now holds — which is not always what was posted, + // since completion latches within a rotation and a report with no `Config` keeps the + // stored tree. .post( '/api/challenge/v2/updateProgress', describeRoute({ tags: ['Econ'], summary: 'Report weekly-challenge progress', description: [ - 'Persists the reported completion into `challenge_status`, keyed by account +', - 'challenge. `Config` is accepted and echoed but not stored. Completion latches within', - 'a rotation, so the echoed `Complete` is the stored value, not the posted one.', + 'Persists the reported completion and rule tree into `challenge_status`, keyed by', + 'account + challenge, so `getCurrent` can serve the player’s own progress back.', + 'Completion latches within a rotation and a report carrying no `Config` keeps the', + 'stored tree, so the echoed fields are the stored values, not the posted ones.', ].join(' '), security: AUTHED, requestBody: jsonBody(ChallengeProgressRequest, 'Challenge ids + the evaluated rule tree'), @@ -2940,14 +2947,16 @@ const app = new Hono({ strict: false }) .catch(() => ({}) as Record) const challengeMapId = Number(body.ChallengeMapId) || 0 const challengeId = Number(body.ChallengeId) || 0 + const config = typeof body.Config === 'string' ? body.Config : null // Nothing to key a row on — echo the body back rather than writing a (0, 0) row. - const complete = + const stored = challengeId === 0 - ? parseBool(body.Complete) + ? { complete: parseBool(body.Complete), config } : await recordChallengeProgress(c.env.DB, id, { challengeMapId, challengeId, complete: parseBool(body.Complete), + config, }) // This report may have been the last one of the set. Only a completing report on // the LIVE rotation can be — an old rotation's set can no longer be finished, and @@ -2955,14 +2964,18 @@ 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 (complete && challengeId !== 0 && challengeMapId === weeklyChallenge.ChallengeMapId) { + if ( + stored.complete && + challengeId !== 0 && + challengeMapId === weeklyChallenge.ChallengeMapId + ) { await awardChallengeGift(c, id) } return c.json({ ChallengeMapId: challengeMapId, ChallengeId: challengeId, - Config: typeof body.Config === 'string' ? body.Config : '', - Complete: complete, + Config: stored.config ?? '', + Complete: stored.complete, }) } ) diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index b18284e..5038237 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -269,7 +269,9 @@ export const MakerAiFreeTrialEligibilityResponse = z export const ChallengeProgressResponse = z.object({ ChallengeMapId: z.int(), ChallengeId: z.int(), - Config: z.string().describe('Echoed back verbatim; not stored'), + Config: z + .string() + .describe('The STORED rule tree — a report carrying none keeps (and echoes) the last one'), Complete: z .boolean() .describe('The STORED completion — latches true within a rotation, so it may differ'), @@ -484,7 +486,9 @@ export const ChallengeProgressRequest = z.object({ Config: z .string() .optional() - .describe('The client-evaluated rule tree, with its running count in `cc`; not stored'), + .describe( + 'The client-evaluated rule tree, with its running count in `cc`; stored as the player’s progress' + ), Complete: z .union([z.string(), z.boolean()]) .optional() diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 3f20a9b..9e3c627 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -1942,6 +1942,56 @@ describe('econ endpoints', () => { expect(await completeOf(await post('18', 'True'))).toBe(true) }) + 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 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. + const inProgress = challenge.Config.replace(/}$/, ',"cc":1}') + expect(inProgress).not.toBe(challenge.Config) + const post = (body: Record) => + exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, { + method: 'POST', + headers, + body: JSON.stringify({ + ChallengeMapId: String(weeklyChallenge.ChallengeMapId), + ChallengeId: String(challenge.ChallengeId), + ...body, + }), + }) + const reported = await post({ Config: inProgress, Complete: 'False' }) + expect(await reported.json()).toEqual({ + ChallengeMapId: weeklyChallenge.ChallengeMapId, + ChallengeId: challenge.ChallengeId, + Config: inProgress, + Complete: false, + }) + + const configOf = async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`, { + headers: bearerHeaders, + }) + const body = (await res.json()) as { + Challenges: Array<{ ChallengeId: number; Config: string }> + } + return body.Challenges.find((ch) => ch.ChallengeId === challenge.ChallengeId)?.Config + } + expect(await configOf()).toBe(inProgress) + + // A report carrying no tree is not a reset — the stored progress stays, and is echoed. + const noConfig = await post({ Complete: 'False' }) + expect(((await noConfig.json()) as { Config: string }).Config).toBe(inProgress) + expect(await configOf()).toBe(inProgress) + + // Challenges this player never reported keep the authored tree, and so does everyone else. + 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) + ) + }) + /** * 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`).