mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
attempting to fix some consumables not appearing in avatar immediately
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 player’s 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 player’s 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`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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') } }
|
||||
`,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
Reference in New Issue
Block a user