mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[econ] change token award to once per activity per day
This commit is contained in:
+21
-18
@@ -466,17 +466,19 @@ rewardType=PostGameActivity&Message=Activity%20completed%21&giftContext=Soccer
|
||||
Since the client asks rather than the server offering, whether a reward is actually **owed**
|
||||
is decided here, from `reward_status` — one row per (account, reward type, gift context)
|
||||
holding the last claim and a count. One claim per type per activity per hour
|
||||
(`REWARD_COOLDOWN_MS`), flat for every type despite what a name like `FirstActivityOfDay`
|
||||
suggests; per-type windows would be a map keyed by type.
|
||||
(`REWARD_COOLDOWN_MS`) — except `FirstActivityOfDay`, which is one per PLAYER per rolling
|
||||
24h (`DAILY_REWARD_COOLDOWN_MS`): it is always claimed on the `''` context, whichever
|
||||
activity asked, so its row's `granted_at` is simply the player's last one.
|
||||
|
||||
- **The claim is one SQL statement** (`ON CONFLICT … DO UPDATE … WHERE`). The client fires
|
||||
these off right after a match, so two can land together; a read-then-write would let both
|
||||
see the same stale `granted_at` and pay out twice.
|
||||
- **A rejected claim leaves `granted_at` alone.** If an on-cooldown ask pushed the timestamp
|
||||
forward, a client that retries in a loop would never become eligible.
|
||||
- **`giftContext` (the activity, e.g. `Soccer`) is part of the key** — the "first activity of
|
||||
the day" is per activity, so a player who moves from Soccer to Paintball is owed another
|
||||
reward while a second Soccer match inside the hour is not.
|
||||
- **`giftContext` (the activity, e.g. `Soccer`) is part of the key** for the hourly types,
|
||||
so a player who moves from Soccer to Paintball is owed another reward while a second Soccer
|
||||
match inside the hour is not. The prize is still picked from the `giftContext` that asked,
|
||||
`FirstActivityOfDay` included.
|
||||
- **A contextless ask keys on `''`, not NULL.** SQLite allows — and does not dedupe — NULLs
|
||||
in a non-INTEGER primary key, so a NULL context would insert a fresh row on every ask
|
||||
instead of hitting the conflict, and the cooldown would never apply. Migration
|
||||
@@ -491,22 +493,23 @@ empty, `GiftContext` 50 (`GameRewards`). The box wears the `Message` the client
|
||||
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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **`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
|
||||
(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.
|
||||
- **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.
|
||||
|
||||
+79
-43
@@ -168,7 +168,7 @@ import {
|
||||
WishlistItemList,
|
||||
WishlistItemRequest,
|
||||
} from './openapi'
|
||||
import { claimReward, isNewActivity } from './reward-db'
|
||||
import { claimDailyTokens, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db'
|
||||
import {
|
||||
awardRoomConsumable,
|
||||
getRoomConsumable,
|
||||
@@ -2285,29 +2285,46 @@ const GIFT_CONTEXT_GAME_REWARDS = 50
|
||||
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.
|
||||
* 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`).
|
||||
*/
|
||||
const NEW_ACTIVITY_BONUS_TOKENS = 250
|
||||
const GAME_REWARD_TOKENS = 250
|
||||
|
||||
/**
|
||||
* The most game-reward tokens a player earns in one rolling 24h window: five claims' worth.
|
||||
* A multiple of {@link GAME_REWARD_TOKENS} — a claim that would cross it pays nothing.
|
||||
*/
|
||||
const GAME_REWARD_DAILY_TOKEN_CAP = 1250
|
||||
|
||||
/**
|
||||
* The reward types whose tokens draw on the daily allowance: the client's ask after a match
|
||||
* and after a quest. {@link FIRST_ACTIVITY_OF_DAY} pays its own on top, outside the cap.
|
||||
*/
|
||||
const TOKEN_REWARD_TYPES = new Set(['PostGameActivity', 'PostQuestActivity'])
|
||||
|
||||
/**
|
||||
* The once-a-day bonus. Its claim is per PLAYER — keyed on the empty context whatever
|
||||
* activity asked — on a 24h cooldown, and it pays {@link GAME_REWARD_TOKENS} outside the
|
||||
* daily cap: the claim itself is what limits it, so it needs no allowance row.
|
||||
*/
|
||||
const FIRST_ACTIVITY_OF_DAY = 'FirstActivityOfDay'
|
||||
|
||||
/**
|
||||
* `GiftContext.GameRewardsTokens` — the tokens variant of {@link GIFT_CONTEXT_GAME_REWARDS},
|
||||
* on the box the new-activity bonus arrives in.
|
||||
* on the box the token payout 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 message on the token payout's box. */
|
||||
const GAME_REWARD_TOKENS_MESSAGE = 'Activity bonus!'
|
||||
|
||||
/**
|
||||
* The gift-drop the new-activity bonus hands over: tokens in a box, no item, no XP. Like
|
||||
* The gift-drop a claimed game reward's token payout 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 {
|
||||
function toGameRewardTokensDrop(): StoreGiftDrop {
|
||||
return {
|
||||
FriendlyName: '',
|
||||
Tooltip: '',
|
||||
@@ -2318,7 +2335,7 @@ function toNewActivityBonusDrop(): StoreGiftDrop {
|
||||
EquipmentModificationGuid: '',
|
||||
Rarity: 0,
|
||||
Context: GIFT_CONTEXT_GAME_REWARDS_TOKENS,
|
||||
Currency: NEW_ACTIVITY_BONUS_TOKENS,
|
||||
Currency: GAME_REWARD_TOKENS,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
Xp: 0,
|
||||
}
|
||||
@@ -6031,7 +6048,8 @@ const app = new Hono<App>({ strict: false })
|
||||
// posting the type and the message to show for it (`rewardType=FirstActivityOfDay&
|
||||
// Message=First Game of the Day`, or `rewardType=PostGameActivity&Message=Activity
|
||||
// completed!&giftContext=Soccer`) — so whether a reward is actually OWED is decided
|
||||
// here, from `reward_status`: one claim per type per activity per hour, atomically.
|
||||
// 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
|
||||
// XP, announced with the same GiftPackageReceivedImmediate frame the weekly gift uses —
|
||||
@@ -6043,19 +6061,20 @@ const app = new Hono<App>({ strict: false })
|
||||
// reference answers its own (different, selection-based) flow with a success envelope,
|
||||
// not a list of rewards.
|
||||
//
|
||||
// `giftContext` (the activity, e.g. `Soccer`) is part of the cooldown key: the first
|
||||
// activity of the day is per ACTIVITY, so a player who moves from Soccer to Paintball is
|
||||
// owed another reward while a second Soccer match inside the hour is not. An ask that
|
||||
// sends no context keys on `''`.
|
||||
// `giftContext` (the activity, e.g. `Soccer`) is part of the cooldown key, so a player who
|
||||
// moves from Soccer to Paintball is owed another reward while a second Soccer match inside
|
||||
// the hour is not. An ask that sends no context keys on `''` — as FirstActivityOfDay always
|
||||
// does, being the player's first activity of the day, not each activity's.
|
||||
//
|
||||
// It also picks the PRIZE: a context that is a key of `static/quest-rewards.json`
|
||||
// (`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.
|
||||
// A `PostGameActivity`/`PostQuestActivity` claim also pays GAME_REWARD_TOKENS, up to
|
||||
// GAME_REWARD_DAILY_TOKEN_CAP per player per rolling 24h across every activity, and a
|
||||
// FirstActivityOfDay claim pays them on top of the cap — 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({
|
||||
@@ -6067,9 +6086,11 @@ 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 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 `[]`.',
|
||||
'any other claim pays XP only. A `PostGameActivity` or `PostQuestActivity` claim also',
|
||||
'pays 250 tokens, in a box of its own, up to 1250 per player per rolling 24 hours',
|
||||
'whatever the activity. `FirstActivityOfDay` is once per player per 24 hours, whatever',
|
||||
'the `giftContext`, and pays 250 tokens outside that cap. 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'),
|
||||
@@ -6086,7 +6107,12 @@ 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 giftContext = typeof body.giftContext === 'string' ? body.giftContext : ''
|
||||
const claimed = await claimReward(c.env.DB, id, rewardType, giftContext)
|
||||
// FirstActivityOfDay is the player's, not the activity's: claimed on the empty context
|
||||
// and a day's cooldown. Its prize is still picked from the activity that asked.
|
||||
const daily = rewardType === FIRST_ACTIVITY_OF_DAY
|
||||
const claimed = daily
|
||||
? await claimReward(c.env.DB, id, rewardType, '', new Date(), DAILY_REWARD_COOLDOWN_MS)
|
||||
: await claimReward(c.env.DB, id, rewardType, giftContext)
|
||||
// On cooldown: nothing was claimed, so nothing is paid and nothing is announced.
|
||||
if (claimed === null) return c.json([])
|
||||
const message =
|
||||
@@ -6107,8 +6133,8 @@ const app = new Hono<App>({ strict: false })
|
||||
// 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.
|
||||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||
if (drop.Currency > 0 && drop.CurrencyType !== CurrencyType.Invalid) {
|
||||
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
|
||||
await ensureStartingBalances(c.env.DB, id, startingTokens)
|
||||
const balance = await creditCurrency(
|
||||
c.env.DB,
|
||||
@@ -6121,25 +6147,34 @@ 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(
|
||||
// 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(
|
||||
c.env.DB,
|
||||
id,
|
||||
bonus.CurrencyType,
|
||||
bonus.Currency,
|
||||
GAME_REWARD_TOKENS,
|
||||
GAME_REWARD_DAILY_TOKEN_CAP
|
||||
)
|
||||
}
|
||||
// FirstActivityOfDay's claim already held it to once a day: it pays without the cap.
|
||||
if (daily || tokensToday !== null) {
|
||||
const tokensDrop = toGameRewardTokensDrop()
|
||||
await ensureStartingBalances(c.env.DB, id, startingTokens)
|
||||
const tokenBalance = await creditCurrency(
|
||||
c.env.DB,
|
||||
id,
|
||||
tokensDrop.CurrencyType,
|
||||
tokensDrop.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
|
||||
await pushBalanceUpdate(c, id, tokensDrop.CurrencyType, tokenBalance)
|
||||
const tokensGift = await grantGiftDrop(c, id, tokensDrop, GAME_REWARD_TOKENS_MESSAGE)
|
||||
await pushGiftReceived(c, id, tokensGift, GAME_REWARD_TOKENS_MESSAGE, COACH_ACCOUNT_ID)
|
||||
tokensGiftId = tokensGift.id
|
||||
}
|
||||
// Every grant moves the bar, whether or not it crossed a level.
|
||||
await pushProgressionUpdate(c, id, progression)
|
||||
@@ -6156,8 +6191,9 @@ const app = new Hono<App>({ strict: false })
|
||||
levelsGained,
|
||||
levelXp: progression.XP,
|
||||
giftId: granted.id,
|
||||
newActivityBonus: bonusGiftId === null ? 0 : NEW_ACTIVITY_BONUS_TOKENS,
|
||||
bonusGiftId,
|
||||
tokens: tokensGiftId === null ? 0 : GAME_REWARD_TOKENS,
|
||||
tokensToday,
|
||||
tokensGiftId,
|
||||
})
|
||||
return c.json([])
|
||||
}
|
||||
|
||||
+55
-25
@@ -30,13 +30,29 @@ export const REWARD_STATUS_SCHEMA_DDL: string[] = [
|
||||
]
|
||||
|
||||
/**
|
||||
* How long a player must wait between rewards of the same type in the same activity. One
|
||||
* hour flat, for every type — despite what a name like `FirstActivityOfDay` suggests.
|
||||
* Per-type windows would be a map keyed by reward type; there's one window until a reward
|
||||
* type needs its own.
|
||||
* How long a player must wait between rewards of the same type in the same activity: one
|
||||
* hour, for every type but `FirstActivityOfDay`, which waits {@link DAILY_REWARD_COOLDOWN_MS}.
|
||||
*/
|
||||
export const REWARD_COOLDOWN_MS = 60 * 60 * 1000
|
||||
|
||||
/** `FirstActivityOfDay`'s cooldown: a rolling 24h since the player's last one. */
|
||||
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.
|
||||
*/
|
||||
export const TOKEN_REWARD_TYPE = '$tokens'
|
||||
|
||||
/**
|
||||
* How long a token 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
|
||||
|
||||
/**
|
||||
* Claim a reward if the player is due one, returning how many of that type they have now
|
||||
* claimed in that context — or `null` when the cooldown hasn't elapsed and nothing was
|
||||
@@ -61,9 +77,10 @@ export async function claimReward(
|
||||
accountId: number,
|
||||
rewardType: string,
|
||||
giftContext = '',
|
||||
now: Date = new Date()
|
||||
now: Date = new Date(),
|
||||
cooldownMs: number = REWARD_COOLDOWN_MS
|
||||
): Promise<number | null> {
|
||||
const cutoff = new Date(now.getTime() - REWARD_COOLDOWN_MS).toISOString()
|
||||
const cutoff = new Date(now.getTime() - cooldownMs).toISOString()
|
||||
const row = await db
|
||||
.prepare(
|
||||
`INSERT INTO reward_status (account_id, reward_type, gift_context, granted_at, grant_count)
|
||||
@@ -80,28 +97,41 @@ export async function claimReward(
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Add `amount` tokens 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.
|
||||
*
|
||||
* 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.
|
||||
* A window older than {@link TOKEN_REWARD_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
|
||||
* remainder, so `cap` should be a multiple of `amount`.
|
||||
*
|
||||
* One statement, for the same reason as {@link claimReward}: asks landing together can't both
|
||||
* 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 isNewActivity(
|
||||
export async function claimDailyTokens(
|
||||
db: D1Database,
|
||||
accountId: number,
|
||||
giftContext: string,
|
||||
grantCount: number
|
||||
): Promise<boolean> {
|
||||
if (giftContext === '' || grantCount !== 1) return false
|
||||
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 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
|
||||
.prepare(
|
||||
`INSERT INTO reward_status (account_id, reward_type, gift_context, granted_at, grant_count)
|
||||
VALUES (?1, ?2, '', ?3, ?4)
|
||||
ON CONFLICT (account_id, reward_type, gift_context) DO UPDATE SET
|
||||
granted_at = CASE WHEN reward_status.granted_at <= ?5
|
||||
THEN excluded.granted_at ELSE reward_status.granted_at END,
|
||||
grant_count = CASE WHEN reward_status.granted_at <= ?5
|
||||
THEN ?4 ELSE reward_status.grant_count + ?4 END
|
||||
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)
|
||||
.first<{ grant_count: number }>()
|
||||
return row?.grant_count ?? null
|
||||
}
|
||||
|
||||
@@ -6580,7 +6580,7 @@ describe('econ endpoints', () => {
|
||||
expect(boxes[0]?.AvatarItemDesc).toBe(entry?.AvatarItemDesc)
|
||||
})
|
||||
|
||||
test('POST /api/gamerewards/v1/request claims once an hour per reward type and activity', async () => {
|
||||
test('POST /api/gamerewards/v1/request claims once an hour per reward type and activity — FirstActivityOfDay once a day per player', async () => {
|
||||
const headers = {
|
||||
...(await bearer('80')),
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
@@ -6608,10 +6608,16 @@ describe('econ endpoints', () => {
|
||||
const claimed = await statusOf('FirstActivityOfDay')
|
||||
expect(claimed?.grant_count).toBe(1)
|
||||
|
||||
// Asking again inside the hour claims nothing — and must not push the cooldown out,
|
||||
// or a client that retries in a loop would never become eligible.
|
||||
// Asking again inside the day claims nothing — and must not push the cooldown out,
|
||||
// or a client that retries in a loop would never become eligible. It is the PLAYER's
|
||||
// first activity of the day, so another activity doesn't claim it either, and writes
|
||||
// no row of its own.
|
||||
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
|
||||
expect(
|
||||
(await request('rewardType=FirstActivityOfDay&giftContext=Soccer')).status
|
||||
).toBe(200)
|
||||
expect(await statusOf('FirstActivityOfDay')).toEqual(claimed)
|
||||
expect(await statusOf('FirstActivityOfDay', 'Soccer')).toBeNull()
|
||||
|
||||
// A different type has its own cooldown — and so does each `giftContext` within a type:
|
||||
// Soccer and Paintball are separate rows that each claim once.
|
||||
@@ -6643,12 +6649,21 @@ describe('econ endpoints', () => {
|
||||
expect((await request('rewardType=PostGameActivity&Message=again')).status).toBe(200)
|
||||
expect((await statusOf('PostGameActivity'))?.grant_count).toBe(1)
|
||||
|
||||
// Once the hour has passed, the same type claims again.
|
||||
await env.DB.prepare(
|
||||
"UPDATE reward_status SET granted_at = ?1 WHERE account_id = 80 AND reward_type = 'FirstActivityOfDay'"
|
||||
)
|
||||
.bind(new Date(Date.now() - 61 * 60 * 1000).toISOString())
|
||||
.run()
|
||||
// Once the hour has passed, an hourly type claims again — but FirstActivityOfDay waits
|
||||
// out the whole day.
|
||||
const age = (rewardType: string, ms: number) =>
|
||||
env.DB.prepare(
|
||||
'UPDATE reward_status SET granted_at = ?1 WHERE account_id = 80 AND reward_type = ?2'
|
||||
)
|
||||
.bind(new Date(Date.now() - ms).toISOString(), rewardType)
|
||||
.run()
|
||||
await age('PostGameActivity', 61 * 60 * 1000)
|
||||
expect((await request('rewardType=PostGameActivity&giftContext=Soccer')).status).toBe(200)
|
||||
expect((await statusOf('PostGameActivity', 'Soccer'))?.grant_count).toBe(2)
|
||||
await age('FirstActivityOfDay', 61 * 60 * 1000)
|
||||
expect((await request('rewardType=FirstActivityOfDay&Message=later')).status).toBe(200)
|
||||
expect((await statusOf('FirstActivityOfDay'))?.grant_count).toBe(1)
|
||||
await age('FirstActivityOfDay', 25 * 60 * 60 * 1000)
|
||||
expect((await request('rewardType=FirstActivityOfDay&Message=tomorrow')).status).toBe(200)
|
||||
expect((await statusOf('FirstActivityOfDay'))?.grant_count).toBe(2)
|
||||
})
|
||||
@@ -6664,12 +6679,21 @@ describe('econ endpoints', () => {
|
||||
body,
|
||||
})
|
||||
/** Age the cooldown so the next ask is eligible again. */
|
||||
const passAnHour = () =>
|
||||
const passADay = () =>
|
||||
env.DB.prepare(
|
||||
"UPDATE reward_status SET granted_at = ?1 WHERE account_id = 82 AND reward_type = 'FirstActivityOfDay'"
|
||||
)
|
||||
.bind(new Date(Date.now() - 61 * 60 * 1000).toISOString())
|
||||
.bind(new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString())
|
||||
.run()
|
||||
/** The reward's own boxes — not the token box (GiftContext 51) each claim also pays. */
|
||||
const rewardBoxes = async () => (await giftBoxes('82')).filter((b) => b.GiftContext !== 51)
|
||||
/** Frames bar the token payout's balance update and box. */
|
||||
const rewardFrames = async () =>
|
||||
(await drainFrames()).filter(
|
||||
(f) =>
|
||||
f.notificationType !== NotificationType.StorefrontBalanceUpdate &&
|
||||
(f.payload as { GiftContext?: number }).GiftContext !== 51
|
||||
)
|
||||
|
||||
await drainFrames()
|
||||
expect((await getProgression(env.DB, 82)).XP).toBe(0)
|
||||
@@ -6681,9 +6705,9 @@ describe('econ endpoints', () => {
|
||||
// bar without levelling anyone up.
|
||||
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
|
||||
|
||||
// One box: the XP reward itself, carrying the message the client asked to show and no
|
||||
// item — a game reward is not an item.
|
||||
const first = await giftBoxes('82')
|
||||
// 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.)
|
||||
const first = await rewardBoxes()
|
||||
expect(first).toHaveLength(1)
|
||||
expect(first[0]).toMatchObject({
|
||||
Xp: 5,
|
||||
@@ -6694,7 +6718,7 @@ describe('econ endpoints', () => {
|
||||
})
|
||||
|
||||
// The box, then the bar — no level-up box, since no level was crossed.
|
||||
const frames = await drainFrames()
|
||||
const frames = await rewardFrames()
|
||||
expect(frames.map((f) => f.notificationType)).toEqual([
|
||||
NotificationType.GiftPackageReceivedImmediate,
|
||||
NotificationType.PlayerProgressionLevelUpdate,
|
||||
@@ -6713,18 +6737,18 @@ describe('econ endpoints', () => {
|
||||
// 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(1)
|
||||
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 passAnHour()
|
||||
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
|
||||
// equipment skin, which is what the avatar-only roll is for.
|
||||
const afterLevel2 = await giftBoxes('82')
|
||||
const afterLevel2 = await rewardBoxes()
|
||||
expect(afterLevel2).toHaveLength(3)
|
||||
const clothingBox = afterLevel2[2]
|
||||
expect(clothingBox?.Message).toBe('Level 2!')
|
||||
@@ -6739,7 +6763,7 @@ 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 drainFrames()).map((f) => f.notificationType)).toEqual([
|
||||
expect((await rewardFrames()).map((f) => f.notificationType)).toEqual([
|
||||
NotificationType.GiftPackageReceivedImmediate,
|
||||
NotificationType.PlayerProgressionLevelUpdate,
|
||||
NotificationType.GiftPackageReceivedImmediate,
|
||||
@@ -6748,7 +6772,7 @@ describe('econ endpoints', () => {
|
||||
// Two more rewards reach 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 passAnHour()
|
||||
await passADay()
|
||||
expect((await request(`rewardType=FirstActivityOfDay&Message=${message}`)).status).toBe(200)
|
||||
}
|
||||
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 3, XP: 0 })
|
||||
@@ -6785,8 +6809,7 @@ 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.
|
||||
// Two boxes: the quest reward, and the PostGameActivity tokens (covered below).
|
||||
const boxes = await giftBoxes('83')
|
||||
expect(boxes).toHaveLength(2)
|
||||
const box = boxes[0]
|
||||
@@ -6836,7 +6859,7 @@ describe('econ endpoints', () => {
|
||||
)
|
||||
|
||||
// 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.
|
||||
// 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 })
|
||||
@@ -6860,7 +6883,7 @@ 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 () => {
|
||||
test('PostGameActivity/PostQuestActivity pay 250 tokens, up to 1250 per player per 24h', async () => {
|
||||
const request = async (body: string) =>
|
||||
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
|
||||
method: 'POST',
|
||||
@@ -6872,61 +6895,85 @@ describe('econ endpoints', () => {
|
||||
})
|
||||
const tokens = () =>
|
||||
getBalance(env.DB, 84, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
|
||||
const bonusBoxes = async () => (await giftBoxes('84')).filter((b) => b.GiftContext === 51)
|
||||
const tokenBoxes = async () => (await giftBoxes('84')).filter((b) => b.GiftContext === 51)
|
||||
const allowance = () =>
|
||||
env.DB.prepare(
|
||||
"SELECT granted_at, grant_count FROM reward_status WHERE account_id = 84 AND reward_type = '$tokens'"
|
||||
).first<{ granted_at: string; grant_count: number }>()
|
||||
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)
|
||||
// FirstActivityOfDay pays its 250 OUTSIDE the allowance: no allowance row, and the cap
|
||||
// below is still five claims' worth on top of it.
|
||||
expect((await request('rewardType=FirstActivityOfDay&giftContext=Soccer')).status).toBe(200)
|
||||
expect(await tokens()).toBe(start + 250)
|
||||
expect(await allowance()).toBeNull()
|
||||
await drainFrames()
|
||||
const base = start + 250
|
||||
|
||||
// First time in Soccer: +250, in a box of its own after the reward's, credited to the
|
||||
// PostGameActivity: +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({
|
||||
expect(await tokens()).toBe(base + 250)
|
||||
const boxes = (await tokenBoxes()).slice(1)
|
||||
expect(boxes).toHaveLength(1)
|
||||
expect(boxes[0]).toMatchObject({
|
||||
Currency: 250,
|
||||
CurrencyType: CurrencyType.RecCenterTokens,
|
||||
Xp: 0,
|
||||
AvatarItemDesc: '',
|
||||
Message: 'New activity bonus!',
|
||||
Message: '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.)
|
||||
// The balance frame carries the resulting total, and the token box is announced like
|
||||
// any other.
|
||||
const frames = await drainFrames()
|
||||
expect(
|
||||
frames.find((f) => f.notificationType === NotificationType.StorefrontBalanceUpdate)?.payload
|
||||
).toMatchObject({ CurrencyType: CurrencyType.RecCenterTokens, Balance: start + 250 })
|
||||
frames.findLast((f) => f.notificationType === NotificationType.StorefrontBalanceUpdate)
|
||||
?.payload
|
||||
).toMatchObject({ CurrencyType: CurrencyType.RecCenterTokens, Balance: base + 250 })
|
||||
expect(
|
||||
frames.filter((f) => f.notificationType === NotificationType.GiftPackageReceivedImmediate)
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
payload: expect.objectContaining({ Currency: 250, GiftContext: 51, Id: bonus[0]?.Id }),
|
||||
payload: expect.objectContaining({ Currency: 250, GiftContext: 51, Id: boxes[0]?.Id }),
|
||||
})
|
||||
)
|
||||
const opened = (await allowance())?.granted_at
|
||||
expect(await allowance()).toMatchObject({ grant_count: 250 })
|
||||
|
||||
// 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.
|
||||
// PostQuestActivity counts the same, and the allowance is the player's, whatever the
|
||||
// activity — a contextless ask included.
|
||||
for (const body of [
|
||||
'rewardType=PostQuestActivity&giftContext=Quest_Goblin_S',
|
||||
'rewardType=PostGameActivity',
|
||||
'rewardType=PostGameActivity&giftContext=Paintball',
|
||||
'rewardType=PostGameActivity&giftContext=Bowling',
|
||||
]) {
|
||||
expect((await request(body)).status).toBe(200)
|
||||
}
|
||||
expect(await tokens()).toBe(base + 1250)
|
||||
// The window keeps its start as the total grows.
|
||||
expect(await allowance()).toEqual({ granted_at: opened, grant_count: 1250 })
|
||||
|
||||
// At the cap: the claim still pays its XP box, but no tokens.
|
||||
const before = (await giftBoxes('84')).length
|
||||
expect((await request('rewardType=PostGameActivity&giftContext=Dodgeball')).status).toBe(200)
|
||||
expect(await tokens()).toBe(base + 1250)
|
||||
expect(await tokenBoxes()).toHaveLength(6)
|
||||
expect((await giftBoxes('84')).length).toBeGreaterThan(before)
|
||||
expect(await allowance()).toMatchObject({ grant_count: 1250 })
|
||||
|
||||
// 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 = 84 AND gift_context = 'Soccer'"
|
||||
"UPDATE reward_status SET granted_at = ?1 WHERE account_id = 84 AND reward_type = '$tokens'"
|
||||
)
|
||||
.bind(new Date(Date.now() - 61 * 60 * 1000).toISOString())
|
||||
.bind(new Date(Date.now() - 25 * 60 * 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)
|
||||
expect((await request('rewardType=PostGameActivity&giftContext=Basketball')).status).toBe(200)
|
||||
expect(await tokens()).toBe(base + 1500)
|
||||
const reopened = await allowance()
|
||||
expect(reopened?.grant_count).toBe(250)
|
||||
expect((reopened?.granted_at ?? '') > (opened ?? '')).toBe(true)
|
||||
})
|
||||
|
||||
test('POST /api/gamerewards/v1/request is 401 without a token, and ignores a typeless ask', async () => {
|
||||
|
||||
Reference in New Issue
Block a user