mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[econ] fix consumable notification
This commit is contained in:
+74
-10
@@ -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()
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
Reference in New Issue
Block a user