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 { 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 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<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',
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
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([])
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user