diff --git a/apps/econ/migrations/0001_reward_selection.sql b/apps/econ/migrations/0001_reward_selection.sql new file mode 100644 index 0000000..adde329 --- /dev/null +++ b/apps/econ/migrations/0001_reward_selection.sql @@ -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); diff --git a/apps/econ/migrations/0002_objective.sql b/apps/econ/migrations/0002_objective.sql new file mode 100644 index 0000000..b0e6397 --- /dev/null +++ b/apps/econ/migrations/0002_objective.sql @@ -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); diff --git a/apps/econ/migrations/0003_objective_group.sql b/apps/econ/migrations/0003_objective_group.sql new file mode 100644 index 0000000..d87a40a --- /dev/null +++ b/apps/econ/migrations/0003_objective_group.sql @@ -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) + ); diff --git a/apps/econ/package.json b/apps/econ/package.json index e6f3526..e405408 100644 --- a/apps/econ/package.json +++ b/apps/econ/package.json @@ -10,6 +10,7 @@ "check:types": "run-tsc", "check:workers-types": "run-wrangler-types --check", "deploy": "run-wrangler-deploy", + "migrate": "run-wrangler-migrate", "dev": "run-wrangler-dev", "fix:workers-types": "run-wrangler-types", "test": "run-vitest" diff --git a/apps/econ/src/context.ts b/apps/econ/src/context.ts index b75159b..42283dc 100644 --- a/apps/econ/src/context.ts +++ b/apps/econ/src/context.ts @@ -1,15 +1,20 @@ import type { HonoApp } from '@repo/hono-helpers' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' +// Type-only import (erased at build) of the DO class owned by the `notify` worker, so +// the RPC methods on the hub binding are typed here. +import type { NotificationsHub } from '../../notify/src/notifications-hub' export type Env = SharedHonoEnv & { // Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value // with `await env.JWT_SECRET.get()`; all workers bind the same store so tokens // signed by `auth` verify here. JWT_SECRET: SecretsStoreSecret - /** Shared `recflare` D1 (accounts table) — stores the player's avatar. */ + /** Shared `recflare` D1: the accounts table (avatar) + this worker's reward_selection. */ DB: D1Database /** Static storefront catalogs (`static/storefronts/sf*.json`), fetched by path. */ ASSETS: Fetcher + /** Notifications hub (DO in `notify`) — game rewards are pushed over the websocket. */ + RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace } /** Variables can be extended */ diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 1427f2a..f7d4fc5 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono' -import { useWorkersLogger } from 'workers-tagged-logger' +import { useWorkersLogger, WorkersLogger } from 'workers-tagged-logger' import { withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' @@ -9,6 +9,19 @@ import defaultAvatar from '../static/default-avatar.json' import myProgress from '../static/my-progress.json' import weeklyChallenge from '../static/weekly-challenge.json' import { getAvatar, setAvatar } from './avatar-db' +import { + clearObjectiveGroup, + getObjectiveGroups, + getObjectives, + updateObjective, +} from './objectives-db' +import { + consumeRewardSelection, + createRewardSelection, + getRewardSelection, + rollRewardDrops, + tokenRewardDrop, +} from './rewards-db' import type { Context } from 'hono' import type { Avatar } from './avatar-db' @@ -35,6 +48,37 @@ function unauthorized(c: Context) { return c.body(null, 401) } +const logger = new WorkersLogger() + +/** The single hub Durable Object every worker talks to. */ +const HUB_INSTANCE = 'global' + +/** + * 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, + playerId: number, + notificationType: string, + data: Record +): Promise { + 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), + }) + } +} + /** * Project a stored avatar into the public render subset returned by * `GET /api/avatar/v2/:id` — the fields needed to draw another player's avatar @@ -101,10 +145,65 @@ const app = new Hono() return c.json({ Results: [], TotalResults: 0 }) }) - // The player's objectives progress. Serves a static JSON file verbatim with - // no auth — same default for everyone until there's a DB binding to track - // per-player progress. - .get('/api/objectives/v1/myprogress', (c) => c.json(myProgress)) + // The player's objectives progress. Their own recorded objectives once they've made + // any (the client reports them through `updateobjective`); the bundled default set + // otherwise, including for a signed-out caller — the client needs a well-formed + // checklist to render either way. + .get('/api/objectives/v1/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, + }) + }) + + // The client clearing an objective group — it's done with that set (its dailies + // rolled over, say). Auth-gated; the JSON body carries `Group`. Marks the group + // completed, stamps the clear time, and returns the group as the client reads it. + .post('/api/objectives/v1/cleargroup', async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const body = (await c.req.json().catch(() => ({}))) as Record + const group = typeof body.Group === 'number' ? body.Group : 0 + + return c.json(await clearObjectiveGroup(c.env.DB, id, group)) + }) + + // The client reporting progress on an objective as it plays. Auth-gated; the body is + // JSON (Group/Index identify the objective within the player's set). Answers a bare + // 200 — the client doesn't read anything back. + .post('/api/objectives/v1/updateobjective', async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const body = (await c.req.json().catch(() => null)) as Record | 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 + + await updateObjective(c.env.DB, id, { + Group: num(body.Group), + Index: num(body.Index), + Progress: num(body.Progress), + VisualProgress: num(body.VisualProgress), + IsCompleted: bool(body.IsCompleted), + IsRewarded: bool(body.IsRewarded), + }) + // The reference awards progression XP the first time an objective completes; we + // have no XP store yet, so completion is only recorded (HasClaimedReward latches + // so the award can't be double-paid once there is one). + return c.body(null, 200) + }) // The player's avatar, stored as a JSON blob on their account row. Falls back // to the default outfit when they haven't saved one — the client's parser NREs @@ -225,6 +324,112 @@ const app = new Hono() // Pending game rewards. Returns "[]". .get('/api/gamerewards/v1/pending', (c) => c.json([])) + // Ask for a reward (a challenge completed, a level gained, …). The HTTP response + // carries *nothing* — the three choices are pushed to the player over the + // notifications hub as `RewardSelectionReceived`, and they pick one with + // `v1/select`. Auth-gated; answers the `{ error, success, value }` envelope. + .post('/api/gamerewards/v1/request', async (c) => { + const accountId = await authedId(c) + if (accountId === null) return unauthorized(c) + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const field = (...names: string[]): string => { + const key = Object.keys(body).find((k) => + names.some((n) => n.toLowerCase() === k.toLowerCase()) + ) + const v = key === undefined ? undefined : body[key] + return typeof v === 'string' ? v : '' + } + const message = field('Message') + const giftContext = Number.parseInt(field('giftContext', 'GiftContext'), 10) || 0 + const rewardType = Number.parseInt(field('rewardType', 'RewardType'), 10) || 0 + + // No reward-drop catalog yet, so all three choices are token drops — the + // reference's own fallback when it runs out of drops for a context. + const drops = rollRewardDrops(giftContext) + const selection = await createRewardSelection(c.env.DB, accountId, { + message, + giftContext, + rewardType, + dropIds: drops.map((d) => d.GiftDropId), + }) + + await pushToPlayer(c, accountId, 'RewardSelectionReceived', { + RewardSelectionId: selection.RewardSelectionId, + Message: message, + GiftContext: giftContext, + RewardType: rewardType, + GiftDrop1: drops[0], + GiftDrop2: drops[1], + GiftDrop3: drops[2], + CreatedAt: selection.CreatedAt, + PlayerId: 0, + }) + + return c.json({ error: '', success: true, value: null }) + }) + + // Claim one of the three rewards a selection offered. The selection must be the + // caller's, unconsumed, and actually contain the claimed drop — otherwise 403, so a + // player can't mint a reward they were never offered or redeem one twice. Returns + // the claimed drop, and pushes the resulting gift over the hub. + .post('/api/gamerewards/v1/select', async (c) => { + const accountId = await authedId(c) + if (accountId === null) return unauthorized(c) + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + 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 !== accountId || + 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) + } + + // Every drop is a token drop for now, and a token drop's id is the negative of + // its amount — so the claim rebuilds without a catalog lookup. + const drop = tokenRewardDrop(-giftDropId, selection.GiftContext) + + await pushToPlayer(c, accountId, 'GiftPackageRewardSelectionReceived', { + Id: selection.RewardSelectionId, + FromGiftDropId: drop.GiftDropId, + FromPlayerId: 1, + ConsumableItemDesc: drop.ConsumableItemDesc, + AvatarItemDesc: drop.AvatarItemDesc, + EquipmentPrefabName: drop.EquipmentPrefabName, + EquipmentModificationGuid: drop.EquipmentModificationGuid, + CurrencyType: drop.CurrencyType, + Currency: drop.Currency, + Xp: 0, + Level: 0, + Platform: -1, + PlatformsToSpawnOn: -1, + BalanceType: -2, + GiftContext: selection.GiftContext, + GiftRarity: drop.Rarity, + Message: selection.Message, + AvatarItemType: drop.AvatarItemType, + }) + + return c.json(drop) + }) + // The player's room keys. Returns "[]". .get('/api/roomkeys/v1/mine', (c) => c.json([])) // Room keys for a given room (client calls this on the econ host). [] with no DB. diff --git a/apps/econ/src/objectives-db.ts b/apps/econ/src/objectives-db.ts new file mode 100644 index 0000000..56d0640 --- /dev/null +++ b/apps/econ/src/objectives-db.ts @@ -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/0002_objective.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 { + 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 { + 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 { + 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 { + 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, + })) +} diff --git a/apps/econ/src/rewards-db.ts b/apps/econ/src/rewards-db.ts new file mode 100644 index 0000000..7e33c2a --- /dev/null +++ b/apps/econ/src/rewards-db.ts @@ -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/0001_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 { + 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 { + 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 { + 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 +} diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index a8e24cf..5814428 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -5,6 +5,8 @@ import { beforeAll, describe, expect, test } from 'vitest' import '../../econ.app' import { SCHEMA_DDL } from '../../avatar-db' +import { SCHEMA_DDL as OBJECTIVES_SCHEMA_DDL } from '../../objectives-db' +import { SCHEMA_DDL as REWARDS_SCHEMA_DDL } from '../../rewards-db' import type { Env } from '../../context' @@ -20,6 +22,10 @@ beforeAll(async () => { // Seed the shared JWT signing key into the local Secrets Store so .get() resolves. await adminSecretsStore(env.JWT_SECRET).create('test-signing-key') for (const stmt of 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)') .bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' })) .run() @@ -224,6 +230,128 @@ describe('econ endpoints', () => { expect(Array.isArray(body.ObjectiveGroups)).toBe(true) }) + test('POST /api/objectives/v1/updateobjective records progress; myprogress reads it back', async () => { + type Progress = { + Objectives: Array<{ + Group: number + Index: number + Progress: number + VisualProgress: number + IsCompleted: boolean + HasClaimedReward: boolean + }> + ObjectiveGroups: unknown[] + } + const update = async (body: unknown, sub = '4242'): Promise => + 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 => { + const res = await exports.default.fetch(`${ORIGIN}/api/objectives/v1/myprogress`, { + headers: await bearer(sub), + }) + expect(res.status).toBe(200) + return (await res.json()) as Progress + } + + // 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/cleargroup clears the group; myprogress reports it', async () => { + 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('GET /api/checklist/v1/current 401s without a token, returns [] with one', async () => { const anon = await exports.default.fetch(`${ORIGIN}/api/checklist/v1/current`) expect(anon.status).toBe(401) @@ -356,6 +484,101 @@ describe('econ endpoints', () => { expect(await res.json()).toEqual([]) }) + 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({ 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({ 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, sub = '77'): Promise => + 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) + }) + test('GET /api/roomkeys/v1/mine returns []', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/mine`) expect(res.status).toBe(200) diff --git a/apps/econ/vitest.config.ts b/apps/econ/vitest.config.ts index de0d903..a9e363d 100644 --- a/apps/econ/vitest.config.ts +++ b/apps/econ/vitest.config.ts @@ -9,6 +9,28 @@ export default defineConfig({ bindings: { ENVIRONMENT: 'VITEST', }, + // The worker's RECFLARE_NOTIFICATIONS_HUB binding points at the `notify` + // worker's DO (script_name: "notify"). That worker isn't part of this + // isolated test, so provide a minimal stub exposing the same + // NotificationsHub RPC surface — enough for the runtime to start and for + // notification sends to no-op. + workers: [ + { + name: 'notify', + modules: true, + compatibilityDate: '2025-09-20', + compatibilityFlags: ['nodejs_compat'], + durableObjects: { RECFLARE_NOTIFICATIONS_HUB: 'NotificationsHub' }, + script: ` + import { DurableObject } from 'cloudflare:workers' + export class NotificationsHub extends DurableObject { + async notifyPlayer() { return { delivered: 0, queued: true } } + async broadcast() { return { delivered: 0 } } + } + export default { fetch() { return new Response('ok') } } + `, + }, + ], }, }), ], diff --git a/apps/econ/wrangler.jsonc b/apps/econ/wrangler.jsonc index 1898313..c74e61c 100644 --- a/apps/econ/wrangler.jsonc +++ b/apps/econ/wrangler.jsonc @@ -11,16 +11,32 @@ "binding": "ASSETS", "directory": "./static/storefronts" }, - // Shared `recflare` D1 (accounts table) — read/write the player's avatar column. - // The accounts schema/migrations are owned by the `auth` worker. The "local" - // placeholder is replaced with the real id from RECFLARE_D1 at deploy time. + // Shared `recflare` D1: the accounts table (avatar column, owned by `auth`) plus the + // `reward_selection` table this worker owns (schema/migration here; its own + // migrations_table keeps history separate from the other workers' on the shared + // database). The "local" placeholder is replaced with the real id from RECFLARE_D1 + // at deploy time. "d1_databases": [ { "binding": "DB", "database_name": "recflare", - "database_id": "local" + "database_id": "local", + "migrations_dir": "migrations", + "migrations_table": "d1_migrations_econ" } ], + // The notifications hub (a Durable Object in the `notify` worker). Game rewards are + // delivered over the websocket, not in the HTTP response: `gamerewards/v1/request` + // answers an empty envelope and pushes the three choices as a notification. + "durable_objects": { + "bindings": [ + { + "name": "RECFLARE_NOTIFICATIONS_HUB", + "class_name": "NotificationsHub", + "script_name": "notify" + } + ] + }, "logpush": false, // Shared Secrets Store holding the HS256 JWT signing key. Every worker binds the // same store as JWT_SECRET so tokens signed by `auth` verify here. The "local"