mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
[econ] fix challenges not persisting
This commit is contained in:
@@ -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
@@ -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;
|
||||||
@@ -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
@@ -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 challenge’s `Complete` stamped from the',
|
'The bundled static rotation, with each challenge’s `Complete` and `Config` stamped',
|
||||||
'caller’s progress rows. Auth is optional — unauthenticated callers get the static',
|
'from the caller’s progress rows — the stored `Config` carries the client’s 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 player’s 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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 player’s 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`).
|
||||||
|
|||||||
Reference in New Issue
Block a user