mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
equipment
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
-- Owned equipment, owned by the `econ` worker. Like avatar items (own-once, one row
|
||||||
|
-- per (account, item)) rather than consumables (which stack): equipment is a boolean
|
||||||
|
-- unlock keyed by its `EquipmentModificationGuid` (the gift-drop's equipment guid), so
|
||||||
|
-- re-buying the same skin is a no-op rather than a duplicate row. Granted at purchase
|
||||||
|
-- time by `/api/storefronts/v2/buyItem` (when the gift-drop carries an
|
||||||
|
-- `EquipmentModificationGuid`) and read back by `/api/equipment/v2/getUnlocked`; `data`
|
||||||
|
-- is the rendered unlocked-equipment DTO. Kept in sync with EQUIPMENT_SCHEMA_DDL in
|
||||||
|
-- src/equipment-db.ts.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS equipment (
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
equipment_modification_guid TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (account_id, equipment_modification_guid)
|
||||||
|
);
|
||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
getConsumables,
|
getConsumables,
|
||||||
grantConsumable,
|
grantConsumable,
|
||||||
} from './consumables-db'
|
} from './consumables-db'
|
||||||
|
import { getEquipment, grantEquipment } from './equipment-db'
|
||||||
import { getInventory, grantItem } from './inventory-db'
|
import { getInventory, grantItem } from './inventory-db'
|
||||||
import {
|
import {
|
||||||
AUTHED,
|
AUTHED,
|
||||||
@@ -59,6 +60,7 @@ 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 { ConsumeResult } from './consumables-db'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
|
import type { Equipment } from './equipment-db'
|
||||||
import type { AvatarItem } from './inventory-db'
|
import type { AvatarItem } from './inventory-db'
|
||||||
import type { Outfit } from './outfit-db'
|
import type { Outfit } from './outfit-db'
|
||||||
|
|
||||||
@@ -275,6 +277,17 @@ function toAvatarItem(giftDrop: StoreGiftDrop): AvatarItem {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build the owned equipment DTO granted into the buyer's inventory from a gift-drop. */
|
||||||
|
function toEquipment(giftDrop: StoreGiftDrop): Equipment {
|
||||||
|
return {
|
||||||
|
EquipmentModificationGuid: giftDrop.EquipmentModificationGuid,
|
||||||
|
EquipmentPrefabName: giftDrop.EquipmentPrefabName,
|
||||||
|
FriendlyName: giftDrop.FriendlyName,
|
||||||
|
Tooltip: giftDrop.Tooltip,
|
||||||
|
Rarity: giftDrop.Rarity,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Quantity of a consumable granted per purchase — our storefront catalogs don't specify one. */
|
/** Quantity of a consumable granted per purchase — our storefront catalogs don't specify one. */
|
||||||
const CONSUMABLE_GRANT_COUNT = 1
|
const CONSUMABLE_GRANT_COUNT = 1
|
||||||
|
|
||||||
@@ -688,9 +701,17 @@ const app = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Unlocked equipment. Returns "[]" with no auth.
|
// Unlocked equipment. [Authorize]. The equipment skins the player has bought (from
|
||||||
.get('/api/equipment/v2/getUnlocked', listRoute('Unlocked equipment', 'Empty for now'), (c) =>
|
// `buyItem`, stored in the `equipment` table). A player who has bought none gets an
|
||||||
c.json([])
|
// empty list.
|
||||||
|
.get(
|
||||||
|
'/api/equipment/v2/getUnlocked',
|
||||||
|
listRoute('Unlocked equipment', 'The equipment skins the player has bought', true),
|
||||||
|
async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return unauthorized(c)
|
||||||
|
return c.json(await getEquipment(c.env.DB, id))
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Room consumables/currencies for a given room. Stubbed as empty lists so the
|
// Room consumables/currencies for a given room. Stubbed as empty lists so the
|
||||||
@@ -951,10 +972,17 @@ const app = new Hono<App>({ strict: false })
|
|||||||
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
|
if (!paid) return c.json({ error: 'Insufficient balance' }, 400)
|
||||||
|
|
||||||
// Grant the item to the recipient. A gift-drop carries an avatar item, a consumable,
|
// Grant the item to the recipient. A gift-drop carries an avatar item, a consumable,
|
||||||
// or neither (currency/xp drops aren't granted yet); grant whichever it actually has.
|
// an equipment skin, or none of these (currency/xp drops aren't granted yet); grant
|
||||||
|
// whichever it actually has.
|
||||||
if (typeof item.GiftDrop.AvatarItemDesc === 'string' && item.GiftDrop.AvatarItemDesc !== '') {
|
if (typeof item.GiftDrop.AvatarItemDesc === 'string' && item.GiftDrop.AvatarItemDesc !== '') {
|
||||||
await grantItem(c.env.DB, receiverId, toAvatarItem(item.GiftDrop))
|
await grantItem(c.env.DB, receiverId, toAvatarItem(item.GiftDrop))
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
typeof item.GiftDrop.EquipmentModificationGuid === 'string' &&
|
||||||
|
item.GiftDrop.EquipmentModificationGuid !== ''
|
||||||
|
) {
|
||||||
|
await grantEquipment(c.env.DB, receiverId, toEquipment(item.GiftDrop))
|
||||||
|
}
|
||||||
const isConsumable =
|
const isConsumable =
|
||||||
typeof item.GiftDrop.ConsumableItemDesc === 'string' &&
|
typeof item.GiftDrop.ConsumableItemDesc === 'string' &&
|
||||||
item.GiftDrop.ConsumableItemDesc !== ''
|
item.GiftDrop.ConsumableItemDesc !== ''
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Owned equipment on the shared `recflare` D1 database — the equipment skins a player
|
||||||
|
* has bought from a storefront (e.g. a "Bow Skin (Dryad Summer)"). One row per
|
||||||
|
* (account, item): like avatar items, owning a piece of equipment is boolean, so the
|
||||||
|
* skin is granted at purchase time (`POST /api/storefronts/v2/buyItem`, when the
|
||||||
|
* gift-drop carries an `EquipmentModificationGuid`) and read back by
|
||||||
|
* `GET /api/equipment/v2/getUnlocked`.
|
||||||
|
*
|
||||||
|
* The item is keyed by its `EquipmentModificationGuid` — the gift-drop's equipment guid
|
||||||
|
* — so re-buying the same skin upserts rather than piling up duplicate rows (these
|
||||||
|
* drops are flagged `Unique`). `data` is the rendered unlocked-equipment DTO, stored
|
||||||
|
* opaquely and served back untouched.
|
||||||
|
*
|
||||||
|
* This worker (`econ`) owns the table and its migration — see apps/econ/migrations/
|
||||||
|
* 0006_equipment.sql. The gift box the purchase also creates lives in a separate table
|
||||||
|
* (@repo/domain's received_gift); ownership does not depend on the box being opened.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Schema DDL (mirror of migrations 0006_equipment.sql) — also builds the table in tests. */
|
||||||
|
export const EQUIPMENT_SCHEMA_DDL: string[] = [
|
||||||
|
`CREATE TABLE IF NOT EXISTS equipment (
|
||||||
|
account_id INTEGER NOT NULL,
|
||||||
|
equipment_modification_guid TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (account_id, equipment_modification_guid)
|
||||||
|
)`,
|
||||||
|
]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rendered piece of unlocked equipment, as `/api/equipment/v2/getUnlocked` serves it.
|
||||||
|
* `EquipmentModificationGuid` is the item's guid string and the row's key;
|
||||||
|
* `EquipmentPrefabName` names the base equipment the modification applies to.
|
||||||
|
*/
|
||||||
|
export interface Equipment extends Record<string, unknown> {
|
||||||
|
EquipmentModificationGuid: string
|
||||||
|
EquipmentPrefabName: string
|
||||||
|
FriendlyName: string
|
||||||
|
Tooltip: string
|
||||||
|
Rarity: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grant a piece of equipment into a player's inventory. Upserts on
|
||||||
|
* (account_id, equipment_modification_guid): owning equipment is boolean, so re-buying
|
||||||
|
* it refreshes the stored DTO rather than adding a second copy.
|
||||||
|
*/
|
||||||
|
export async function grantEquipment(
|
||||||
|
db: D1Database,
|
||||||
|
accountId: number,
|
||||||
|
equipment: Equipment
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO equipment (account_id, equipment_modification_guid, data) VALUES (?1, ?2, ?3)
|
||||||
|
ON CONFLICT (account_id, equipment_modification_guid) DO UPDATE SET data = ?3`
|
||||||
|
)
|
||||||
|
.bind(accountId, equipment.EquipmentModificationGuid, JSON.stringify(equipment))
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every piece of equipment a player owns, ordered by guid for a stable listing. */
|
||||||
|
export async function getEquipment(db: D1Database, accountId: number): Promise<Equipment[]> {
|
||||||
|
const { results } = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT data FROM equipment WHERE account_id = ?1 ORDER BY equipment_modification_guid'
|
||||||
|
)
|
||||||
|
.bind(accountId)
|
||||||
|
.all<{ data: string }>()
|
||||||
|
return results.map((r) => JSON.parse(r.data) as Equipment)
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
spendCurrency,
|
spendCurrency,
|
||||||
} from '../../balance-db'
|
} from '../../balance-db'
|
||||||
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
|
import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
|
||||||
|
import { EQUIPMENT_SCHEMA_DDL } from '../../equipment-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'
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ beforeAll(async () => {
|
|||||||
for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of OUTFIT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
|
for (const stmt of EQUIPMENT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
for (const stmt of RECEIVED_GIFT_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||||
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||||
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
|
.bind(JSON.stringify({ accountId: 42, username: 'Tester', displayName: 'Tester' }))
|
||||||
@@ -364,8 +366,13 @@ describe('econ endpoints', () => {
|
|||||||
expect(await res.json()).toEqual([])
|
expect(await res.json()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('GET /api/equipment/v2/getUnlocked returns [] (no auth)', async () => {
|
test('GET /api/equipment/v2/getUnlocked 401s without a token, returns [] when none owned', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`)
|
const anon = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`)
|
||||||
|
expect(anon.status).toBe(401)
|
||||||
|
// Account 30 has bought no equipment → empty list.
|
||||||
|
const res = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`, {
|
||||||
|
headers: await bearer('30'),
|
||||||
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toEqual([])
|
expect(await res.json()).toEqual([])
|
||||||
})
|
})
|
||||||
@@ -732,6 +739,64 @@ describe('econ endpoints', () => {
|
|||||||
expect(second[0].CreatedAts).toHaveLength(2)
|
expect(second[0].CreatedAts).toHaveLength(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('POST /api/storefronts/v2/buyItem grants equipment, read back by getUnlocked, no re-buy dupe', async () => {
|
||||||
|
// Item 1950 (Disc Skin (Coop)) in storefront 3 is a pure equipment drop — its
|
||||||
|
// gift-drop carries an EquipmentModificationGuid but no avatar/consumable desc.
|
||||||
|
const guid = '19ef59c7-f74b-4c63-935a-1d4b1abd8518'
|
||||||
|
const buy = async () =>
|
||||||
|
exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(await bearer('31')), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
StorefrontType: 3,
|
||||||
|
PurchasableItemId: 1950,
|
||||||
|
CurrencyType: 2,
|
||||||
|
RequestedPrice: 3500,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
const res = await buy()
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
const body = (await res.json()) as {
|
||||||
|
Balance: number
|
||||||
|
BalanceUpdates: Array<{
|
||||||
|
Data: Array<{ Id: number; EquipmentModificationGuid: string; EquipmentPrefabName: string }>
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
expect(body.Balance).toBe(-3500)
|
||||||
|
const gift = body.BalanceUpdates[0].Data[0]
|
||||||
|
expect(gift.EquipmentModificationGuid).toBe(guid)
|
||||||
|
expect(gift.EquipmentPrefabName).toBe('[DiscGolfDisc]')
|
||||||
|
|
||||||
|
const unlocked = async () => {
|
||||||
|
const r = await exports.default.fetch(`${ORIGIN}/api/equipment/v2/getUnlocked`, {
|
||||||
|
headers: await bearer('31'),
|
||||||
|
})
|
||||||
|
expect(r.status).toBe(200)
|
||||||
|
return (await r.json()) as Array<{
|
||||||
|
EquipmentModificationGuid: string
|
||||||
|
EquipmentPrefabName: string
|
||||||
|
FriendlyName: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
const first = await unlocked()
|
||||||
|
expect(first).toHaveLength(1)
|
||||||
|
expect(first[0].EquipmentModificationGuid).toBe(guid)
|
||||||
|
expect(first[0].EquipmentPrefabName).toBe('[DiscGolfDisc]')
|
||||||
|
expect(first[0].FriendlyName).toBe('Disc Skin (Coop)')
|
||||||
|
|
||||||
|
// Equipment is not an avatar item — it does not show up in v4/items.
|
||||||
|
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
||||||
|
headers: await bearer('31'),
|
||||||
|
})
|
||||||
|
const list = (await items.json()) as Array<{ FriendlyName: string }>
|
||||||
|
expect(list.every((i) => i.FriendlyName !== 'Disc Skin (Coop)')).toBe(true)
|
||||||
|
|
||||||
|
// Owning equipment is boolean: re-buying upserts, it does not add a second row.
|
||||||
|
expect((await buy()).status).toBe(200)
|
||||||
|
expect(await unlocked()).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
test('POST /api/storefronts/v2/buyItem 409s when the sent price no longer matches', async () => {
|
test('POST /api/storefronts/v2/buyItem 409s when the sent price no longer matches', async () => {
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v2/buyItem`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
Reference in New Issue
Block a user