mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 06:31:27 -07:00
Issue #10: rewards bootstrapping
This commit is contained in:
@@ -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);
|
||||||
@@ -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
@@ -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 player’s 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 player’s 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 can’t 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',
|
||||||
|
'isn’t the caller’s, 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 caller’s 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)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -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 client’s 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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<{
|
||||||
expect(res.status).toBe(200)
|
Group: number
|
||||||
expect(await res.json()).toEqual([])
|
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`, {
|
||||||
test('POST /api/objectives/v1/updateobjective echoes the group, never completed', async () => {
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/updateobjective`, {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'content-type': 'application/json' },
|
headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body),
|
||||||
Index: 2,
|
})
|
||||||
Group: 3,
|
const progress = async (sub = '4242'): Promise<Progress> => {
|
||||||
Progress: 1,
|
const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/myprogress`, {
|
||||||
VisualProgress: 0,
|
headers: await bearer(sub),
|
||||||
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 }
|
return (await res.json()) as Progress
|
||||||
expect(body.group).toBe(3)
|
}
|
||||||
expect(body.isCompleted).toBe(false)
|
|
||||||
expect(Number.isNaN(Date.parse(body.clearedAt))).toBe(false)
|
// Partial progress on one objective.
|
||||||
|
const res = await update({
|
||||||
|
Group: 0,
|
||||||
|
Index: 2,
|
||||||
|
Progress: 0.5,
|
||||||
|
VisualProgress: 0.5,
|
||||||
|
IsCompleted: false,
|
||||||
|
IsRewarded: false,
|
||||||
|
})
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
|
||||||
|
const mid = await progress()
|
||||||
|
expect(mid.Objectives).toEqual([
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
|||||||
Reference in New Issue
Block a user