From af64327fea6712ede541e055d67d2aba9d42d111 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 10 Aug 2026 23:31:40 -0400 Subject: [PATCH] [api][econ] add levels, xp storage and basic game rewards --- apps/api/src/routes/progression.ts | 18 ++-- apps/api/src/test/integration/api.test.ts | 22 +++++ apps/econ/README.md | 52 ++++++++---- apps/econ/migrations/0012_progression.sql | 21 +++++ apps/econ/src/econ.app.ts | 98 ++++++++++++++++++---- apps/econ/src/test/integration/api.test.ts | 57 ++++++++++++- packages/domain/src/index.ts | 1 + packages/domain/src/progression-db.ts | 98 ++++++++++++++++++++++ 8 files changed, 328 insertions(+), 39 deletions(-) create mode 100644 apps/econ/migrations/0012_progression.sql create mode 100644 packages/domain/src/progression-db.ts diff --git a/apps/api/src/routes/progression.ts b/apps/api/src/routes/progression.ts index 9124a49..e8ab08d 100644 --- a/apps/api/src/routes/progression.ts +++ b/apps/api/src/routes/progression.ts @@ -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({ strict: false }) describeRoute({ tags: ['Progression'], summary: 'A player’s 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 player’s 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({ 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', diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index e54affb..6ce0758 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -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', diff --git a/apps/econ/README.md b/apps/econ/README.md index e9726b1..85d959d 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -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 diff --git a/apps/econ/migrations/0012_progression.sql b/apps/econ/migrations/0012_progression.sql new file mode 100644 index 0000000..b001713 --- /dev/null +++ b/apps/econ/migrations/0012_progression.sql @@ -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 + ); diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 22631c2..e7402a0 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -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({ 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({ 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([]) } ) diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index f193e3f..f0e8f4e 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -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', diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 5e4b33c..2139759 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -8,5 +8,6 @@ export * from './room-instance-db' export * from './presence-db' export * from './gifts-db' export * from './inventory-invention-db' +export * from './progression-db' export * from './relationships-db' export * from './validation' diff --git a/packages/domain/src/progression-db.ts b/packages/domain/src/progression-db.ts new file mode 100644 index 0000000..3ac318c --- /dev/null +++ b/packages/domain/src/progression-db.ts @@ -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 { + 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 { + 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 { + 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 } + }) +}