[api][econ] add levels, xp storage and basic game rewards

This commit is contained in:
Devin Zuczek
2026-08-10 23:31:40 -04:00
parent 4109317f0e
commit af64327fea
8 changed files with 328 additions and 39 deletions
+12 -6
View File
@@ -1,6 +1,8 @@
import { Hono } from 'hono'
import { describeRoute } from 'hono-openapi'
import { getProgression, getProgressions } from '@repo/domain'
import { parseFormIds, queryIds } from '../http'
import {
BulkIdsRequest,
@@ -69,13 +71,16 @@ export const progressionRoutes = new Hono<App>({ strict: false })
describeRoute({
tags: ['Progression'],
summary: 'A players level and XP',
description: 'Nothing awards XP yet, so everyone is level 1 with 0 XP.',
description:
'The XP banked in `progression` (game rewards pay into it from the `econ` worker). ' +
'A player who has earned none has no row and reads back as level 1 with 0 XP. ' +
'Levelling is not wired up yet, so `Level` is always 1.',
parameters: [idParam('id', 'Account id')],
responses: { 200: json(ProgressionDto, 'The players progression') },
}),
(c) => {
async (c) => {
const id = Number.parseInt(c.req.param('id'), 10)
return c.json({ PlayerId: id, Level: 1, XP: 0 })
return c.json(await getProgression(c.env.DB, id))
}
)
.post(
@@ -160,12 +165,13 @@ export const progressionRoutes = new Hono<App>({ strict: false })
tags: ['Progression'],
summary: 'Progressions in bulk (GET form)',
description:
'What the 2023 client sends. Unlike the POST forms this one does answer — a ' +
'default level-1 progression per requested id, in request order.',
'What the 2023 client sends. Unlike the POST forms this one does answer — one ' +
'progression per requested id, in request order, defaulting to level 1 / 0 XP for ' +
'ids that have earned nothing.',
parameters: BULK_ID_QUERY,
responses: { 200: json(ProgressionDto.array(), 'One progression per requested id') },
}),
(c) => c.json(queryIds(c).map((id) => ({ PlayerId: id, Level: 1, XP: 0 })))
async (c) => c.json(await getProgressions(c.env.DB, queryIds(c)))
)
.post(
'/api/v1/progression/bulk',
+22
View File
@@ -3,9 +3,11 @@ import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest'
import {
addXp,
GAME_VERSION,
grantInvention,
INVENTORY_INVENTION_SCHEMA_DDL,
PROGRESSION_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL,
@@ -104,6 +106,7 @@ beforeAll(async () => {
// Bought-invention ownership (owned by the econ worker) — `v2/mine` folds it in.
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of PROGRESSION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Reports table (owned by the api worker) — player reports are recorded here.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -303,6 +306,25 @@ describe('public endpoints', () => {
expect(body[0]).toMatchObject({ Level: 1, XP: 0 })
})
test('progression reads back the XP game rewards banked', async () => {
// What `econ` writes when a game reward is claimed — the two workers share the table.
await addXp(env.DB, 4242, 25)
await addXp(env.DB, 4242, 25)
const single = await exports.default.fetch(`${ORIGIN}/api/players/v1/progression/4242`)
expect(await single.json()).toEqual({ PlayerId: 4242, Level: 1, XP: 50 })
// A player who has earned nothing has no row, and still gets a record — the bulk form
// renders a card per id, so a missing one must not shorten the list.
const bulk = await exports.default.fetch(
`${ORIGIN}/api/players/v2/progression/bulk?id=4242&id=4243`
)
expect(await bulk.json()).toEqual([
{ PlayerId: 4242, Level: 1, XP: 50 },
{ PlayerId: 4243, Level: 1, XP: 0 },
])
})
test('POST /api/players/v2/progression/bulk returns an array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/players/v2/progression/bulk`, {
method: 'POST',
+38 -14
View File
@@ -46,7 +46,7 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
| 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` | ✓ | Claim a game reward (hourly, per type) |
| POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 25 XP + gift box |
| 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) |
@@ -377,24 +377,47 @@ keyed by type.
- **`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.
**What a claim pays: 25 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
empty, `GiftContext` 50 (`GameRewards`). The box wears the `Message` the client posted
(`First Game of the Day`), and a `GiftPackageReceivedImmediate` frame goes out with it, the
same push the weekly-challenge gift uses. XP is banked **before** the box is created, so a
failure can't leave a box promising XP nobody was credited.
- **One flat amount for every reward type**, matching the one flat cooldown they share.
Pricing `FirstActivityOfDay` differently from `PostGameActivity` is a map keyed by type,
the same shape the per-type cooldown would take.
- **The response stays `[]`.** It's what the client already accepts, and the reward is
delivered as a box, so there's nothing to put in the body. The reference answers its own
(different) flow with `{ error, success, value: null }`, not a list of rewards.
- **An on-cooldown ask pays nothing** — no XP, no box, no frame. That's the whole point of
getting eligibility right first: a client that retries in a loop must not mint boxes.
**Progression (`progression`) is shared.** `econ` writes it here; `api` reads it back for
`GET /api/players/v{1,2}/progression/…`. It lives in `@repo/domain` for that reason, the
same split as gift boxes. A player with no row reads as level 1 / 0 XP, so a GET never
inserts. `Level` is stored but never moves: the reference levels up by subtracting a tier's
`RequiredXp` from the running XP, with thresholds from a config file (`configv2.json`'s
`LevelProgressionMaps`) we don't have.
**Not ported:** the reference's `request` doesn't grant at all — it offers **three** drops,
pushes a `RewardSelectionReceived` frame and waits for `POST /api/gamerewards/v1/select` to
grant the one the player picked. We grant on request instead, so there is no selection state
and no `/select`. It also caps activity XP per day (`daily_xp_ledgers`); the hourly cooldown
is our cap.
`GET /api/gamerewards/v1/pending` stays `[]`: with rewards claimed on request, nothing sits
waiting to be collected.
## Bindings
| Binding | Type | Notes |
| ---------------------------- | -------------- | -------------------------------------------------------- |
| `DB` | D1 | Shared `recflare` database — balances, inventory, etc. |
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
| `ASSETS` | static assets | Serves `sf{N}.json` storefront catalogs |
| `RECFLARE_NOTIFICATIONS_HUB` | Durable Object | Cross-worker RPC to the `notify` worker's hub |
| `STARTING_TOKENS` | var | Optional; new-player token grant (default in balance-db) |
| Binding | Type | Notes |
| ---------------------------- | -------------- | ---------------------------------------------------------- |
| `DB` | D1 | Shared `recflare` database — balances, inventory, XP, etc. |
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
| `ASSETS` | static assets | Serves `sf{N}.json` storefront catalogs |
| `RECFLARE_NOTIFICATIONS_HUB` | Durable Object | Cross-worker RPC to the `notify` worker's hub |
| `STARTING_TOKENS` | var | Optional; new-player token grant (default in balance-db) |
Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no code change.
@@ -411,7 +434,8 @@ Add a storefront by dropping a new `sfN.json` in `static/storefronts` — no cod
- Consumables are granted and listed but never spent by gameplay, so `Count` only grows.
- 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.
- Game rewards pay a flat 25 XP into `progression`; levelling never happens (no curve) and
there is no daily XP cap beyond the hourly cooldown.
- The weekly-challenge gift is granted but not announced: the box appears in the gifts list
with no `GiftPackageReceived` notification, so the player sees it the next time the client
reads that list rather than the moment they finish the set. Same gap as gifting to another
+21
View File
@@ -0,0 +1,21 @@
-- Player progression (level + XP), owned by the `econ` worker as the writer, but shared:
-- `econ` pays XP out (game rewards) and `api` reads it back for
-- `GET /api/players/v{1,2}/progression/…`, so the helpers live in @repo/domain rather than
-- in either worker. Same split as `received_gift`.
--
-- One row per account, created on the first grant. A missing row means "nothing earned
-- yet", which is the level-1/0-XP default the progression endpoints already served — so
-- reads fall back to it instead of inserting on a GET.
--
-- `level` is stored rather than derived: the reference server levels a player up by
-- subtracting the tier's RequiredXp from the running XP, using thresholds from a config we
-- don't have (configv2.json's LevelProgressionMaps). Until those numbers exist XP
-- accumulates and everyone stays level 1; the column is here so turning the curve on later
-- is a write, not a migration. Kept in sync with PROGRESSION_SCHEMA_DDL in
-- packages/domain/src/progression-db.ts.
CREATE TABLE IF NOT EXISTS progression (
account_id INTEGER PRIMARY KEY,
level INTEGER NOT NULL DEFAULT 1,
xp INTEGER NOT NULL DEFAULT 0
);
+80 -18
View File
@@ -3,6 +3,7 @@ import { describeRoute, openAPIRouteHandler } from 'hono-openapi'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
addXp,
consumeGift,
createGift,
getGift,
@@ -306,6 +307,12 @@ interface StoreGiftDrop {
* to `Rarity`.
*/
QueryRedirectRarity?: number
/**
* XP the drop pays out. No storefront catalog sets it — a bought item is an item — but a
* game reward is XP in a gift box, so the box and its notification carry the amount from
* here. The XP itself is banked in `progression`, not read back off the box.
*/
Xp?: number
}
interface StorePrice {
CurrencyType: number
@@ -392,7 +399,7 @@ function toGiftContent(
AvatarItemType: giftDrop.AvatarItemType,
CurrencyType: giftDrop.CurrencyType,
Currency: giftDrop.Currency,
Xp: 0,
Xp: giftDrop.Xp ?? 0,
PackageType: 0,
Message: message,
EquipmentPrefabName: giftDrop.EquipmentPrefabName,
@@ -413,8 +420,8 @@ function toGiftContent(
*
* The payload is the reference's field-for-field: the stored box's contents plus its `Id`,
* a `FromGiftDropId` of 0 (the reference never populates it either) and the
* platform/balance constants. `Xp` and `Level` are 0 — the drop shape doesn't carry them
* and nothing grants them yet.
* platform/balance constants. `Xp` is the drop's, so a game reward's box announces the XP it
* paid; `Level` is 0, since nothing levels a player up yet.
*
* "Immediate" (31) rather than GiftPackageReceived (30) is what the reference sends for a
* box handed over by the server: a purchase gifted to another player, an admin token grant,
@@ -444,7 +451,7 @@ async function pushGiftReceived(
EquipmentModificationGuid: gift.drop.EquipmentModificationGuid,
CurrencyType: gift.drop.CurrencyType,
Currency: gift.drop.Currency,
Xp: 0,
Xp: gift.drop.Xp ?? 0,
Level: 0,
Platform: -1,
PlatformsToSpawnOn: -1,
@@ -630,6 +637,46 @@ async function grantGiftDrop(
return { id, drop: giftDrop }
}
/**
* XP paid for a claimed game reward. One flat amount for every reward type, matching the
* one flat cooldown they share — "First Game of the Day" and "Activity completed!" are the
* same size of pat on the back until there's reason to price them apart.
*/
const GAME_REWARD_XP = 25
/**
* `GiftContext.GameRewards` — what the box says it came from, so the client files it under
* gameplay rewards rather than a purchase or a player's gift. (`51` is the tokens variant,
* for when a reward pays currency instead of XP.)
*/
const GIFT_CONTEXT_GAME_REWARDS = 50
/** Shown on the box when the client asks for a reward without saying what to call it. */
const DEFAULT_GAME_REWARD_MESSAGE = 'Reward earned!'
/**
* The gift-drop a claimed game reward hands over: XP in a box, no item. Every item field is
* empty on purpose — this is not a purchase and not a roll, so `grantGiftDrop` grants
* nothing into the inventory and only creates the box. The XP is banked in `progression`;
* the copy here is what the box and its notification display.
*/
function toGameRewardDrop(): StoreGiftDrop {
return {
FriendlyName: '',
Tooltip: '',
ConsumableItemDesc: '',
AvatarItemDesc: '',
AvatarItemType: null,
EquipmentPrefabName: '',
EquipmentModificationGuid: '',
Rarity: 0,
Context: GIFT_CONTEXT_GAME_REWARDS,
Currency: 0,
CurrencyType: 0,
Xp: GAME_REWARD_XP,
}
}
/**
* The rotation's reward, as static/weekly-challenge.json writes it. Same item vocabulary as
* a storefront `GiftDrop` but with `Context`/`Rarity` spelled `GiftContext`/`GiftRarity`,
@@ -1905,10 +1952,15 @@ const app = new Hono<App>({ strict: false })
// 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.
// 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 —
// the client posted the message to show, so the box wears it. An on-cooldown ask changes
// nothing and pays nothing.
//
// The response stays `[]` either way. It is what the client already accepts, and the box
// is how a reward is delivered, so there is no captured shape to put the payout in — the
// 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.
@@ -1937,16 +1989,26 @@ const app = new Hono<App>({ strict: false })
// 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 : '',
})
}
// On cooldown: nothing was claimed, so nothing is paid and nothing is announced.
if (claimed === null) return c.json([])
const message =
typeof body.Message === 'string' && body.Message !== ''
? body.Message
: DEFAULT_GAME_REWARD_MESSAGE
// Bank the XP first: it is the reward, and the box is the wrapper the client shows.
// A failure here must not leave a box promising XP that was never credited.
const progression = await addXp(c.env.DB, id, GAME_REWARD_XP)
const granted = await grantGiftDrop(c, id, toGameRewardDrop(), message)
await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID)
logger.info('game reward claimed', {
accountId: id,
rewardType,
grantCount: claimed,
message,
xp: GAME_REWARD_XP,
totalXp: progression.XP,
giftId: granted.id,
})
return c.json([])
}
)
+56 -1
View File
@@ -6,7 +6,9 @@ import '../../econ.app'
import {
getOwnedInventionIds,
getProgression,
INVENTORY_INVENTION_SCHEMA_DDL,
PROGRESSION_SCHEMA_DDL,
RECEIVED_GIFT_SCHEMA_DDL,
} from '@repo/domain'
@@ -55,6 +57,7 @@ beforeAll(async () => {
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 CHALLENGE_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of PROGRESSION_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()
@@ -1661,7 +1664,7 @@ describe('econ endpoints', () => {
.bind(rewardType)
.first<{ granted_at: string; grant_count: number }>()
// The payload is stubbed, so a claim still answers the empty list the client accepts.
// A claim answers the empty list the client accepts — the reward rides in a gift box.
const first = await request(
'rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day'
)
@@ -1703,6 +1706,58 @@ describe('econ endpoints', () => {
expect((await statusOf('FirstActivityOfDay'))?.grant_count).toBe(2)
})
test('a claimed game reward pays XP into a gift box, and announces it', async () => {
const request = async (body: string) =>
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST',
headers: {
...(await bearer('82')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body,
})
await drainFrames()
expect((await getProgression(env.DB, 82)).XP).toBe(0)
const res = await request('rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day')
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
// The XP is banked, not just displayed on the box.
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 25 })
// The box carries the XP and the message the client asked to show, and nothing else —
// a game reward is not an item.
const boxes = await giftBoxes('82')
expect(boxes).toHaveLength(1)
expect(boxes[0]).toMatchObject({
Xp: 25,
Message: 'First Game of the Day',
AvatarItemDesc: '',
EquipmentModificationGuid: '',
ConsumableItemDesc: '',
})
const frames = await drainFrames()
expect(frames).toHaveLength(1)
expect(frames[0]?.accountId).toBe(82)
expect(frames[0]?.notificationType).toBe(NotificationType.GiftPackageReceivedImmediate)
expect(frames[0]?.payload).toMatchObject({
Id: boxes[0]?.Id,
FromPlayerId: 1,
Xp: 25,
// GiftContext.GameRewards — the box came from gameplay, not a purchase.
GiftContext: 50,
Message: 'First Game of the Day',
})
// An on-cooldown ask pays nothing: no second box, no second frame, no more XP.
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
expect((await getProgression(env.DB, 82)).XP).toBe(25)
expect(await giftBoxes('82')).toHaveLength(1)
expect(await drainFrames()).toEqual([])
})
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',