[econ] reward scaffolding

This commit is contained in:
Devin Zuczek
2026-08-10 19:02:40 -04:00
parent 927c6757bb
commit 0b33e0b46f
8 changed files with 632 additions and 74 deletions
+96 -34
View File
@@ -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.
@@ -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);
@@ -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)
);
+100
View File
@@ -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<boolean> {
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<Set<number>> {
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))
}
+124 -23
View File
@@ -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<App>) {
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<App>({ 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 challenges `Complete` stamped from the',
'callers 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<string, never>)
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<App>({ 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<string, unknown>)
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 "[]".
+31 -3
View File
@@ -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 clients verdict — sent as .NETs `"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'),
})
/**
+69
View File
@@ -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<number | null> {
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
}
+167 -14
View File
@@ -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 () => {