From 0b33e0b46fe4e6e83f4bb3c975bdef180601d263 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 10 Aug 2026 19:02:40 -0400 Subject: [PATCH] [econ] reward scaffolding --- apps/econ/README.md | 130 +++++++++---- .../econ/migrations/0009_challenge_status.sql | 25 +++ apps/econ/migrations/0010_reward_status.sql | 20 ++ apps/econ/src/challenge-db.ts | 100 ++++++++++ apps/econ/src/econ.app.ts | 147 +++++++++++--- apps/econ/src/openapi.ts | 34 +++- apps/econ/src/reward-db.ts | 69 +++++++ apps/econ/src/test/integration/api.test.ts | 181 ++++++++++++++++-- 8 files changed, 632 insertions(+), 74 deletions(-) create mode 100644 apps/econ/migrations/0009_challenge_status.sql create mode 100644 apps/econ/migrations/0010_reward_status.sql create mode 100644 apps/econ/src/challenge-db.ts create mode 100644 apps/econ/src/reward-db.ts diff --git a/apps/econ/README.md b/apps/econ/README.md index d44f21e..447c5ab 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -4,14 +4,15 @@ Economy Worker served on the `econ` subdomain (`econ.recflare.net`). Hosts the avatar/economy endpoints the game client calls on the `econ` service (distinct from the main `api` worker, which also serves many of them — the client may call either host). -Balances, inventory, consumables, saved outfits, avatars and gift boxes are D1-backed; -storefront catalogs are static assets (`static/storefronts/sf{N}.json`) served via the -ASSETS binding. Several routes are still empty-list stubs. +Balances, inventory, consumables, saved outfits, avatars, gift boxes, weekly-challenge +progress and game-reward eligibility are D1-backed; storefront catalogs and the weekly-challenge rotation are static +assets (`static/`), the storefronts served via the ASSETS binding. Several routes are still +empty-list stubs. ## Routes `✓` = auth-gated (validates the Bearer JWT from the `auth` worker; empty-body 401 when -missing/invalid). +missing/invalid). `~` = optional auth: served to anyone, personalised for a valid bearer. | Method | Path | Auth | Description | | -------- | ---------------------------------------------------- | ---- | --------------------------------------- | @@ -42,10 +43,10 @@ missing/invalid). | GET | `/api/storefronts/v3/giftdropstore/:id` | | Gift-drop storefront catalog | | POST | `/api/storefronts/v2/buyItem` | ✓ | Buy a storefront item | | GET | `/api/storefronts/v1/adcarouselitems` | | Ad-carousel items (static) | -| GET | `/api/challenge/v2/getCurrent` | | Current weekly challenge (static) | -| POST | `/api/challenge/v2/updateProgress` | | Report challenge progress (stub) | +| GET | `/api/challenge/v2/getCurrent` | ~ | Weekly rotation + the caller's progress | +| POST | `/api/challenge/v2/updateProgress` | ✓ | Report challenge progress | | GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) | -| POST | `/api/gamerewards/v1/request` | | Request a game reward (stub `[]`) | +| POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward (hourly, per type) | | GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) | | GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) | | POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | | Subscription lookup (both null) | @@ -97,15 +98,18 @@ mismatched call is a harmless no-op (opening _another_ player's box is a 403). ## Weekly challenge (`static/weekly-challenge.json`) -Served verbatim by `GET /api/challenge/v2/getCurrent`. The server never evaluates it: the -client reads the rule tree in each challenge's `Config`, watches its own gameplay, and -posts the tree back to `/api/challenge/v2/updateProgress` with its verdict. So this file -is the entire definition of a week's challenges — ids, display strings, matching rules and -the reward preview. +Served by `GET /api/challenge/v2/getCurrent` (with each challenge's per-player `Complete` +stamped in — see Progress below). The server never evaluates the rules: the client reads +the rule tree in each challenge's `Config`, watches its own gameplay, and posts the tree +back to `/api/challenge/v2/updateProgress` with its verdict. So this file is the entire +definition of a week's challenges — ids, display strings, matching rules and the reward +preview. Everything below was read off reference data (one captured live rotation), not a spec. Field meanings marked _(inferred)_ are read from how the values line up with the strings -the client renders; the rest are pinned by the data itself. +the client renders; the rest are pinned by the data itself. The file itself is edited +freely as rotations change — the examples here are the captured week, so expect the shipped +rotation to differ. ### Top level @@ -134,7 +138,7 @@ rotation with a ~1-day countdown rather than an expired one. If you edit the win | `Config` | The rule tree, as an **escaped JSON string** (not a nested object). See below. | | `Description` | The one-line goal, e.g. `"Complete 10 games in ^Paintball"`. | | `Tooltip` | The longer hint under it. | -| `Complete` | Per-player state, so meaningless in a static catalog: always `false` here, and `updateProgress` is stubbed and never flips it. | +| `Complete` | Per-player state, so always `false` in the file — `getCurrent` overwrites it per caller from `challenge_status`. | `^Token` in `Description`/`Tooltip` is a client-side room link: the client resolves the token to a room and renders a tappable name. Subrooms use a dotted path @@ -184,24 +188,20 @@ The two idioms in the file, unescaped: Note the quest challenges have **no `t`** (one qualifying session is the whole goal) and the counted ones have **no `won` predicate** (finishing counts, winning is irrelevant). -On `updateProgress` the client posts the same tree back with **`cc`** added to the counter -node — its current count (`…,"t":5,"cc":1`). `cc` never appears in this file; it is -progress, not definition. Since the server persists nothing, that count lives only in the -client. +On `updateProgress` the client posts the same tree back with its own progress written into +it: **`cc`** on the counter node is the current count (`…,"t":5,"cc":1`), and **`c`** (`"c":true`) +marks a node it now considers satisfied. Neither appears in this file — they are progress, +not definition, which is why the tree isn't stored (only the top-level `Complete` is). The +count itself lives only in the client. **Scene ids, not room ids.** Because `ct: 7` matches `UnitySceneId`, a screens room and its -VR twin share ids and both count — the six Paintball scenes listed for challenge `44` are -the subrooms of _both_ `Paintball` and `PaintballVR`, and each also exists as a standalone -base room (`River`, `Clearcut`, …). One list covers every way in. How the rotation's five -challenges resolve: - -| Challenge | Scenes | -| --------- | ----------------------------------------------------------------- | -| `37` | TheRiseofJumbotron / Home | -| `38` | Crescendo / Home | -| `44` | Paintball: River, Homestead, Quarry, Clearcut, Spillway, Drive-in | -| `49` | 3DCharades / InkSpaceHome + Legacy3DCharades / Home | -| `63` | Clearcut only | +VR twin share ids and both count: the captured "Complete 10 games in Paintball" listed six +scenes, which are the subrooms of _both_ `Paintball` and `PaintballVR` — and each is also a +standalone base room (`River`, `Clearcut`, …). One list covers every way in. Resolve a guid +against the `SubRooms[].UnitySceneId` values in `apps/rooms/migrations/0002_import_rooms.sql`; +a "one map only" challenge is the same shape with a single-entry list. Watch for one trap +this creates: `Soccer / Home` and `Stadium / Home` are the same scene, so a soccer challenge +also completes in the Stadium. ### The `Gift` block @@ -218,6 +218,67 @@ _not_ by `GiftDropId`: this block's `GiftDropId` is `3994`, while the same skin `sf3.json` as `2121` ("Camera Skin (Comic)"). Nothing grants it — the reward is preview only (see Known gaps). +### 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 +"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: + +- **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 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. + +`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 +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 +caller. + +## Game rewards (`reward_status`) + +The client asks for a reward whenever it thinks one is due, posting a form body of the type +and the message to show for it: + +``` +rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day +rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Soccer +``` + +Since the client asks rather than the server offering, whether a reward is actually **owed** +is decided here, from `reward_status` — one row per (account, reward type) holding the last +claim and a count. One claim per type per hour (`REWARD_COOLDOWN_MS`), flat for every type +despite what a name like `FirstActivityOfDay` suggests; per-type windows would be a map +keyed by type. + +- **The claim is one SQL statement** (`ON CONFLICT … DO UPDATE … WHERE`). The client fires + these off right after a match, so two can land together; a read-then-write would let both + see the same stale `granted_at` and pay out twice. +- **A rejected claim leaves `granted_at` alone.** If an on-cooldown ask pushed the timestamp + forward, a client that retries in a loop would never become eligible. +- **`giftContext` (the activity, e.g. `Soccer`) is accepted and ignored** — the cooldown is + per type, shared across activities, so it is not part of the key. + +**The reward payload itself is a stub:** a successful claim records the cooldown, logs a +`game reward claimed` line, and grants nothing, so a claim and an on-cooldown ask both +answer the same empty list the client already accepts. Paying one out is the +`claimed !== null` branch in the handler. Getting eligibility right first is the point — +it's what stops a repeat ask paying twice once there's something to pay. + +`GET /api/gamerewards/v1/pending` stays `[]`: with rewards claimed on request, nothing sits +waiting to be collected. + ## Bindings | Binding | Type | Notes | @@ -235,7 +296,8 @@ Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no cod - Gifting to another player grants the item and box but does not notify the recipient. - `buyItem` grants avatar-item and consumable drops; currency/xp drops aren't granted. - Consumables are granted and listed but never spent by gameplay, so `Count` only grows. -- Several routes (room keys, wishlist, equipment, room consumables/currencies, game - rewards) are empty-list stubs pending their own stores. -- Weekly-challenge progress is never persisted and the rotation's `Gift` is never granted: - `updateProgress` echoes `Complete: false`, so nothing ever completes. +- Several routes (room keys, wishlist, equipment, room consumables/currencies) are + empty-list stubs pending their own stores. +- Game rewards gate correctly but pay nothing out — see the `reward_status` section. +- Weekly-challenge completion is persisted, but the rotation's `Gift` is never granted — + nothing watches for the last challenge finishing, and there is no claim endpoint. diff --git a/apps/econ/migrations/0009_challenge_status.sql b/apps/econ/migrations/0009_challenge_status.sql new file mode 100644 index 0000000..5711225 --- /dev/null +++ b/apps/econ/migrations/0009_challenge_status.sql @@ -0,0 +1,25 @@ +-- Weekly-challenge progress, owned by the `econ` worker. One row per (account, +-- challenge): the client evaluates a challenge's rule tree locally and posts its verdict +-- to `/api/challenge/v2/updateProgress`, which upserts here; `/api/challenge/v2/getCurrent` +-- reads the rows back to stamp each challenge's per-player `Complete`. +-- +-- Only the completion flag is stored. The `Config` rule tree posted alongside it is the +-- challenge's definition (static/weekly-challenge.json, identical for every player) plus +-- the client's running count in `cc`; the server evaluates none of it, so a per-player copy +-- would just be a staler duplicate of the catalog. +-- +-- `challenge_map_id` is the rotation the report belongs to. It is not part of the key, but +-- it scopes reads and resets the row when a challenge id comes back in a later rotation: +-- ids are only unique within one. Kept in sync with CHALLENGE_STATUS_SCHEMA_DDL in +-- src/challenge-db.ts. + +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, + updated_at TEXT NOT NULL, + PRIMARY KEY (account_id, challenge_id) + ); + +CREATE INDEX IF NOT EXISTS idx_challenge_status_account_map ON challenge_status (account_id, challenge_map_id); diff --git a/apps/econ/migrations/0010_reward_status.sql b/apps/econ/migrations/0010_reward_status.sql new file mode 100644 index 0000000..e4e6d9e --- /dev/null +++ b/apps/econ/migrations/0010_reward_status.sql @@ -0,0 +1,20 @@ +-- Game-reward eligibility, owned by the `econ` worker. One row per (account, reward type): +-- the client asks for a reward whenever it thinks one is due (`POST +-- /api/gamerewards/v1/request` with `rewardType`/`Message`), so this table is what decides +-- whether one is actually owed and keeps a repeat ask from paying out twice. +-- +-- `granted_at` is when the type was last claimed and `grant_count` how many times it has +-- been; the claim is a conditional upsert, so the check and the write are one atomic +-- statement (the client can fire two requests at once after a match). +-- +-- The reward TYPE is the whole key. The client also sends a `giftContext` (the activity, +-- e.g. `Soccer`), deliberately not keyed on: one cooldown per type, shared across +-- activities. Kept in sync with REWARD_STATUS_SCHEMA_DDL in src/reward-db.ts. + +CREATE TABLE IF NOT EXISTS reward_status ( + account_id INTEGER NOT NULL, + reward_type TEXT NOT NULL, + granted_at TEXT NOT NULL, + grant_count INTEGER NOT NULL, + PRIMARY KEY (account_id, reward_type) + ); diff --git a/apps/econ/src/challenge-db.ts b/apps/econ/src/challenge-db.ts new file mode 100644 index 0000000..9c9bde3 --- /dev/null +++ b/apps/econ/src/challenge-db.ts @@ -0,0 +1,100 @@ +/** + * 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`. + * + * 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 the README's weekly-challenge section for the grammar. + * + * 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. + * + * The `econ` worker owns this table and its migration + * (apps/econ/migrations/0009_challenge_status.sql). + */ + +/** Schema DDL (mirror of migrations 0009_challenge_status.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, + updated_at TEXT NOT NULL, + PRIMARY KEY (account_id, challenge_id) + )`, +] + +/** One challenge's progress as the client reports it. */ +export interface ChallengeProgress { + challengeMapId: number + challengeId: number + complete: boolean +} + +/** + * Record a progress report and return the completion 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`. + * + * 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 + * statement overwrites it. + */ +export async function recordChallengeProgress( + db: D1Database, + accountId: number, + progress: ChallengeProgress +): 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) + 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, + challenge_map_id = excluded.challenge_map_id, + updated_at = excluded.updated_at + RETURNING complete` + ) + .bind( + accountId, + progress.challengeId, + progress.challengeMapId, + progress.complete ? 1 : 0, + new Date().toISOString() + ) + .first<{ complete: number }>() + return row?.complete === 1 +} + +/** + * 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. + */ +export async function getCompletedChallengeIds( + db: D1Database, + accountId: number, + challengeMapId: number +): Promise> { + const { results } = await db + .prepare( + `SELECT challenge_id FROM challenge_status + WHERE account_id = ?1 AND challenge_map_id = ?2 AND complete = 1` + ) + .bind(accountId, challengeMapId) + .all<{ challenge_id: number }>() + return new Set(results.map((r) => r.challenge_id)) +} diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 840eed3..415375c 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -36,6 +36,7 @@ import { isSpendable, spendCurrency, } from './balance-db' +import { getCompletedChallengeIds, recordChallengeProgress } from './challenge-db' import { consumeConsumable, countConsumable, @@ -60,11 +61,13 @@ import { EquipmentUpdateRequest, ErrorResponse, form, + GameRewardRequest, json, JsonArray, jsonBody, JsonObject, OpaqueJsonBody, + OPTIONAL_AUTHED, SaveOutfitRequest, SaveOutfitV4Response, SubscriptionResponse, @@ -73,6 +76,7 @@ import { UpdateObjectiveResponse, } from './openapi' import { getOutfits, setOutfit } from './outfit-db' +import { claimReward } from './reward-db' import type { Context } from 'hono' import type { GiftContent, StoredGift } from '@repo/domain' @@ -87,7 +91,7 @@ import type { Outfit } from './outfit-db' * Economy Worker. Hosts the avatar/economy endpoints the game client calls on * the `econ` service (these are separate from the main `api` worker). Balances, * inventory (avatar items, equipment, bought inventions), consumables, saved outfits, - * avatars and gift boxes are D1-backed; + * avatars, gift boxes, weekly-challenge progress and game-reward eligibility are D1-backed; * storefront catalogs are static assets (`sf{N}.json`) served via the ASSETS * binding. Some routes are still empty-list stubs (room keys, wishlist, …). * @@ -107,6 +111,16 @@ function unauthorized(c: Context) { return c.body(null, 401) } +/** + * A boolean the client may send either as a JSON `true` or as .NET's `bool.ToString()` + * output — `"True"`/`"False"`, capitalized. `Boolean(value)` is a trap here: the string + * `"False"` is truthy, so a client reporting "not complete" would read as complete. + * Anything unrecognised (missing, `null`, `""`) is false. + */ +function parseBool(value: string | boolean | undefined): boolean { + return typeof value === 'boolean' ? value : String(value).toLowerCase() === 'true' +} + /** * Shared parse/validate/store for the save-outfit routes (v3 and v4). Persists the * posted outfit into its `Slot` verbatim and returns the stored `Outfit`; on the @@ -1381,51 +1395,95 @@ const app = new Hono({ strict: false }) (c) => c.json(adCarouselItems) ) - // Current weekly challenge. Served from the bundled static JSON until - // per-rotation challenge data is wired up. + // 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. + // 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. .get( '/api/challenge/v2/getCurrent', describeRoute({ tags: ['Econ'], summary: 'Current weekly challenge', - description: 'Served from the bundled static challenge until per-rotation data is wired up.', + 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.', + ].join(' '), + security: OPTIONAL_AUTHED, responses: { 200: json(JsonObject, 'The current weekly challenge') }, }), - (c) => c.json(weeklyChallenge) + 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) + // 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. + return c.json({ + ...weeklyChallenge, + Challenges: weeklyChallenge.Challenges.map((challenge) => ({ + ...challenge, + Complete: complete.has(challenge.ChallengeId), + })), + }) + } ) - // Report progress on a weekly challenge. 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`. Stubbed: with no challenge- - // progress DB yet we persist nothing and never mark a challenge complete (so the - // gift flow isn't triggered). Echo the identifying fields back with Complete=false - // so the client gets a well-formed, non-null body to deserialize. + // 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. .post( '/api/challenge/v2/updateProgress', describeRoute({ tags: ['Econ'], summary: 'Report weekly-challenge progress', description: [ - 'Stubbed: with no challenge-progress store we persist nothing and never mark a', - 'challenge complete. Echoes the identifying fields back with `Complete: false` so the', - 'client gets a well-formed body.', + '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.', ].join(' '), + security: AUTHED, requestBody: jsonBody(ChallengeProgressRequest, 'Challenge ids + the evaluated rule tree'), - responses: { 200: json(ChallengeProgressResponse, 'Echoed fields, Complete false') }, + responses: { + 200: json(ChallengeProgressResponse, 'Echoed fields with the stored completion'), + 401: UNAUTHORIZED_RESPONSE, + }, }), async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) const body = await c.req .json<{ ChallengeMapId?: string | number ChallengeId?: string | number Config?: string + Complete?: string | boolean }>() .catch(() => ({}) as Record) + const challengeMapId = Number(body.ChallengeMapId) || 0 + const challengeId = Number(body.ChallengeId) || 0 + // Nothing to key a row on — echo the body back rather than writing a (0, 0) row. + const complete = + challengeId === 0 + ? parseBool(body.Complete) + : await recordChallengeProgress(c.env.DB, id, { + challengeMapId, + challengeId, + complete: parseBool(body.Complete), + }) return c.json({ - ChallengeMapId: Number(body.ChallengeMapId) || 0, - ChallengeId: Number(body.ChallengeId) || 0, + ChallengeMapId: challengeMapId, + ChallengeId: challengeId, Config: typeof body.Config === 'string' ? body.Config : '', - Complete: false, + Complete: complete, }) } ) @@ -1435,13 +1493,56 @@ const app = new Hono({ strict: false }) c.json([]) ) - // Request a game reward (client posts `rewardType`/`Message`, e.g. - // FirstActivityOfDay). Stubbed: with no reward DB yet we grant nothing and return an - // empty list of rewards — matching the `pending` shape so the client deserializes it. + // Request a game reward. [Authorize]. The client asks whenever it thinks one is due, + // posting the type and the message to show for it (`rewardType=FirstActivityOfDay& + // Message=First Game of the Day`, or `rewardType=PostGameActivity&Message=Activity + // completed!&giftContext=Soccer`) — so whether a reward is actually OWED is decided + // here, from `reward_status`: one claim per type per hour, atomically. + // + // The reward itself is still a stub: a claim records the cooldown and grants nothing, + // so both outcomes answer the same empty list the client already accepts. Paying one + // out later is the `claimed !== null` branch below — the eligibility half is what has + // to be right first, since that's what stops a repeat ask paying twice. + // + // `giftContext` (the activity, e.g. `Soccer`) is accepted and ignored: the cooldown is + // per reward type, shared across activities. .post( '/api/gamerewards/v1/request', - listRoute('Request a game reward', 'Stubbed — grants nothing, returns []'), - (c) => c.json([]) + describeRoute({ + tags: ['Econ'], + summary: 'Request a game reward', + description: [ + 'Claims one reward of `rewardType` per hour per player, recorded in `reward_status`.', + 'The reward payload is still a stub — a claim grants nothing and both a claim and a', + 'rejected (on-cooldown) ask answer `[]`. `giftContext` is accepted and ignored.', + ].join(' '), + security: AUTHED, + requestBody: form(GameRewardRequest, 'The reward type and its display message'), + responses: { + 200: json(JsonArray, 'The rewards granted — always [] while the payload is stubbed'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const body = await c.req.parseBody().catch(() => ({}) as Record) + const rewardType = typeof body.rewardType === 'string' ? body.rewardType : '' + // No type, nothing to gate: don't write a row keyed on an empty string. + if (rewardType === '') return c.json([]) + const claimed = await claimReward(c.env.DB, id, rewardType) + if (claimed !== null) { + // The reward would be granted here. Logged for now so the faucet is visible in + // production before it pays anything out. + logger.info('game reward claimed', { + accountId: id, + rewardType, + grantCount: claimed, + message: typeof body.Message === 'string' ? body.Message : '', + }) + } + return c.json([]) + } ) // The player's room keys. Returns "[]". diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index cd8a5bc..3968ac9 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -50,6 +50,13 @@ export const UNAUTHORIZED_RESPONSE = { description: 'Missing or invalid bearer t /** Bearer-JWT security requirement, for the auth-gated routes. */ export const AUTHED = [{ bearerAuth: [] }] +/** + * Optional bearer JWT — the empty requirement object makes "no credentials" a valid + * alternative. For routes that serve public data but personalise it for a known caller + * (the weekly challenge's per-player `Complete`) instead of 401ing. + */ +export const OPTIONAL_AUTHED: OpenAPIV3_1.SecurityRequirementObject[] = [{}, { bearerAuth: [] }] + // ---- Loose shapes ---------------------------------------------------------- // Several routes serve opaque static catalogs (avatar items, the weekly challenge) or // empty-list stubs. Modelling every catalog field adds noise without value, so these @@ -108,8 +115,10 @@ export const SubscriptionResponse = z.object({ export const ChallengeProgressResponse = z.object({ ChallengeMapId: z.int(), ChallengeId: z.int(), - Config: z.string(), - Complete: z.boolean().describe('Always false — no challenge-progress store yet'), + Config: z.string().describe('Echoed back verbatim; not stored'), + Complete: z + .boolean() + .describe('The STORED completion — latches true within a rotation, so it may differ'), }) /** @@ -206,7 +215,26 @@ export const ConsumeGiftRequest = z.object({ export const ChallengeProgressRequest = z.object({ ChallengeMapId: z.union([z.string(), z.int()]).optional(), ChallengeId: z.union([z.string(), z.int()]).optional(), - Config: z.string().optional().describe('The client-evaluated rule tree'), + Config: z + .string() + .optional() + .describe('The client-evaluated rule tree, with its running count in `cc`; not stored'), + Complete: z + .union([z.string(), z.boolean()]) + .optional() + .describe('The client’s verdict — sent as .NET’s `"True"`/`"False"`'), +}) + +/** `POST /api/gamerewards/v1/request` form body. */ +export const GameRewardRequest = z.object({ + rewardType: z + .string() + .describe('The reward being asked for, e.g. `FirstActivityOfDay`, `PostGameActivity`'), + Message: z.string().optional().describe('The message to show for the reward'), + giftContext: z + .string() + .optional() + .describe('The activity it came from, e.g. `Soccer` — accepted and ignored'), }) /** diff --git a/apps/econ/src/reward-db.ts b/apps/econ/src/reward-db.ts new file mode 100644 index 0000000..519bce8 --- /dev/null +++ b/apps/econ/src/reward-db.ts @@ -0,0 +1,69 @@ +/** + * Game-reward eligibility on the shared `recflare` D1 database — one row per (account, + * reward type), written by `POST /api/gamerewards/v1/request`. + * + * The client asks for a reward whenever it thinks one is due ("First Game of the Day" + * after an activity, "Activity completed!" after a match), so the server, not the client, + * has to decide whether one is actually owed: this table is what makes a second ask for + * the same reward a no-op instead of a second payout. + * + * Keyed by reward TYPE only. The client also sends a `giftContext` (the activity, e.g. + * `Soccer`), but it is deliberately not part of the key — one cooldown per type, shared + * across every activity, rather than one per activity. + * + * The `econ` worker owns this table and its migration + * (apps/econ/migrations/0010_reward_status.sql). + */ + +/** Schema DDL (mirror of migrations 0010_reward_status.sql) — also builds the table in tests. */ +export const REWARD_STATUS_SCHEMA_DDL: string[] = [ + `CREATE TABLE IF NOT EXISTS reward_status ( + account_id INTEGER NOT NULL, + reward_type TEXT NOT NULL, + granted_at TEXT NOT NULL, + grant_count INTEGER NOT NULL, + PRIMARY KEY (account_id, reward_type) + )`, +] + +/** + * How long a player must wait between rewards of the same type. One hour flat, for every + * type — despite what a name like `FirstActivityOfDay` suggests. Per-type windows would be + * a map keyed by reward type; there's one window until a reward type needs its own. + */ +export const REWARD_COOLDOWN_MS = 60 * 60 * 1000 + +/** + * Claim a reward if the player is due one, returning how many of that type they have now + * claimed — or `null` when the cooldown hasn't elapsed and nothing was claimed. + * + * The check and the claim are ONE statement. The client fires these off after a match, so + * two requests can land together; a read-then-write would let both see the same stale + * `granted_at` and pay out twice. `ON CONFLICT … DO UPDATE … WHERE` gives us the atomic + * version: when the cooldown hasn't elapsed the update is skipped, no row is returned, and + * the stored `granted_at` is left alone (so a rejected claim doesn't extend the cooldown). + * + * `granted_at` holds `toISOString()` output — fixed-width UTC, so the lexical `<=` against + * the cutoff is a chronological comparison with no date parsing in SQL. + */ +export async function claimReward( + db: D1Database, + accountId: number, + rewardType: string, + now: Date = new Date() +): Promise { + const cutoff = new Date(now.getTime() - REWARD_COOLDOWN_MS).toISOString() + const row = await db + .prepare( + `INSERT INTO reward_status (account_id, reward_type, granted_at, grant_count) + VALUES (?1, ?2, ?3, 1) + ON CONFLICT (account_id, reward_type) DO UPDATE SET + granted_at = excluded.granted_at, + grant_count = reward_status.grant_count + 1 + WHERE reward_status.granted_at <= ?4 + RETURNING grant_count` + ) + .bind(accountId, rewardType, now.toISOString(), cutoff) + .first<{ grant_count: number }>() + return row?.grant_count ?? null +} diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index f694b3c..dec6394 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -13,6 +13,9 @@ import { // The `invention` table belongs to the `api` worker; buyInvention reads it, so its DDL // is built here too (see the same cross-worker import in econ.app.ts). import { SCHEMA_DDL as INVENTION_SCHEMA_DDL } from '../../../../api/src/inventions-db' +// The live weekly rotation, so the challenge tests exercise whatever it currently holds +// instead of hard-coded ids from a rotation that has since been replaced. +import weeklyChallenge from '../../../static/weekly-challenge.json' import { SCHEMA_DDL } from '../../avatar-db' import { BALANCE_SCHEMA_DDL, @@ -21,10 +24,12 @@ import { getBalance, spendCurrency, } from '../../balance-db' +import { CHALLENGE_STATUS_SCHEMA_DDL } from '../../challenge-db' import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db' import { EQUIPMENT_SCHEMA_DDL } from '../../equipment-db' import { INVENTORY_SCHEMA_DDL } from '../../inventory-db' import { OUTFIT_SCHEMA_DDL } from '../../outfit-db' +import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db' import type { Env } from '../../context' @@ -34,6 +39,9 @@ declare module 'cloudflare:test' { const ORIGIN = 'https://example.com' +/** The first challenge of the live rotation — the progress tests report against it. */ +const CURRENT_CHALLENGE = weeklyChallenge.Challenges[0] + // Build the accounts table and seed the test player (the default token's sub, 42) // so avatar reads/writes have a row to attach to. beforeAll(async () => { @@ -42,6 +50,8 @@ beforeAll(async () => { for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of BALANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of CHALLENGE_STATUS_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of REWARD_STATUS_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of EQUIPMENT_SCHEMA_DDL) await env.DB.prepare(stmt).run() @@ -1342,36 +1352,179 @@ describe('econ endpoints', () => { expect(await res.json()).toEqual([]) }) - test('POST /api/challenge/v2/updateProgress echoes the challenge, never complete (stub)', async () => { - const config = - '{"ct":1,"ipc":false,"ctc":[{"ct":0,"ipc":false,"wc":[{"ct":6,"vs":[2]},{"ct":7,"vs":[{"l":"a673712c-877f-4749-b69a-4a4c6310d545"}]}]}],"t":5,"cc":1}' + test('POST /api/challenge/v2/updateProgress echoes the challenge and its stored completion', async () => { + // Post the live rotation's own challenge and rule tree — what the client actually + // sends — so editing static/weekly-challenge.json can't quietly stale this test. + const challenge = CURRENT_CHALLENGE const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { ...(await bearer('70')), 'Content-Type': 'application/json' }, body: JSON.stringify({ - ChallengeMapId: '17', - ChallengeId: '49', - Config: config, + ChallengeMapId: String(weeklyChallenge.ChallengeMapId), + ChallengeId: String(challenge.ChallengeId), + Config: challenge.Config, + // .NET's bool.ToString() — the capitalized string, which `Boolean("False")` + // would read as complete. Complete: 'False', }), }) expect(res.status).toBe(200) expect(await res.json()).toEqual({ - ChallengeMapId: 17, - ChallengeId: 49, - Config: config, + ChallengeMapId: weeklyChallenge.ChallengeMapId, + ChallengeId: challenge.ChallengeId, + Config: challenge.Config, Complete: false, }) }) - test('POST /api/gamerewards/v1/request returns [] (stub)', async () => { - const res = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, { + test('POST /api/challenge/v2/updateProgress is 401 without a token', async () => { + const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ChallengeMapId: '17', ChallengeId: '49', Complete: 'True' }), + }) + expect(res.status).toBe(401) + }) + + test('a completed challenge persists and getCurrent stamps it for that player only', async () => { + const completedId = CURRENT_CHALLENGE.ChallengeId + const bearerHeaders = await bearer('71') + const posted = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, { + method: 'POST', + headers: { ...bearerHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ChallengeMapId: String(weeklyChallenge.ChallengeMapId), + ChallengeId: completedId, + Complete: 'True', + }), + }) + expect(posted.status).toBe(200) + + const mine = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`, { + headers: bearerHeaders, + }) + const body = (await mine.json()) as { + Challenges: Array<{ ChallengeId: number; Complete: boolean }> + } + // Only the reported one is stamped; the rest of the rotation is untouched. + expect(body.Challenges.filter((ch) => ch.Complete).map((ch) => ch.ChallengeId)).toEqual([ + completedId, + ]) + + // A different player, and an anonymous caller, still see the static catalog. + const other = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`, { + headers: await bearer('72'), + }) + const otherBody = (await other.json()) as { Challenges: Array<{ Complete: boolean }> } + expect(otherBody.Challenges.some((ch) => ch.Complete)).toBe(false) + const anon = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`) + const anonBody = (await anon.json()) as { Challenges: Array<{ Complete: boolean }> } + expect(anonBody.Challenges.some((ch) => ch.Complete)).toBe(false) + }) + + test('completion latches within a rotation but resets on a new one', async () => { + const headers = { ...(await bearer('73')), 'Content-Type': 'application/json' } + // A challenge id of its own, so this says nothing about the live rotation. + const post = (ChallengeMapId: string, Complete: string) => + exports.default.fetch(`${ORIGIN}/api/challenge/v2/updateProgress`, { + method: 'POST', + headers, + body: JSON.stringify({ ChallengeMapId, ChallengeId: '9001', Complete }), + }) + const completeOf = async (res: Response) => + ((await res.json()) as { Complete: boolean }).Complete + + expect(await completeOf(await post('17', 'True'))).toBe(true) + // A later report that says "not complete" must not un-finish it. + expect(await completeOf(await post('17', 'False'))).toBe(true) + // …but the same challenge id in the NEXT rotation starts over. + expect(await completeOf(await post('18', 'False'))).toBe(false) + expect(await completeOf(await post('18', 'True'))).toBe(true) + }) + + test('POST /api/gamerewards/v1/request claims once an hour per reward type', async () => { + const headers = { + ...(await bearer('80')), + 'Content-Type': 'application/x-www-form-urlencoded', + } + const request = (body: string) => + exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, { + method: 'POST', + headers, + body, + }) + const statusOf = (rewardType: string) => + env.DB.prepare( + 'SELECT granted_at, grant_count FROM reward_status WHERE account_id = 80 AND reward_type = ?1' + ) + .bind(rewardType) + .first<{ granted_at: string; grant_count: number }>() + + // The payload is stubbed, so a claim still answers the empty list the client accepts. + const first = await request( + 'rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day' + ) + expect(first.status).toBe(200) + expect(await first.json()).toEqual([]) + const claimed = await statusOf('FirstActivityOfDay') + expect(claimed?.grant_count).toBe(1) + + // Asking again inside the hour claims nothing — and must not push the cooldown out, + // or a client that retries in a loop would never become eligible. + expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200) + expect(await statusOf('FirstActivityOfDay')).toEqual(claimed) + + // A different type has its own cooldown; `giftContext` doesn't split it. + expect( + ( + await request( + 'rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Soccer' + ) + ).status + ).toBe(200) + expect((await statusOf('PostGameActivity'))?.grant_count).toBe(1) + expect( + ( + await request( + 'rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Paintball' + ) + ).status + ).toBe(200) + expect((await statusOf('PostGameActivity'))?.grant_count).toBe(1) + + // Once the hour has passed, the same type claims again. + await env.DB.prepare( + "UPDATE reward_status SET granted_at = ?1 WHERE account_id = 80 AND reward_type = 'FirstActivityOfDay'" + ) + .bind(new Date(Date.now() - 61 * 60 * 1000).toISOString()) + .run() + expect((await request('rewardType=FirstActivityOfDay&Message=tomorrow')).status).toBe(200) + expect((await statusOf('FirstActivityOfDay'))?.grant_count).toBe(2) + }) + + test('POST /api/gamerewards/v1/request is 401 without a token, and ignores a typeless ask', async () => { + const anon = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day', }) - expect(res.status).toBe(200) - expect(await res.json()).toEqual([]) + expect(anon.status).toBe(401) + + // No reward type: nothing to gate, so no row keyed on an empty string. + const typeless = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, { + method: 'POST', + headers: { + ...(await bearer('81')), + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: 'Message=First%20Game%20of%20the%20Day', + }) + expect(typeless.status).toBe(200) + expect(await typeless.json()).toEqual([]) + const rows = await env.DB.prepare( + 'SELECT COUNT(*) AS count FROM reward_status WHERE account_id = 81' + ).first<{ count: number }>() + expect(rows?.count).toBe(0) }) test('GET /api/roomkeys/v1/mine returns []', async () => {