[econ] fix challenges not persisting

This commit is contained in:
Devin Zuczek
2026-08-25 15:28:16 -04:00
parent f6d92ec1e1
commit 7c3f2a36cd
7 changed files with 215 additions and 88 deletions
@@ -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. marks a node it now considers satisfied.
Neither belongs in `weekly-challenge.json` — they are progress, not definition. The server 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 stores the posted tree per player (`challenge_status.config`; see
only the completion flag; see `apps/econ/src/challenge-db.ts`), so the running count lives `apps/econ/src/challenge-db.ts`) and `getCurrent` serves it back in place of the authored
only in the client. Don't author `cc`/`c`, and don't try to read progress out of one. 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 This is also the cheapest way to decode an unfamiliar tree: serve it, play the activity, and
watch which node grows a `cc`. watch which node grows a `cc`.
+25 -13
View File
@@ -370,29 +370,41 @@ the block empty and naming the tier.
### Progress (`challenge_status`) ### Progress (`challenge_status`)
`POST /api/challenge/v2/updateProgress` (auth-gated) upserts one row per (account, `POST /api/challenge/v2/updateProgress` (auth-gated) upserts one row per (account,
challenge) into `challenge_status`, and `getCurrent` reads them back to stamp `Complete`. challenge) into `challenge_status`, and `getCurrent` reads them back to stamp each
The body is `{ ChallengeMapId, ChallengeId, Config, Complete }` with the ids as **strings** challenge's `Complete` **and `Config`**. The body is
and `Complete` as .NET's `"True"`/`"False"` — capitalized, so `Boolean(body.Complete)` reads `{ 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`). "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 **The client does the evaluating, and `Config` is its scratchpad.** It walks the rule tree
running count, so a per-player copy would just be a staler duplicate of static data — it is locally and posts that tree back with its own progress written into the nodes — `cc` on a
echoed back untouched but never persisted. The response is the four posted fields, except counter is the running count, `c` marks a satisfied node — so the posted tree is per-player
`Complete` is the **stored** value rather than the posted one, because: 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 - **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 report saying "not complete" (a fresh session, a retry arriving out of order) must not
un-finish something already finished. 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 - **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 the same id in a later week would otherwise start out already complete, and half-counted.
`ChallengeMapId` differs from the stored one replaces the row instead of latching; reads A report whose `ChallengeMapId` differs from the stored one replaces the row instead of
are scoped to the rotation for the same reason. latching; reads are scoped to the rotation for the same reason.
`getCurrent`'s auth is **optional** — an unauthenticated caller gets the static rotation `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 with every `Complete` false and every `Config` as authored, rather than a 401, since the
on this route can stall the client's load. The overlay rebuilds the response object rather 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 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. caller.
## Game rewards (`reward_status`) ## Game rewards (`reward_status`)
@@ -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;
+63 -33
View File
@@ -1,36 +1,46 @@
/** /**
* Weekly-challenge progress on the shared `recflare` D1 database — one row per * Weekly-challenge progress on the shared `recflare` D1 database — one row per
* (account, challenge), written by `POST /api/challenge/v2/updateProgress` and read back * (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. * The CLIENT owns the evaluating: it walks the challenge's rule tree locally and posts the
* That tree is the challenge's DEFINITION (it comes from static/weekly-challenge.json and * tree back with its own progress written into the nodes — `cc` on a counter is the running
* is identical for everyone), decorated with the client's running count in `cc`; the * count, `c` marks a satisfied node (see .agents/skills/weekly-challenge-config/SKILL.md for
* server evaluates none of it, so persisting a per-player copy would only be a second, * the grammar). So the posted `Config` is not the catalog's copy of the definition, it is
* staler copy of the catalog. See .agents/weekly-challenge-config/SKILL.md for the grammar. * 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 * 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 * 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 * retry) must not un-finish something already finished. `config` does NOT latch — it is the
* `ChallengeMapId` is a new rotation and REPLACES the row instead — challenge ids are only * running tally, so the newest report wins — but a report that carries none leaves the
* unique within a rotation, so a challenge that returns in a later week would otherwise * stored tree alone rather than blanking it. A report carrying a different `ChallengeMapId`
* start out already complete on the old week's row. * 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 * 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 * 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. * second table here, `challenge_gift` — one row per (account, rotation), claimed once.
* *
* The `econ` worker owns both tables and their migrations * 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[] = [ export const CHALLENGE_STATUS_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS challenge_status ( `CREATE TABLE IF NOT EXISTS challenge_status (
account_id INTEGER NOT NULL, account_id INTEGER NOT NULL,
challenge_id INTEGER NOT NULL, challenge_id INTEGER NOT NULL,
challenge_map_id INTEGER NOT NULL, challenge_map_id INTEGER NOT NULL,
complete INTEGER NOT NULL, complete INTEGER NOT NULL,
config TEXT,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY (account_id, challenge_id) PRIMARY KEY (account_id, challenge_id)
)`, )`,
@@ -41,70 +51,90 @@ export interface ChallengeProgress {
challengeMapId: number challengeMapId: number
challengeId: number challengeId: number
complete: boolean 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` * 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 * 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 * 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. * statement overwrites it.
*/ */
export async function recordChallengeProgress( export async function recordChallengeProgress(
db: D1Database, db: D1Database,
accountId: number, accountId: number,
progress: ChallengeProgress progress: ChallengeProgress
): Promise<boolean> { ): Promise<ChallengeStatus> {
const row = await db const row = await db
.prepare( .prepare(
`INSERT INTO challenge_status (account_id, challenge_id, challenge_map_id, complete, updated_at) `INSERT INTO challenge_status (account_id, challenge_id, challenge_map_id, complete, config, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT (account_id, challenge_id) DO UPDATE SET ON CONFLICT (account_id, challenge_id) DO UPDATE SET
complete = CASE complete = CASE
WHEN challenge_status.challenge_map_id = excluded.challenge_map_id WHEN challenge_status.challenge_map_id = excluded.challenge_map_id
THEN MAX(challenge_status.complete, excluded.complete) THEN MAX(challenge_status.complete, excluded.complete)
ELSE excluded.complete ELSE excluded.complete
END, 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, challenge_map_id = excluded.challenge_map_id,
updated_at = excluded.updated_at updated_at = excluded.updated_at
RETURNING complete` RETURNING complete, config`
) )
.bind( .bind(
accountId, accountId,
progress.challengeId, progress.challengeId,
progress.challengeMapId, progress.challengeMapId,
progress.complete ? 1 : 0, progress.complete ? 1 : 0,
progress.config,
new Date().toISOString() new Date().toISOString()
) )
.first<{ complete: number }>() .first<{ complete: number; config: string | null }>()
return row?.complete === 1 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 * What a player has stored for one rotation's challenges, keyed by challenge id. Scoped to
* so a stale row from an earlier week — same challenge id, different `challenge_map_id` — * the rotation so a stale row from an earlier week — same challenge id, different
* doesn't show up pre-completed before the client has reported anything against it. * `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 * Read by `getCurrent` to overwrite the static rotation, and by the gift path: the `Gift` is
* challenges in static/weekly-challenge.json appear here — three of the five a week * due once ENOUGH of the challenges in static/weekly-challenge.json are complete here —
* publishes, not all of them (see `CHALLENGES_REQUIRED_FOR_GIFT` in econ.app.ts). * 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, db: D1Database,
accountId: number, accountId: number,
challengeMapId: number challengeMapId: number
): Promise<Set<number>> { ): Promise<Map<number, ChallengeStatus>> {
const { results } = await db const { results } = await db
.prepare( .prepare(
`SELECT challenge_id FROM challenge_status `SELECT challenge_id, complete, config FROM challenge_status
WHERE account_id = ?1 AND challenge_map_id = ?2 AND complete = 1` WHERE account_id = ?1 AND challenge_map_id = ?2`
) )
.bind(accountId, challengeMapId) .bind(accountId, challengeMapId)
.all<{ challenge_id: number }>() .all<{ challenge_id: number; complete: number; config: string | null }>()
return new Set(results.map((r) => r.challenge_id)) 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. */ /** Schema DDL (mirror of migrations 0011_challenge_gift.sql) — also builds the table in tests. */
+50 -37
View File
@@ -45,11 +45,7 @@ import {
isSpendable, isSpendable,
spendCurrency, spendCurrency,
} from './balance-db' } from './balance-db'
import { import { claimChallengeGift, getChallengeStatuses, recordChallengeProgress } from './challenge-db'
claimChallengeGift,
getCompletedChallengeIds,
recordChallengeProgress,
} from './challenge-db'
import { import {
consumeConsumable, consumeConsumable,
countConsumable, countConsumable,
@@ -1446,12 +1442,10 @@ function challengesRequiredForGift(): number {
async function awardChallengeGift(c: Context<App>, accountId: number): Promise<void> { async function awardChallengeGift(c: Context<App>, accountId: number): Promise<void> {
try { try {
if (weeklyChallenge.Challenges.length === 0) return if (weeklyChallenge.Challenges.length === 0) return
const complete = await getCompletedChallengeIds( const statuses = await getChallengeStatuses(c.env.DB, accountId, weeklyChallenge.ChallengeMapId)
c.env.DB, const done = weeklyChallenge.Challenges.filter(
accountId, (ch) => statuses.get(ch.ChallengeId)?.complete === true
weeklyChallenge.ChallengeMapId ).length
)
const done = weeklyChallenge.Challenges.filter((ch) => complete.has(ch.ChallengeId)).length
if (done < challengesRequiredForGift()) return if (done < challengesRequiredForGift()) return
// Claim first: this is what stops the next report paying out a second time. // Claim first: this is what stops the next report paying out a second time.
const claimed = await claimChallengeGift(c.env.DB, accountId, weeklyChallenge.ChallengeMapId) const claimed = await claimChallengeGift(c.env.DB, accountId, weeklyChallenge.ChallengeMapId)
@@ -2866,8 +2860,11 @@ const app = new Hono<App>({ strict: false })
) )
// Current weekly challenge. The rotation itself is the bundled static JSON (its format // 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 // is documented in the README) but each challenge's state is per-player, so the caller's
// caller's rows from `challenge_status` are stamped over the static `false`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 // 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 // rather than 401, since the rotation is public information and a 404/401 on this
// route can stall the client's load orchestration. // route can stall the client's load orchestration.
@@ -2877,9 +2874,10 @@ const app = new Hono<App>({ strict: false })
tags: ['Econ'], tags: ['Econ'],
summary: 'Current weekly challenge', summary: 'Current weekly challenge',
description: [ description: [
'The bundled static rotation, with each challenges `Complete` stamped from the', 'The bundled static rotation, with each challenges `Complete` and `Config` stamped',
'callers progress rows. Auth is optional — unauthenticated callers get the static', 'from the callers progress rows — the stored `Config` carries the clients running',
'catalog with every `Complete` false.', 'counts. Auth is optional — unauthenticated callers get the static catalog with every',
'`Complete` false and every `Config` as authored.',
].join(' '), ].join(' '),
security: OPTIONAL_AUTHED, security: OPTIONAL_AUTHED,
responses: { 200: json(JsonObject, 'The current weekly challenge') }, responses: { 200: json(JsonObject, 'The current weekly challenge') },
@@ -2887,38 +2885,47 @@ const app = new Hono<App>({ strict: false })
async (c) => { async (c) => {
const id = await authedId(c) const id = await authedId(c)
if (id === null) return c.json(weeklyChallenge) if (id === null) return c.json(weeklyChallenge)
const complete = await getCompletedChallengeIds(c.env.DB, id, weeklyChallenge.ChallengeMapId) const statuses = await getChallengeStatuses(c.env.DB, id, weeklyChallenge.ChallengeMapId)
if (complete.size === 0) return c.json(weeklyChallenge) if (statuses.size === 0) return c.json(weeklyChallenge)
// Rebuild rather than mutate: the static import is module state shared by every // 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 // 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({ return c.json({
...weeklyChallenge, ...weeklyChallenge,
Challenges: weeklyChallenge.Challenges.map((challenge) => ({ Challenges: weeklyChallenge.Challenges.map((challenge) => {
...challenge, const status = statuses.get(challenge.ChallengeId)
Complete: complete.has(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 // Report progress on a weekly challenge. [Authorize]. The client evaluates the
// challenge's rule tree locally and posts ChallengeMapId/ChallengeId, that tree in // challenge's rule tree locally and posts ChallengeMapId/ChallengeId, that tree in
// `Config`, and whether it now considers the challenge `Complete`. Only the // `Config`, and whether it now considers the challenge `Complete`. Both are persisted
// completion is persisted (keyed by account + challenge); `Config` is the catalog's // (keyed by account + challenge): the posted tree is the catalog's definition with the
// own definition plus the client's running count, so storing it would duplicate // client's running counts written into it, so it is this player's progress, and
// static data. Echoes the identifying fields back with the completion the row now // `getCurrent` serves it back in place of the authored tree. Echoes the identifying
// holds — which is not always what was posted, since completion latches within a // fields back with the state the row now holds — which is not always what was posted,
// rotation. // since completion latches within a rotation and a report with no `Config` keeps the
// stored tree.
.post( .post(
'/api/challenge/v2/updateProgress', '/api/challenge/v2/updateProgress',
describeRoute({ describeRoute({
tags: ['Econ'], tags: ['Econ'],
summary: 'Report weekly-challenge progress', summary: 'Report weekly-challenge progress',
description: [ description: [
'Persists the reported completion into `challenge_status`, keyed by account +', 'Persists the reported completion and rule tree into `challenge_status`, keyed by',
'challenge. `Config` is accepted and echoed but not stored. Completion latches within', 'account + challenge, so `getCurrent` can serve the players own progress back.',
'a rotation, so the echoed `Complete` is the stored value, not the posted one.', '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(' '), ].join(' '),
security: AUTHED, security: AUTHED,
requestBody: jsonBody(ChallengeProgressRequest, 'Challenge ids + the evaluated rule tree'), requestBody: jsonBody(ChallengeProgressRequest, 'Challenge ids + the evaluated rule tree'),
@@ -2940,14 +2947,16 @@ const app = new Hono<App>({ strict: false })
.catch(() => ({}) as Record<string, never>) .catch(() => ({}) as Record<string, never>)
const challengeMapId = Number(body.ChallengeMapId) || 0 const challengeMapId = Number(body.ChallengeMapId) || 0
const challengeId = Number(body.ChallengeId) || 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. // Nothing to key a row on — echo the body back rather than writing a (0, 0) row.
const complete = const stored =
challengeId === 0 challengeId === 0
? parseBool(body.Complete) ? { complete: parseBool(body.Complete), config }
: await recordChallengeProgress(c.env.DB, id, { : await recordChallengeProgress(c.env.DB, id, {
challengeMapId, challengeMapId,
challengeId, challengeId,
complete: parseBool(body.Complete), complete: parseBool(body.Complete),
config,
}) })
// This report may have been the last one of the set. Only a completing report on // 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 // the LIVE rotation can be — an old rotation's set can no longer be finished, and
@@ -2955,14 +2964,18 @@ const app = new Hono<App>({ strict: false })
// The response is unchanged whether or not a gift was won: the client learns about // 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 // the box from `GET /api/avatar/v2/gifts`, and adding a field here would be
// inventing response shape the client never sent us. // 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) await awardChallengeGift(c, id)
} }
return c.json({ return c.json({
ChallengeMapId: challengeMapId, ChallengeMapId: challengeMapId,
ChallengeId: challengeId, ChallengeId: challengeId,
Config: typeof body.Config === 'string' ? body.Config : '', Config: stored.config ?? '',
Complete: complete, Complete: stored.complete,
}) })
} }
) )
+6 -2
View File
@@ -269,7 +269,9 @@ export const MakerAiFreeTrialEligibilityResponse = z
export const ChallengeProgressResponse = z.object({ export const ChallengeProgressResponse = z.object({
ChallengeMapId: z.int(), ChallengeMapId: z.int(),
ChallengeId: 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 Complete: z
.boolean() .boolean()
.describe('The STORED completion — latches true within a rotation, so it may differ'), .describe('The STORED completion — latches true within a rotation, so it may differ'),
@@ -484,7 +486,9 @@ export const ChallengeProgressRequest = z.object({
Config: z Config: z
.string() .string()
.optional() .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 players progress'
),
Complete: z Complete: z
.union([z.string(), z.boolean()]) .union([z.string(), z.boolean()])
.optional() .optional()
@@ -1942,6 +1942,56 @@ describe('econ endpoints', () => {
expect(await completeOf(await post('18', 'True'))).toBe(true) 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<string, string>) =>
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 * 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`). * publishes fewer or declares itself all-or-nothing (`CHALLENGES_REQUIRED_FOR_GIFT`).