From 916e42c33c0dcc5351f44d63356e73fd09f8a2c9 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 15 Jul 2026 17:25:22 -0400 Subject: [PATCH] attempting to fix some consumables not appearing in avatar immediately --- apps/api/src/routes/avatar.ts | 21 +-- apps/api/src/test/integration/api.test.ts | 72 ---------- apps/econ/src/consumables-db.ts | 79 ++++++++++- apps/econ/src/context.ts | 5 + apps/econ/src/econ.app.ts | 143 +++++++++++++++++-- apps/econ/src/test/integration/api.test.ts | 155 ++++++++++++++++++++- apps/econ/vitest.config.ts | 22 +++ apps/econ/wrangler.jsonc | 11 ++ apps/notify/src/notification-types.ts | 52 +++++++ packages/domain/src/gifts-db.ts | 46 ++++-- 10 files changed, 494 insertions(+), 112 deletions(-) create mode 100644 apps/notify/src/notification-types.ts diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts index 77f3d79..afb76c7 100644 --- a/apps/api/src/routes/avatar.ts +++ b/apps/api/src/routes/avatar.ts @@ -1,7 +1,5 @@ import { Hono } from 'hono' -import { consumeGift } from '@repo/domain' - import { authedId, unauthorized } from '../http' import { createInvention, @@ -50,9 +48,9 @@ async function creatorsInvention( } // ---- Avatar gifts ---------------------------------------------------------- -// The avatar read endpoints (`v4/items`, `v2`, `v2/set`, `v3/saved`, `v2/gifts`) -// live in the `econ` worker, which the client calls on the econ host — not here. -// Only the gift generate/consume actions remain on this worker. +// The avatar read endpoints (`v4/items`, `v2`, `v2/set`, `v3/saved`, `v2/gifts`) and +// gift-box consume live in the `econ` worker, which the client calls on the econ host +// — not here. Only the gift `generate` action remains on this worker. export const avatarRoutes = new Hono({ strict: false }) .post('/api/avatar/v2/gifts/generate', async (c) => { const id = await authedId(c) @@ -89,19 +87,6 @@ export const avatarRoutes = new Hono({ strict: false }) Message: message, }) }) - .post('/api/avatar/v2/gifts/consume', async (c) => { - const id = await authedId(c) - const body = await c.req.parseBody().catch(() => ({}) as Record) - const giftId = typeof body.Id === 'string' ? Number.parseInt(body.Id, 10) || 0 : 0 - // Opening a box just deletes it — the item was granted into the player's inventory - // when they bought it (see the `econ` worker's buyItem), so there's nothing to grant. - // Answers the `{ error, success, value }` envelope a captured real consume returns - // (not an empty body — the client parses it to finish opening the box). A missing/zero - // id, no token, or a box that's already gone (or isn't theirs) is a scoped no-op, not - // an error. Mirrors the econ worker's consume route (the client may call either host). - if (id !== null && giftId !== 0) await consumeGift(c.env.DB, id, giftId) - return c.json({ error: '', success: true, value: null }) - }) // Custom avatar item gates — real Rec Room client endpoints with no backing // implementation yet; we enable them. Flip to `false` to disable the diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 4bf7232..63e291c 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -4,8 +4,6 @@ import { beforeAll, describe, expect, test } from 'vitest' import '../../api.app' -import { createGift, getPendingGifts, RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain' - import { createImage, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db' import { SCHEMA_DDL as INVENTIONS_SCHEMA_DDL } from '../../inventions-db' import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-db' @@ -79,10 +77,6 @@ beforeAll(async () => { // Inventions table (owned by the api worker) — invention save/mine use it. for (const stmt of INVENTIONS_SCHEMA_DDL) await env.DB.prepare(stmt).run() - - // Received-gift boxes (schema owned by the `econ` worker, on the shared DB) — the - // gift consume endpoint deletes from it. - for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run() }) // Mint a token the way the `auth` worker does, signing with the shared test key seeded into the JWT_SECRET store, so the @@ -281,72 +275,6 @@ describe('public endpoints', () => { expect(await res.json()).toEqual([]) }) - test('POST /api/avatar/v2/gifts/consume deletes the player’s gift box', async () => { - // Seed a box for account 42 directly, then consume it. - const { id: giftId } = await createGift(env.DB, 42, { - ConsumableItemDesc: '', - ConsumableCount: 0, - AvatarItemDesc: 'd0a9262f-5504-46a7-bb10-7507503db58e,,,', - AvatarItemType: 0, - CurrencyType: 0, - Currency: 0, - Xp: 0, - PackageType: 0, - Message: 'A gift for you <3', - EquipmentPrefabName: '', - EquipmentModificationGuid: '', - GiftRarity: 50, - Platform: -1, - PlatformsToSpawnOn: -1, - BalanceType: null, - }) - // Consume is fire-and-forget: always 200 with the success envelope. The box is gone after. - const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume`, { - method: 'POST', - headers: await bearer('42'), - body: new URLSearchParams({ Id: String(giftId), UnlockedLevel: '0' }), - }) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ error: '', success: true, value: null }) - expect(await getPendingGifts(env.DB, 42)).toHaveLength(0) - - // Consuming it again is a no-op — still 200, nothing changes. - const again = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume`, { - method: 'POST', - headers: await bearer('42'), - body: new URLSearchParams({ Id: String(giftId) }), - }) - expect(again.status).toBe(200) - }) - - test('POST /api/avatar/v2/gifts/consume leaves another player’s box untouched', async () => { - const { id: giftId } = await createGift(env.DB, 99, { - ConsumableItemDesc: '', - ConsumableCount: 0, - AvatarItemDesc: 'a,,,', - AvatarItemType: 0, - CurrencyType: 0, - Currency: 0, - Xp: 0, - PackageType: 0, - Message: '', - EquipmentPrefabName: '', - EquipmentModificationGuid: '', - GiftRarity: 0, - Platform: -1, - PlatformsToSpawnOn: -1, - BalanceType: null, - }) - // Account 42 consuming account 99's box is a scoped no-op (still 200), and 99 keeps it. - const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume`, { - method: 'POST', - headers: await bearer('42'), - body: new URLSearchParams({ Id: String(giftId) }), - }) - expect(res.status).toBe(200) - expect((await getPendingGifts(env.DB, 99)).some((g) => g.Id === giftId)).toBe(true) - }) - test('GET /api/customAvatarItems/v1/isCreationAllowedForAccount returns a success envelope', async () => { const res = await exports.default.fetch( `${ORIGIN}/api/customAvatarItems/v1/isCreationAllowedForAccount` diff --git a/apps/econ/src/consumables-db.ts b/apps/econ/src/consumables-db.ts index b2e1e6d..ec6a043 100644 --- a/apps/econ/src/consumables-db.ts +++ b/apps/econ/src/consumables-db.ts @@ -44,20 +44,89 @@ export interface UnlockedConsumable { IsTransferable: boolean } -/** Grant `count` of a consumable to a player as a new owned instance (they stack). */ +/** + * Grant `count` of a consumable to a player as a new owned instance (they stack). + * Returns the new row's id — the consumable mapping id the client keys on. + */ export async function grantConsumable( db: D1Database, accountId: number, consumableItemDesc: string, count: number -): Promise { - await db +): Promise { + const row = await db .prepare( `INSERT INTO consumable (account_id, consumable_item_desc, count, created_at) - VALUES (?1, ?2, ?3, ?4)` + VALUES (?1, ?2, ?3, ?4) RETURNING id` ) .bind(accountId, consumableItemDesc, count, new Date().toISOString()) - .run() + .first<{ id: number }>() + return row?.id ?? 0 +} + +/** A player's total owned count of a consumable, summed across its stacked instances. */ +export async function countConsumable( + db: D1Database, + accountId: number, + consumableItemDesc: string +): Promise { + const row = await db + .prepare( + 'SELECT COALESCE(SUM(count), 0) AS total FROM consumable WHERE account_id = ?1 AND consumable_item_desc = ?2' + ) + .bind(accountId, consumableItemDesc) + .first<{ total: number }>() + return row?.total ?? 0 +} + +/** The outcome of consuming an instance — its identity plus the resulting count. */ +export interface ConsumeResult { + id: number + consumableItemDesc: string + createdAt: string + /** The instance's count before this consumption. */ + previousCount: number + /** The count left after consuming (0 when the row was deleted). */ + remaining: number +} + +/** + * Consume `deltaCount` from one owned consumable instance, by row `id` and scoped to + * its owner (so a player can only consume their own). Reduces that instance's `count`; + * once it would reach zero (or below) the row is deleted entirely. Returns the + * instance's details plus the resulting count, or null when the row didn't exist / + * isn't the caller's. + */ +export async function consumeConsumable( + db: D1Database, + accountId: number, + id: number, + deltaCount: number +): Promise { + const row = await db + .prepare( + 'SELECT consumable_item_desc, count, created_at FROM consumable WHERE id = ?1 AND account_id = ?2' + ) + .bind(id, accountId) + .first<{ consumable_item_desc: string; count: number; created_at: string }>() + if (row === null) return null + + const remaining = row.count - deltaCount + if (remaining > 0) { + await db.prepare('UPDATE consumable SET count = ?2 WHERE id = ?1').bind(id, remaining).run() + } else { + await db + .prepare('DELETE FROM consumable WHERE id = ?1 AND account_id = ?2') + .bind(id, accountId) + .run() + } + return { + id, + consumableItemDesc: row.consumable_item_desc, + createdAt: row.created_at, + previousCount: row.count, + remaining: Math.max(remaining, 0), + } } interface ConsumableRow { diff --git a/apps/econ/src/context.ts b/apps/econ/src/context.ts index 1cf0d44..ffbfda6 100644 --- a/apps/econ/src/context.ts +++ b/apps/econ/src/context.ts @@ -1,5 +1,8 @@ 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 this worker can push websocket notifications through its RPC surface. +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 @@ -10,6 +13,8 @@ export type Env = SharedHonoEnv & { DB: D1Database /** Static storefront catalogs (`static/storefronts/sf*.json`), fetched by path. */ ASSETS: Fetcher + /** The `notify` worker's NotificationsHub DO — push websocket notifications to a player. */ + RECFLARE_NOTIFICATIONS_HUB: DurableObjectNamespace /** * The RecCenterTokens a new player is granted (see balance-db.ts). Optional — unset * falls back to DEFAULT_STARTING_TOKENS, and 0 means players start broke. diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 44ae20b..d2dd7f2 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -1,10 +1,14 @@ import { Hono } from 'hono' import { useWorkersLogger } from 'workers-tagged-logger' -import { consumeGift, createGift, getPendingGifts } from '@repo/domain' -import { intVar, withNotFound, withOnError } from '@repo/hono-helpers' +import { consumeGift, createGift, getGift, getPendingGifts } from '@repo/domain' +import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers' import { validateAndGetAccountId } from '@repo/jwt' +// The notification-type ids the hub carries (owned by the `notify` worker). Imported +// as a value — the enum has no runtime dependencies. +import { NotificationType } from '../../notify/src/notification-types' + import adCarouselItems from '../static/ad-carousel-items.json' import defaultAvatarItems from '../static/default-avatar-items.json' import defaultAvatar from '../static/default-avatar.json' @@ -18,13 +22,14 @@ import { isSpendable, spendCurrency, } from './balance-db' -import { getConsumables, grantConsumable } from './consumables-db' +import { consumeConsumable, countConsumable, getConsumables, grantConsumable } from './consumables-db' import { getInventory, grantItem } from './inventory-db' import { getOutfits, setOutfit } from './outfit-db' import type { Context } from 'hono' -import type { GiftContent } from '@repo/domain' +import type { GiftContent, StoredGift } from '@repo/domain' import type { Avatar } from './avatar-db' +import type { ConsumeResult } from './consumables-db' import type { App } from './context' import type { AvatarItem } from './inventory-db' import type { Outfit } from './outfit-db' @@ -50,6 +55,80 @@ function unauthorized(c: Context) { return c.body(null, 401) } +/** The notifications hub is a single global DO instance (see the `notify` worker). */ +const HUB_INSTANCE = 'global' + +/** + * Push a ConsumableMappingRemoved notification to a player after they consume a + * consumable, mirroring the reference's + * `HubSendToPlayer(accountID, NotifFrame(ConsumableMappingRemoved, {...}))` — the + * client uses it to update/remove the item from inventory. Best-effort: a hub failure + * is logged and swallowed, since the consume has already committed. + */ +async function pushConsumableRemoved( + c: Context, + accountId: number, + consumed: ConsumeResult +): Promise { + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( + accountId, + NotificationType.ConsumableMappingRemoved, + { + Id: consumed.id, + ConsumableItemDesc: consumed.consumableItemDesc, + CreatedAt: consumed.createdAt, + Count: consumed.remaining, + InitialCount: consumed.previousCount, + IsActive: false, + ActiveDurationMinutes: 0, + IsTransferable: false, + } + ) + } catch (err) { + logger.error('failed to push ConsumableMappingRemoved notification', { + accountId, + error: err instanceof Error ? err.message : String(err), + }) + } +} + +/** + * Push a ConsumableMappingAdded notification to a player after they open a gift box + * that carried a consumable, mirroring the reference's + * `HubSendToPlayer(accountID, NotifFrame(ConsumableMappingAdded, {...}))` — the client + * uses it to show the newly-unlocked consumable. The mapping id and pre-existing count + * were stamped onto the box at purchase (see toGiftContent). Best-effort like the + * removed push. + */ +async function pushConsumableAdded( + c: Context, + accountId: number, + gift: StoredGift +): Promise { + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( + accountId, + NotificationType.ConsumableMappingAdded, + { + Id: gift.ConsumableMappingId ?? 0, + ConsumableItemDesc: gift.ConsumableItemDesc, + CreatedAt: new Date().toISOString(), + Count: gift.ConsumableCount, + InitialCount: gift.ConsumablePreExistingCount ?? 0, + IsActive: false, + ActiveDurationMinutes: 0, + IsTransferable: false, + } + ) + } catch (err) { + logger.error('failed to push ConsumableMappingAdded notification', { + accountId, + 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 @@ -143,11 +222,15 @@ const COACH_ACCOUNT_ID = 1 function toGiftContent( giftDrop: StoreGiftDrop, message: string, - consumableCount: number + consumableCount: number, + consumableMappingId = 0, + consumablePreExistingCount = 0 ): GiftContent { return { ConsumableItemDesc: giftDrop.ConsumableItemDesc, ConsumableCount: consumableCount, + ConsumableMappingId: consumableMappingId, + ConsumablePreExistingCount: consumablePreExistingCount, AvatarItemDesc: giftDrop.AvatarItemDesc, AvatarItemType: giftDrop.AvatarItemType, CurrencyType: giftDrop.CurrencyType, @@ -304,7 +387,22 @@ const app = new Hono({ strict: false }) const id = await authedId(c) const body = await c.req.parseBody().catch(() => ({}) as Record) const giftId = typeof body.Id === 'string' ? Number.parseInt(body.Id, 10) || 0 : 0 - if (id !== null && giftId !== 0) await consumeGift(c.env.DB, id, giftId) + if (id !== null && giftId !== 0) { + // Scoped delete: only the box's owner deletes it. A returned box means it was + // theirs and is now consumed. + const gift = await consumeGift(c.env.DB, id, giftId) + if (gift !== null) { + // If the box carried a consumable, tell the client it now has it (so it shows + // up in inventory without a refetch). Avatar-item boxes carry no ConsumableItemDesc. + if (gift.ConsumableItemDesc !== '') await pushConsumableAdded(c, id, gift) + } else { + // Nothing was consumed: either the box is already gone (a harmless no-op — + // re-opening your own consumed box still succeeds) or it belongs to another + // player, which is forbidden. + const other = await getGift(c.env.DB, giftId) + if (other !== null && other.accountId !== id) return c.body(null, 403) + } + } return c.json({ error: '', success: true, value: null }) }) @@ -346,6 +444,26 @@ const app = new Hono({ strict: false }) return c.json(await getConsumables(c.env.DB, id)) }) + // Consume a quantity of an owned consumable instance. [Authorize]. Body is JSON + // `{ Id, DeltaCount }` where `Id` is the consumable row id. Reduces that instance's + // count by DeltaCount, deleting the row once it hits zero. Scoped to the caller so + // they can only consume their own. Envelope mirrors the gift-consume ack. + .post('/api/consumables/v1/consume', async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + const body = await c.req + .json<{ Id?: unknown; DeltaCount?: unknown }>() + .catch(() => ({}) as { Id?: unknown; DeltaCount?: unknown }) + const consumableId = typeof body.Id === 'number' ? body.Id : Number.NaN + const delta = typeof body.DeltaCount === 'number' ? body.DeltaCount : 1 + if (!Number.isNaN(consumableId) && delta > 0) { + const consumed = await consumeConsumable(c.env.DB, id, consumableId, delta) + // Notify the player so their client removes/updates the item in inventory. + if (consumed !== null) await pushConsumableRemoved(c, id, consumed) + } + return c.json({ error: '', success: true, value: null }) + }) + // Currency balance. [Authorize]. The trailing int is a CurrencyType — the client // fetches `/balance/2` (RecCenterTokens) on load. Backed by the `balance` table; a // player who has never been granted gets their starting balance on this first read. @@ -462,8 +580,17 @@ const app = new Hono({ strict: false }) typeof item.GiftDrop.ConsumableItemDesc === 'string' && item.GiftDrop.ConsumableItemDesc !== '' const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT : 0 + // Capture the granted consumable's row id and the player's pre-existing count so + // the gift box can carry them — gift-consume fires ConsumableMappingAdded from these. + let consumableMappingId = 0 + let consumablePreExisting = 0 if (isConsumable) { - await grantConsumable( + consumablePreExisting = await countConsumable( + c.env.DB, + receiverId, + item.GiftDrop.ConsumableItemDesc + ) + consumableMappingId = await grantConsumable( c.env.DB, receiverId, item.GiftDrop.ConsumableItemDesc, @@ -473,7 +600,7 @@ const app = new Hono({ strict: false }) const { id: giftId } = await createGift( c.env.DB, receiverId, - toGiftContent(item.GiftDrop, message, consumableCount) + toGiftContent(item.GiftDrop, message, consumableCount, consumableMappingId, consumablePreExisting) ) // The response mirrors a captured real buyItem: `Balance` is the change applied (the diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 3abff31..c06e8e4 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -14,7 +14,7 @@ import { getBalance, spendCurrency, } from '../../balance-db' -import { CONSUMABLE_SCHEMA_DDL } from '../../consumables-db' +import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db' import { INVENTORY_SCHEMA_DDL } from '../../inventory-db' import { OUTFIT_SCHEMA_DDL } from '../../outfit-db' @@ -418,6 +418,76 @@ describe('econ endpoints', () => { expect(await res.json()).toEqual([]) }) + test('POST /api/consumables/v1/consume reduces the count and deletes the row at zero', async () => { + // Seed account 313 with two Supreme Pizza instances (counts 3 and 1). + await grantConsumable(env.DB, 313, 'Supreme Pizza', 3) + await grantConsumable(env.DB, 313, 'Supreme Pizza', 1) + + type Group = { ConsumableItemDesc: string; Ids: number[]; Count: number } + const pizza = async (sub = '313'): Promise => { + const groups = (await ( + await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, { + headers: await bearer(sub), + }) + ).json()) as Group[] + return groups.find((g) => g.ConsumableItemDesc === 'Supreme Pizza') + } + const consume = async (Id: number, DeltaCount: number, sub = '313') => + exports.default.fetch(`${ORIGIN}/api/consumables/v1/consume`, { + method: 'POST', + headers: { ...(await bearer(sub)), 'Content-Type': 'application/json' }, + body: JSON.stringify({ Id, DeltaCount }), + }) + + const before = (await pizza())! + expect(before.Count).toBe(4) + const [firstId, secondId] = before.Ids // firstId: count 3, secondId: count 1 + + // No token → 401. + expect( + ( + await exports.default.fetch(`${ORIGIN}/api/consumables/v1/consume`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ Id: firstId, DeltaCount: 1 }), + }) + ).status + ).toBe(401) + + // Consume 1 from the count-3 instance → it drops to 2, still present. + expect((await consume(firstId, 1)).status).toBe(200) + expect((await pizza())!.Count).toBe(3) + + // Consume the whole count-1 instance → its row is deleted. + await consume(secondId, 1) + const afterSecond = (await pizza())! + expect(afterSecond.Ids).not.toContain(secondId) + expect(afterSecond.Count).toBe(2) + + // Over-consume the remaining instance (delta > count) → row deleted, group gone. + await consume(firstId, 5) + expect(await pizza()).toBeUndefined() + + // Consuming a row you don't own is a no-op (scoped to the owner). + await grantConsumable(env.DB, 314, 'Soda', 2) + const sodaId = ( + (await ( + await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, { + headers: await bearer('314'), + }) + ).json()) as Array<{ Ids: number[] }> + )[0].Ids[0] + await consume(sodaId, 2, '313') // account 313 tries to consume 314's row + const soda = ( + (await ( + await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, { + headers: await bearer('314'), + }) + ).json()) as Array<{ Count: number }> + )[0] + expect(soda.Count).toBe(2) + }) + test('GET /api/storefronts/v4/balance/2 401s without a token, returns the token balance', async () => { const anon = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`) expect(anon.status).toBe(401) @@ -763,6 +833,89 @@ describe('econ endpoints', () => { expect(again.status).toBe(200) }) + test('POST /api/avatar/v2/gifts/consume opens a consumable box (fires ConsumableMappingAdded)', async () => { + // Buy a consumable (Supreme Pizza, item 2266 in storefront 300) for account 26 — + // its gift box carries a ConsumableItemDesc, so opening it notifies the client. + const buy = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { + method: 'POST', + headers: { ...(await bearer('26')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + StorefrontType: 300, + PurchasableItemId: 2266, + CurrencyType: 2, + RequestedPrice: 95, + }), + }) + expect(buy.status).toBe(200) + const giftId = ((await buy.json()) as { BalanceUpdates: Array<{ Data: Array<{ Id: number }> }> }) + .BalanceUpdates[0].Data[0].Id + + // Opening the box succeeds and fires the ConsumableMappingAdded push (which no-ops + // against the test hub stub — this asserts the notify path doesn't throw). + const res = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume`, { + method: 'POST', + headers: { ...(await bearer('26')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ Id: String(giftId) }), + }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ error: '', success: true, value: null }) + + // The box is gone; the consumable stays owned (granted at purchase). + expect( + await ( + await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, { headers: await bearer('26') }) + ).json() + ).toEqual([]) + const unlocked = (await ( + await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`, { + headers: await bearer('26'), + }) + ).json()) as Array<{ ConsumableItemDesc: string }> + expect(unlocked.length).toBeGreaterThan(0) + }) + + test('POST /api/avatar/v2/gifts/consume 403s when the box belongs to another player', async () => { + // Account 27 buys an item, producing a gift box owned by 27. + const buy = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, { + method: 'POST', + headers: { ...(await bearer('27')), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + StorefrontType: 3, + PurchasableItemId: 73, + CurrencyType: 2, + RequestedPrice: 4500, + }), + }) + const giftId = ((await buy.json()) as { BalanceUpdates: Array<{ Data: Array<{ Id: number }> }> }) + .BalanceUpdates[0].Data[0].Id + + // Account 28 trying to open 27's box is forbidden — and 27 keeps it. + const forbidden = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume`, { + method: 'POST', + headers: { ...(await bearer('28')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ Id: String(giftId) }), + }) + expect(forbidden.status).toBe(403) + const stillThere = (await ( + await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts`, { headers: await bearer('27') }) + ).json()) as Array<{ Id: number }> + expect(stillThere.some((g) => g.Id === giftId)).toBe(true) + + // The owner (27) opens it fine, and re-opening the now-gone box is a harmless 200. + const ok = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume`, { + method: 'POST', + headers: { ...(await bearer('27')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ Id: String(giftId) }), + }) + expect(ok.status).toBe(200) + const again = await exports.default.fetch(`${ORIGIN}/api/avatar/v2/gifts/consume`, { + method: 'POST', + headers: { ...(await bearer('27')), 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ Id: String(giftId) }), + }) + expect(again.status).toBe(200) + }) + test('GET /api/challenge/v2/getCurrent returns the weekly challenge', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`) expect(res.status).toBe(200) diff --git a/apps/econ/vitest.config.ts b/apps/econ/vitest.config.ts index de0d903..71c1001 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: '2026-06-16', + 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 84941ad..4a28d14 100644 --- a/apps/econ/wrangler.jsonc +++ b/apps/econ/wrangler.jsonc @@ -37,6 +37,17 @@ "secret_name": "JWT_SECRET" } ], + // The `notify` worker's NotificationsHub DO — used to push websocket notifications + // to a player (e.g. ConsumableMappingRemoved when a consumable is consumed). + "durable_objects": { + "bindings": [ + { + "name": "RECFLARE_NOTIFICATIONS_HUB", + "class_name": "NotificationsHub", + "script_name": "notify" + } + ] + }, "upload_source_maps": true, "observability": { "logs": { diff --git a/apps/notify/src/notification-types.ts b/apps/notify/src/notification-types.ts new file mode 100644 index 0000000..a639442 --- /dev/null +++ b/apps/notify/src/notification-types.ts @@ -0,0 +1,52 @@ +/** + * The client's `NotificationType` enum — the integer `Id` carried on a hub + * notification frame (`{ Id, Msg }`, see {@link NotificationsHub}). The reference + * server sends these as the notification type so the client's dispatcher can route + * each frame (e.g. remove a consumed item from inventory on ConsumableMappingRemoved). + * + * Lives in the `notify` worker (the hub owner); other workers import it to send a + * typed notification instead of a magic number. No runtime dependencies, so it's safe + * to import as a value from another worker's bundle. + */ +export enum NotificationType { + RelationshipChanged = 1, + MessageReceived = 2, + MessageDeleted = 3, + PresenceHeartbeatResponse = 4, + RefreshLogin = 5, + Logout = 6, + SubscriptionUpdateProfile = 11, + SubscriptionUpdatePresence = 12, + SubscriptionUpdateGameSession = 13, + SubscriptionUpdateRoom = 15, + SubscriptionUpdateRoomPlaylist = 16, + ModerationQuitGame = 20, + ModerationUpdateRequired = 21, + ModerationKick = 22, + ModerationKickAttemptFailed = 23, + ModerationRoomBan = 24, + ServerMaintenance = 25, + GiftPackageReceived = 30, + GiftPackageReceivedImmediate = 31, + GiftPackageRewardSelectionReceived = 32, + ProfileJuniorStatusUpdate = 40, + RelationshipsInvalid = 50, + StorefrontBalanceAdd = 60, + StorefrontBalanceUpdate = 61, + StorefrontBalancePurchase = 62, + ConsumableMappingAdded = 70, + ConsumableMappingRemoved = 71, + PlayerEventCreated = 80, + PlayerEventUpdated = 81, + PlayerEventDeleted = 82, + PlayerEventResponseChanged = 83, + PlayerEventResponseDeleted = 84, + PlayerEventStateChanged = 85, + ChatMessageReceived = 90, + CommunityBoardUpdate = 95, + CommunityBoardAnnouncementUpdate = 96, + InventionModerationStateChanged = 100, + FreeGiftButtonItemsAdded = 110, + LocalRoomKeyCreated = 120, + LocalRoomKeyDeleted = 121, +} diff --git a/packages/domain/src/gifts-db.ts b/packages/domain/src/gifts-db.ts index 2e8c9b0..aff7077 100644 --- a/packages/domain/src/gifts-db.ts +++ b/packages/domain/src/gifts-db.ts @@ -36,6 +36,12 @@ export const RECEIVED_GIFT_SCHEMA_DDL: string[] = [ export interface GiftContent extends Record { ConsumableItemDesc: string ConsumableCount: number + // When the gift carries a consumable, the id of the `consumable` row granted at + // purchase and the player's total of that consumable *before* this grant. They let + // gift-consume fire an accurate ConsumableMappingAdded notification without having + // to re-correlate the box to its row. Absent on non-consumable gifts. + ConsumableMappingId?: number + ConsumablePreExistingCount?: number AvatarItemDesc: string AvatarItemType: number | null CurrencyType: number @@ -83,6 +89,26 @@ export async function createGift( return { id: row?.id ?? 0, createdAt } } +/** + * Read a gift box by id regardless of owner, returning the box plus its owner account + * id (or null when no such box exists). Lets a consumer tell "already gone" (a + * harmless no-op) apart from "belongs to another player" (which must be forbidden). + */ +export async function getGift( + db: D1Database, + giftId: number +): Promise<{ accountId: number; gift: StoredGift } | null> { + const row = await db + .prepare('SELECT id, account_id, data, created_at FROM received_gift WHERE id = ?1') + .bind(giftId) + .first<{ id: number; account_id: number; data: string; created_at: string }>() + if (row === null) return null + return { + accountId: row.account_id, + gift: { ...(JSON.parse(row.data) as GiftContent), Id: row.id, CreatedAt: row.created_at }, + } +} + /** A player's pending gift boxes, oldest first, with `Id`/`CreatedAt` merged in. */ export async function getPendingGifts(db: D1Database, accountId: number): Promise { const { results } = await db @@ -97,18 +123,22 @@ export async function getPendingGifts(db: D1Database, accountId: number): Promis } /** - * Delete (consume) a player's gift box by id. Returns false — changing nothing — - * when the box doesn't exist or isn't theirs. The item was already granted at - * purchase, so this only dismisses the box. + * Delete (consume) a player's gift box by id, returning the box's content (so the + * caller can act on what it held, e.g. notify the client of a new consumable), or + * null — changing nothing — when the box doesn't exist or isn't theirs. The item was + * already granted at purchase, so this only dismisses the box. */ export async function consumeGift( db: D1Database, accountId: number, giftId: number -): Promise { - const { meta } = await db - .prepare('DELETE FROM received_gift WHERE id = ?1 AND account_id = ?2') +): Promise { + const row = await db + .prepare( + 'DELETE FROM received_gift WHERE id = ?1 AND account_id = ?2 RETURNING id, data, created_at' + ) .bind(giftId, accountId) - .run() - return meta.changes > 0 + .first() + if (row === null) return null + return { ...(JSON.parse(row.data) as GiftContent), Id: row.id, CreatedAt: row.created_at } }