From 2091483cc30fbb4172d6a48b5e8af3b51a23e0dd Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 19 Oct 2026 13:54:00 -0400 Subject: [PATCH] [econ] fix consumable notification --- apps/econ/src/econ.app.ts | 84 +++++++++++++++++++--- apps/econ/src/test/integration/api.test.ts | 40 +++++++++++ apps/notify/src/notification-payloads.ts | 19 ++++- 3 files changed, 132 insertions(+), 11 deletions(-) diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 1769c41..5e296e1 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -216,6 +216,7 @@ import type { CustomAvatarItem } from '../../api/src/custom-avatar-items-db' import type { SavedInvention } from '../../api/src/inventions-db' import type { BalanceResponsePayload, + ConsumableMappingPayload, LocalRoomKeyPayload, PurchaseBalanceModificationPayload, RoomCurrencyPayload, @@ -564,6 +565,24 @@ async function pushRoomKeyCreated( ) } +/** + * Whether a consumable is a first-party (Rec Room Original) item, as its definition in + * `static/consumables.json` says — the value every consumable instance we serve must carry. + * + * The 2026 client keys an instance on this flag and checks it against the definition it holds: + * a mismatch throws (`Key IsRRO=False does not match Definition IsRRO=True!`) and the frame is + * lost, so the purchase never shows. Every definition in the file says true; a desc with none + * is still a built-in store item, so it defaults to true too. + */ +const RRO_BY_DESC = new Map( + (consumableDefinitions as Array<{ ConsumableItemDesc: string; IsRRO?: boolean }>).map((d) => [ + d.ConsumableItemDesc, + d.IsRRO !== false, + ]) +) +const isRroConsumable = (consumableItemDesc: string): boolean => + RRO_BY_DESC.get(consumableItemDesc) ?? true + /** * Push a ConsumableMappingRemoved notification to a player after they consume a * consumable, mirroring the reference's @@ -589,7 +608,9 @@ async function pushConsumableRemoved( IsActive: false, ActiveDurationMinutes: 0, IsTransferable: false, - } + ActivatedByRoomie: false, + IsRRO: isRroConsumable(consumed.consumableItemDesc), + } satisfies ConsumableMappingPayload ) } catch (err) { logger.error('failed to push ConsumableMappingRemoved notification', { @@ -611,21 +632,49 @@ async function pushConsumableAdded( c: Context, accountId: number, gift: StoredGift +): Promise { + await pushConsumableMappingAdded(c, accountId, { + mappingId: gift.ConsumableMappingId ?? 0, + consumableItemDesc: gift.ConsumableItemDesc, + count: gift.ConsumableCount, + preExistingCount: gift.ConsumablePreExistingCount ?? 0, + }) +} + +/** + * The `ConsumableMappingAdded` frame itself — one granted consumable instance (its row id, + * how many it holds, and the player's count before it). Sent when a box carrying it is + * opened ({@link pushConsumableAdded}) or, when there IS no box, at grant time: a purchase + * under `BypassGiftPackages` is never opened, and without this the consumable didn't show + * until the client refetched its inventory. Never both for one grant — a second frame for + * the same row id could be counted twice. Best-effort like the removed push. + */ +async function pushConsumableMappingAdded( + c: Context, + accountId: number, + granted: { + mappingId: number + consumableItemDesc: string + count: number + preExistingCount: number + } ): Promise { try { await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( accountId, NotificationType.ConsumableMappingAdded, { - Id: gift.ConsumableMappingId ?? 0, - ConsumableItemDesc: gift.ConsumableItemDesc, + Id: granted.mappingId, + ConsumableItemDesc: granted.consumableItemDesc, CreatedAt: new Date().toISOString(), - Count: gift.ConsumableCount, - InitialCount: gift.ConsumablePreExistingCount ?? 0, + Count: granted.count, + InitialCount: granted.preExistingCount, IsActive: false, ActiveDurationMinutes: 0, IsTransferable: false, - } + ActivatedByRoomie: false, + IsRRO: isRroConsumable(granted.consumableItemDesc), + } satisfies ConsumableMappingPayload ) } catch (err) { logger.error('failed to push ConsumableMappingAdded notification', { @@ -1834,7 +1883,19 @@ async function grantGiftDrop( consumableCount ) } - if (options.skipGiftBox === true) return { id: 0, drop: giftDrop } + if (options.skipGiftBox === true) { + // No box means nothing will ever be opened to announce the consumable, so announce it + // now — otherwise a bag bought with `BypassGiftPackages` doesn't show it until a refetch. + if (isConsumable) { + await pushConsumableMappingAdded(c, accountId, { + mappingId: consumableMappingId, + consumableItemDesc: giftDrop.ConsumableItemDesc, + count: consumableCount, + preExistingCount: consumablePreExisting, + }) + } + return { id: 0, drop: giftDrop } + } const { id } = await createGift( db, accountId, @@ -5168,11 +5229,14 @@ const app = new Hono({ strict: false }) const id = await authedId(c) if (id === null) return unauthorized(c) const consumables = await getConsumables(c.env.DB, id) - // These rows are purchases of built-in store items. The 2026 client - // includes this flag in the instance key and verifies it against the definition. + // The 2026 client includes this flag in the instance key and verifies it against the + // definition, so it comes from the definition — see `isRroConsumable`. return c.json( ((await authedBuild(c)) ?? 0) >= 20260323 - ? consumables.map((item) => ({ ...item, IsRRO: true })) + ? consumables.map((item) => ({ + ...item, + IsRRO: isRroConsumable(item.ConsumableItemDesc), + })) : consumables ) } diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 3b874c4..d71a6ba 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -5167,6 +5167,46 @@ describe('econ endpoints', () => { expect(list[0].friendlyName).toBe(SF3_ITEM.name) }) + test('POST /api/items/bulkpurchase announces a box-less consumable with ConsumableMappingAdded', async () => { + // No box means nothing is ever opened to announce the consumable, so the bag announces it + // at grant time — without this it didn't show in the client until an inventory refetch. + // 2182 is a consumable; three copies stack into one granted row. + await drainFrames() + const res = await bulkPurchase('951', { + BypassGiftPackages: true, + PurchaseItemRequests: [line(2182, 100, { DuplicateItemCount: 3 })], + }) + expect(((await res.json()) as BulkBody).Success).toBe(true) + const added = (await drainFrames()).filter( + (f) => f.notificationType === NotificationType.ConsumableMappingAdded + ) + expect(added).toHaveLength(1) + const [frame] = added + expect(frame!.accountId).toBe(951) + expect(frame!.payload).toMatchObject({ + // The item's own desc, as the store it was bought from (2023 sf3: Assorted Donuts) + // lists it — 2026 applies the frame only for a desc it knows. + ConsumableItemDesc: 'ZuvkidodzkuOfGLDnTOFyg', + Count: 3, + InitialCount: 0, + IsActive: false, + IsTransferable: false, + ActivatedByRoomie: false, + // Must equal the definition's (consumables.json: true) or 2026 throws and drops it. + IsRRO: true, + }) + expect((frame!.payload as { Id: number }).Id).toBeGreaterThan(0) + + // With a box, the frame waits for the box to be opened: never sent twice for one grant. + await drainFrames() + await bulkPurchase('951', { PurchaseItemRequests: [line(2182, 100)] }) + expect( + (await drainFrames()).filter( + (f) => f.notificationType === NotificationType.ConsumableMappingAdded + ) + ).toEqual([]) + }) + test('POST /api/items/bulkpurchase routes a gifted line to its receiver', async () => { await seedAccount(207, 'BagReceiver') await drainFrames() diff --git a/apps/notify/src/notification-payloads.ts b/apps/notify/src/notification-payloads.ts index 4c6ae8d..bb26e40 100644 --- a/apps/notify/src/notification-payloads.ts +++ b/apps/notify/src/notification-payloads.ts @@ -178,17 +178,34 @@ export interface RewardBalanceModificationPayload { BalanceInGiftBox: boolean } -/** `ConsumableMappingAdded` (70) / `ConsumableMappingRemoved` (71). */ +/** + * `ConsumableMappingAdded` (70) / `ConsumableMappingRemoved` (71) — one consumable INSTANCE. + * + * A superset of two builds. 2026 decodes it as `ConsumableInstanceDTO` (9 keys: `Id` — a + * `[DataMember]` rename of `InstanceId` — `ConsumableItemDesc`, `CreatedAt`, `Count`, + * `ActiveDurationMinutes`, `IsActive`, `IsTransferable`, `ActivatedByRoomie`, `IsRRO`) and + * applies the frame only for a `ConsumableItemDesc` it knows, so send the item's own desc. + * `InitialCount` is gone on 2026 but still read by 2025; each decoder drops the keys it + * doesn't know, so one payload serves both. + */ export interface ConsumableMappingPayload { Id: number ConsumableItemDesc: string Count: number + /** 2025 only; 2026 dropped it. */ InitialCount: number /** ISO-8601. */ CreatedAt: string ActiveDurationMinutes: number | null IsActive: boolean IsTransferable: boolean + /** 2026. */ + ActivatedByRoomie: boolean + /** + * 2026. Must EQUAL the item's definition (`IsRRO` in econ's `static/consumables.json`): the + * client checks it and throws on a mismatch, losing the frame. + */ + IsRRO: boolean } /** `ModerationKick` (22) and `ModerationUnkick`. Room bans go out as this with `IsBan`. */