Issue #10: rewards bootstrapping

This commit is contained in:
Devin Zuczek
2026-07-12 23:53:39 -04:00
parent f6d92ec1e1
commit aef4cc0139
8 changed files with 1166 additions and 150 deletions
@@ -0,0 +1,22 @@
-- Game-reward selections — the three-choice reward the client shows after a
-- challenge or level-up. `/api/gamerewards/v1/request` mints one and pushes it to the
-- player over the notifications hub; `/api/gamerewards/v1/select` consumes it.
--
-- The three offered drop ids are recorded so `select` can verify the player is
-- claiming something they were actually offered, and `consumed` makes the selection
-- single-use. Owned by the `econ` worker; generated from src/rewards-db.ts
-- (SCHEMA_DDL) — keep in sync.
CREATE TABLE IF NOT EXISTS reward_selection (
reward_selection_id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
message TEXT NOT NULL DEFAULT '',
gift_context INTEGER NOT NULL DEFAULT 0,
reward_type INTEGER NOT NULL DEFAULT 0,
gift_drop_1_id INTEGER NOT NULL,
gift_drop_2_id INTEGER NOT NULL,
gift_drop_3_id INTEGER NOT NULL,
consumed INTEGER NOT NULL DEFAULT 0,
created_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_reward_selection_account ON reward_selection (account_id);
+22
View File
@@ -0,0 +1,22 @@
-- Per-player objective progress — the daily/weekly challenge checklist. The client
-- reports progress with `/api/objectives/v1/updateobjective` and reads it back from
-- `/api/objectives/v1/myprogress`.
--
-- An objective is keyed by (account, group, index) — the client's own identifiers —
-- so updates upsert on that triple. `has_claimed_reward` latches on first completion
-- so a reward can't be paid twice. `group`/`index` are SQL keywords, hence the
-- `group_id`/`idx` column names. Owned by the `econ` worker; generated from
-- src/objectives-db.ts (SCHEMA_DDL) — keep in sync.
CREATE TABLE IF NOT EXISTS objective (
account_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
idx INTEGER NOT NULL,
progress REAL NOT NULL DEFAULT 0,
visual_progress REAL NOT NULL DEFAULT 0,
is_completed INTEGER NOT NULL DEFAULT 0,
is_rewarded INTEGER NOT NULL DEFAULT 0,
has_claimed_reward INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (account_id, group_id, idx)
);
CREATE INDEX IF NOT EXISTS idx_objective_account ON objective (account_id);
@@ -0,0 +1,16 @@
-- A player's objective *groups* — the daily/weekly sets their objectives belong to.
-- The client clears a group when it's finished with it (`/api/objectives/v1/cleargroup`),
-- which marks it completed and stamps the clear time; `myprogress` reads the groups
-- back alongside the objectives themselves.
--
-- Keyed by (account, group), the client's own identifier. `group` is a SQL keyword,
-- hence `group_id`. Owned by the `econ` worker; generated from src/objectives-db.ts
-- (SCHEMA_DDL) — keep in sync.
CREATE TABLE IF NOT EXISTS objective_group (
account_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
is_completed INTEGER NOT NULL DEFAULT 0,
cleared_at TEXT,
PRIMARY KEY (account_id, group_id)
);
+325 -74
View File
@@ -58,6 +58,12 @@ import {
} from './consumables-db' } from './consumables-db'
import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db' import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db'
import { getInventory, grantItem, toAvatarItemV4 } from './inventory-db' import { getInventory, grantItem, toAvatarItemV4 } from './inventory-db'
import {
clearObjectiveGroup,
getObjectiveGroups,
getObjectives,
updateObjective,
} from './objectives-db'
import { import {
AUTHED, AUTHED,
AvatarItemV4Dto, AvatarItemV4Dto,
@@ -88,6 +94,7 @@ import {
jsonBody, jsonBody,
JsonObject, JsonObject,
MakerAiFreeTrialEligibilityResponse, MakerAiFreeTrialEligibilityResponse,
ObjectiveGroupDto,
OpaqueJsonBody, OpaqueJsonBody,
OPTIONAL_AUTHED, OPTIONAL_AUTHED,
ReferralProgressResponse, ReferralProgressResponse,
@@ -95,24 +102,35 @@ import {
RRPlusSignUpBonus, RRPlusSignUpBonus,
SaveOutfitRequest, SaveOutfitRequest,
SaveOutfitV4Response, SaveOutfitV4Response,
SelectGameRewardRequest,
SubscriptionResponse, SubscriptionResponse,
UNAUTHORIZED_RESPONSE, UNAUTHORIZED_RESPONSE,
UpdateObjectiveRequest, UpdateObjectiveRequest,
UpdateObjectiveResponse, UpdateObjectiveResponse,
} from './openapi' } from './openapi'
import { claimReward } from './reward-db' import { claimReward } from './reward-db'
import {
consumeRewardSelection,
createRewardSelection,
getRewardSelection,
rollRewardDrops,
tokenRewardDrop,
} from './rewards-db'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain' import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
import type { import type {
BalanceResponsePayload, BalanceResponsePayload,
GiftPackagePayload,
PurchaseBalanceModificationPayload, PurchaseBalanceModificationPayload,
RewardSelectionPayload,
} from '../../notify/src/notification-payloads' } from '../../notify/src/notification-payloads'
import type { Avatar } from './avatar-db' import type { Avatar } from './avatar-db'
import type { ConsumeResult } from './consumables-db' import type { ConsumeResult } from './consumables-db'
import type { App } from './context' import type { App } from './context'
import type { Equipment } from './equipment-db' import type { Equipment } from './equipment-db'
import type { AvatarItem } from './inventory-db' import type { AvatarItem } from './inventory-db'
import type { GameRewardDrop } from './rewards-db'
/** /**
* Economy Worker. Hosts the avatar/economy endpoints the game client calls on * Economy Worker. Hosts the avatar/economy endpoints the game client calls on
@@ -148,6 +166,32 @@ function unauthorized(c: Context<App>) {
return c.body(null, 401) return c.body(null, 401)
} }
/**
* Push a notification to a player over the websocket hub. Rewards are *delivered*
* this way — the HTTP response carries none of it — but a hub that's down shouldn't
* fail the request that already committed, so a delivery failure is logged, not thrown.
*/
async function pushToPlayer(
c: Context<App>,
playerId: number,
notificationType: NotificationType,
data: Record<string, unknown>
): Promise<void> {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId,
notificationType,
data
)
} catch (err) {
logger.error('failed to push notification', {
playerId,
notificationType,
error: err instanceof Error ? err.message : String(err),
})
}
}
/** /**
* A boolean the client may send either as a JSON `true` or as .NET's `bool.ToString()` * A boolean the client may send either as a JSON `true` or as .NET's `bool.ToString()`
* output — `"True"`/`"False"`, capitalized. `Boolean(value)` is a trap here: the string * output — `"True"`/`"False"`, capitalized. `Boolean(value)` is a trap here: the string
@@ -1190,24 +1234,25 @@ const GIFT_CONTEXT_GAME_REWARDS = 50
const DEFAULT_GAME_REWARD_MESSAGE = 'Reward earned!' 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 * The gift box a CHOSEN reward selection hands over. The selection's drop is Rec Room's
* empty on purpose — this is not a purchase and not a roll, so `grantGiftDrop` grants * `GiftDrop` wire shape (what the client was offered); this is the subset `grantGiftDrop`
* nothing into the inventory and only creates the box. The XP is banked in `progression`; * needs to wrap it. Every drop is a token drop for now, so there is nothing to grant into
* the copy here is what the box and its notification display. * the inventory — the tokens are credited by the caller and the box is what the player
* opens. `Xp` is the reward's, so the box carries the same amount banked in `progression`.
*/ */
function toGameRewardDrop(): StoreGiftDrop { function toSelectedRewardDrop(drop: GameRewardDrop): StoreGiftDrop {
return { return {
FriendlyName: '', FriendlyName: drop.FriendlyName,
Tooltip: '', Tooltip: drop.Tooltip,
ConsumableItemDesc: '', ConsumableItemDesc: drop.ConsumableItemDesc,
AvatarItemDesc: '', AvatarItemDesc: drop.AvatarItemDesc,
AvatarItemType: null, AvatarItemType: drop.AvatarItemType,
EquipmentPrefabName: '', EquipmentPrefabName: drop.EquipmentPrefabName,
EquipmentModificationGuid: '', EquipmentModificationGuid: drop.EquipmentModificationGuid,
Rarity: 0, Rarity: drop.Rarity,
Context: GIFT_CONTEXT_GAME_REWARDS, Context: drop.Context,
Currency: 0, Currency: drop.Currency,
CurrencyType: 0, CurrencyType: drop.CurrencyType,
Xp: GAME_REWARD_XP, Xp: GAME_REWARD_XP,
} }
} }
@@ -1606,63 +1651,121 @@ const app = new Hono<App>({ strict: false })
} }
) )
// The player's objectives progress. Serves a static JSON file verbatim with // The player's objectives progress. Their own recorded objectives once they've made
// no auth — same default for everyone until there's a DB binding to track // any (the client reports them through `updateobjective`); the bundled default set
// per-player progress. // otherwise, including for a signed-out caller — the client needs a well-formed
// checklist to render either way.
.get( .get(
'/api/objectives/v1/myprogress', '/api/objectives/v1/myprogress',
describeRoute({ describeRoute({
tags: ['Econ'], tags: ['Econ'],
summary: 'Objectives progress', summary: 'Objectives progress',
description: description: [
'Serves the bundled static progress verbatim (no per-player store yet). No auth.', 'The players own recorded objectives, or the bundled default set when they have',
responses: { 200: json(JsonObject, 'The bundled objectives-progress default') }, 'reported none yet. A signed-out caller gets the default rather than a 401 — the',
'client needs a well-formed checklist either way.',
].join(' '),
security: OPTIONAL_AUTHED,
responses: { 200: json(JsonObject, 'The players objectives, or the bundled default') },
}), }),
(c) => c.json(myProgress) async (c) => {
const id = await authedId(c)
if (id === null) return c.json(myProgress)
const [objectives, groups] = await Promise.all([
getObjectives(c.env.DB, id),
getObjectiveGroups(c.env.DB, id),
])
if (objectives.length === 0 && groups.length === 0) return c.json(myProgress)
return c.json({
Objectives: objectives,
// Fall back to the default groups until the player has cleared one of their own.
ObjectiveGroups: groups.length === 0 ? myProgress.ObjectiveGroups : groups,
})
}
) )
// Clears a group of objectives. No per-player progress to clear yet, so this // The client clearing an objective group — it's done with that set (its dailies
// is a no-op that returns an empty array (a 404 here breaks the client). Accepts // rolled over, say). Auth-gated. Marks the group completed, stamps the clear time,
// GET or POST since the client may use either. // and returns the group as the client reads it. Accepts GET or POST since the client
// may use either, so `Group` is taken from the JSON body or the query string.
.on( .on(
['GET', 'POST'], ['GET', 'POST'],
'/api/objectives/v1/cleargroup', '/api/objectives/v1/cleargroup',
describeRoute({ describeRoute({
tags: ['Econ'], tags: ['Econ'],
summary: 'Clear an objectives group (no-op)', summary: 'Clear an objectives group',
description: 'No per-player progress to clear yet → []. Accepts GET or POST.', description:
responses: { 200: json(JsonArray, 'Always empty for now') }, 'Marks the group completed and stamps `ClearedAt`. Accepts GET or POST; `Group` comes from the JSON body or the query string.',
security: AUTHED,
responses: {
200: json(ObjectiveGroupDto, 'The cleared group'),
401: UNAUTHORIZED_RESPONSE,
},
}), }),
(c) => c.json([]) async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.json().catch(() => ({}))) as Record<string, unknown>
const group = Number(body.Group ?? c.req.query('Group')) || 0
return c.json(await clearObjectiveGroup(c.env.DB, id, group))
}
) )
// Report one objective's progress. The client posts the whole objective as it now // The client reporting progress on an objective as it plays. Auth-gated; the body is
// sees it (Index/Group identify it within `myprogress`) and reads back the state of // the whole objective as the client now sees it (Index/Group identify it within the
// the GROUP that objective belongs to — camelCase here, unlike the PascalCase body it // player's set), and it reads back the state of the GROUP that objective belongs to —
// posted. Stubbed: with no objectives store yet we persist nothing, echo the group // camelCase here, unlike the PascalCase body it posted.
// back and never complete it, so the reward-claim flow isn't triggered. `clearedAt` //
// is the clear time, which for a group we didn't clear is just now. // The completion flag the client posts is `HasClaimedReward` — the same spelling
// `myprogress` serves. `IsRewarded` is accepted as well because the DTO carries that
// name internally, but the client never sends it.
.post( .post(
'/api/objectives/v1/updateobjective', '/api/objectives/v1/updateobjective',
describeRoute({ describeRoute({
tags: ['Econ'], tags: ['Econ'],
summary: 'Report objective progress', summary: 'Report objective progress',
description: [ description: [
'Stubbed: with no objectives store we persist nothing and never complete a group.', 'Upserts the objective on (account, group, index) and answers the state of its group.',
'Echoes `Group` back as camelCase `group` with `isCompleted: false` so the client', '`has_claimed_reward` latches on first completion so a reward cant be paid twice.',
'gets a well-formed body.',
].join(' '), ].join(' '),
security: AUTHED,
requestBody: jsonBody(UpdateObjectiveRequest, 'The objective as the client now sees it'), requestBody: jsonBody(UpdateObjectiveRequest, 'The objective as the client now sees it'),
responses: { 200: json(UpdateObjectiveResponse, 'The echoed group, never completed') }, responses: {
200: json(UpdateObjectiveResponse, 'The state of the group the objective belongs to'),
400: { description: 'Body was not JSON' },
401: UNAUTHORIZED_RESPONSE,
},
}), }),
async (c) => { async (c) => {
const body = await c.req const id = await authedId(c)
.json<{ Group?: string | number }>() if (id === null) return unauthorized(c)
.catch(() => ({}) as Record<string, never>)
const body = (await c.req.json().catch(() => null)) as Record<string, unknown> | null
if (body === null) return c.body(null, 400)
const num = (v: unknown): number => (typeof v === 'number' ? v : 0)
const bool = (v: unknown): boolean => v === true
const group = num(body.Group)
await updateObjective(c.env.DB, id, {
Group: group,
Index: num(body.Index),
Progress: num(body.Progress),
VisualProgress: num(body.VisualProgress),
IsCompleted: bool(body.IsCompleted),
IsRewarded: bool(body.HasClaimedReward ?? body.IsRewarded),
})
// Read the group back rather than echoing the request: the client re-renders the
// checklist from this, so a group the player already cleared must come back cleared.
const stored = (await getObjectiveGroups(c.env.DB, id)).find((g) => g.Group === group)
return c.json({ return c.json({
group: Number(body.Group) || 0, group,
isCompleted: false, isCompleted: stored?.IsCompleted ?? false,
clearedAt: new Date().toISOString(), clearedAt: stored?.ClearedAt ?? new Date().toISOString(),
}) })
} }
) )
@@ -2978,15 +3081,18 @@ const app = new Hono<App>({ strict: false })
// completed!&giftContext=Soccer`) — so whether a reward is actually OWED is decided // completed!&giftContext=Soccer`) — so whether a reward is actually OWED is decided
// here, from `reward_status`: one claim per type per activity per hour, atomically. // here, from `reward_status`: one claim per type per activity per hour, atomically.
// //
// A claim pays GAME_REWARD_XP into `progression` and hands over a gift box carrying that // What a claim hands over is a CHOICE, not a payout. The reference offers three drops and
// XP, announced with the same GiftPackageReceivedImmediate frame the weekly gift uses // lets the player pick one, so an owed reward mints a `reward_selection` and pushes the
// the client posted the message to show, so the box wears it. An on-cooldown ask changes // three options as `RewardSelectionReceived`; nothing is paid until the player picks with
// nothing and pays nothing. // `v1/select`. The HTTP response therefore carries none of it — it is the `{ error,
// success, value }` envelope the reference answers this flow with. An on-cooldown ask
// mints nothing, pushes nothing, and pays nothing.
// //
// The response stays `[]` either way. It is what the client already accepts, and the box // The cooldown key is the type and the activity AS THE CLIENT SPELLS THEM (strings), which
// is how a reward is delivered, so there is no captured shape to put the payout in the // is what `reward_status` stores. The frame's `RewardType`/`GiftContext` are numeric in the
// reference answers its own (different, selection-based) flow with a success envelope, // client's decoder and there is no captured mapping from those names to their ids, so they
// not a list of rewards. // carry the numeric form when the client sends one and fall back to `GameRewards` (50)
// otherwise — the same context the gift boxes on this worker already use.
// //
// `giftContext` (the activity, e.g. `Soccer`) is part of the cooldown key: the first // `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 // activity of the day is per ACTIVITY, so a player who moves from Soccer to Paintball is
@@ -2999,15 +3105,16 @@ const app = new Hono<App>({ strict: false })
summary: 'Request a game reward', summary: 'Request a game reward',
description: [ description: [
'Claims one reward of `rewardType` in `giftContext` per hour per player, recorded in', 'Claims one reward of `rewardType` in `giftContext` per hour per player, recorded in',
'`reward_status`. The cooldown is per (type, activity), so a different activity is', '`reward_status`. An owed claim mints a three-drop `reward_selection` and pushes it as',
'owed another reward while the same one is not; an ask with no `giftContext` keys on', '`RewardSelectionReceived`; the player picks one with `/api/gamerewards/v1/select`.',
'the empty context. The reward rides in a gift box, so a claim and a rejected', 'The cooldown is per (type, activity), so a different activity is owed another reward',
'(on-cooldown) ask both answer `[]`.', 'while the same one is not; an ask with no `giftContext` keys on the empty context.',
'The choices ride on the hub, so a claim and an on-cooldown ask answer the same envelope.',
].join(' '), ].join(' '),
security: AUTHED, security: AUTHED,
requestBody: form(GameRewardRequest, 'The reward type and its display message'), requestBody: form(GameRewardRequest, 'The reward type and its display message'),
responses: { responses: {
200: json(JsonArray, 'The rewards granted — always [] while the payload is stubbed'), 200: json(ConsumeEnvelope, 'Success envelope — the choices are pushed, not returned'),
401: UNAUTHORIZED_RESPONSE, 401: UNAUTHORIZED_RESPONSE,
}, },
}), }),
@@ -3017,37 +3124,181 @@ const app = new Hono<App>({ strict: false })
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>) const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const rewardType = typeof body.rewardType === 'string' ? body.rewardType : '' const rewardType = typeof body.rewardType === 'string' ? body.rewardType : ''
// No type, nothing to gate: don't write a row keyed on an empty string. // No type, nothing to gate: don't write a row keyed on an empty string.
if (rewardType === '') return c.json([]) if (rewardType === '') return c.json({ error: '', success: true, value: null })
const giftContext = typeof body.giftContext === 'string' ? body.giftContext : '' const giftContext = typeof body.giftContext === 'string' ? body.giftContext : ''
const claimed = await claimReward(c.env.DB, id, rewardType, giftContext) const claimed = await claimReward(c.env.DB, id, rewardType, giftContext)
// On cooldown: nothing was claimed, so nothing is paid and nothing is announced. // On cooldown: nothing was claimed, so no selection is minted and nothing is announced.
if (claimed === null) return c.json([]) if (claimed === null) return c.json({ error: '', success: true, value: null })
const message = const message =
typeof body.Message === 'string' && body.Message !== '' typeof body.Message === 'string' && body.Message !== ''
? body.Message ? body.Message
: DEFAULT_GAME_REWARD_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. // The numeric forms the hub frame carries (see the note above).
const { progression, levelsGained } = await addXp(c.env.DB, id, GAME_REWARD_XP) const contextId = Number.parseInt(giftContext, 10) || GIFT_CONTEXT_GAME_REWARDS
const granted = await grantGiftDrop(c, id, toGameRewardDrop(), message) const rewardTypeId = Number.parseInt(rewardType, 10) || 0
await pushGiftReceived(c, id, granted, message, COACH_ACCOUNT_ID)
// Every grant moves the bar, whether or not it crossed a level. const drops = rollRewardDrops(contextId)
await pushProgressionUpdate(c, id, progression) const selection = await createRewardSelection(c.env.DB, id, {
// …and every level crossed is worth a box of its own tier. message,
await grantLevelUpGifts(c, id, { progression, levelsGained }) giftContext: contextId,
logger.info('game reward claimed', { rewardType: rewardTypeId,
dropIds: drops.map((d) => d.GiftDropId),
})
// `satisfies` rather than an annotation: the hub takes a Record<string, unknown> and an
// interface has no implicit index signature, but every key is still checked against the
// shape the client's decoder parses.
const payload = {
RewardSelectionId: selection.RewardSelectionId,
RewardType: rewardTypeId,
Message: message,
GiftContext: contextId,
GiftDrop1: drops[0],
GiftDrop2: drops[1],
GiftDrop3: drops[2],
// The reference sends the third drop twice — once plain, once under the
// subscriber key. With no subscriber-only drop pool the two are the same drop.
Subscriber_GiftDrop3: drops[2],
CreatedAt: selection.CreatedAt,
} satisfies RewardSelectionPayload
await pushToPlayer(c, id, NotificationType.RewardSelectionReceived, payload)
logger.info('game reward selection offered', {
accountId: id, accountId: id,
rewardType, rewardType,
giftContext, giftContext,
grantCount: claimed, grantCount: claimed,
message, message,
rewardSelectionId: selection.RewardSelectionId,
dropIds: selection.GiftDropIds,
})
return c.json({ error: '', success: true, value: null })
}
)
// Claim one of the three rewards a selection offered. [Authorize]. The selection must be
// the caller's, unconsumed, and must actually contain the claimed drop — otherwise 403, so
// a player can't mint a reward they were never offered or redeem one twice.
//
// This is where a game reward is finally PAID: the chosen drop's tokens are credited, the
// reward's XP goes into `progression`, and the drop is wrapped in a gift box so the client
// has something to open. The box is announced with GiftPackageRewardSelectionReceived (32)
// — the gift-package frame for a box that came from a selection, as opposed to the
// Immediate (31) one a weekly gift or a direct grant uses.
//
// Every drop is a token drop for now, and a token drop's id is the NEGATIVE of its amount,
// which is how the claim rebuilds it without a catalog lookup.
.post(
'/api/gamerewards/v1/select',
describeRoute({
tags: ['Econ'],
summary: 'Claim one of an offered reward selection',
description: [
'Consumes the `reward_selection` minted by `/api/gamerewards/v1/request` and pays the',
'chosen drop: its tokens are credited, `GAME_REWARD_XP` is banked, and a gift box is',
'created and announced as `GiftPackageRewardSelectionReceived`. 403 when the selection',
'isnt the callers, is already consumed, or never offered the claimed drop — the',
'consume is conditional, so two racing claims mean the second one loses.',
].join(' '),
security: AUTHED,
requestBody: form(SelectGameRewardRequest, 'The selection and the drop being claimed'),
responses: {
200: json(JsonObject, 'The claimed gift-drop'),
400: { description: '`giftDropId` was missing' },
401: UNAUTHORIZED_RESPONSE,
403: { description: 'Not the callers selection, already consumed, or not offered' },
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
const int = (name: string): number => {
const key = Object.keys(body).find((k) => k.toLowerCase() === name.toLowerCase())
const v = key === undefined ? undefined : body[key]
return typeof v === 'string' ? Number.parseInt(v, 10) || 0 : 0
}
const rewardSelectionId = int('rewardSelectionId')
const giftDropId = int('giftDropId')
if (giftDropId === 0) return c.json({ error: 'giftDropId is required' }, 400)
const selection =
rewardSelectionId <= 0 ? null : await getRewardSelection(c.env.DB, rewardSelectionId)
if (
selection === null ||
selection.AccountId !== id ||
selection.Consumed ||
!selection.GiftDropIds.includes(giftDropId)
) {
return c.body(null, 403)
}
// Consume conditionally: two racing claims mean the second one loses.
if (!(await consumeRewardSelection(c.env.DB, selection.RewardSelectionId))) {
return c.body(null, 403)
}
const drop = tokenRewardDrop(-giftDropId, selection.GiftContext)
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
// Credit BEFORE the box: the box is only the "you got something" panel, and opening one
// grants nothing (see /api/avatar/v2/gifts/consume). A box promising tokens that were
// never credited would read as a reward that silently paid nothing.
await ensureStartingBalances(c.env.DB, id, startingTokens)
const balance = await creditCurrency(
c.env.DB,
id,
CurrencyType.RecCenterTokens,
drop.Currency,
startingTokens
)
// The frame carries the RESULTING total, never the payout — see the balance-bucket
// note in CLAUDE.md.
await pushBalanceUpdate(c, id, CurrencyType.RecCenterTokens, balance)
// The reward's XP is the same GAME_REWARD_XP the flow was always worth; the tokens are
// what the player CHOSE on top of it.
const { progression, levelsGained } = await addXp(c.env.DB, id, GAME_REWARD_XP)
const granted = await grantGiftDrop(c, id, toSelectedRewardDrop(drop), selection.Message)
const payload = {
Id: granted.id,
FromPlayerId: COACH_ACCOUNT_ID,
ConsumableItemDesc: drop.ConsumableItemDesc,
AvatarItemType: drop.AvatarItemType,
AvatarItemDesc: drop.AvatarItemDesc,
EquipmentPrefabName: drop.EquipmentPrefabName,
EquipmentModificationGuid: drop.EquipmentModificationGuid,
CurrencyType: drop.CurrencyType,
Currency: drop.Currency,
Xp: GAME_REWARD_XP,
GiftContext: selection.GiftContext,
GiftRarity: drop.Rarity,
Message: selection.Message,
Platform: -1,
PlatformsToSpawnOn: -1,
BalanceType: ALL_PLATFORMS,
} satisfies GiftPackagePayload
await pushToPlayer(c, id, NotificationType.GiftPackageRewardSelectionReceived, payload)
// 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.
await grantLevelUpGifts(c, id, { progression, levelsGained })
logger.info('game reward selected', {
accountId: id,
rewardSelectionId: selection.RewardSelectionId,
giftDropId,
tokens: drop.Currency,
balance,
xp: GAME_REWARD_XP, xp: GAME_REWARD_XP,
level: progression.Level, level: progression.Level,
levelsGained, levelsGained,
levelXp: progression.XP,
giftId: granted.id, giftId: granted.id,
}) })
return c.json([]) return c.json(drop)
} }
) )
+187
View File
@@ -0,0 +1,187 @@
/**
* Per-player objective progress — the daily/weekly challenge checklist the client
* shows. The client reports progress as it plays (`/api/objectives/v1/updateobjective`)
* and reads it back on load (`/api/objectives/v1/myprogress`).
*
* An objective is identified by its (group, index) within a player's set, so updates
* upsert on that triple rather than allocating ids. `has_claimed_reward` is latched
* the first time an objective completes — the reference awards progression XP at that
* moment, and the flag is what stops it being awarded twice.
*/
/** Schema DDL (mirror of migrations/0015_objective.sql and 0016_objective_group.sql). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS objective (
account_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
idx INTEGER NOT NULL,
progress REAL NOT NULL DEFAULT 0,
visual_progress REAL NOT NULL DEFAULT 0,
is_completed INTEGER NOT NULL DEFAULT 0,
is_rewarded INTEGER NOT NULL DEFAULT 0,
has_claimed_reward INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (account_id, group_id, idx)
)`,
`CREATE INDEX IF NOT EXISTS idx_objective_account ON objective (account_id)`,
// A player's objective *groups* — the daily/weekly sets. The client clears a group
// once it's done with it (`cleargroup`), which stamps `cleared_at`.
`CREATE TABLE IF NOT EXISTS objective_group (
account_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
is_completed INTEGER NOT NULL DEFAULT 0,
cleared_at TEXT,
PRIMARY KEY (account_id, group_id)
)`,
]
/** One objective's progress, as the client reads it back from `myprogress`. */
export interface Objective {
Group: number
Index: number
Progress: number
VisualProgress: number
IsCompleted: boolean
HasClaimedReward: boolean
}
/** What the client posts when it makes progress on an objective. */
export interface ObjectiveUpdate {
Group: number
Index: number
Progress: number
VisualProgress: number
IsCompleted: boolean
IsRewarded: boolean
}
/**
* Record progress on an objective. Upserts on (account, group, index).
* `has_claimed_reward` latches on the first completion and never unlatches, so an
* objective that completes twice (or is replayed by the client) only ever pays out
* once. Returns true when this call is the one that completed it.
*/
export async function updateObjective(
db: D1Database,
accountId: number,
update: ObjectiveUpdate
): Promise<boolean> {
const existing = await db
.prepare(
`SELECT is_completed, has_claimed_reward FROM objective
WHERE account_id = ?1 AND group_id = ?2 AND idx = ?3`
)
.bind(accountId, update.Group, update.Index)
.first<{ is_completed: number; has_claimed_reward: number }>()
const wasCompleted = existing?.is_completed === 1
const newlyCompleted = update.IsCompleted && !wasCompleted
const hasClaimedReward = existing?.has_claimed_reward === 1 || newlyCompleted
await db
.prepare(
`INSERT INTO objective
(account_id, group_id, idx, progress, visual_progress,
is_completed, is_rewarded, has_claimed_reward)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(account_id, group_id, idx) DO UPDATE SET
progress = ?4,
visual_progress = ?5,
is_completed = ?6,
is_rewarded = ?7,
has_claimed_reward = ?8`
)
.bind(
accountId,
update.Group,
update.Index,
update.Progress,
update.VisualProgress,
update.IsCompleted ? 1 : 0,
update.IsRewarded ? 1 : 0,
hasClaimedReward ? 1 : 0
)
.run()
return newlyCompleted
}
/** An objective group's state, as `myprogress` and `cleargroup` report it. */
export interface ObjectiveGroup {
Group: number
IsCompleted: boolean
ClearedAt: string
}
/**
* Clear an objective group — the client saying it's finished with that set (its
* dailies rolled over, say). Stamps the clear time and marks the group completed,
* returning the group as the client reads it back.
*
* The group's individual objectives are deliberately left in place: the client still
* renders what was achieved, and `updateobjective` overwrites them by (group, index)
* when the next set is issued.
*/
export async function clearObjectiveGroup(
db: D1Database,
accountId: number,
group: number
): Promise<ObjectiveGroup> {
const clearedAt = new Date().toISOString()
await db
.prepare(
`INSERT INTO objective_group (account_id, group_id, is_completed, cleared_at)
VALUES (?1, ?2, 1, ?3)
ON CONFLICT(account_id, group_id) DO UPDATE SET is_completed = 1, cleared_at = ?3`
)
.bind(accountId, group, clearedAt)
.run()
return { Group: group, IsCompleted: true, ClearedAt: clearedAt }
}
/** A player's objective groups, or an empty list when they've cleared none. */
export async function getObjectiveGroups(
db: D1Database,
accountId: number
): Promise<ObjectiveGroup[]> {
const { results } = await db
.prepare(
`SELECT group_id, is_completed, cleared_at FROM objective_group
WHERE account_id = ?1 ORDER BY group_id`
)
.bind(accountId)
.all<{ group_id: number; is_completed: number; cleared_at: string | null }>()
return results.map((r) => ({
Group: r.group_id,
IsCompleted: r.is_completed === 1,
ClearedAt: r.cleared_at ?? '',
}))
}
/** A player's objectives, or an empty list when they've made no progress yet. */
export async function getObjectives(db: D1Database, accountId: number): Promise<Objective[]> {
const { results } = await db
.prepare(
`SELECT group_id, idx, progress, visual_progress, is_completed, has_claimed_reward
FROM objective WHERE account_id = ?1
ORDER BY group_id, idx`
)
.bind(accountId)
.all<{
group_id: number
idx: number
progress: number
visual_progress: number
is_completed: number
has_claimed_reward: number
}>()
return results.map((r) => ({
Group: r.group_id,
Index: r.idx,
Progress: r.progress,
VisualProgress: r.visual_progress,
IsCompleted: r.is_completed === 1,
HasClaimedReward: r.has_claimed_reward === 1,
}))
}
+27 -3
View File
@@ -281,9 +281,21 @@ export const ChallengeProgressResponse = z.object({
* `ObjectiveGroups` entries `myprogress` serves — three spellings of the same group. * `ObjectiveGroups` entries `myprogress` serves — three spellings of the same group.
*/ */
export const UpdateObjectiveResponse = z.object({ export const UpdateObjectiveResponse = z.object({
group: z.int().describe('Echoed back from the request'), group: z.int().describe('The group the reported objective belongs to'),
isCompleted: z.boolean().describe('Always false — no objectives store yet'), isCompleted: z.boolean().describe('Whether that group has been cleared'),
clearedAt: z.string().describe('When the group was cleared — now, since nothing persists'), clearedAt: z.string().describe('When the group was cleared'),
})
/**
* An objective GROUP as the client reads it back — the PascalCase shape served inside
* `myprogress` and returned by `cleargroup`. Note `updateobjective` answers the same
* three facts in camelCase (`UpdateObjectiveResponse`); the client parses both, so the
* two spellings are deliberate rather than an inconsistency to clean up.
*/
export const ObjectiveGroupDto = z.object({
Group: z.int().describe('The clients own group identifier'),
IsCompleted: z.boolean(),
ClearedAt: z.string().nullable().describe('ISO-8601; null before the group is cleared'),
}) })
/** /**
@@ -503,6 +515,18 @@ export const GameRewardRequest = z.object({
.describe('The activity it came from, e.g. `Soccer` — part of the cooldown key'), .describe('The activity it came from, e.g. `Soccer` — part of the cooldown key'),
}) })
/**
* `POST /api/gamerewards/v1/select` form body — which of the three offered drops the
* player picked. Both ids are read case-insensitively: the client's casing for these is
* not pinned down, and a mis-cased field would silently read as 0 and 403 the claim.
*/
export const SelectGameRewardRequest = z.object({
rewardSelectionId: z.string().describe('The `reward_selection` being claimed against'),
giftDropId: z
.string()
.describe('The chosen drop; must be one of the three the selection offered'),
})
/** /**
* `POST /api/objectives/v1/updateobjective` JSON body — one objective's state as the * `POST /api/objectives/v1/updateobjective` JSON body — one objective's state as the
* client now sees it. `Index`/`Group` identify it within `myprogress`; the rest is the * client now sees it. `Index`/`Group` identify it within `myprogress`; the rest is the
+201
View File
@@ -0,0 +1,201 @@
/**
* Game-reward selections — the three-choice reward the client shows after a
* challenge/level-up. `/api/gamerewards/v1/request` mints a selection and pushes it
* to the player over the notifications hub (the HTTP response carries nothing); the
* player then picks one with `/api/gamerewards/v1/select`, which consumes it.
*
* The three offered drops are recorded so `select` can verify the player is claiming
* a drop they were actually offered, and `consumed` makes a selection single-use — a
* player can't redeem the same reward twice.
*
* There's no reward-drop catalog (avatar items, consumables) yet, so every offered
* drop is a token choice. That's the reference's own fallback path when it runs out
* of drops: a token drop's id is the negative of its amount, which is how `select`
* reconstructs it without a catalog lookup.
*/
/** Schema DDL (mirror of migrations/0014_reward_selection.sql). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS reward_selection (
reward_selection_id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
message TEXT NOT NULL DEFAULT '',
gift_context INTEGER NOT NULL DEFAULT 0,
reward_type INTEGER NOT NULL DEFAULT 0,
gift_drop_1_id INTEGER NOT NULL,
gift_drop_2_id INTEGER NOT NULL,
gift_drop_3_id INTEGER NOT NULL,
consumed INTEGER NOT NULL DEFAULT 0,
created_at TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_reward_selection_account ON reward_selection (account_id)`,
]
/** One of the three rewards a player is offered (Rec Room's `GiftDrop` wire shape). */
export interface GameRewardDrop {
GiftDropId: number
FriendlyName: string
Tooltip: string
ConsumableItemDesc: string
AvatarItemDesc: string
AvatarItemType: number
EquipmentPrefabName: string
EquipmentModificationGuid: string
IsQuery: boolean
Unique: boolean
SubscribersOnly: boolean
Rarity: number
CurrencyType: number
Currency: number
Context: number
ItemSetId: number
ItemSetFriendlyName: string
}
/** The token amounts a reward choice can be worth. */
const TOKEN_AMOUNTS = [10, 25, 50, 100, 250, 500]
/**
* A token reward choice. The drop id is the *negative* of the amount, which is how a
* token drop is told apart from a catalog drop (positive id) and how `select` rebuilds
* it — the reference does the same.
*/
export function tokenRewardDrop(amount: number, context: number): GameRewardDrop {
return {
GiftDropId: -amount,
FriendlyName: `${amount} Tokens!`,
Tooltip: 'Winner!',
ConsumableItemDesc: '',
AvatarItemDesc: '',
AvatarItemType: 0,
EquipmentPrefabName: '',
EquipmentModificationGuid: '',
IsQuery: false,
Unique: false,
SubscribersOnly: false,
Rarity: 0,
CurrencyType: 2, // RecCenterTokens
Currency: amount,
Context: context,
ItemSetId: 1,
ItemSetFriendlyName: '',
}
}
/** Three distinct token choices for a reward selection. */
export function rollRewardDrops(context: number): GameRewardDrop[] {
const amounts = [...TOKEN_AMOUNTS]
const picked: number[] = []
for (let i = 0; i < 3; i++) {
const [amount] = amounts.splice(Math.floor(Math.random() * amounts.length), 1)
picked.push(amount)
}
return picked.map((amount) => tokenRewardDrop(amount, context))
}
/** A stored reward selection — the three drops offered to a player, and whether they picked. */
export interface RewardSelection {
RewardSelectionId: number
AccountId: number
Message: string
GiftContext: number
RewardType: number
GiftDropIds: number[]
Consumed: boolean
CreatedAt: string
}
/** Record a reward selection (the three drops a player was offered). */
export async function createRewardSelection(
db: D1Database,
accountId: number,
input: { message: string; giftContext: number; rewardType: number; dropIds: number[] }
): Promise<RewardSelection> {
const createdAt = new Date().toISOString()
const row = await db
.prepare(
`INSERT INTO reward_selection
(account_id, message, gift_context, reward_type,
gift_drop_1_id, gift_drop_2_id, gift_drop_3_id, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
RETURNING reward_selection_id`
)
.bind(
accountId,
input.message,
input.giftContext,
input.rewardType,
input.dropIds[0],
input.dropIds[1],
input.dropIds[2],
createdAt
)
.first<{ reward_selection_id: number }>()
return {
RewardSelectionId: row?.reward_selection_id ?? 0,
AccountId: accountId,
Message: input.message,
GiftContext: input.giftContext,
RewardType: input.rewardType,
GiftDropIds: input.dropIds,
Consumed: false,
CreatedAt: createdAt,
}
}
/** Look up a reward selection by id, or null when there's no such row. */
export async function getRewardSelection(
db: D1Database,
rewardSelectionId: number
): Promise<RewardSelection | null> {
const row = await db
.prepare(
`SELECT reward_selection_id, account_id, message, gift_context, reward_type,
gift_drop_1_id, gift_drop_2_id, gift_drop_3_id, consumed, created_at
FROM reward_selection WHERE reward_selection_id = ?1`
)
.bind(rewardSelectionId)
.first<{
reward_selection_id: number
account_id: number
message: string
gift_context: number
reward_type: number
gift_drop_1_id: number
gift_drop_2_id: number
gift_drop_3_id: number
consumed: number
created_at: string | null
}>()
if (row === null) return null
return {
RewardSelectionId: row.reward_selection_id,
AccountId: row.account_id,
Message: row.message,
GiftContext: row.gift_context,
RewardType: row.reward_type,
GiftDropIds: [row.gift_drop_1_id, row.gift_drop_2_id, row.gift_drop_3_id],
Consumed: row.consumed === 1,
CreatedAt: row.created_at ?? '',
}
}
/**
* Mark a selection consumed. Returns false when it was already consumed — the
* conditional update is what makes a reward single-use even if the client sends the
* same claim twice.
*/
export async function consumeRewardSelection(
db: D1Database,
rewardSelectionId: number
): Promise<boolean> {
const result = await db
.prepare(
'UPDATE reward_selection SET consumed = 1 WHERE reward_selection_id = ?1 AND consumed = 0'
)
.bind(rewardSelectionId)
.run()
return (result.meta.changes ?? 0) > 0
}
+366 -73
View File
@@ -34,7 +34,9 @@ import { CHALLENGE_GIFT_SCHEMA_DDL, CHALLENGE_STATUS_SCHEMA_DDL } from '../../ch
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db' import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db' import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db'
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db' import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
import { SCHEMA_DDL as OBJECTIVES_SCHEMA_DDL } from '../../objectives-db'
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db' import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
import { SCHEMA_DDL as REWARDS_SCHEMA_DDL } from '../../rewards-db'
import type { Env } from '../../context' import type { Env } from '../../context'
@@ -65,6 +67,10 @@ beforeAll(async () => {
for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of INVENTORY_INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Reward selections (owned by this worker) — game rewards record what was offered.
for (const stmt of REWARDS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Objectives (owned by this worker) — per-player challenge progress.
for (const stmt of OBJECTIVES_SCHEMA_DDL) await env.DB.prepare(stmt).run()
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)') await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' })) .bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
.run() .run()
@@ -381,43 +387,143 @@ describe('econ endpoints', () => {
expect(Array.isArray(body.ObjectiveGroups)).toBe(true) expect(Array.isArray(body.ObjectiveGroups)).toBe(true)
}) })
test('objectives/v1/cleargroup returns [] for GET and POST (no auth)', async () => { test('POST /api/objectives/v1/updateobjective records progress; myprogress reads it back', async () => {
for (const method of ['GET', 'POST'] as const) { type Progress = {
const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/cleargroup`, { method }) Objectives: Array<{
Group: number
Index: number
Progress: number
VisualProgress: number
IsCompleted: boolean
HasClaimedReward: boolean
}>
ObjectiveGroups: unknown[]
}
const update = async (body: unknown, sub = '4242'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/api/objectives/v1/updateobjective`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
const progress = async (sub = '4242'): Promise<Progress> => {
const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/myprogress`, {
headers: await bearer(sub),
})
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual([]) return (await res.json()) as Progress
} }
})
test('POST /api/objectives/v1/updateobjective echoes the group, never completed', async () => { // Partial progress on one objective.
const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/updateobjective`, { const res = await update({
method: 'POST', Group: 0,
headers: { 'content-type': 'application/json' }, Index: 2,
body: JSON.stringify({ Progress: 0.5,
Index: 2, VisualProgress: 0.5,
Group: 3, IsCompleted: false,
Progress: 1, IsRewarded: false,
VisualProgress: 0,
IsCompleted: true,
HasClaimedReward: false,
}),
}) })
expect(res.status).toBe(200) expect(res.status).toBe(200)
const body = (await res.json()) as { group: number; isCompleted: boolean; clearedAt: string }
expect(body.group).toBe(3) const mid = await progress()
expect(body.isCompleted).toBe(false) expect(mid.Objectives).toEqual([
expect(Number.isNaN(Date.parse(body.clearedAt))).toBe(false) {
Group: 0,
Index: 2,
Progress: 0.5,
VisualProgress: 0.5,
IsCompleted: false,
HasClaimedReward: false,
},
])
// The default groups still ride along.
expect(mid.ObjectiveGroups.length).toBeGreaterThan(0)
// Completing it latches HasClaimedReward — the reward can only be paid once.
await update({
Group: 0,
Index: 2,
Progress: 1,
VisualProgress: 1,
IsCompleted: true,
IsRewarded: false,
})
const done = await progress()
expect(done.Objectives[0]).toMatchObject({ IsCompleted: true, HasClaimedReward: true })
// A second objective is tracked separately, keyed by (group, index).
await update({
Group: 1,
Index: 0,
Progress: 0.25,
VisualProgress: 0.25,
IsCompleted: false,
IsRewarded: false,
})
expect((await progress()).Objectives.map((o) => [o.Group, o.Index])).toEqual([
[0, 2],
[1, 0],
])
// Another player's progress is their own; a signed-out reader gets the default set.
expect((await progress('4243')).Objectives.length).toBeGreaterThanOrEqual(0)
const anon = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/myprogress`)
expect(anon.status).toBe(200)
// Auth-gated.
const noToken = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/updateobjective`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Group: 0, Index: 0 }),
})
expect(noToken.status).toBe(401)
}) })
test('POST /api/objectives/v1/updateobjective tolerates a non-JSON body', async () => { test('POST /api/objectives/v1/cleargroup clears the group; myprogress reports it', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/updateobjective`, { const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/cleargroup`, {
method: 'POST',
headers: { ...(await bearer('4444')), 'Content-Type': 'application/json' },
body: JSON.stringify({ Group: 1 }),
})
expect(res.status).toBe(200)
const cleared = (await res.json()) as {
Group: number
IsCompleted: boolean
ClearedAt: string
}
expect(cleared).toMatchObject({ Group: 1, IsCompleted: true })
expect(typeof cleared.ClearedAt).toBe('string')
// The cleared group comes back on the player's progress.
const progress = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/myprogress`, {
headers: await bearer('4444'),
})
const body = (await progress.json()) as { ObjectiveGroups: Array<{ Group: number }> }
expect(body.ObjectiveGroups.map((g) => g.Group)).toEqual([1])
// Auth-gated.
const anon = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/cleargroup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Group: 1 }),
})
expect(anon.status).toBe(401)
})
test('POST /api/objectives/v1/updateobjective is auth-gated and 400s on a non-JSON body', async () => {
// No token: the objective belongs to a player, so there is nobody to record it against.
const anon = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/updateobjective`, {
method: 'POST', method: 'POST',
body: 'not json', body: 'not json',
}) })
expect(res.status).toBe(200) expect(anon.status).toBe(401)
const body = (await res.json()) as { group: number; isCompleted: boolean }
expect(body.group).toBe(0) // Authenticated but unparseable: nothing to upsert, so this is the client's error.
expect(body.isCompleted).toBe(false) const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/updateobjective`, {
method: 'POST',
headers: await bearer('4646'),
body: 'not json',
})
expect(res.status).toBe(400)
}) })
test('GET /api/checklist/v1|v2/current 401s without a token, serves the NUX list with one', async () => { test('GET /api/checklist/v1|v2/current 401s without a token, serves the NUX list with one', async () => {
@@ -2211,12 +2317,12 @@ describe('econ endpoints', () => {
.bind(rewardType, giftContext) .bind(rewardType, giftContext)
.first<{ granted_at: string; grant_count: number }>() .first<{ granted_at: string; grant_count: number }>()
// A claim answers the empty list the client accepts — the reward rides in a gift box. // A claim answers the envelope — the three choices ride out on the hub, not in the body.
const first = await request( const first = await request(
'rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day' 'rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day'
) )
expect(first.status).toBe(200) expect(first.status).toBe(200)
expect(await first.json()).toEqual([]) expect(await first.json()).toEqual({ error: '', success: true, value: null })
const claimed = await statusOf('FirstActivityOfDay') const claimed = await statusOf('FirstActivityOfDay')
expect(claimed?.grant_count).toBe(1) expect(claimed?.grant_count).toBe(1)
@@ -2265,7 +2371,7 @@ describe('econ endpoints', () => {
expect((await statusOf('FirstActivityOfDay'))?.grant_count).toBe(2) expect((await statusOf('FirstActivityOfDay'))?.grant_count).toBe(2)
}) })
test('a claimed game reward pays XP into a gift box, and announces it', async () => { test('a game reward offers three choices, and picking one pays it', async () => {
const request = async (body: string) => const request = async (body: string) =>
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, { exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST', method: 'POST',
@@ -2275,6 +2381,24 @@ describe('econ endpoints', () => {
}, },
body, body,
}) })
const select = async (fields: Record<string, string>, sub = '82'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/select`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields).toString(),
})
/** The selection most recently offered to 82, and the three drops it holds. */
const latestSelection = async () =>
env.DB.prepare(
`SELECT reward_selection_id, gift_drop_1_id, gift_drop_2_id, gift_drop_3_id
FROM reward_selection WHERE account_id = 82
ORDER BY reward_selection_id DESC LIMIT 1`
).first<{
reward_selection_id: number
gift_drop_1_id: number
gift_drop_2_id: number
gift_drop_3_id: number
}>()
/** Age the cooldown so the next ask is eligible again. */ /** Age the cooldown so the next ask is eligible again. */
const passAnHour = () => const passAnHour = () =>
env.DB.prepare( env.DB.prepare(
@@ -2285,45 +2409,110 @@ describe('econ endpoints', () => {
await drainFrames() await drainFrames()
expect((await getProgression(env.DB, 82)).XP).toBe(0) expect((await getProgression(env.DB, 82)).XP).toBe(0)
// Asking mints a selection and announces the three choices. Nothing is paid yet: no
// XP, no box, no tokens — the player hasn't picked.
const res = await request('rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day') const res = await request('rewardType=FirstActivityOfDay&Message=First%20Game%20of%20the%20Day')
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual([]) expect(await res.json()).toEqual({ error: '', success: true, value: null })
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 0 })
expect(await giftBoxes('82')).toHaveLength(0)
// 5 XP is deliberately less than the 10 the first level costs, so one action moves the const offerFrames = await drainFrames()
expect(offerFrames.map((f) => f.notificationType)).toEqual([
NotificationType.RewardSelectionReceived,
])
const offer = offerFrames[0]!
expect(offer.accountId).toBe(82)
expect(offer.payload).toMatchObject({
Message: 'First Game of the Day',
// No numeric giftContext was sent, so the frame falls back to GiftContext.GameRewards.
GiftContext: 50,
})
// Three distinct token choices, plus the subscriber duplicate of the third.
const drops = [
offer.payload.GiftDrop1,
offer.payload.GiftDrop2,
offer.payload.GiftDrop3,
] as Array<{ GiftDropId: number; Currency: number }>
expect(new Set(drops.map((d) => d.GiftDropId)).size).toBe(3)
expect(offer.payload.Subscriber_GiftDrop3).toEqual(offer.payload.GiftDrop3)
// An on-cooldown ask offers nothing at all — no second selection, no frame.
const before = await latestSelection()
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
expect((await latestSelection())?.reward_selection_id).toBe(before?.reward_selection_id)
expect(await drainFrames()).toEqual([])
// Picking one pays it: the tokens are credited, the XP banked, and a box created.
const chosen = before!.gift_drop_2_id
const tokensBefore = await getBalance(
env.DB,
82,
CurrencyType.RecCenterTokens,
DEFAULT_STARTING_TOKENS
)
const claim = await select({
rewardSelectionId: String(before!.reward_selection_id),
giftDropId: String(chosen),
})
expect(claim.status).toBe(200)
expect(await claim.json()).toMatchObject({
GiftDropId: chosen,
CurrencyType: CurrencyType.RecCenterTokens,
Currency: -chosen,
FriendlyName: `${-chosen} Tokens!`,
})
// A token drop's id is the negative of its amount, so that is what lands on the balance.
expect(
await getBalance(env.DB, 82, CurrencyType.RecCenterTokens, DEFAULT_STARTING_TOKENS)
).toBe(tokensBefore + -chosen)
// 5 XP is deliberately less than the 10 the first level costs, so one reward moves the
// bar without levelling anyone up. // bar without levelling anyone up.
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 }) 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 // One box, carrying the message the client asked to show and no item — a game reward
// item — a game reward is not an item. // is tokens and XP, not an item.
const first = await giftBoxes('82') const boxes = await giftBoxes('82')
expect(first).toHaveLength(1) expect(boxes).toHaveLength(1)
expect(first[0]).toMatchObject({ expect(boxes[0]).toMatchObject({
Xp: 5,
Message: 'First Game of the Day', Message: 'First Game of the Day',
AvatarItemDesc: '', AvatarItemDesc: '',
EquipmentModificationGuid: '', EquipmentModificationGuid: '',
ConsumableItemDesc: '', ConsumableItemDesc: '',
}) })
// The box, then the bar — no level-up box, since no level was crossed. // The balance, the box, then the bar — no level-up box, since no level was crossed.
const frames = await drainFrames() const paid = await drainFrames()
expect(frames.map((f) => f.notificationType)).toEqual([ expect(paid.map((f) => f.notificationType)).toEqual([
NotificationType.GiftPackageReceivedImmediate, NotificationType.StorefrontBalanceUpdate,
NotificationType.GiftPackageRewardSelectionReceived,
NotificationType.PlayerProgressionLevelUpdate, NotificationType.PlayerProgressionLevelUpdate,
]) ])
expect(frames[0]?.accountId).toBe(82) // The balance frame carries the RESULTING total, never the payout.
expect(frames[0]?.payload).toMatchObject({ expect(paid[0]?.payload).toMatchObject({
Id: first[0]?.Id, Balance: tokensBefore + -chosen,
CurrencyType: CurrencyType.RecCenterTokens,
})
expect(paid[1]?.payload).toMatchObject({
Id: boxes[0]?.Id,
FromPlayerId: 1, FromPlayerId: 1,
Xp: 5, Xp: 5,
// GiftContext.GameRewards — the box came from gameplay, not a purchase. Currency: -chosen,
GiftContext: 50,
Message: 'First Game of the Day', Message: 'First Game of the Day',
}) })
expect(frames[1]?.payload).toEqual({ PlayerId: 82, Level: 1, XP: 5 }) expect(paid[2]?.payload).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
// An on-cooldown ask pays nothing: no more boxes, no frames, no more XP. // The selection is single-use: the same claim again is refused, and pays nothing more.
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200) expect(
(
await select({
rewardSelectionId: String(before!.reward_selection_id),
giftDropId: String(chosen),
})
).status
).toBe(403)
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 }) expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 1, XP: 5 })
expect(await giftBoxes('82')).toHaveLength(1) expect(await giftBoxes('82')).toHaveLength(1)
expect(await drainFrames()).toEqual([]) expect(await drainFrames()).toEqual([])
@@ -2332,6 +2521,17 @@ describe('econ endpoints', () => {
// is the pacing the smaller grant buys. // is the pacing the smaller grant buys.
await passAnHour() await passAnHour()
expect((await request('rewardType=FirstActivityOfDay&Message=Second')).status).toBe(200) expect((await request('rewardType=FirstActivityOfDay&Message=Second')).status).toBe(200)
const second = await latestSelection()
expect(second?.reward_selection_id).not.toBe(before?.reward_selection_id)
await drainFrames()
expect(
(
await select({
rewardSelectionId: String(second!.reward_selection_id),
giftDropId: String(second!.gift_drop_1_id),
})
).status
).toBe(200)
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 2, XP: 0 }) 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 // …and level 2 pays 2-Star Clothing per the published table: an AVATAR ITEM, never an
@@ -2351,32 +2551,13 @@ describe('econ endpoints', () => {
// v4 serves the camelCase DTO, unlike the PascalCase records on the gift box. // v4 serves the camelCase DTO, unlike the PascalCase records on the gift box.
const owned = (await items.json()) as Array<{ avatarItemDesc: string }> const owned = (await items.json()) as Array<{ avatarItemDesc: string }>
expect(owned.map((i) => i.avatarItemDesc)).toContain(clothingBox?.AvatarItemDesc) expect(owned.map((i) => i.avatarItemDesc)).toContain(clothingBox?.AvatarItemDesc)
// The level-up box rides the Immediate channel, after the selection's own payout frames.
expect((await drainFrames()).map((f) => f.notificationType)).toEqual([ expect((await drainFrames()).map((f) => f.notificationType)).toEqual([
NotificationType.GiftPackageReceivedImmediate, NotificationType.StorefrontBalanceUpdate,
NotificationType.GiftPackageRewardSelectionReceived,
NotificationType.PlayerProgressionLevelUpdate, NotificationType.PlayerProgressionLevelUpdate,
NotificationType.GiftPackageReceivedImmediate, NotificationType.GiftPackageReceivedImmediate,
]) ])
// 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()
expect((await request(`rewardType=FirstActivityOfDay&Message=${message}`)).status).toBe(200)
}
expect(await getProgression(env.DB, 82)).toEqual({ PlayerId: 82, Level: 3, XP: 0 })
const afterLevel3 = await giftBoxes('82')
const consumableBox = afterLevel3[afterLevel3.length - 1]
expect(consumableBox?.Message).toBe('Level 3!')
expect(consumableBox?.ConsumableItemDesc).not.toBe('')
expect(consumableBox?.AvatarItemDesc).toBe('')
expect(consumableBox?.EquipmentModificationGuid).toBe('')
const consumables = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, {
headers: await bearer('82'),
})
const held = (await consumables.json()) as Array<{ ConsumableItemDesc: string }>
expect(held.map((cons) => cons.ConsumableItemDesc)).toContain(consumableBox?.ConsumableItemDesc)
}) })
test('POST /api/gamerewards/v1/request is 401 without a token, and ignores a typeless ask', async () => { test('POST /api/gamerewards/v1/request is 401 without a token, and ignores a typeless ask', async () => {
@@ -2397,11 +2578,122 @@ describe('econ endpoints', () => {
body: 'Message=First%20Game%20of%20the%20Day', body: 'Message=First%20Game%20of%20the%20Day',
}) })
expect(typeless.status).toBe(200) expect(typeless.status).toBe(200)
expect(await typeless.json()).toEqual([]) expect(await typeless.json()).toEqual({ error: '', success: true, value: null })
const rows = await env.DB.prepare( const rows = await env.DB.prepare(
'SELECT COUNT(*) AS count FROM reward_status WHERE account_id = 81' 'SELECT COUNT(*) AS count FROM reward_status WHERE account_id = 81'
).first<{ count: number }>() ).first<{ count: number }>()
expect(rows?.count).toBe(0) expect(rows?.count).toBe(0)
// …and nothing was offered either: no cooldown row means no selection to pick from.
const offered = await env.DB.prepare(
'SELECT COUNT(*) AS count FROM reward_selection WHERE account_id = 81'
).first<{ count: number }>()
expect(offered?.count).toBe(0)
})
test('POST /api/gamerewards/v1/request mints a three-choice selection', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST',
headers: { ...(await bearer('42')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
rewardType: 'PostGameActivity',
Message: 'nice work',
giftContext: '4',
}).toString(),
})
expect(res.status).toBe(200)
// The HTTP body carries nothing — the choices go out over the websocket hub.
expect(await res.json()).toEqual({ error: '', success: true, value: null })
// The selection is recorded, with three distinct token choices for this player.
const row = await env.DB.prepare(
`SELECT account_id, message, gift_context, consumed,
gift_drop_1_id, gift_drop_2_id, gift_drop_3_id
FROM reward_selection ORDER BY reward_selection_id DESC LIMIT 1`
).first<{
account_id: number
message: string
gift_context: number
consumed: number
gift_drop_1_id: number
gift_drop_2_id: number
gift_drop_3_id: number
}>()
expect(row).toMatchObject({
account_id: 42,
message: 'nice work',
gift_context: 4,
consumed: 0,
})
const ids = [row!.gift_drop_1_id, row!.gift_drop_2_id, row!.gift_drop_3_id]
// Token drops carry the negative of their amount as their id.
expect(new Set(ids).size).toBe(3)
expect(ids.every((id) => id < 0)).toBe(true)
expect(
(await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, { method: 'POST' }))
.status
).toBe(401)
})
test('POST /api/gamerewards/v1/select claims a drop once, and only if offered', async () => {
await exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/request`, {
method: 'POST',
headers: { ...(await bearer('77')), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
rewardType: 'LevelUp',
Message: 'level up',
giftContext: '7',
}).toString(),
})
const sel = await env.DB.prepare(
`SELECT reward_selection_id, gift_drop_1_id FROM reward_selection
WHERE account_id = 77 ORDER BY reward_selection_id DESC LIMIT 1`
).first<{ reward_selection_id: number; gift_drop_1_id: number }>()
const selectionId = sel!.reward_selection_id
const offeredId = sel!.gift_drop_1_id
const select = async (fields: Record<string, string>, sub = '77'): Promise<Response> =>
exports.default.fetch(`${ORIGIN}/api/gamerewards/v1/select`, {
method: 'POST',
headers: { ...(await bearer(sub)), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(fields).toString(),
})
// A drop that wasn't offered is refused, as is another player's selection.
expect(
(await select({ rewardSelectionId: String(selectionId), giftDropId: '-999' })).status
).toBe(403)
expect(
(
await select(
{ rewardSelectionId: String(selectionId), giftDropId: String(offeredId) },
'42'
)
).status
).toBe(403)
// Claiming an offered drop returns it — a token drop worth its id's magnitude.
const res = await select({
rewardSelectionId: String(selectionId),
giftDropId: String(offeredId),
})
expect(res.status).toBe(200)
expect(await res.json()).toMatchObject({
GiftDropId: offeredId,
CurrencyType: 2,
Currency: -offeredId,
Context: 7,
FriendlyName: `${-offeredId} Tokens!`,
})
// The selection is single-use: claiming again is refused.
expect(
(await select({ rewardSelectionId: String(selectionId), giftDropId: String(offeredId) }))
.status
).toBe(403)
// A missing drop id is the client's error, not a refusal.
expect((await select({ rewardSelectionId: String(selectionId) })).status).toBe(400)
}) })
test('GET /api/roomkeys/v1/mine returns []', async () => { test('GET /api/roomkeys/v1/mine returns []', async () => {
@@ -2646,6 +2938,7 @@ describe('econ endpoints', () => {
'POST /api/checklist/v2/complete', 'POST /api/checklist/v2/complete',
'POST /api/consumables/v1/consume', 'POST /api/consumables/v1/consume',
'POST /api/gamerewards/v1/request', 'POST /api/gamerewards/v1/request',
'POST /api/gamerewards/v1/select',
'POST /api/items/bulkpurchase', 'POST /api/items/bulkpurchase',
'POST /api/objectives/v1/cleargroup', 'POST /api/objectives/v1/cleargroup',
'POST /api/objectives/v1/updateobjective', 'POST /api/objectives/v1/updateobjective',