attempting to fix some consumables not appearing in avatar immediately

This commit is contained in:
Devin Zuczek
2026-07-15 17:25:22 -04:00
parent 5345bd0517
commit 916e42c33c
10 changed files with 494 additions and 112 deletions
+3 -18
View File
@@ -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<App>({ strict: false })
.post('/api/avatar/v2/gifts/generate', async (c) => {
const id = await authedId(c)
@@ -89,19 +87,6 @@ export const avatarRoutes = new Hono<App>({ 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<string, unknown>)
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
-72
View File
@@ -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 players 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 players 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`
+74 -5
View File
@@ -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<void> {
await db
): Promise<number> {
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<number> {
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<ConsumeResult | null> {
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 {
+5
View File
@@ -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<NotificationsHub>
/**
* 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.
+135 -8
View File
@@ -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<App>) {
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<App>,
accountId: number,
consumed: ConsumeResult
): Promise<void> {
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<App>,
accountId: number,
gift: StoredGift
): Promise<void> {
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<App>({ strict: false })
const id = await authedId(c)
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
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<App>({ 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<App>({ 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<App>({ 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
+154 -1
View File
@@ -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<Group | undefined> => {
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)
+22
View File
@@ -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') } }
`,
},
],
},
}),
],
+11
View File
@@ -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": {
+52
View File
@@ -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,
}