mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[econ] add giftContext to the key so people get rewards for different activities per day
This commit is contained in:
+13
-6
@@ -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**
|
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
|
is decided here, from `reward_status` — one row per (account, reward type, gift context)
|
||||||
claim and a count. One claim per type per hour (`REWARD_COOLDOWN_MS`), flat for every type
|
holding the last claim and a count. One claim per type per activity per hour
|
||||||
despite what a name like `FirstActivityOfDay` suggests; per-type windows would be a map
|
(`REWARD_COOLDOWN_MS`), flat for every type despite what a name like `FirstActivityOfDay`
|
||||||
keyed by type.
|
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
|
- **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
|
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.
|
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
|
- **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.
|
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
|
- **`giftContext` (the activity, e.g. `Soccer`) is part of the key** — the "first activity of
|
||||||
per type, shared across activities, so it is not part of the key.
|
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
|
**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
|
`progression` and the box is the wrapper the client shows for it — no item, every item field
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -2258,7 +2258,7 @@ const app = new Hono<App>({ strict: false })
|
|||||||
// posting the type and the message to show for it (`rewardType=FirstActivityOfDay&
|
// posting the type and the message to show for it (`rewardType=FirstActivityOfDay&
|
||||||
// Message=First Game of the Day`, or `rewardType=PostGameActivity&Message=Activity
|
// Message=First Game of the Day`, or `rewardType=PostGameActivity&Message=Activity
|
||||||
// completed!&giftContext=Soccer`) — so whether a reward is actually OWED is decided
|
// 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
|
// 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 —
|
// XP, announced with the same GiftPackageReceivedImmediate frame the weekly gift uses —
|
||||||
@@ -2270,17 +2270,21 @@ const app = new Hono<App>({ strict: false })
|
|||||||
// reference answers its own (different, selection-based) flow with a success envelope,
|
// reference answers its own (different, selection-based) flow with a success envelope,
|
||||||
// not a list of rewards.
|
// not a list of rewards.
|
||||||
//
|
//
|
||||||
// `giftContext` (the activity, e.g. `Soccer`) is accepted and ignored: the cooldown is
|
// `giftContext` (the activity, e.g. `Soccer`) is part of the cooldown key: the first
|
||||||
// per reward type, shared across activities.
|
// 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(
|
.post(
|
||||||
'/api/gamerewards/v1/request',
|
'/api/gamerewards/v1/request',
|
||||||
describeRoute({
|
describeRoute({
|
||||||
tags: ['Econ'],
|
tags: ['Econ'],
|
||||||
summary: 'Request a game reward',
|
summary: 'Request a game reward',
|
||||||
description: [
|
description: [
|
||||||
'Claims one reward of `rewardType` per hour per player, recorded in `reward_status`.',
|
'Claims one reward of `rewardType` in `giftContext` per hour per player, recorded in',
|
||||||
'The reward payload is still a stub — a claim grants nothing and both a claim and a',
|
'`reward_status`. The cooldown is per (type, activity), so a different activity is',
|
||||||
'rejected (on-cooldown) ask answer `[]`. `giftContext` is accepted and ignored.',
|
'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(' '),
|
].join(' '),
|
||||||
security: AUTHED,
|
security: AUTHED,
|
||||||
requestBody: form(GameRewardRequest, 'The reward type and its display message'),
|
requestBody: form(GameRewardRequest, 'The reward type and its display message'),
|
||||||
@@ -2296,7 +2300,8 @@ const app = new Hono<App>({ strict: false })
|
|||||||
const rewardType = typeof body.rewardType === 'string' ? body.rewardType : ''
|
const rewardType = typeof body.rewardType === 'string' ? body.rewardType : ''
|
||||||
// No type, nothing to gate: don't write a row keyed on an empty string.
|
// No type, nothing to gate: don't write a row keyed on an empty string.
|
||||||
if (rewardType === '') return c.json([])
|
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.
|
// On cooldown: nothing was claimed, so nothing is paid and nothing is announced.
|
||||||
if (claimed === null) return c.json([])
|
if (claimed === null) return c.json([])
|
||||||
const message =
|
const message =
|
||||||
@@ -2315,6 +2320,7 @@ const app = new Hono<App>({ strict: false })
|
|||||||
logger.info('game reward claimed', {
|
logger.info('game reward claimed', {
|
||||||
accountId: id,
|
accountId: id,
|
||||||
rewardType,
|
rewardType,
|
||||||
|
giftContext,
|
||||||
grantCount: claimed,
|
grantCount: claimed,
|
||||||
message,
|
message,
|
||||||
xp: GAME_REWARD_XP,
|
xp: GAME_REWARD_XP,
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ export const GameRewardRequest = z.object({
|
|||||||
giftContext: z
|
giftContext: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.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'),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+28
-17
@@ -1,41 +1,51 @@
|
|||||||
/**
|
/**
|
||||||
* Game-reward eligibility on the shared `recflare` D1 database — one row per (account,
|
* 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"
|
* 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,
|
* 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
|
* 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.
|
* 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.
|
* The `giftContext` the client sends (the activity, e.g. `Soccer`) is PART of the key: a
|
||||||
* `Soccer`), but it is deliberately not part of the key — one cooldown per type, shared
|
* cooldown is per (type, activity), so the same activity can't pay twice inside the hour
|
||||||
* across every activity, rather than one per activity.
|
* 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
|
* The `econ` worker owns this table and its migrations
|
||||||
* (apps/econ/migrations/0010_reward_status.sql).
|
* (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[] = [
|
export const REWARD_STATUS_SCHEMA_DDL: string[] = [
|
||||||
`CREATE TABLE IF NOT EXISTS reward_status (
|
`CREATE TABLE IF NOT EXISTS reward_status (
|
||||||
account_id INTEGER NOT NULL,
|
account_id INTEGER NOT NULL,
|
||||||
reward_type TEXT NOT NULL,
|
reward_type TEXT NOT NULL,
|
||||||
|
gift_context TEXT NOT NULL,
|
||||||
granted_at TEXT NOT NULL,
|
granted_at TEXT NOT NULL,
|
||||||
grant_count INTEGER 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
|
* How long a player must wait between rewards of the same type in the same activity. One
|
||||||
* type — despite what a name like `FirstActivityOfDay` suggests. Per-type windows would be
|
* hour flat, for every type — despite what a name like `FirstActivityOfDay` suggests.
|
||||||
* a map keyed by reward type; there's one window until a reward type needs its own.
|
* 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
|
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
|
* 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
|
* 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
|
* 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,
|
db: D1Database,
|
||||||
accountId: number,
|
accountId: number,
|
||||||
rewardType: string,
|
rewardType: string,
|
||||||
|
giftContext = '',
|
||||||
now: Date = new Date()
|
now: Date = new Date()
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
const cutoff = new Date(now.getTime() - REWARD_COOLDOWN_MS).toISOString()
|
const cutoff = new Date(now.getTime() - REWARD_COOLDOWN_MS).toISOString()
|
||||||
const row = await db
|
const row = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO reward_status (account_id, reward_type, granted_at, grant_count)
|
`INSERT INTO reward_status (account_id, reward_type, gift_context, granted_at, grant_count)
|
||||||
VALUES (?1, ?2, ?3, 1)
|
VALUES (?1, ?2, ?3, ?4, 1)
|
||||||
ON CONFLICT (account_id, reward_type) DO UPDATE SET
|
ON CONFLICT (account_id, reward_type, gift_context) DO UPDATE SET
|
||||||
granted_at = excluded.granted_at,
|
granted_at = excluded.granted_at,
|
||||||
grant_count = reward_status.grant_count + 1
|
grant_count = reward_status.grant_count + 1
|
||||||
WHERE reward_status.granted_at <= ?4
|
WHERE reward_status.granted_at <= ?5
|
||||||
RETURNING grant_count`
|
RETURNING grant_count`
|
||||||
)
|
)
|
||||||
.bind(accountId, rewardType, now.toISOString(), cutoff)
|
.bind(accountId, rewardType, giftContext, now.toISOString(), cutoff)
|
||||||
.first<{ grant_count: number }>()
|
.first<{ grant_count: number }>()
|
||||||
return row?.grant_count ?? null
|
return row?.grant_count ?? null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1726,7 +1726,7 @@ describe('econ endpoints', () => {
|
|||||||
expect(boxes[0]?.AvatarItemDesc).toBe(entry?.AvatarItemDesc)
|
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 = {
|
const headers = {
|
||||||
...(await bearer('80')),
|
...(await bearer('80')),
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
@@ -1737,11 +1737,12 @@ describe('econ endpoints', () => {
|
|||||||
headers,
|
headers,
|
||||||
body,
|
body,
|
||||||
})
|
})
|
||||||
const statusOf = (rewardType: string) =>
|
const statusOf = (rewardType: string, giftContext = '') =>
|
||||||
env.DB.prepare(
|
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 }>()
|
.first<{ granted_at: string; grant_count: number }>()
|
||||||
|
|
||||||
// A claim answers the empty list the client accepts — the reward rides in a gift box.
|
// 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 request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
|
||||||
expect(await statusOf('FirstActivityOfDay')).toEqual(claimed)
|
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(
|
expect(
|
||||||
(
|
(
|
||||||
await request(
|
await request(
|
||||||
@@ -1766,7 +1768,7 @@ describe('econ endpoints', () => {
|
|||||||
)
|
)
|
||||||
).status
|
).status
|
||||||
).toBe(200)
|
).toBe(200)
|
||||||
expect((await statusOf('PostGameActivity'))?.grant_count).toBe(1)
|
expect((await statusOf('PostGameActivity', 'Soccer'))?.grant_count).toBe(1)
|
||||||
expect(
|
expect(
|
||||||
(
|
(
|
||||||
await request(
|
await request(
|
||||||
@@ -1774,6 +1776,17 @@ describe('econ endpoints', () => {
|
|||||||
)
|
)
|
||||||
).status
|
).status
|
||||||
).toBe(200)
|
).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)
|
expect((await statusOf('PostGameActivity'))?.grant_count).toBe(1)
|
||||||
|
|
||||||
// Once the hour has passed, the same type claims again.
|
// Once the hour has passed, the same type claims again.
|
||||||
|
|||||||
Reference in New Issue
Block a user