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 { SavedInvention } from '../../api/src/inventions-db'
|
||||||
import type {
|
import type {
|
||||||
BalanceResponsePayload,
|
BalanceResponsePayload,
|
||||||
|
ConsumableMappingPayload,
|
||||||
LocalRoomKeyPayload,
|
LocalRoomKeyPayload,
|
||||||
PurchaseBalanceModificationPayload,
|
PurchaseBalanceModificationPayload,
|
||||||
RoomCurrencyPayload,
|
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
|
* Push a ConsumableMappingRemoved notification to a player after they consume a
|
||||||
* consumable, mirroring the reference's
|
* consumable, mirroring the reference's
|
||||||
@@ -589,7 +608,9 @@ async function pushConsumableRemoved(
|
|||||||
IsActive: false,
|
IsActive: false,
|
||||||
ActiveDurationMinutes: 0,
|
ActiveDurationMinutes: 0,
|
||||||
IsTransferable: false,
|
IsTransferable: false,
|
||||||
}
|
ActivatedByRoomie: false,
|
||||||
|
IsRRO: isRroConsumable(consumed.consumableItemDesc),
|
||||||
|
} satisfies ConsumableMappingPayload
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('failed to push ConsumableMappingRemoved notification', {
|
logger.error('failed to push ConsumableMappingRemoved notification', {
|
||||||
@@ -611,21 +632,49 @@ async function pushConsumableAdded(
|
|||||||
c: Context<App>,
|
c: Context<App>,
|
||||||
accountId: number,
|
accountId: number,
|
||||||
gift: StoredGift
|
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> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
|
||||||
accountId,
|
accountId,
|
||||||
NotificationType.ConsumableMappingAdded,
|
NotificationType.ConsumableMappingAdded,
|
||||||
{
|
{
|
||||||
Id: gift.ConsumableMappingId ?? 0,
|
Id: granted.mappingId,
|
||||||
ConsumableItemDesc: gift.ConsumableItemDesc,
|
ConsumableItemDesc: granted.consumableItemDesc,
|
||||||
CreatedAt: new Date().toISOString(),
|
CreatedAt: new Date().toISOString(),
|
||||||
Count: gift.ConsumableCount,
|
Count: granted.count,
|
||||||
InitialCount: gift.ConsumablePreExistingCount ?? 0,
|
InitialCount: granted.preExistingCount,
|
||||||
IsActive: false,
|
IsActive: false,
|
||||||
ActiveDurationMinutes: 0,
|
ActiveDurationMinutes: 0,
|
||||||
IsTransferable: false,
|
IsTransferable: false,
|
||||||
}
|
ActivatedByRoomie: false,
|
||||||
|
IsRRO: isRroConsumable(granted.consumableItemDesc),
|
||||||
|
} satisfies ConsumableMappingPayload
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('failed to push ConsumableMappingAdded notification', {
|
logger.error('failed to push ConsumableMappingAdded notification', {
|
||||||
@@ -1834,7 +1883,19 @@ async function grantGiftDrop(
|
|||||||
consumableCount
|
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(
|
const { id } = await createGift(
|
||||||
db,
|
db,
|
||||||
accountId,
|
accountId,
|
||||||
@@ -5168,11 +5229,14 @@ const app = new Hono<App>({ strict: false })
|
|||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
if (id === null) return unauthorized(c)
|
if (id === null) return unauthorized(c)
|
||||||
const consumables = await getConsumables(c.env.DB, id)
|
const consumables = await getConsumables(c.env.DB, id)
|
||||||
// These rows are purchases of built-in store items. The 2026 client
|
// The 2026 client includes this flag in the instance key and verifies it against the
|
||||||
// includes this flag in the instance key and verifies it against the definition.
|
// definition, so it comes from the definition — see `isRroConsumable`.
|
||||||
return c.json(
|
return c.json(
|
||||||
((await authedBuild(c)) ?? 0) >= 20260323
|
((await authedBuild(c)) ?? 0) >= 20260323
|
||||||
? consumables.map((item) => ({ ...item, IsRRO: true }))
|
? consumables.map((item) => ({
|
||||||
|
...item,
|
||||||
|
IsRRO: isRroConsumable(item.ConsumableItemDesc),
|
||||||
|
}))
|
||||||
: consumables
|
: consumables
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5167,6 +5167,46 @@ describe('econ endpoints', () => {
|
|||||||
expect(list[0].friendlyName).toBe(SF3_ITEM.name)
|
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 () => {
|
test('POST /api/items/bulkpurchase routes a gifted line to its receiver', async () => {
|
||||||
await seedAccount(207, 'BagReceiver')
|
await seedAccount(207, 'BagReceiver')
|
||||||
await drainFrames()
|
await drainFrames()
|
||||||
|
|||||||
@@ -178,17 +178,34 @@ export interface RewardBalanceModificationPayload {
|
|||||||
BalanceInGiftBox: boolean
|
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 {
|
export interface ConsumableMappingPayload {
|
||||||
Id: number
|
Id: number
|
||||||
ConsumableItemDesc: string
|
ConsumableItemDesc: string
|
||||||
Count: number
|
Count: number
|
||||||
|
/** 2025 only; 2026 dropped it. */
|
||||||
InitialCount: number
|
InitialCount: number
|
||||||
/** ISO-8601. */
|
/** ISO-8601. */
|
||||||
CreatedAt: string
|
CreatedAt: string
|
||||||
ActiveDurationMinutes: number | null
|
ActiveDurationMinutes: number | null
|
||||||
IsActive: boolean
|
IsActive: boolean
|
||||||
IsTransferable: 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`. */
|
/** `ModerationKick` (22) and `ModerationUnkick`. Room bans go out as this with `IsBan`. */
|
||||||
|
|||||||
Reference in New Issue
Block a user