mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[api][econ] add levels, xp storage and basic game rewards
This commit is contained in:
@@ -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 player’s level and XP',
|
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')],
|
parameters: [idParam('id', 'Account id')],
|
||||||
responses: { 200: json(ProgressionDto, 'The player’s progression') },
|
responses: { 200: json(ProgressionDto, 'The player’s 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',
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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([])
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user