mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[econ] add new activity bonus tokens
This commit is contained in:
@@ -487,6 +487,16 @@ failure can't leave a box promising XP nobody was credited.
|
||||
- **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.
|
||||
- **A NEW activity pays 250 tokens on top** (`NEW_ACTIVITY_BONUS_TOKENS`): the first claim
|
||||
ever in a `giftContext`, whichever reward type asked for it — `isNewActivity` counts the
|
||||
player's rows in that context right after the claim and is true when the claim's own row
|
||||
is the only one. Counting after the write is what stops two asks landing together
|
||||
(`FirstActivityOfDay` and `PostGameActivity` for the same match) from both paying: a race
|
||||
can skip the bonus, never double 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. The contextless `''` bucket is not an
|
||||
activity and never pays it.
|
||||
- **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.
|
||||
|
||||
@@ -147,7 +147,7 @@ import {
|
||||
UpdateRoomCurrencyRequest,
|
||||
UpsertRoomConsumableRequest,
|
||||
} from './openapi'
|
||||
import { claimReward } from './reward-db'
|
||||
import { claimReward, isNewActivity } from './reward-db'
|
||||
import {
|
||||
awardRoomConsumable,
|
||||
getRoomConsumable,
|
||||
@@ -2141,6 +2141,46 @@ 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!'
|
||||
|
||||
/**
|
||||
* Tokens paid the FIRST time a player completes an activity — a `giftContext` no row of
|
||||
* theirs in `reward_status` has seen before, whatever the reward type. A one-off per
|
||||
* activity on top of the hourly XP: the reward for trying something new, sized as a
|
||||
* visible nudge (a cheap catalog item) rather than an income, since the catalog of
|
||||
* activities is finite and each pays it exactly once.
|
||||
*/
|
||||
const NEW_ACTIVITY_BONUS_TOKENS = 250
|
||||
|
||||
/**
|
||||
* `GiftContext.GameRewardsTokens` — the tokens variant of {@link GIFT_CONTEXT_GAME_REWARDS},
|
||||
* on the box the new-activity bonus arrives in.
|
||||
*/
|
||||
const GIFT_CONTEXT_GAME_REWARDS_TOKENS = 51
|
||||
|
||||
/** The message on the new-activity bonus box. */
|
||||
const NEW_ACTIVITY_BONUS_MESSAGE = 'New activity bonus!'
|
||||
|
||||
/**
|
||||
* The gift-drop the new-activity bonus hands over: tokens in a box, no item, no XP. Like
|
||||
* {@link toGameRewardDrop}, `grantGiftDrop` creates only the box from it — the tokens are
|
||||
* credited to the balance separately, and the copy here is what the box displays.
|
||||
*/
|
||||
function toNewActivityBonusDrop(): StoreGiftDrop {
|
||||
return {
|
||||
FriendlyName: '',
|
||||
Tooltip: '',
|
||||
ConsumableItemDesc: '',
|
||||
AvatarItemDesc: '',
|
||||
AvatarItemType: null,
|
||||
EquipmentPrefabName: '',
|
||||
EquipmentModificationGuid: '',
|
||||
Rarity: 0,
|
||||
Context: GIFT_CONTEXT_GAME_REWARDS_TOKENS,
|
||||
Currency: NEW_ACTIVITY_BONUS_TOKENS,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
Xp: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -5325,6 +5365,10 @@ const app = new Hono<App>({ strict: false })
|
||||
// (`Dodgeball`, `Quest_Goblin_S`, …) draws one of that activity's rewards — an avatar item
|
||||
// granted into the inventory, or Laser Tag's ticket payout — and the box carries it, with
|
||||
// the activity's own `GiftContext`. A context the table doesn't know gets the XP-only box.
|
||||
//
|
||||
// And the FIRST claim ever in a context — a new activity for this player, whichever type
|
||||
// asked — pays NEW_ACTIVITY_BONUS_TOKENS on top, credited to the balance and delivered in
|
||||
// a second box of its own so the player sees what the tokens were for.
|
||||
.post(
|
||||
'/api/gamerewards/v1/request',
|
||||
describeRoute({
|
||||
@@ -5336,8 +5380,9 @@ const app = new Hono<App>({ strict: false })
|
||||
'owed another reward while the same one is not; an ask with no `giftContext` keys on',
|
||||
'the empty context. A `giftContext` that names an activity in `quest-rewards.json`',
|
||||
'(`Dodgeball`, `Quest_Goblin_S`, …) draws one of that activity’s rewards and grants it;',
|
||||
'any other claim pays XP only. The reward rides in a gift box, so a claim and a',
|
||||
'rejected (on-cooldown) ask both answer `[]`.',
|
||||
'any other claim pays XP only. The first claim ever in a `giftContext` — a new activity',
|
||||
'for the player — also pays a one-off 250 tokens, in a box of its own. The reward rides',
|
||||
'in a gift box, so a claim and a rejected (on-cooldown) ask both answer `[]`.',
|
||||
].join(' '),
|
||||
security: AUTHED,
|
||||
requestBody: form(GameRewardRequest, 'The reward type and its display message'),
|
||||
@@ -5389,6 +5434,26 @@ const app = new Hono<App>({ strict: false })
|
||||
}
|
||||
const granted = await grantGiftDrop(c, id, drop, message)
|
||||
await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID)
|
||||
// A new activity pays its token bonus in a second box — after the reward's own, so
|
||||
// the player sees the reward they asked for first and the bonus as the extra it is.
|
||||
// Same order as the currency reward above: balance first, then the box that shows it.
|
||||
let bonusGiftId: number | null = null
|
||||
if (await isNewActivity(c.env.DB, id, giftContext, claimed)) {
|
||||
const bonus = toNewActivityBonusDrop()
|
||||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||
await ensureStartingBalances(c.env.DB, id, startingTokens)
|
||||
const balance = await creditCurrency(
|
||||
c.env.DB,
|
||||
id,
|
||||
bonus.CurrencyType,
|
||||
bonus.Currency,
|
||||
startingTokens
|
||||
)
|
||||
await pushBalanceUpdate(c, id, bonus.CurrencyType, balance)
|
||||
const bonusGift = await grantGiftDrop(c, id, bonus, NEW_ACTIVITY_BONUS_MESSAGE)
|
||||
await pushGiftReceived(c, id, bonusGift, NEW_ACTIVITY_BONUS_MESSAGE, COACH_ACCOUNT_ID)
|
||||
bonusGiftId = bonusGift.id
|
||||
}
|
||||
// Every grant moves the bar, whether or not it crossed a level.
|
||||
await pushProgressionUpdate(c, id, progression)
|
||||
// …and every level crossed is worth a box of its own tier.
|
||||
@@ -5404,6 +5469,8 @@ const app = new Hono<App>({ strict: false })
|
||||
levelsGained,
|
||||
levelXp: progression.XP,
|
||||
giftId: granted.id,
|
||||
newActivityBonus: bonusGiftId === null ? 0 : NEW_ACTIVITY_BONUS_TOKENS,
|
||||
bonusGiftId,
|
||||
})
|
||||
return c.json([])
|
||||
}
|
||||
|
||||
@@ -78,3 +78,30 @@ export async function claimReward(
|
||||
.first<{ grant_count: number }>()
|
||||
return row?.grant_count ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the claim just written by {@link claimReward} is the player's FIRST in
|
||||
* `giftContext` — across every reward type, so an activity is "new" once, not once per
|
||||
* type it happens to be asked under. Call it right after the claim with the count it
|
||||
* returned: it is true when that claim INSERTED its row (`grantCount` 1; a repeat past the
|
||||
* cooldown is 2+) and the player's rows in that context are exactly that one.
|
||||
*
|
||||
* Counting AFTER the claim is what keeps two asks landing together (say
|
||||
* `FirstActivityOfDay` and `PostGameActivity` for the same match) from both reading "new":
|
||||
* D1 serializes the inserts, so at most the first insert's count is 1 and the second's is
|
||||
* 2 — a race can skip the bonus, never pay it twice. The contextless `''` bucket is not an
|
||||
* activity and is never new.
|
||||
*/
|
||||
export async function isNewActivity(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
giftContext: string,
|
||||
grantCount: number
|
||||
): Promise<boolean> {
|
||||
if (giftContext === '' || grantCount !== 1) return false
|
||||
const row = await db
|
||||
.prepare('SELECT COUNT(*) AS n FROM reward_status WHERE account_id = ?1 AND gift_context = ?2')
|
||||
.bind(accountId, giftContext)
|
||||
.first<{ n: number }>()
|
||||
return row?.n === 1
|
||||
}
|
||||
|
||||
@@ -4770,6 +4770,8 @@ describe('econ endpoints', () => {
|
||||
ConsumableItemDesc: string
|
||||
GiftRarity: number
|
||||
GiftContext: number
|
||||
Currency: number
|
||||
CurrencyType: number
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -5187,10 +5189,13 @@ describe('econ endpoints', () => {
|
||||
)
|
||||
expect(goblin.status).toBe(200)
|
||||
expect(await goblin.json()).toEqual([])
|
||||
// Two boxes: the quest reward, and the new-activity token bonus (covered below) that a
|
||||
// first-ever claim in a context also pays.
|
||||
const boxes = await giftBoxes('83')
|
||||
expect(boxes).toHaveLength(1)
|
||||
expect(boxes).toHaveLength(2)
|
||||
const box = boxes[0]
|
||||
expect(box).toMatchObject({ Xp: 5, Message: 'Quest complete', GiftContext: 4003 })
|
||||
expect(boxes[1]).toMatchObject({ GiftContext: 51, Currency: 250 })
|
||||
expect(box?.AvatarItemDesc).not.toBe('')
|
||||
const row = questRewards.Quest_Goblin_S.find((r) => r.AvatarItemDesc === box?.AvatarItemDesc)
|
||||
expect(row).toBeDefined()
|
||||
@@ -5220,7 +5225,9 @@ describe('econ endpoints', () => {
|
||||
expect(
|
||||
await getBalance(env.DB, 83, CurrencyType.LaserTagTickets, DEFAULT_STARTING_TOKENS)
|
||||
).toBe(before + 50)
|
||||
const ticketBox = (await giftBoxes('83'))[1]
|
||||
const ticketBox = (await giftBoxes('83')).find(
|
||||
(b) => b.CurrencyType === CurrencyType.LaserTagTickets
|
||||
)
|
||||
expect(ticketBox).toMatchObject({
|
||||
Currency: 50,
|
||||
CurrencyType: CurrencyType.LaserTagTickets,
|
||||
@@ -5232,10 +5239,10 @@ describe('econ endpoints', () => {
|
||||
NotificationType.StorefrontBalanceUpdate
|
||||
)
|
||||
|
||||
// An activity the table doesn't know pays the plain XP box, as before. (The LAST box:
|
||||
// the two claims above also crossed level 1, and that level-up box sits in between.)
|
||||
// An activity the table doesn't know pays the plain XP box, as before — the last
|
||||
// GameRewards box, behind Bowling's own new-activity bonus.
|
||||
expect((await request('rewardType=PostGameActivity&giftContext=Bowling')).status).toBe(200)
|
||||
const plain = (await giftBoxes('83')).at(-1)
|
||||
const plain = (await giftBoxes('83')).findLast((b) => b.GiftContext === 50)
|
||||
expect(plain).toMatchObject({ Xp: 5, AvatarItemDesc: '', Currency: 0, GiftContext: 50 })
|
||||
|
||||
// A reward the player already owns is never drawn again: Dodgeball has three rows, so
|
||||
@@ -5257,6 +5264,75 @@ describe('econ endpoints', () => {
|
||||
expect(handed.toSorted()).toEqual(dodgeball.toSorted())
|
||||
})
|
||||
|
||||
test('the first claim ever in a giftContext pays a one-off 250-token bonus', async () => {
|
||||
const request = async (body: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(await bearer('84')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
})
|
||||
const tokens = () =>
|
||||
getBalance(env.DB, 84, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||
const bonusBoxes = async () => (await giftBoxes('84')).filter((b) => b.GiftContext === 51)
|
||||
await drainFrames()
|
||||
const start = await tokens()
|
||||
|
||||
// A contextless ask is not an activity: XP box only, no tokens.
|
||||
expect((await request('rewardType=FirstActivityOfDay&Message=First')).status).toBe(200)
|
||||
expect(await tokens()).toBe(start)
|
||||
expect(await bonusBoxes()).toHaveLength(0)
|
||||
|
||||
// First time in Soccer: +250, in a box of its own after the reward's, credited to the
|
||||
// balance and announced as such.
|
||||
expect((await request('rewardType=PostGameActivity&giftContext=Soccer')).status).toBe(200)
|
||||
expect(await tokens()).toBe(start + 250)
|
||||
const bonus = await bonusBoxes()
|
||||
expect(bonus).toHaveLength(1)
|
||||
expect(bonus[0]).toMatchObject({
|
||||
Currency: 250,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
Xp: 0,
|
||||
AvatarItemDesc: '',
|
||||
Message: 'New activity bonus!',
|
||||
})
|
||||
// The balance frame carries the resulting total, and the bonus box is announced like
|
||||
// any other. (This being the second 5 XP, a level-2 box rides along too.)
|
||||
const frames = await drainFrames()
|
||||
expect(
|
||||
frames.find((f) => f.notificationType === NotificationType.StorefrontBalanceUpdate)?.payload
|
||||
).toMatchObject({ CurrencyType: CurrencyType.RecCenterTokens, Balance: start + 250 })
|
||||
expect(
|
||||
frames.filter((f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate)
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({ Currency: 250, GiftContext: 51, Id: bonus[0]?.Id }),
|
||||
})
|
||||
)
|
||||
|
||||
// Soccer under ANOTHER type is the same activity — no second bonus…
|
||||
expect((await request('rewardType=FirstActivityOfDay&giftContext=Soccer')).status).toBe(200)
|
||||
expect(await tokens()).toBe(start + 250)
|
||||
// …and neither is Soccer again once its cooldown has passed.
|
||||
await env.DB.prepare(
|
||||
"UPDATE reward_status SET granted_at = ?1 WHERE account_id = 84 AND gift_context = 'Soccer'"
|
||||
)
|
||||
.bind(new Date(Date.now() - 61 * 60 * 1000).toISOString())
|
||||
.run()
|
||||
expect((await request('rewardType=PostGameActivity&giftContext=Soccer')).status).toBe(200)
|
||||
expect(await tokens()).toBe(start + 250)
|
||||
// An on-cooldown ask in a context never claimed pays nothing — it wrote no row.
|
||||
expect((await request('rewardType=PostGameActivity&giftContext=Soccer')).status).toBe(200)
|
||||
expect(await tokens()).toBe(start + 250)
|
||||
|
||||
// A different activity is new again.
|
||||
expect((await request('rewardType=PostGameActivity&giftContext=Paintball')).status).toBe(200)
|
||||
expect(await tokens()).toBe(start + 500)
|
||||
expect(await bonusBoxes()).toHaveLength(2)
|
||||
})
|
||||
|
||||
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