From d12806625d00456c84b82f1b3d6e018b8f551f37 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 13 Aug 2026 12:54:01 -0400 Subject: [PATCH] [econ] add giftContext to the key so people get rewards for different activities per day --- apps/econ/README.md | 19 +++++--- .../0013_reward_status_gift_context.sql | 31 +++++++++++++ apps/econ/src/econ.app.ts | 20 ++++++--- apps/econ/src/openapi.ts | 2 +- apps/econ/src/reward-db.ts | 45 ++++++++++++------- apps/econ/src/test/integration/api.test.ts | 25 ++++++++--- 6 files changed, 105 insertions(+), 37 deletions(-) create mode 100644 apps/econ/migrations/0013_reward_status_gift_context.sql diff --git a/apps/econ/README.md b/apps/econ/README.md index d604203..59a9dc4 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -325,18 +325,25 @@ 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. +is decided here, from `reward_status` — one row per (account, reward type, gift context) +holding the last claim and a count. One claim per type per activity 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. +- **`giftContext` (the activity, e.g. `Soccer`) is part of the key** — the "first activity of + the day" is per activity, so a player who moves from Soccer to Paintball is owed another + reward while a second Soccer match inside the hour is not. +- **A contextless ask keys on `''`, not NULL.** SQLite allows — and does not dedupe — NULLs + in a non-INTEGER primary key, so a NULL context would insert a fresh row on every ask + instead of hitting the conflict, and the cooldown would never apply. Migration + `0013_reward_status_gift_context.sql` rebuilds the table (SQLite can't add a column to a + primary key) and lands the pre-existing rows on that same `''` bucket, so cooldowns from + before it keep counting. **What a claim pays: 5 XP, in a gift box.** The XP (`GAME_REWARD_XP`) is banked in `progression` and the box is the wrapper the client shows for it — no item, every item field diff --git a/apps/econ/migrations/0013_reward_status_gift_context.sql b/apps/econ/migrations/0013_reward_status_gift_context.sql new file mode 100644 index 0000000..d40b869 --- /dev/null +++ b/apps/econ/migrations/0013_reward_status_gift_context.sql @@ -0,0 +1,31 @@ +-- Widen the game-reward cooldown key to include the activity the reward came from. +-- +-- The client posts a `giftContext` alongside the type (`rewardType=PostGameActivity& +-- giftContext=Soccer`), which migration 0010 deliberately dropped: one cooldown per type, +-- shared across activities. That means the first activity of the day pays once no matter +-- how many different activities a player runs. Keying on (type, context) instead gives +-- each activity its own cooldown, so a different activity pays again while the same one +-- stays on cooldown. +-- +-- SQLite can't add a column to a primary key, so the table is rebuilt and the rows copied +-- across. Existing rows have no context and take `''` — NOT the NULL that would read more +-- naturally, because SQLite allows (and does not dedupe) NULLs in a non-INTEGER primary +-- key, which would let the upsert insert a second unkeyed row instead of updating the +-- first and pay out every time. Asks that carry no `giftContext` land on that same `''` +-- bucket, so a pre-migration cooldown keeps counting. + +CREATE TABLE reward_status_new ( + account_id INTEGER NOT NULL, + reward_type TEXT NOT NULL, + gift_context TEXT NOT NULL, + granted_at TEXT NOT NULL, + grant_count INTEGER NOT NULL, + PRIMARY KEY (account_id, reward_type, gift_context) + ); + +INSERT INTO reward_status_new (account_id, reward_type, gift_context, granted_at, grant_count) +SELECT account_id, reward_type, '', granted_at, grant_count FROM reward_status; + +DROP TABLE reward_status; + +ALTER TABLE reward_status_new RENAME TO reward_status; diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 1d9416c..3c09628 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -2258,7 +2258,7 @@ const app = new Hono({ strict: false }) // 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. + // here, from `reward_status`: one claim per type per activity per hour, atomically. // // A claim pays GAME_REWARD_XP into `progression` and hands over a gift box carrying that // XP, announced with the same GiftPackageReceivedImmediate frame the weekly gift uses — @@ -2270,17 +2270,21 @@ const app = new Hono({ strict: false }) // reference answers its own (different, selection-based) flow with a success envelope, // not a list of rewards. // - // `giftContext` (the activity, e.g. `Soccer`) is accepted and ignored: the cooldown is - // per reward type, shared across activities. + // `giftContext` (the activity, e.g. `Soccer`) is part of the cooldown key: the first + // activity of the day is per ACTIVITY, so a player who moves from Soccer to Paintball is + // owed another reward while a second Soccer match inside the hour is not. An ask that + // sends no context keys on `''`. .post( '/api/gamerewards/v1/request', 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.', + 'Claims one reward of `rewardType` in `giftContext` per hour per player, recorded in', + '`reward_status`. The cooldown is per (type, activity), so a different activity is', + 'owed another reward while the same one is not; an ask with no `giftContext` keys on', + 'the empty context. The reward rides in a gift box, so a claim and a rejected', + '(on-cooldown) ask both answer `[]`.', ].join(' '), security: AUTHED, requestBody: form(GameRewardRequest, 'The reward type and its display message'), @@ -2296,7 +2300,8 @@ const app = new Hono({ strict: false }) 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) + const giftContext = typeof body.giftContext === 'string' ? body.giftContext : '' + const claimed = await claimReward(c.env.DB, id, rewardType, giftContext) // On cooldown: nothing was claimed, so nothing is paid and nothing is announced. if (claimed === null) return c.json([]) const message = @@ -2315,6 +2320,7 @@ const app = new Hono({ strict: false }) logger.info('game reward claimed', { accountId: id, rewardType, + giftContext, grantCount: claimed, message, xp: GAME_REWARD_XP, diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index a60f491..fb300b7 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -268,7 +268,7 @@ export const GameRewardRequest = z.object({ giftContext: z .string() .optional() - .describe('The activity it came from, e.g. `Soccer` — accepted and ignored'), + .describe('The activity it came from, e.g. `Soccer` — part of the cooldown key'), }) /** diff --git a/apps/econ/src/reward-db.ts b/apps/econ/src/reward-db.ts index 519bce8..a74de3c 100644 --- a/apps/econ/src/reward-db.ts +++ b/apps/econ/src/reward-db.ts @@ -1,41 +1,51 @@ /** * Game-reward eligibility on the shared `recflare` D1 database — one row per (account, - * reward type), written by `POST /api/gamerewards/v1/request`. + * reward type, gift context), 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 `giftContext` the client sends (the activity, e.g. `Soccer`) is PART of the key: a + * cooldown is per (type, activity), so the same activity can't pay twice inside the hour + * but a different one can. An ask with no context keys on `''` — see `claimReward` for why + * that isn't NULL. * - * The `econ` worker owns this table and its migration - * (apps/econ/migrations/0010_reward_status.sql). + * The `econ` worker owns this table and its migrations + * (apps/econ/migrations/0010_reward_status.sql, widened by + * apps/econ/migrations/0013_reward_status_gift_context.sql). */ -/** Schema DDL (mirror of migrations 0010_reward_status.sql) — also builds the table in tests. */ +/** Schema DDL (mirror of the migrations above) — 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, + gift_context TEXT NOT NULL, granted_at TEXT NOT NULL, grant_count INTEGER NOT NULL, - PRIMARY KEY (account_id, reward_type) + PRIMARY KEY (account_id, reward_type, gift_context) )`, ] /** - * 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. + * How long a player must wait between rewards of the same type in the same activity. 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. + * claimed in that context — or `null` when the cooldown hasn't elapsed and nothing was + * claimed. + * + * `giftContext` defaults to `''` rather than NULL for the contextless ask: SQLite allows + * (and does not dedupe) NULLs in a non-INTEGER primary key, so a NULL context would insert + * a fresh row on every ask instead of hitting the conflict, and the cooldown would never + * apply. * * 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 @@ -50,20 +60,21 @@ export async function claimReward( db: D1Database, accountId: number, rewardType: string, + giftContext = '', 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 + `INSERT INTO reward_status (account_id, reward_type, gift_context, granted_at, grant_count) + VALUES (?1, ?2, ?3, ?4, 1) + ON CONFLICT (account_id, reward_type, gift_context) DO UPDATE SET granted_at = excluded.granted_at, grant_count = reward_status.grant_count + 1 - WHERE reward_status.granted_at <= ?4 + WHERE reward_status.granted_at <= ?5 RETURNING grant_count` ) - .bind(accountId, rewardType, now.toISOString(), cutoff) + .bind(accountId, rewardType, giftContext, 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 da15e09..a9d3536 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -1726,7 +1726,7 @@ describe('econ endpoints', () => { expect(boxes[0]?.AvatarItemDesc).toBe(entry?.AvatarItemDesc) }) - test('POST /api/gamerewards/v1/request claims once an hour per reward type', async () => { + test('POST /api/gamerewards/v1/request claims once an hour per reward type and activity', async () => { const headers = { ...(await bearer('80')), 'Content-Type': 'application/x-www-form-urlencoded', @@ -1737,11 +1737,12 @@ describe('econ endpoints', () => { headers, body, }) - const statusOf = (rewardType: string) => + const statusOf = (rewardType: string, giftContext = '') => env.DB.prepare( - 'SELECT granted_at, grant_count FROM reward_status WHERE account_id = 80 AND reward_type = ?1' + `SELECT granted_at, grant_count FROM reward_status + WHERE account_id = 80 AND reward_type = ?1 AND gift_context = ?2` ) - .bind(rewardType) + .bind(rewardType, giftContext) .first<{ granted_at: string; grant_count: number }>() // A claim answers the empty list the client accepts — the reward rides in a gift box. @@ -1758,7 +1759,8 @@ describe('econ endpoints', () => { 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. + // A different type has its own cooldown — and so does each `giftContext` within a type: + // Soccer and Paintball are separate rows that each claim once. expect( ( await request( @@ -1766,7 +1768,7 @@ describe('econ endpoints', () => { ) ).status ).toBe(200) - expect((await statusOf('PostGameActivity'))?.grant_count).toBe(1) + expect((await statusOf('PostGameActivity', 'Soccer'))?.grant_count).toBe(1) expect( ( await request( @@ -1774,6 +1776,17 @@ describe('econ endpoints', () => { ) ).status ).toBe(200) + expect((await statusOf('PostGameActivity', 'Paintball'))?.grant_count).toBe(1) + + // …but the same activity again inside the hour claims nothing. + const soccer = await statusOf('PostGameActivity', 'Soccer') + expect((await request('rewardType=PostGameActivity&giftContext=Soccer')).status).toBe(200) + expect(await statusOf('PostGameActivity', 'Soccer')).toEqual(soccer) + + // A contextless ask is its own bucket (`''`), not a wildcard over the two above. + expect((await request('rewardType=PostGameActivity&Message=no%20context')).status).toBe(200) + expect((await statusOf('PostGameActivity'))?.grant_count).toBe(1) + expect((await request('rewardType=PostGameActivity&Message=again')).status).toBe(200) expect((await statusOf('PostGameActivity'))?.grant_count).toBe(1) // Once the hour has passed, the same type claims again.