[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 { Hono } from 'hono'
import { describeRoute } from 'hono-openapi' import { describeRoute } from 'hono-openapi'
import { getProgression, getProgressions } from '@repo/domain'
import { parseFormIds, queryIds } from '../http' import { parseFormIds, queryIds } from '../http'
import { import {
BulkIdsRequest, BulkIdsRequest,
@@ -69,13 +71,16 @@ export const progressionRoutes = new Hono<App>({ strict: false })
describeRoute({ describeRoute({
tags: ['Progression'], tags: ['Progression'],
summary: 'A players level and XP', 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')], parameters: [idParam('id', 'Account id')],
responses: { 200: json(ProgressionDto, 'The players progression') }, responses: { 200: json(ProgressionDto, 'The players progression') },
}), }),
(c) => { async (c) => {
const id = Number.parseInt(c.req.param('id'), 10) 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( .post(
@@ -160,12 +165,13 @@ export const progressionRoutes = new Hono<App>({ strict: false })
tags: ['Progression'], tags: ['Progression'],
summary: 'Progressions in bulk (GET form)', summary: 'Progressions in bulk (GET form)',
description: description:
'What the 2023 client sends. Unlike the POST forms this one does answer — a ' + 'What the 2023 client sends. Unlike the POST forms this one does answer — one ' +
'default level-1 progression per requested id, in request order.', 'progression per requested id, in request order, defaulting to level 1 / 0 XP for ' +
'ids that have earned nothing.',
parameters: BULK_ID_QUERY, parameters: BULK_ID_QUERY,
responses: { 200: json(ProgressionDto.array(), 'One progression per requested id') }, 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( .post(
'/api/v1/progression/bulk', '/api/v1/progression/bulk',
+22
View File
@@ -3,9 +3,11 @@ import { exports } from 'cloudflare:workers'
import { beforeAll, describe, expect, test } from 'vitest' import { beforeAll, describe, expect, test } from 'vitest'
import { import {
addXp,
GAME_VERSION, GAME_VERSION,
grantInvention, grantInvention,
INVENTORY_INVENTION_SCHEMA_DDL, INVENTORY_INVENTION_SCHEMA_DDL,
PROGRESSION_SCHEMA_DDL,
ROOM_SCHEMA_DDL, ROOM_SCHEMA_DDL,
seedRoomWithSubRooms, seedRoomWithSubRooms,
SUBROOM_SCHEMA_DDL, SUBROOM_SCHEMA_DDL,
@@ -104,6 +106,7 @@ beforeAll(async () => {
// Bought-invention ownership (owned by the econ worker) — `v2/mine` folds it in. // 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 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. // 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() 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 }) 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 () => { test('POST /api/players/v2/progression/bulk returns an array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/players/v2/progression/bulk`, { const res = await exports.default.fetch(`${ORIGIN}/api/players/v2/progression/bulk`, {
method: 'POST', 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 | | GET | `/api/challenge/v2/getCurrent` | ~ | Weekly rotation + the caller's progress |
| POST | `/api/challenge/v2/updateProgress` | ✓ | Report challenge progress | | POST | `/api/challenge/v2/updateProgress` | ✓ | Report challenge progress |
| GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) | | 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/mine` | | The player's room keys (stub `[]`) |
| GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) | | GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) |
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | | Subscription lookup (both null) | | 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 - **`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. 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 **What a claim pays: 25 XP, in a gift box.** The XP (`GAME_REWARD_XP`) is banked in
`game reward claimed` line, and grants nothing, so a claim and an on-cooldown ask both `progression` and the box is the wrapper the client shows for it — no item, every item field
answer the same empty list the client already accepts. Paying one out is the empty, `GiftContext` 50 (`GameRewards`). The box wears the `Message` the client posted
`claimed !== null` branch in the handler. Getting eligibility right first is the point — (`First Game of the Day`), and a `GiftPackageReceivedImmediate` frame goes out with it, the
it's what stops a repeat ask paying twice once there's something to pay. 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 `GET /api/gamerewards/v1/pending` stays `[]`: with rewards claimed on request, nothing sits
waiting to be collected. waiting to be collected.
## Bindings ## Bindings
| Binding | Type | Notes | | Binding | Type | Notes |
| ---------------------------- | -------------- | -------------------------------------------------------- | | ---------------------------- | -------------- | ---------------------------------------------------------- |
| `DB` | D1 | Shared `recflare` database — balances, inventory, etc. | | `DB` | D1 | Shared `recflare` database — balances, inventory, XP, etc. |
| `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) | | `JWT_SECRET` | Secrets Store | Shared HS256 signing key (see the `auth` README) |
| `ASSETS` | static assets | Serves `sf{N}.json` storefront catalogs | | `ASSETS` | static assets | Serves `sf{N}.json` storefront catalogs |
| `RECFLARE_NOTIFICATIONS_HUB` | Durable Object | Cross-worker RPC to the `notify` worker's hub | | `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) | | `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. 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. - Consumables are granted and listed but never spent by gameplay, so `Count` only grows.
- Several routes (room keys, wishlist, equipment, room consumables/currencies) are - Several routes (room keys, wishlist, equipment, room consumables/currencies) are
empty-list stubs pending their own stores. 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 - 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 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 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 { useWorkersLogger } from 'workers-tagged-logger'
import { import {
addXp,
consumeGift, consumeGift,
createGift, createGift,
getGift, getGift,
@@ -306,6 +307,12 @@ interface StoreGiftDrop {
* to `Rarity`. * to `Rarity`.
*/ */
QueryRedirectRarity?: number 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 { interface StorePrice {
CurrencyType: number CurrencyType: number
@@ -392,7 +399,7 @@ function toGiftContent(
AvatarItemType: giftDrop.AvatarItemType, AvatarItemType: giftDrop.AvatarItemType,
CurrencyType: giftDrop.CurrencyType, CurrencyType: giftDrop.CurrencyType,
Currency: giftDrop.Currency, Currency: giftDrop.Currency,
Xp: 0, Xp: giftDrop.Xp ?? 0,
PackageType: 0, PackageType: 0,
Message: message, Message: message,
EquipmentPrefabName: giftDrop.EquipmentPrefabName, 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`, * 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 * 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 * platform/balance constants. `Xp` is the drop's, so a game reward's box announces the XP it
* and nothing grants them yet. * paid; `Level` is 0, since nothing levels a player up yet.
* *
* "Immediate" (31) rather than GiftPackageReceived (30) is what the reference sends for a * "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, * 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, EquipmentModificationGuid: gift.drop.EquipmentModificationGuid,
CurrencyType: gift.drop.CurrencyType, CurrencyType: gift.drop.CurrencyType,
Currency: gift.drop.Currency, Currency: gift.drop.Currency,
Xp: 0, Xp: gift.drop.Xp ?? 0,
Level: 0, Level: 0,
Platform: -1, Platform: -1,
PlatformsToSpawnOn: -1, PlatformsToSpawnOn: -1,
@@ -630,6 +637,46 @@ async function grantGiftDrop(
return { id, drop: giftDrop } 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 * 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`, * 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 // 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 hour, atomically.
// //
// The reward itself is still a stub: a claim records the cooldown and grants nothing, // A claim pays GAME_REWARD_XP into `progression` and hands over a gift box carrying that
// so both outcomes answer the same empty list the client already accepts. Paying one // XP, announced with the same GiftPackageReceivedImmediate frame the weekly gift uses —
// out later is the `claimed !== null` branch below — the eligibility half is what has // the client posted the message to show, so the box wears it. An on-cooldown ask changes
// to be right first, since that's what stops a repeat ask paying twice. // 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 // `giftContext` (the activity, e.g. `Soccer`) is accepted and ignored: the cooldown is
// per reward type, shared across activities. // 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. // 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 claimed = await claimReward(c.env.DB, id, rewardType)
if (claimed !== null) { // On cooldown: nothing was claimed, so nothing is paid and nothing is announced.
// The reward would be granted here. Logged for now so the faucet is visible in if (claimed === null) return c.json([])
// production before it pays anything out. const message =
logger.info('game reward claimed', { typeof body.Message === 'string' && body.Message !== ''
accountId: id, ? body.Message
rewardType, : DEFAULT_GAME_REWARD_MESSAGE
grantCount: claimed, // Bank the XP first: it is the reward, and the box is the wrapper the client shows.
message: typeof body.Message === 'string' ? body.Message : '', // 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([]) return c.json([])
} }
) )
+56 -1
View File
@@ -6,7 +6,9 @@ import '../../econ.app'
import { import {
getOwnedInventionIds, getOwnedInventionIds,
getProgression,
INVENTORY_INVENTION_SCHEMA_DDL, INVENTORY_INVENTION_SCHEMA_DDL,
PROGRESSION_SCHEMA_DDL,
RECEIVED_GIFT_SCHEMA_DDL, RECEIVED_GIFT_SCHEMA_DDL,
} from '@repo/domain' } 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 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_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 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 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 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 CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -1661,7 +1664,7 @@ describe('econ endpoints', () => {
.bind(rewardType) .bind(rewardType)
.first<{ granted_at: string; grant_count: number }>() .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( const first = await request(
'rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day' 'rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day'
) )
@@ -1703,6 +1706,58 @@ describe('econ endpoints', () => {
expect((await statusOf('FirstActivityOfDay'))?.grant_count).toBe(2) 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 () => { 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`, { const anon = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST', method: 'POST',
+1
View File
@@ -8,5 +8,6 @@ export * from './room-instance-db'
export * from './presence-db' export * from './presence-db'
export * from './gifts-db' export * from './gifts-db'
export * from './inventory-invention-db' export * from './inventory-invention-db'
export * from './progression-db'
export * from './relationships-db' export * from './relationships-db'
export * from './validation' export * from './validation'
+98
View File
@@ -0,0 +1,98 @@
/**
* Player progression the level and XP shown on a profile on the shared `recflare` D1.
* One row per account, created on the first grant.
*
* Two workers share it, so it lives here rather than in either: `econ` WRITES it (game
* rewards pay XP out) and `api` READS it (`GET /api/players/v{1,2}/progression/…`). Same
* split as the gift boxes next door.
*
* A missing row is not an error it means "nothing earned yet", which is exactly the
* level-1/0-XP default the progression endpoints already served, so reads fall back to it
* rather than inserting on a GET.
*
* `level` is stored, not derived. The reference server levels a player up by subtracting
* the tier's `RequiredXp` from the running XP, with the thresholds coming from a config
* file (`configv2.json`'s `LevelProgressionMaps`) that we don't have so XP accumulates
* here and everyone stays level 1 until those numbers exist. The column is present so
* turning the curve on later is a write, not a migration.
*
* The `econ` worker owns the migration (apps/econ/migrations/0012_progression.sql), being
* the writer.
*/
/** Schema DDL (mirror of apps/econ/migrations/0012_progression.sql). */
export const PROGRESSION_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS progression (
account_id INTEGER PRIMARY KEY,
level INTEGER NOT NULL DEFAULT 1,
xp INTEGER NOT NULL DEFAULT 0
)`,
]
/** A player's progression, as the client's progression DTO renders it. */
export interface Progression {
PlayerId: number
Level: number
XP: number
}
/** What a player with no row has: nothing earned yet. */
export function defaultProgression(accountId: number): Progression {
return { PlayerId: accountId, Level: 1, XP: 0 }
}
/**
* Add XP to a player and return what they now hold. The add is one statement, so two
* rewards landing together can't both read the same stale total and write it back the
* client fires reward requests off right after a match.
*
* Non-positive amounts are dropped rather than written: nothing takes XP away, and a 0 XP
* grant would otherwise create a row that says the same as no row at all.
*/
export async function addXp(db: D1Database, accountId: number, xp: number): Promise<Progression> {
if (xp <= 0) return await getProgression(db, accountId)
const row = await db
.prepare(
`INSERT INTO progression (account_id, level, xp) VALUES (?1, 1, ?2)
ON CONFLICT (account_id) DO UPDATE SET xp = progression.xp + excluded.xp
RETURNING level, xp`
)
.bind(accountId, xp)
.first<{ level: number; xp: number }>()
if (row === null) return defaultProgression(accountId)
return { PlayerId: accountId, Level: row.level, XP: row.xp }
}
/** One player's progression, defaulted when they've earned nothing yet. */
export async function getProgression(db: D1Database, accountId: number): Promise<Progression> {
const row = await db
.prepare('SELECT level, xp FROM progression WHERE account_id = ?1')
.bind(accountId)
.first<{ level: number; xp: number }>()
if (row === null) return defaultProgression(accountId)
return { PlayerId: accountId, Level: row.level, XP: row.xp }
}
/**
* Progressions for a list of ids, in the order asked and one per id the bulk lookups
* render a profile card per entry, so an id with no row still gets its default rather than
* being dropped from the list.
*/
export async function getProgressions(
db: D1Database,
accountIds: number[]
): Promise<Progression[]> {
if (accountIds.length === 0) return []
const placeholders = accountIds.map((_, i) => `?${i + 1}`).join(', ')
const { results } = await db
.prepare(`SELECT account_id, level, xp FROM progression WHERE account_id IN (${placeholders})`)
.bind(...accountIds)
.all<{ account_id: number; level: number; xp: number }>()
const stored = new Map(results.map((r) => [r.account_id, r]))
return accountIds.map((id) => {
const row = stored.get(id)
return row === undefined
? defaultProgression(id)
: { PlayerId: id, Level: row.level, XP: row.xp }
})
}