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 { Hono } from 'hono'
|
||||||
|
|
||||||
import { consumeGift } from '@repo/domain'
|
|
||||||
|
|
||||||
import { authedId, unauthorized } from '../http'
|
import { authedId, unauthorized } from '../http'
|
||||||
import {
|
import {
|
||||||
createInvention,
|
createInvention,
|
||||||
@@ -50,9 +48,9 @@ async function creatorsInvention(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- Avatar gifts ----------------------------------------------------------
|
// ---- Avatar gifts ----------------------------------------------------------
|
||||||
// The avatar read endpoints (`v4/items`, `v2`, `v2/set`, `v3/saved`, `v2/gifts`)
|
// The avatar read endpoints (`v4/items`, `v2`, `v2/set`, `v3/saved`, `v2/gifts`) and
|
||||||
// live in the `econ` worker, which the client calls on the econ host — not here.
|
// gift-box consume live in the `econ` worker, which the client calls on the econ host
|
||||||
// Only the gift generate/consume actions remain on this worker.
|
// — not here. Only the gift `generate` action remains on this worker.
|
||||||
export const avatarRoutes = new Hono<App>({ strict: false })
|
export const avatarRoutes = new Hono<App>({ strict: false })
|
||||||
.post('/api/avatar/v2/gifts/generate', async (c) => {
|
.post('/api/avatar/v2/gifts/generate', async (c) => {
|
||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
@@ -89,19 +87,6 @@ export const avatarRoutes = new Hono<App>({ strict: false })
|
|||||||
Message: message,
|
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
|
// Custom avatar item gates — real Rec Room client endpoints with no backing
|
||||||
// implementation yet; we enable them. Flip to `false` to disable the
|
// 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 '../../api.app'
|
||||||
|
|
||||||
import { createGift, getPendingGifts, RECEIVED_GIFT_SCHEMA_DDL } from '@repo/domain'
|
|
||||||
|
|
||||||
import { createImage, SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
|
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 INVENTIONS_SCHEMA_DDL } from '../../inventions-db'
|
||||||
import { SCHEMA_DDL as RELATIONSHIPS_SCHEMA_DDL } from '../../relationships-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.
|
// 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()
|
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
|
// 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([])
|
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 () => {
|
test('GET /api/customAvatarItems/v1/isCreationAllowedForAccount returns a success envelope', async () => {
|
||||||
const res = await exports.default.fetch(
|
const res = await exports.default.fetch(
|
||||||
`${ORIGIN}/api/customAvatarItems/v1/isCreationAllowedForAccount`
|
`${ORIGIN}/api/customAvatarItems/v1/isCreationAllowedForAccount`
|
||||||
|
|||||||
@@ -44,20 +44,89 @@ export interface UnlockedConsumable {
|
|||||||
IsTransferable: boolean
|
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(
|
export async function grantConsumable(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
accountId: number,
|
accountId: number,
|
||||||
consumableItemDesc: string,
|
consumableItemDesc: string,
|
||||||
count: number
|
count: number
|
||||||
): Promise<void> {
|
): Promise<number> {
|
||||||
await db
|
const row = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO consumable (account_id, consumable_item_desc, count, created_at)
|
`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())
|
.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 {
|
interface ConsumableRow {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import type { HonoApp } from '@repo/hono-helpers'
|
import type { HonoApp } from '@repo/hono-helpers'
|
||||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
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 & {
|
export type Env = SharedHonoEnv & {
|
||||||
// Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value
|
// Shared Secrets Store binding for the HS256 JWT signing key. Resolve the value
|
||||||
@@ -10,6 +13,8 @@ export type Env = SharedHonoEnv & {
|
|||||||
DB: D1Database
|
DB: D1Database
|
||||||
/** Static storefront catalogs (`static/storefronts/sf*.json`), fetched by path. */
|
/** Static storefront catalogs (`static/storefronts/sf*.json`), fetched by path. */
|
||||||
ASSETS: Fetcher
|
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
|
* 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.
|
* falls back to DEFAULT_STARTING_TOKENS, and 0 means players start broke.
|
||||||
|
|||||||
+135
-8
@@ -1,10 +1,14 @@
|
|||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||||
|
|
||||||
import { consumeGift, createGift, getPendingGifts } from '@repo/domain'
|
import { consumeGift, createGift, getGift, getPendingGifts } from '@repo/domain'
|
||||||
import { intVar, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { intVar, logger, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
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 adCarouselItems from '../static/ad-carousel-items.json'
|
||||||
import defaultAvatarItems from '../static/default-avatar-items.json'
|
import defaultAvatarItems from '../static/default-avatar-items.json'
|
||||||
import defaultAvatar from '../static/default-avatar.json'
|
import defaultAvatar from '../static/default-avatar.json'
|
||||||
@@ -18,13 +22,14 @@ import {
|
|||||||
isSpendable,
|
isSpendable,
|
||||||
spendCurrency,
|
spendCurrency,
|
||||||
} from './balance-db'
|
} from './balance-db'
|
||||||
import { getConsumables, grantConsumable } from './consumables-db'
|
import { consumeConsumable, countConsumable, getConsumables, grantConsumable } from './consumables-db'
|
||||||
import { getInventory, grantItem } from './inventory-db'
|
import { getInventory, grantItem } from './inventory-db'
|
||||||
import { getOutfits, setOutfit } from './outfit-db'
|
import { getOutfits, setOutfit } from './outfit-db'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
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 { Avatar } from './avatar-db'
|
||||||
|
import type { ConsumeResult } from './consumables-db'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
import type { AvatarItem } from './inventory-db'
|
import type { AvatarItem } from './inventory-db'
|
||||||
import type { Outfit } from './outfit-db'
|
import type { Outfit } from './outfit-db'
|
||||||
@@ -50,6 +55,80 @@ function unauthorized(c: Context<App>) {
|
|||||||
return c.body(null, 401)
|
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
|
* 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
|
* `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(
|
function toGiftContent(
|
||||||
giftDrop: StoreGiftDrop,
|
giftDrop: StoreGiftDrop,
|
||||||
message: string,
|
message: string,
|
||||||
consumableCount: number
|
consumableCount: number,
|
||||||
|
consumableMappingId = 0,
|
||||||
|
consumablePreExistingCount = 0
|
||||||
): GiftContent {
|
): GiftContent {
|
||||||
return {
|
return {
|
||||||
ConsumableItemDesc: giftDrop.ConsumableItemDesc,
|
ConsumableItemDesc: giftDrop.ConsumableItemDesc,
|
||||||
ConsumableCount: consumableCount,
|
ConsumableCount: consumableCount,
|
||||||
|
ConsumableMappingId: consumableMappingId,
|
||||||
|
ConsumablePreExistingCount: consumablePreExistingCount,
|
||||||
AvatarItemDesc: giftDrop.AvatarItemDesc,
|
AvatarItemDesc: giftDrop.AvatarItemDesc,
|
||||||
AvatarItemType: giftDrop.AvatarItemType,
|
AvatarItemType: giftDrop.AvatarItemType,
|
||||||
CurrencyType: giftDrop.CurrencyType,
|
CurrencyType: giftDrop.CurrencyType,
|
||||||
@@ -304,7 +387,22 @@ const app = new Hono<App>({ strict: false })
|
|||||||
const id = await authedId(c)
|
const id = await authedId(c)
|
||||||
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
const body = await c.req.parseBody().catch(() => ({}) as Record<string, unknown>)
|
||||||
const giftId = typeof body.Id === 'string' ? Number.parseInt(body.Id, 10) || 0 : 0
|
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 })
|
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))
|
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
|
// Currency balance. [Authorize]. The trailing int is a CurrencyType — the client
|
||||||
// fetches `/balance/2` (RecCenterTokens) on load. Backed by the `balance` table; a
|
// 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.
|
// 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' &&
|
typeof item.GiftDrop.ConsumableItemDesc === 'string' &&
|
||||||
item.GiftDrop.ConsumableItemDesc !== ''
|
item.GiftDrop.ConsumableItemDesc !== ''
|
||||||
const consumableCount = isConsumable ? CONSUMABLE_GRANT_COUNT : 0
|
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) {
|
if (isConsumable) {
|
||||||
await grantConsumable(
|
consumablePreExisting = await countConsumable(
|
||||||
|
c.env.DB,
|
||||||
|
receiverId,
|
||||||
|
item.GiftDrop.ConsumableItemDesc
|
||||||
|
)
|
||||||
|
consumableMappingId = await grantConsumable(
|
||||||
c.env.DB,
|
c.env.DB,
|
||||||
receiverId,
|
receiverId,
|
||||||
item.GiftDrop.ConsumableItemDesc,
|
item.GiftDrop.ConsumableItemDesc,
|
||||||
@@ -473,7 +600,7 @@ const app = new Hono<App>({ strict: false })
|
|||||||
const { id: giftId } = await createGift(
|
const { id: giftId } = await createGift(
|
||||||
c.env.DB,
|
c.env.DB,
|
||||||
receiverId,
|
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
|
// The response mirrors a captured real buyItem: `Balance` is the change applied (the
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
getBalance,
|
getBalance,
|
||||||
spendCurrency,
|
spendCurrency,
|
||||||
} from '../../balance-db'
|
} 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 { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
|
||||||
import { OUTFIT_SCHEMA_DDL } from '../../outfit-db'
|
import { OUTFIT_SCHEMA_DDL } from '../../outfit-db'
|
||||||
|
|
||||||
@@ -418,6 +418,76 @@ describe('econ endpoints', () => {
|
|||||||
expect(await res.json()).toEqual([])
|
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 () => {
|
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`)
|
const anon = await exports.default.fetch(`${ORIGIN}/api/storefronts/v4/balance/2`)
|
||||||
expect(anon.status).toBe(401)
|
expect(anon.status).toBe(401)
|
||||||
@@ -763,6 +833,89 @@ describe('econ endpoints', () => {
|
|||||||
expect(again.status).toBe(200)
|
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 () => {
|
test('GET /api/challenge/v2/getCurrent returns the weekly challenge', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
|
const res = await exports.default.fetch(`${ORIGIN}/api/challenge/v2/getCurrent`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
|
|||||||
@@ -9,6 +9,28 @@ export default defineConfig({
|
|||||||
bindings: {
|
bindings: {
|
||||||
ENVIRONMENT: 'VITEST',
|
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"
|
"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,
|
"upload_source_maps": true,
|
||||||
"observability": {
|
"observability": {
|
||||||
"logs": {
|
"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,
|
||||||
|
}
|
||||||
@@ -36,6 +36,12 @@ export const RECEIVED_GIFT_SCHEMA_DDL: string[] = [
|
|||||||
export interface GiftContent extends Record<string, unknown> {
|
export interface GiftContent extends Record<string, unknown> {
|
||||||
ConsumableItemDesc: string
|
ConsumableItemDesc: string
|
||||||
ConsumableCount: number
|
ConsumableCount: number
|
||||||
|
// When the gift carries a consumable, the id of the `consumable` row granted at
|
||||||
|
// purchase and the player's total of that consumable *before* this grant. They let
|
||||||
|
// gift-consume fire an accurate ConsumableMappingAdded notification without having
|
||||||
|
// to re-correlate the box to its row. Absent on non-consumable gifts.
|
||||||
|
ConsumableMappingId?: number
|
||||||
|
ConsumablePreExistingCount?: number
|
||||||
AvatarItemDesc: string
|
AvatarItemDesc: string
|
||||||
AvatarItemType: number | null
|
AvatarItemType: number | null
|
||||||
CurrencyType: number
|
CurrencyType: number
|
||||||
@@ -83,6 +89,26 @@ export async function createGift(
|
|||||||
return { id: row?.id ?? 0, createdAt }
|
return { id: row?.id ?? 0, createdAt }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read a gift box by id regardless of owner, returning the box plus its owner account
|
||||||
|
* id (or null when no such box exists). Lets a consumer tell "already gone" (a
|
||||||
|
* harmless no-op) apart from "belongs to another player" (which must be forbidden).
|
||||||
|
*/
|
||||||
|
export async function getGift(
|
||||||
|
db: D1Database,
|
||||||
|
giftId: number
|
||||||
|
): Promise<{ accountId: number; gift: StoredGift } | null> {
|
||||||
|
const row = await db
|
||||||
|
.prepare('SELECT id, account_id, data, created_at FROM received_gift WHERE id = ?1')
|
||||||
|
.bind(giftId)
|
||||||
|
.first<{ id: number; account_id: number; data: string; created_at: string }>()
|
||||||
|
if (row === null) return null
|
||||||
|
return {
|
||||||
|
accountId: row.account_id,
|
||||||
|
gift: { ...(JSON.parse(row.data) as GiftContent), Id: row.id, CreatedAt: row.created_at },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A player's pending gift boxes, oldest first, with `Id`/`CreatedAt` merged in. */
|
/** A player's pending gift boxes, oldest first, with `Id`/`CreatedAt` merged in. */
|
||||||
export async function getPendingGifts(db: D1Database, accountId: number): Promise<StoredGift[]> {
|
export async function getPendingGifts(db: D1Database, accountId: number): Promise<StoredGift[]> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
@@ -97,18 +123,22 @@ export async function getPendingGifts(db: D1Database, accountId: number): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete (consume) a player's gift box by id. Returns false — changing nothing —
|
* Delete (consume) a player's gift box by id, returning the box's content (so the
|
||||||
* when the box doesn't exist or isn't theirs. The item was already granted at
|
* caller can act on what it held, e.g. notify the client of a new consumable), or
|
||||||
* purchase, so this only dismisses the box.
|
* null — changing nothing — when the box doesn't exist or isn't theirs. The item was
|
||||||
|
* already granted at purchase, so this only dismisses the box.
|
||||||
*/
|
*/
|
||||||
export async function consumeGift(
|
export async function consumeGift(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
accountId: number,
|
accountId: number,
|
||||||
giftId: number
|
giftId: number
|
||||||
): Promise<boolean> {
|
): Promise<StoredGift | null> {
|
||||||
const { meta } = await db
|
const row = await db
|
||||||
.prepare('DELETE FROM received_gift WHERE id = ?1 AND account_id = ?2')
|
.prepare(
|
||||||
|
'DELETE FROM received_gift WHERE id = ?1 AND account_id = ?2 RETURNING id, data, created_at'
|
||||||
|
)
|
||||||
.bind(giftId, accountId)
|
.bind(giftId, accountId)
|
||||||
.run()
|
.first<GiftRow>()
|
||||||
return meta.changes > 0
|
if (row === null) return null
|
||||||
|
return { ...(JSON.parse(row.data) as GiftContent), Id: row.id, CreatedAt: row.created_at }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user