mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[econ] tweak xp rewards
This commit is contained in:
+14
-9
@@ -52,7 +52,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 → 5 XP + gift box |
|
||||
| POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 10 XP + gift box |
|
||||
| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) |
|
||||
| GET | `/api/roomkeys/v1/room` | | The keys a room has listed |
|
||||
| POST | `/api/roomkeys/v1/create` | ✓ | List a key for a room (owner/co-owner) |
|
||||
@@ -486,7 +486,7 @@ activity asked, so its row's `granted_at` is simply the player's last one.
|
||||
primary key) and lands the pre-existing rows on that same `''` bucket, so cooldowns from
|
||||
before it keep counting.
|
||||
|
||||
**What a claim pays: 5 XP, in a gift box.** The XP (`GAME_REWARD_XP`) is banked in
|
||||
**What a claim pays: 10 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
|
||||
@@ -495,21 +495,26 @@ failure can't leave a box promising XP nobody was credited.
|
||||
|
||||
- **One flat amount for every reward type.** Pricing `FirstActivityOfDay` differently from
|
||||
`PostGameActivity` is a map keyed by type.
|
||||
- **Deliberately smaller than a level.** The first level costs 10 XP, so a single action
|
||||
can't be a level-up — it takes two rewards to reach level 2, and the early levels are paced
|
||||
by the hourly cooldown rather than cleared in one match.
|
||||
- **Exactly an early level.** Each of the first three levels costs 10 XP, so a fresh
|
||||
player's first rewards each level them up; from level 4 (20 XP a level) it takes two or
|
||||
more, and the daily XP cap paces it from there.
|
||||
- **`PostGameActivity`/`PostQuestActivity` pay 250 tokens, up to 1,250 a day**
|
||||
(`GAME_REWARD_TOKENS`, `GAME_REWARD_DAILY_TOKEN_CAP`) — per player, whatever the
|
||||
`giftContext`. `FirstActivityOfDay` pays 250 more OUTSIDE that cap — its own once-a-day
|
||||
claim already limits it, so it has no allowance row. The running total
|
||||
is one row per player in `reward_status` under the reserved type `$tokens` (context `''`):
|
||||
`grant_count` is the TOKENS paid in the window and `granted_at` when the window opened.
|
||||
`claimDailyTokens` adds to it in one atomic upsert, refusing a grant that would cross the cap
|
||||
`claimAllowance` adds to it in one atomic upsert, refusing a grant that would cross the cap
|
||||
(all or nothing, so the cap is a multiple of the amount), and resets it in place once the
|
||||
window is 24h old — a rolling window, so no time zone's midnight decides it. The tokens are
|
||||
credited to the RecCenterTokens balance (`StorefrontBalanceUpdate` with the resulting total)
|
||||
and arrive in a **second box** of their own — `GiftContext` 51 (`GameRewardsTokens`),
|
||||
`Currency` 250, no XP — after the reward's, so the player sees what they were for.
|
||||
- **XP is capped too: 150 a day** (`GAME_REWARD_DAILY_XP_CAP`), fifteen claims' worth, on the
|
||||
same machinery — the player's `$xp` row, `grant_count` the XP paid in the window. Every
|
||||
type's XP draws on it but `FirstActivityOfDay`'s, a bonus on top like its tokens. Past the
|
||||
cap a claim still counts against its cooldown and still pays its activity's item (in a box
|
||||
with `Xp` 0) and its tokens; a claim with nothing left to carry gets no box at all.
|
||||
- **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.
|
||||
@@ -549,7 +554,7 @@ costs are easy to edit one at a time and hard to eyeball as a curve.
|
||||
| 50 | 5-Star Clothing (rarity 50) — the only one |
|
||||
|
||||
**One reward per level crossed** — a grant spanning several levels pays each of them. In
|
||||
practice a 5 XP game reward crosses at most one, so the second reward a fresh player claims
|
||||
practice a 10 XP game reward crosses at most one, so the first reward a fresh player claims
|
||||
hands over two boxes: the XP reward itself and the 2-Star Clothing for reaching level 2. Each
|
||||
arrives as a gift box announced like any other (`Level 2!`).
|
||||
|
||||
@@ -652,8 +657,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 (equipment, room consumables/currencies) are empty-list stubs pending
|
||||
their own stores.
|
||||
- Game rewards pay a flat 5 XP; there is no daily XP cap beyond the hourly cooldown (the
|
||||
reference caps activity XP per day in `daily_xp_ledgers`).
|
||||
- Game rewards pay a flat 10 XP, capped at 150 a day (the reference caps activity XP per day
|
||||
in `daily_xp_ledgers`).
|
||||
- The level-reward table and the served config's `GiftRarity` disagree in places (see the
|
||||
level section); we grant from the table and leave the config as captured, so a client that
|
||||
previews an upcoming reward would preview the config's answer, not ours.
|
||||
|
||||
+61
-27
@@ -168,7 +168,7 @@ import {
|
||||
WishlistItemList,
|
||||
WishlistItemRequest,
|
||||
} from './openapi'
|
||||
import { claimDailyTokens, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db'
|
||||
import { claimAllowance, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db'
|
||||
import {
|
||||
awardRoomConsumable,
|
||||
getRoomConsumable,
|
||||
@@ -2264,15 +2264,24 @@ function isBulkLine(resolved: BulkPurchaseLine | BulkLineFailure): resolved is B
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* XP paid for a claimed game reward. One flat amount for every reward type — "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.
|
||||
*
|
||||
* Deliberately smaller than the 10 XP the first level costs: a single action shouldn't be a
|
||||
* level-up, let alone two of them. At 5 it takes two rewards to reach level 2, and the early
|
||||
* levels are paced by the hourly cooldown rather than cleared in one match.
|
||||
* Exactly what each of the first three levels costs, so a fresh player's first rewards each
|
||||
* level them up; from level 4 (20 XP a level) it takes two or more. The daily
|
||||
* {@link GAME_REWARD_DAILY_XP_CAP} paces it from there.
|
||||
*/
|
||||
const GAME_REWARD_XP = 5
|
||||
const GAME_REWARD_XP = 10
|
||||
|
||||
/**
|
||||
* The most game-reward XP a player earns in one rolling 24h window: fifteen claims' worth,
|
||||
* whatever the type or activity — `FirstActivityOfDay` aside, whose XP is a bonus on top, like
|
||||
* its tokens. The running total is the player's `$xp` row in `reward_status`
|
||||
* (`claimAllowance`). A multiple of {@link GAME_REWARD_XP} — a claim that would cross it pays
|
||||
* no XP.
|
||||
*/
|
||||
const GAME_REWARD_DAILY_XP_CAP = 150
|
||||
|
||||
/**
|
||||
* `GiftContext.GameRewards` — what the box says it came from, so the client files it under
|
||||
@@ -2287,7 +2296,7 @@ const DEFAULT_GAME_REWARD_MESSAGE = 'Reward earned!'
|
||||
/**
|
||||
* Tokens paid with each claimed {@link TOKEN_REWARD_TYPES} reward, until the player has had
|
||||
* {@link GAME_REWARD_DAILY_TOKEN_CAP} in the current 24h window — whatever the activity.
|
||||
* The running total is the player's `$tokens` row in `reward_status` (`claimDailyTokens`).
|
||||
* The running total is the player's `$tokens` row in `reward_status` (`claimAllowance`).
|
||||
*/
|
||||
const GAME_REWARD_TOKENS = 250
|
||||
|
||||
@@ -2347,7 +2356,7 @@ function toGameRewardTokensDrop(): StoreGiftDrop {
|
||||
* 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 {
|
||||
function toGameRewardDrop(xp: number): StoreGiftDrop {
|
||||
return {
|
||||
FriendlyName: '',
|
||||
Tooltip: '',
|
||||
@@ -2360,7 +2369,7 @@ function toGameRewardDrop(): StoreGiftDrop {
|
||||
Context: GIFT_CONTEXT_GAME_REWARDS,
|
||||
Currency: 0,
|
||||
CurrencyType: 0,
|
||||
Xp: GAME_REWARD_XP,
|
||||
Xp: xp,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2425,10 +2434,14 @@ async function pickQuestReward(
|
||||
* row, so the item IS granted — unlike {@link toGameRewardDrop}'s empty box. The catalog
|
||||
* row for the item, when it resolves, supplies what the table doesn't carry (name, tooltip,
|
||||
* `AvatarItemType`), so the inventory entry reads like a bought one rather than blank.
|
||||
* The XP is the flat game-reward amount, not the row's (always 0): the reward is the item,
|
||||
* and the XP is the same pat on the back every claim gets.
|
||||
* The XP is the game reward's (`xp`), not the row's (always 0): the reward is the item, and
|
||||
* the XP is the same pat on the back every claim gets while the daily cap has room.
|
||||
*/
|
||||
function toQuestRewardDrop(reward: QuestReward, catalog: CatalogRow | null): StoreGiftDrop {
|
||||
function toQuestRewardDrop(
|
||||
reward: QuestReward,
|
||||
catalog: CatalogRow | null,
|
||||
xp: number
|
||||
): StoreGiftDrop {
|
||||
return {
|
||||
FriendlyName: catalog?.friendly_name ?? '',
|
||||
Tooltip: catalog?.tooltip ?? '',
|
||||
@@ -2441,7 +2454,7 @@ function toQuestRewardDrop(reward: QuestReward, catalog: CatalogRow | null): Sto
|
||||
Context: reward.Context,
|
||||
Currency: reward.Currency,
|
||||
CurrencyType: reward.CurrencyType,
|
||||
Xp: GAME_REWARD_XP,
|
||||
Xp: xp,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6051,7 +6064,8 @@ const app = new Hono<App>({ strict: false })
|
||||
// here, from `reward_status`: one claim per type per activity per hour, atomically —
|
||||
// except FirstActivityOfDay, one per player per 24h.
|
||||
//
|
||||
// A claim pays GAME_REWARD_XP into `progression` and hands over a gift box carrying that
|
||||
// A claim pays GAME_REWARD_XP into `progression` (up to GAME_REWARD_DAILY_XP_CAP per player
|
||||
// per rolling 24h, FirstActivityOfDay's on top) 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.
|
||||
@@ -6119,17 +6133,27 @@ const app = new Hono<App>({ strict: false })
|
||||
typeof body.Message === 'string' && body.Message !== ''
|
||||
? body.Message
|
||||
: DEFAULT_GAME_REWARD_MESSAGE
|
||||
// The XP draws on the player's daily allowance — FirstActivityOfDay's is a bonus on top,
|
||||
// its once-a-day claim being limit enough. Past the cap the claim pays no XP.
|
||||
const xpToday = daily
|
||||
? null
|
||||
: await claimAllowance(c.env.DB, id, '$xp', GAME_REWARD_XP, GAME_REWARD_DAILY_XP_CAP)
|
||||
const xp = daily || xpToday !== null ? GAME_REWARD_XP : 0
|
||||
// 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, levelsGained } = await addXp(c.env.DB, id, GAME_REWARD_XP)
|
||||
const gained = xp > 0 ? await addXp(c.env.DB, id, xp) : null
|
||||
// An activity the reward table knows pays one of ITS rewards the player lacks — the
|
||||
// item rides in the box and is granted with it. Anything else gets the plain XP box.
|
||||
const questReward = await pickQuestReward(c.env.DB, id, giftContext)
|
||||
const itemKey = questReward?.AvatarItemDesc || questReward?.EquipmentModificationGuid
|
||||
const drop =
|
||||
questReward === null
|
||||
? toGameRewardDrop()
|
||||
: toQuestRewardDrop(questReward, itemKey ? await getCatalogItem(c.env.DB, itemKey) : null)
|
||||
? toGameRewardDrop(xp)
|
||||
: toQuestRewardDrop(
|
||||
questReward,
|
||||
itemKey ? await getCatalogItem(c.env.DB, itemKey) : null,
|
||||
xp
|
||||
)
|
||||
// A currency reward (Laser Tag's tickets) is credited here: `grantGiftDrop` grants
|
||||
// items, not balances. Seed the signup grant first — `creditCurrency` upserts the
|
||||
// row, and a never-touched RecCenterTokens balance would otherwise lose it.
|
||||
@@ -6145,17 +6169,24 @@ const app = new Hono<App>({ strict: false })
|
||||
)
|
||||
await pushBalanceUpdate(c, id, drop.CurrencyType, balance)
|
||||
}
|
||||
// A box with nothing in it — the XP capped and no item or currency to carry — is not
|
||||
// handed over at all.
|
||||
let rewardGiftId: number | null = null
|
||||
if (questReward !== null || xp > 0) {
|
||||
const granted = await grantGiftDrop(c, id, drop, message)
|
||||
await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID)
|
||||
rewardGiftId = granted.id
|
||||
}
|
||||
// The tokens ride in a second box — after the reward's own, so the player sees the
|
||||
// reward they asked for first — while the player's daily allowance has room. Same
|
||||
// order as the currency reward above: balance first, then the box that shows it.
|
||||
let tokensGiftId: number | null = null
|
||||
let tokensToday: number | null = null
|
||||
if (TOKEN_REWARD_TYPES.has(rewardType)) {
|
||||
tokensToday = await claimDailyTokens(
|
||||
tokensToday = await claimAllowance(
|
||||
c.env.DB,
|
||||
id,
|
||||
'$tokens',
|
||||
GAME_REWARD_TOKENS,
|
||||
GAME_REWARD_DAILY_TOKEN_CAP
|
||||
)
|
||||
@@ -6176,21 +6207,24 @@ const app = new Hono<App>({ strict: false })
|
||||
await pushGiftReceived(c, id, tokensGift, GAME_REWARD_TOKENS_MESSAGE, COACH_ACCOUNT_ID)
|
||||
tokensGiftId = tokensGift.id
|
||||
}
|
||||
if (gained !== null) {
|
||||
// Every grant moves the bar, whether or not it crossed a level.
|
||||
await pushProgressionUpdate(c, id, progression)
|
||||
await pushProgressionUpdate(c, id, gained.progression)
|
||||
// …and every level crossed is worth a box of its own tier.
|
||||
await grantLevelUpGifts(c, id, { progression, levelsGained })
|
||||
await grantLevelUpGifts(c, id, gained)
|
||||
}
|
||||
logger.info('game reward claimed', {
|
||||
accountId: id,
|
||||
rewardType,
|
||||
giftContext,
|
||||
grantCount: claimed,
|
||||
message,
|
||||
xp: GAME_REWARD_XP,
|
||||
level: progression.Level,
|
||||
levelsGained,
|
||||
levelXp: progression.XP,
|
||||
giftId: granted.id,
|
||||
xp,
|
||||
xpToday,
|
||||
level: gained?.progression.Level,
|
||||
levelsGained: gained?.levelsGained,
|
||||
levelXp: gained?.progression.XP,
|
||||
giftId: rewardGiftId,
|
||||
tokens: tokensGiftId === null ? 0 : GAME_REWARD_TOKENS,
|
||||
tokensToday,
|
||||
tokensGiftId,
|
||||
|
||||
+14
-13
@@ -39,19 +39,19 @@ export const REWARD_COOLDOWN_MS = 60 * 60 * 1000
|
||||
export const DAILY_REWARD_COOLDOWN_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* The game reward's tokens are a per-PLAYER daily allowance, kept in `reward_status` as one
|
||||
* row under this reserved type and the empty context: `grant_count` is the TOKENS paid in the
|
||||
* current window (not a count of claims) and `granted_at` is when that window opened. The `$`
|
||||
* keeps it apart from the client's own type names (`FirstActivityOfDay`, `PostGameActivity`),
|
||||
* which a client-sent type could otherwise forge.
|
||||
* The game reward's per-PLAYER daily allowances, each kept in `reward_status` as one row under
|
||||
* its reserved type and the empty context: `grant_count` is the AMOUNT paid in the current
|
||||
* window (tokens, or XP — not a count of claims) and `granted_at` is when that window opened.
|
||||
* The `$` keeps them apart from the client's own type names (`FirstActivityOfDay`,
|
||||
* `PostGameActivity`), which a client-sent type could otherwise forge.
|
||||
*/
|
||||
export const TOKEN_REWARD_TYPE = '$tokens'
|
||||
export type Allowance = '$tokens' | '$xp'
|
||||
|
||||
/**
|
||||
* How long a token window lasts: a rolling 24h from the first grant in it, rather than a
|
||||
* How long an allowance window lasts: a rolling 24h from the first grant in it, rather than a
|
||||
* calendar day — so no time zone's midnight decides when a player's allowance comes back.
|
||||
*/
|
||||
export const TOKEN_REWARD_WINDOW_MS = 24 * 60 * 60 * 1000
|
||||
export const ALLOWANCE_WINDOW_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
/**
|
||||
* Claim a reward if the player is due one, returning how many of that type they have now
|
||||
@@ -97,10 +97,10 @@ export async function claimReward(
|
||||
}
|
||||
|
||||
/**
|
||||
* Add `amount` tokens to the player's daily allowance if it fits under `cap`, returning the
|
||||
* Add `amount` to the player's daily `allowance` if it fits under `cap`, returning the
|
||||
* window's new total — or `null` when it doesn't fit and nothing was granted.
|
||||
*
|
||||
* A window older than {@link TOKEN_REWARD_WINDOW_MS} is reset in place (the row's key allows
|
||||
* A window older than {@link ALLOWANCE_WINDOW_MS} is reset in place (the row's key allows
|
||||
* one per player): `granted_at` moves to now and the total restarts at `amount`. Otherwise the
|
||||
* amount is added to the running total, and `granted_at` is left alone so the window keeps its
|
||||
* start. All grant-or-nothing: a claim that would cross the cap pays nothing rather than the
|
||||
@@ -110,15 +110,16 @@ export async function claimReward(
|
||||
* read a total under the cap and both pay. SQLite evaluates every `SET` against the row as it
|
||||
* was, so both `CASE`s see the old `granted_at`.
|
||||
*/
|
||||
export async function claimDailyTokens(
|
||||
export async function claimAllowance(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
allowance: Allowance,
|
||||
amount: number,
|
||||
cap: number,
|
||||
now: Date = new Date()
|
||||
): Promise<number | null> {
|
||||
if (amount > cap) return null
|
||||
const cutoff = new Date(now.getTime() - TOKEN_REWARD_WINDOW_MS).toISOString()
|
||||
const cutoff = new Date(now.getTime() - ALLOWANCE_WINDOW_MS).toISOString()
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO reward_status (account_id, reward_type, gift_context, granted_at, grant_count)
|
||||
@@ -131,7 +132,7 @@ export async function claimDailyTokens(
|
||||
WHERE reward_status.granted_at <= ?5 OR reward_status.grant_count + ?4 <= ?6
|
||||
RETURNING grant_count`
|
||||
)
|
||||
.bind(accountId, TOKEN_REWARD_TYPE, now.toISOString(), amount, cutoff, cap)
|
||||
.bind(accountId, allowance, now.toISOString(), amount, cutoff, cap)
|
||||
.first<{ grant_count: number }>()
|
||||
return row?.grant_count ?? null
|
||||
}
|
||||
|
||||
@@ -6701,56 +6701,43 @@ describe('econ endpoints', () => {
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
|
||||
// 5 XP is deliberately less than the 10 the first level costs, so one action moves the
|
||||
// bar without levelling anyone up.
|
||||
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
|
||||
// 10 XP is exactly what level 1 costs, so a fresh player's first reward is a level-up.
|
||||
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 2, XP: 0 })
|
||||
|
||||
// One reward box: the XP itself, carrying the message the client asked to show and no
|
||||
// item — a game reward is not an item. (Its tokens come in a box of their own.)
|
||||
// Two reward boxes: the XP itself, carrying the message the client asked to show and no
|
||||
// item — a game reward is not an item — then the level-up's. (The tokens come in a box
|
||||
// of their own.)
|
||||
const first = await rewardBoxes()
|
||||
expect(first).toHaveLength(1)
|
||||
expect(first).toHaveLength(2)
|
||||
expect(first[0]).toMatchObject({
|
||||
Xp: 5,
|
||||
Xp: 10,
|
||||
Message: 'First Game of the Day',
|
||||
AvatarItemDesc: '',
|
||||
EquipmentModificationGuid: '',
|
||||
ConsumableItemDesc: '',
|
||||
})
|
||||
|
||||
// The box, then the bar — no level-up box, since no level was crossed.
|
||||
// The box, then the bar, then the level-up box.
|
||||
const frames = await rewardFrames()
|
||||
expect(frames.map((f) => f.notificationType)).toEqual([
|
||||
NotificationType.GiftPackageReceivedImmediate,
|
||||
NotificationType.PlayerProgressionLevelUpdate,
|
||||
NotificationType.GiftPackageReceivedImmediate,
|
||||
])
|
||||
expect(frames[0]?.accountId).toBe(82)
|
||||
expect(frames[0]?.payload).toMatchObject({
|
||||
Id: first[0]?.Id,
|
||||
FromPlayerId: 1,
|
||||
Xp: 5,
|
||||
Xp: 10,
|
||||
// GiftContext.GameRewards — the box came from gameplay, not a purchase.
|
||||
GiftContext: 50,
|
||||
Message: 'First Game of the Day',
|
||||
})
|
||||
expect(frames[1]?.payload).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
|
||||
expect(frames[1]?.payload).toEqual({ PlayerId: 82, Level: 2, XP: 0 })
|
||||
|
||||
// An on-cooldown ask pays nothing: no more boxes, no frames, no more XP.
|
||||
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
|
||||
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
|
||||
expect(await giftBoxes('82')).toHaveLength(2)
|
||||
expect(await drainFrames()).toEqual([])
|
||||
|
||||
// A SECOND reward completes the 10 XP level 1 costs — two actions per early level, which
|
||||
// is the pacing the smaller grant buys.
|
||||
await passADay()
|
||||
expect((await request('rewardType=FirstActivityOfDay&Message=Second')).status).toBe(200)
|
||||
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 2, XP: 0 })
|
||||
|
||||
// …and level 2 pays 2-Star Clothing per the published table: an AVATAR ITEM, never an
|
||||
// Level 2 pays 2-Star Clothing per the published table: an AVATAR ITEM, never an
|
||||
// equipment skin, which is what the avatar-only roll is for.
|
||||
const afterLevel2 = await rewardBoxes()
|
||||
expect(afterLevel2).toHaveLength(3)
|
||||
const clothingBox = afterLevel2[2]
|
||||
const clothingBox = first[1]
|
||||
expect(clothingBox?.Message).toBe('Level 2!')
|
||||
expect(clothingBox?.AvatarItemDesc).not.toBe('')
|
||||
expect(clothingBox?.EquipmentModificationGuid).toBe('')
|
||||
@@ -6763,18 +6750,18 @@ describe('econ endpoints', () => {
|
||||
// v4 serves the camelCase DTO, unlike the PascalCase records on the gift box.
|
||||
const owned = (await items.json()) as Array<{ avatarItemDesc: string }>
|
||||
expect(owned.map((i) => i.avatarItemDesc)).toContain(clothingBox?.AvatarItemDesc)
|
||||
expect((await rewardFrames()).map((f) => f.notificationType)).toEqual([
|
||||
NotificationType.GiftPackageReceivedImmediate,
|
||||
NotificationType.PlayerProgressionLevelUpdate,
|
||||
NotificationType.GiftPackageReceivedImmediate,
|
||||
])
|
||||
|
||||
// Two more rewards reach level 3, which the table pays as a CONSUMABLE rather than
|
||||
// An on-cooldown ask pays nothing: no more boxes, no frames, no more XP.
|
||||
const boxCount = (await giftBoxes('82')).length
|
||||
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
|
||||
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 2, XP: 0 })
|
||||
expect(await giftBoxes('82')).toHaveLength(boxCount)
|
||||
expect(await drainFrames()).toEqual([])
|
||||
|
||||
// The next reward reaches level 3, which the table pays as a CONSUMABLE rather than
|
||||
// clothing — rolled without a rarity, since the table names none for them.
|
||||
for (const message of ['Third', 'Fourth']) {
|
||||
await passADay()
|
||||
expect((await request(`rewardType=FirstActivityOfDay&Message=${message}`)).status).toBe(200)
|
||||
}
|
||||
expect((await request('rewardType=FirstActivityOfDay&Message=Second')).status).toBe(200)
|
||||
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 3, XP: 0 })
|
||||
|
||||
const afterLevel3 = await giftBoxes('82')
|
||||
@@ -6809,12 +6796,14 @@ describe('econ endpoints', () => {
|
||||
)
|
||||
expect(goblin.status).toBe(200)
|
||||
expect(await goblin.json()).toEqual([])
|
||||
// Two boxes: the quest reward, and the PostGameActivity tokens (covered below).
|
||||
// Three boxes: the quest reward, the PostGameActivity tokens (covered below), and — 10 XP
|
||||
// being level 1's whole cost — the level-2 box.
|
||||
const boxes = await giftBoxes('83')
|
||||
expect(boxes).toHaveLength(2)
|
||||
expect(boxes).toHaveLength(3)
|
||||
const box = boxes[0]
|
||||
expect(box).toMatchObject({ Xp: 5, Message: 'Quest complete', GiftContext: 4003 })
|
||||
expect(box).toMatchObject({ Xp: 10, Message: 'Quest complete', GiftContext: 4003 })
|
||||
expect(boxes[1]).toMatchObject({ GiftContext: 51, Currency: 250 })
|
||||
expect(boxes[2]?.Message).toBe('Level 2!')
|
||||
expect(box?.AvatarItemDesc).not.toBe('')
|
||||
const row = questRewards.Quest_Goblin_S.find((r) => r.AvatarItemDesc === box?.AvatarItemDesc)
|
||||
expect(row).toBeDefined()
|
||||
@@ -6862,7 +6851,7 @@ describe('econ endpoints', () => {
|
||||
// GameRewards box, behind Bowling's own tokens.
|
||||
expect((await request('rewardType=PostGameActivity&giftContext=Bowling')).status).toBe(200)
|
||||
const plain = (await giftBoxes('83')).findLast((b) => b.GiftContext === 50)
|
||||
expect(plain).toMatchObject({ Xp: 5, AvatarItemDesc: '', Currency: 0, GiftContext: 50 })
|
||||
expect(plain).toMatchObject({ Xp: 10, AvatarItemDesc: '', Currency: 0, GiftContext: 50 })
|
||||
|
||||
// A reward the player already owns is never drawn again: Dodgeball has three rows, so
|
||||
// three claims hand over all three, and a fourth — nothing left to give — pays the
|
||||
@@ -6976,6 +6965,86 @@ describe('econ endpoints', () => {
|
||||
expect((reopened?.granted_at ?? '') > (opened ?? '')).toBe(true)
|
||||
})
|
||||
|
||||
test('game-reward XP stops at 150 per player per 24h, FirstActivityOfDay aside', async () => {
|
||||
const request = async (body: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer('85')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
})
|
||||
const xp = async () => {
|
||||
const { Level, XP } = await getProgression(env.DB, 85)
|
||||
return { Level, XP }
|
||||
}
|
||||
const allowance = () =>
|
||||
env.DB.prepare(
|
||||
"SELECT granted_at, grant_count FROM reward_status WHERE account_id = 85 AND reward_type = '$xp'"
|
||||
).first<{ granted_at: string; grant_count: number }>()
|
||||
const rewardBoxes = async () =>
|
||||
(await giftBoxes('85')).filter((b) => b.GiftContext !== 51 && !b.Message?.startsWith('Level'))
|
||||
|
||||
// A claim's XP draws on the allowance.
|
||||
expect((await request('rewardType=PostGameActivity&giftContext=Soccer')).status).toBe(200)
|
||||
expect(await allowance()).toMatchObject({ grant_count: 10 })
|
||||
expect(await xp()).toEqual({ Level: 2, XP: 0 })
|
||||
|
||||
// One claim short of the cap: the next pays its 10 and fills it.
|
||||
await env.DB.prepare(
|
||||
"UPDATE reward_status SET grant_count = 140 WHERE account_id = 85 AND reward_type = '$xp'"
|
||||
).run()
|
||||
expect((await request('rewardType=PostGameActivity&giftContext=Paintball')).status).toBe(200)
|
||||
expect(await allowance()).toMatchObject({ grant_count: 150 })
|
||||
expect(await xp()).toEqual({ Level: 3, XP: 0 })
|
||||
const boxesAtCap = (await rewardBoxes()).length
|
||||
await drainFrames()
|
||||
|
||||
// At the cap: no XP, no progression frame, and — nothing else to carry — no reward box.
|
||||
// The tokens are their own allowance and still pay.
|
||||
const tokensBefore = await getBalance(
|
||||
env.DB,
|
||||
85,
|
||||
CurrencyType.RecCenterTokens,
|
||||
DEFAULT_STARTING_TOKENS
|
||||
)
|
||||
expect((await request('rewardType=PostGameActivity&giftContext=Bowling')).status).toBe(200)
|
||||
expect(await xp()).toEqual({ Level: 3, XP: 0 })
|
||||
expect(await allowance()).toMatchObject({ grant_count: 150 })
|
||||
expect(await rewardBoxes()).toHaveLength(boxesAtCap)
|
||||
expect(
|
||||
await getBalance(env.DB, 85, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||
).toBe(tokensBefore + 250)
|
||||
expect((await drainFrames()).map((f) => f.notificationType)).not.toContain(
|
||||
NotificationType.PlayerProgressionLevelUpdate
|
||||
)
|
||||
|
||||
// An activity with a prize still hands it over, in a box carrying no XP.
|
||||
expect(
|
||||
(await request('rewardType=PostQuestActivity&giftContext=Quest_Goblin_S')).status
|
||||
).toBe(200)
|
||||
const quest = (await rewardBoxes()).at(-1)
|
||||
expect(quest).toMatchObject({ GiftContext: 4003, Xp: 0 })
|
||||
expect(quest?.AvatarItemDesc).not.toBe('')
|
||||
expect(await xp()).toEqual({ Level: 3, XP: 0 })
|
||||
|
||||
// FirstActivityOfDay's XP is a bonus on top of the cap.
|
||||
expect((await request('rewardType=FirstActivityOfDay&giftContext=Soccer')).status).toBe(200)
|
||||
expect(await xp()).toEqual({ Level: 4, XP: 0 })
|
||||
expect(await allowance()).toMatchObject({ grant_count: 150 })
|
||||
|
||||
// Once the window is 24h old, the next claim opens a fresh one.
|
||||
await env.DB.prepare(
|
||||
"UPDATE reward_status SET granted_at = ?1 WHERE account_id = 85 AND reward_type = '$xp'"
|
||||
)
|
||||
.bind(new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString())
|
||||
.run()
|
||||
expect((await request('rewardType=PostGameActivity&giftContext=Basketball')).status).toBe(200)
|
||||
expect(await allowance()).toMatchObject({ grant_count: 10 })
|
||||
expect(await xp()).toEqual({ Level: 4, XP: 10 })
|
||||
})
|
||||
|
||||
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