[econ] fix consumable notification

This commit is contained in:
Devin Zuczek
2026-10-19 13:54:00 -04:00
parent d918936795
commit 2091483cc3
3 changed files with 132 additions and 11 deletions
+74 -10
View File
@@ -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<App>,
accountId: number,
gift: StoredGift
): Promise<void> {
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<App>,
accountId: number,
granted: {
mappingId: number
consumableItemDesc: string
count: number
preExistingCount: number
}
): Promise<void> {
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<App>({ 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
)
}
@@ -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()
+18 -1
View File
@@ -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`. */