mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
add equipment update
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
-- Rewrite the stored unlocked-equipment DTOs onto the shape the client actually reads.
|
||||||
|
-- Rows written before this used `EquipmentModificationGuid`/`EquipmentPrefabName` (the
|
||||||
|
-- gift-drop's prefixed names, carried straight over at grant time) and had no
|
||||||
|
-- `Favorited`/`PlatformMask`. The live endpoint serves the unprefixed
|
||||||
|
-- `ModificationGuid`/`PrefabName` plus both of those, and the entries the client PUTs
|
||||||
|
-- back to `/api/equipment/v1/update` use the unprefixed names too — so an un-rewritten
|
||||||
|
-- row renders with a blank prefab and can never be favourited (the update matches on a
|
||||||
|
-- guid the row's `data` no longer spells the same way).
|
||||||
|
--
|
||||||
|
-- The `data` column is the DTO verbatim, so the fix is a JSON rewrite in place; the row
|
||||||
|
-- key (`equipment_modification_guid`) is unchanged. Guarded on the old key being
|
||||||
|
-- present, which also makes it a no-op on re-run.
|
||||||
|
|
||||||
|
UPDATE equipment
|
||||||
|
SET data = json_object(
|
||||||
|
'ModificationGuid', json_extract(data, '$.EquipmentModificationGuid'),
|
||||||
|
'PrefabName', json_extract(data, '$.EquipmentPrefabName'),
|
||||||
|
'FriendlyName', json_extract(data, '$.FriendlyName'),
|
||||||
|
'Tooltip', json_extract(data, '$.Tooltip'),
|
||||||
|
'Rarity', json_extract(data, '$.Rarity'),
|
||||||
|
'PlatformMask', -1,
|
||||||
|
'Favorited', json('false')
|
||||||
|
)
|
||||||
|
WHERE json_extract(data, '$.EquipmentModificationGuid') IS NOT NULL;
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
getConsumables,
|
getConsumables,
|
||||||
grantConsumable,
|
grantConsumable,
|
||||||
} from './consumables-db'
|
} from './consumables-db'
|
||||||
import { getEquipment, grantEquipment } from './equipment-db'
|
import { getEquipment, grantEquipment, setEquipmentFavorited } from './equipment-db'
|
||||||
import { getInventory, grantItem } from './inventory-db'
|
import { getInventory, grantItem } from './inventory-db'
|
||||||
import {
|
import {
|
||||||
AUTHED,
|
AUTHED,
|
||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
ConsumeEnvelope,
|
ConsumeEnvelope,
|
||||||
ConsumeGiftRequest,
|
ConsumeGiftRequest,
|
||||||
CustomAvatarItemsResponse,
|
CustomAvatarItemsResponse,
|
||||||
|
EquipmentUpdateRequest,
|
||||||
ErrorResponse,
|
ErrorResponse,
|
||||||
form,
|
form,
|
||||||
json,
|
json,
|
||||||
@@ -280,11 +281,13 @@ function toAvatarItem(giftDrop: StoreGiftDrop): AvatarItem {
|
|||||||
/** Build the owned equipment DTO granted into the buyer's inventory from a gift-drop. */
|
/** Build the owned equipment DTO granted into the buyer's inventory from a gift-drop. */
|
||||||
function toEquipment(giftDrop: StoreGiftDrop): Equipment {
|
function toEquipment(giftDrop: StoreGiftDrop): Equipment {
|
||||||
return {
|
return {
|
||||||
EquipmentModificationGuid: giftDrop.EquipmentModificationGuid,
|
ModificationGuid: giftDrop.EquipmentModificationGuid,
|
||||||
EquipmentPrefabName: giftDrop.EquipmentPrefabName,
|
PrefabName: giftDrop.EquipmentPrefabName,
|
||||||
FriendlyName: giftDrop.FriendlyName,
|
FriendlyName: giftDrop.FriendlyName,
|
||||||
Tooltip: giftDrop.Tooltip,
|
Tooltip: giftDrop.Tooltip,
|
||||||
Rarity: giftDrop.Rarity,
|
Rarity: giftDrop.Rarity,
|
||||||
|
PlatformMask: -1,
|
||||||
|
Favorited: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,6 +717,44 @@ const app = new Hono<App>({ strict: false })
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Favourite/un-favourite owned equipment. [Authorize]. The client PUTs the entries
|
||||||
|
// it wants changed (one request can carry several) and reads nothing back. Only
|
||||||
|
// `Favorited` is written — the rest of each entry is the client echoing what it was
|
||||||
|
// served, and a guid the caller doesn't own matches no row and is dropped.
|
||||||
|
.put(
|
||||||
|
'/api/equipment/v1/update',
|
||||||
|
describeRoute({
|
||||||
|
tags: ['Equipment'],
|
||||||
|
summary: 'Update owned equipment',
|
||||||
|
description:
|
||||||
|
'Applies the posted `Favorited` flags to the caller’s owned equipment, matched by ' +
|
||||||
|
'`ModificationGuid`. Everything else in each entry is ignored, and a guid the caller ' +
|
||||||
|
'doesn’t own is silently skipped. Empty body on success.',
|
||||||
|
security: AUTHED,
|
||||||
|
requestBody: jsonBody(EquipmentUpdateRequest, 'The entries to update'),
|
||||||
|
responses: {
|
||||||
|
200: { description: 'Applied (empty body)' },
|
||||||
|
400: { description: 'Body isn’t a JSON array (empty body)' },
|
||||||
|
401: UNAUTHORIZED_RESPONSE,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
async (c) => {
|
||||||
|
const id = await authedId(c)
|
||||||
|
if (id === null) return unauthorized(c)
|
||||||
|
const body = (await c.req.json().catch(() => null)) as unknown
|
||||||
|
if (!Array.isArray(body)) return c.body(null, 400)
|
||||||
|
const updates = body
|
||||||
|
.filter((e): e is Record<string, unknown> => typeof e === 'object' && e !== null)
|
||||||
|
.filter((e) => typeof e.ModificationGuid === 'string' && e.ModificationGuid !== '')
|
||||||
|
.map((e) => ({
|
||||||
|
ModificationGuid: e.ModificationGuid as string,
|
||||||
|
Favorited: e.Favorited === true,
|
||||||
|
}))
|
||||||
|
await setEquipmentFavorited(c.env.DB, id, updates)
|
||||||
|
return c.body(null, 200)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 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
|
||||||
// client doesn't 404.
|
// client doesn't 404.
|
||||||
.get(
|
.get(
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
* gift-drop carries an `EquipmentModificationGuid`) and read back by
|
* gift-drop carries an `EquipmentModificationGuid`) and read back by
|
||||||
* `GET /api/equipment/v2/getUnlocked`.
|
* `GET /api/equipment/v2/getUnlocked`.
|
||||||
*
|
*
|
||||||
* The item is keyed by its `EquipmentModificationGuid` — the gift-drop's equipment guid
|
* The item is keyed by the gift-drop's equipment guid, so re-buying the same skin
|
||||||
* — so re-buying the same skin upserts rather than piling up duplicate rows (these
|
* upserts rather than piling up duplicate rows (these drops are flagged `Unique`).
|
||||||
* drops are flagged `Unique`). `data` is the rendered unlocked-equipment DTO, stored
|
* `data` is the rendered unlocked-equipment DTO, stored opaquely and served back
|
||||||
* opaquely and served back untouched.
|
* untouched.
|
||||||
*
|
*
|
||||||
* This worker (`econ`) owns the table and its migration — see apps/econ/migrations/
|
* 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
|
* 0006_equipment.sql. The gift box the purchase also creates lives in a separate table
|
||||||
@@ -28,21 +28,34 @@ export const EQUIPMENT_SCHEMA_DDL: string[] = [
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* A rendered piece of unlocked equipment, as `/api/equipment/v2/getUnlocked` serves it.
|
* 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;
|
* `ModificationGuid` is the item's guid string and the row's key; `PrefabName` names the
|
||||||
* `EquipmentPrefabName` names the base equipment the modification applies to.
|
* base equipment the modification applies to.
|
||||||
|
*
|
||||||
|
* The names are UNPREFIXED here, unlike the gift-drop/gift-box shapes that carry the
|
||||||
|
* same two values as `EquipmentPrefabName`/`EquipmentModificationGuid`. That's not an
|
||||||
|
* inconsistency to tidy up: a drop is a flat record holding avatar, consumable and
|
||||||
|
* equipment fields side by side, so it needs the prefix to disambiguate, while this
|
||||||
|
* record is all equipment. Confirmed against the live endpoint, and the entries the
|
||||||
|
* client PUTs back to `/api/equipment/v1/update` use the same unprefixed names.
|
||||||
*/
|
*/
|
||||||
export interface Equipment extends Record<string, unknown> {
|
export interface Equipment extends Record<string, unknown> {
|
||||||
EquipmentModificationGuid: string
|
ModificationGuid: string
|
||||||
EquipmentPrefabName: string
|
PrefabName: string
|
||||||
FriendlyName: string
|
FriendlyName: string
|
||||||
Tooltip: string
|
Tooltip: string
|
||||||
Rarity: number
|
Rarity: number
|
||||||
|
/** Always -1 (all platforms) — we don't gate equipment per platform. */
|
||||||
|
PlatformMask: number
|
||||||
|
/** Player-set favourite flag, toggled by `PUT /api/equipment/v1/update`. */
|
||||||
|
Favorited: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Grant a piece of equipment into a player's inventory. Upserts on
|
* Grant a piece of equipment into a player's inventory. Upserts on
|
||||||
* (account_id, equipment_modification_guid): owning equipment is boolean, so re-buying
|
* (account_id, equipment_modification_guid): owning equipment is boolean, so re-buying
|
||||||
* it refreshes the stored DTO rather than adding a second copy.
|
* it refreshes the stored DTO rather than adding a second copy. The refresh carries the
|
||||||
|
* player's `Favorited` flag over, so re-buying doesn't quietly un-favourite the skin
|
||||||
|
* (a row written before the flag existed reads as not favourited).
|
||||||
*/
|
*/
|
||||||
export async function grantEquipment(
|
export async function grantEquipment(
|
||||||
db: D1Database,
|
db: D1Database,
|
||||||
@@ -52,12 +65,44 @@ export async function grantEquipment(
|
|||||||
await db
|
await db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO equipment (account_id, equipment_modification_guid, data) VALUES (?1, ?2, ?3)
|
`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`
|
ON CONFLICT (account_id, equipment_modification_guid) DO UPDATE SET
|
||||||
|
data = json_set(?3, '$.Favorited',
|
||||||
|
json(CASE WHEN json_extract(equipment.data, '$.Favorited') THEN 'true' ELSE 'false' END))`
|
||||||
)
|
)
|
||||||
.bind(accountId, equipment.EquipmentModificationGuid, JSON.stringify(equipment))
|
.bind(accountId, equipment.ModificationGuid, JSON.stringify(equipment))
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One entry of the `PUT /api/equipment/v1/update` body. */
|
||||||
|
export interface EquipmentFavoriteUpdate {
|
||||||
|
ModificationGuid: string
|
||||||
|
Favorited: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the client's favourite toggles. Only the `Favorited` flag is writable — the
|
||||||
|
* rest of the posted entry (PrefabName, Rarity, …) is the client echoing back what it
|
||||||
|
* was served, and the reference server ignores it too.
|
||||||
|
*
|
||||||
|
* A guid the caller doesn't own matches no row and is silently dropped: equipment is
|
||||||
|
* only ever granted by a purchase, so there is nothing to favourite until then (and an
|
||||||
|
* insert here would let a client mint equipment for itself).
|
||||||
|
*/
|
||||||
|
export async function setEquipmentFavorited(
|
||||||
|
db: D1Database,
|
||||||
|
accountId: number,
|
||||||
|
updates: EquipmentFavoriteUpdate[]
|
||||||
|
): Promise<void> {
|
||||||
|
if (updates.length === 0) return
|
||||||
|
const stmt = db.prepare(
|
||||||
|
`UPDATE equipment SET data = json_set(data, '$.Favorited', json(?3))
|
||||||
|
WHERE account_id = ?1 AND equipment_modification_guid = ?2`
|
||||||
|
)
|
||||||
|
await db.batch(
|
||||||
|
updates.map((u) => stmt.bind(accountId, u.ModificationGuid, u.Favorited ? 'true' : 'false'))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/** Every piece of equipment a player owns, ordered by guid for a stable listing. */
|
/** Every piece of equipment a player owns, ordered by guid for a stable listing. */
|
||||||
export async function getEquipment(db: D1Database, accountId: number): Promise<Equipment[]> {
|
export async function getEquipment(db: D1Database, accountId: number): Promise<Equipment[]> {
|
||||||
const { results } = await db
|
const { results } = await db
|
||||||
|
|||||||
@@ -177,5 +177,20 @@ export const SaveOutfitRequest = z
|
|||||||
.catchall(z.unknown())
|
.catchall(z.unknown())
|
||||||
.describe('Plus opaque outfit fields (OutfitSelectionsV2, FaceFeatures, …) stored verbatim')
|
.describe('Plus opaque outfit fields (OutfitSelectionsV2, FaceFeatures, …) stored verbatim')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `PUT /api/equipment/v1/update` JSON body — the client's favourite toggles. It echoes
|
||||||
|
* back the whole entry it was served, but only `Favorited` is written; the rest is
|
||||||
|
* ignored (as on the reference server).
|
||||||
|
*/
|
||||||
|
export const EquipmentUpdateRequest = z.array(
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
ModificationGuid: z.string().describe('Identifies the owned equipment row'),
|
||||||
|
Favorited: z.boolean(),
|
||||||
|
})
|
||||||
|
.catchall(z.unknown())
|
||||||
|
.describe('Plus the echoed-back PrefabName / FriendlyName / Tooltip / Rarity, all ignored')
|
||||||
|
)
|
||||||
|
|
||||||
/** An opaque JSON body stored verbatim (the avatar blob for `POST /api/avatar/v2/set`). */
|
/** An opaque JSON body stored verbatim (the avatar blob for `POST /api/avatar/v2/set`). */
|
||||||
export const OpaqueJsonBody = JsonObject.describe('Stored verbatim and echoed back')
|
export const OpaqueJsonBody = JsonObject.describe('Stored verbatim and echoed back')
|
||||||
|
|||||||
@@ -413,16 +413,6 @@ describe('econ endpoints', () => {
|
|||||||
expect(await res.json()).toEqual([])
|
expect(await res.json()).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
test('POST /api/settings/v2/set 401s without a token, 200s with one', async () => {
|
|
||||||
const anon = await exports.default.fetch(`${ORIGIN}/api/settings/v2/set`, { method: 'POST' })
|
|
||||||
expect(anon.status).toBe(401)
|
|
||||||
const res = await exports.default.fetch(`${ORIGIN}/api/settings/v2/set`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: await bearer(),
|
|
||||||
})
|
|
||||||
expect(res.status).toBe(200)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('GET /api/consumables/v2/getUnlocked 401s without a token, returns []', async () => {
|
test('GET /api/consumables/v2/getUnlocked 401s without a token, returns []', async () => {
|
||||||
const anon = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`)
|
const anon = await exports.default.fetch(`${ORIGIN}/api/consumables/v2/getUnlocked`)
|
||||||
expect(anon.status).toBe(401)
|
expect(anon.status).toBe(401)
|
||||||
@@ -774,16 +764,20 @@ describe('econ endpoints', () => {
|
|||||||
})
|
})
|
||||||
expect(r.status).toBe(200)
|
expect(r.status).toBe(200)
|
||||||
return (await r.json()) as Array<{
|
return (await r.json()) as Array<{
|
||||||
EquipmentModificationGuid: string
|
ModificationGuid: string
|
||||||
EquipmentPrefabName: string
|
PrefabName: string
|
||||||
FriendlyName: string
|
FriendlyName: string
|
||||||
|
PlatformMask: number
|
||||||
|
Favorited: boolean
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
const first = await unlocked()
|
const first = await unlocked()
|
||||||
expect(first).toHaveLength(1)
|
expect(first).toHaveLength(1)
|
||||||
expect(first[0].EquipmentModificationGuid).toBe(guid)
|
// The unlocked DTO is unprefixed, unlike the gift-drop the grant came from.
|
||||||
expect(first[0].EquipmentPrefabName).toBe('[DiscGolfDisc]')
|
expect(first[0].ModificationGuid).toBe(guid)
|
||||||
|
expect(first[0].PrefabName).toBe('[DiscGolfDisc]')
|
||||||
expect(first[0].FriendlyName).toBe('Disc Skin (Coop)')
|
expect(first[0].FriendlyName).toBe('Disc Skin (Coop)')
|
||||||
|
expect(first[0].PlatformMask).toBe(-1)
|
||||||
|
|
||||||
// Equipment is not an avatar item — it does not show up in v4/items.
|
// 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`, {
|
const items = await exports.default.fetch(`${ORIGIN}/api/avatar/v4/items`, {
|
||||||
@@ -792,9 +786,48 @@ describe('econ endpoints', () => {
|
|||||||
const list = (await items.json()) as Array<{ FriendlyName: string }>
|
const list = (await items.json()) as Array<{ FriendlyName: string }>
|
||||||
expect(list.every((i) => i.FriendlyName !== 'Disc Skin (Coop)')).toBe(true)
|
expect(list.every((i) => i.FriendlyName !== 'Disc Skin (Coop)')).toBe(true)
|
||||||
|
|
||||||
|
expect(first[0].Favorited).toBe(false)
|
||||||
|
|
||||||
// Owning equipment is boolean: re-buying upserts, it does not add a second row.
|
// Owning equipment is boolean: re-buying upserts, it does not add a second row.
|
||||||
expect((await buy()).status).toBe(200)
|
expect((await buy()).status).toBe(200)
|
||||||
expect(await unlocked()).toHaveLength(1)
|
expect(await unlocked()).toHaveLength(1)
|
||||||
|
|
||||||
|
// Favouriting sticks.
|
||||||
|
const update = async (favorited: boolean) =>
|
||||||
|
exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { ...(await bearer('31')), 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify([
|
||||||
|
{ PrefabName: '[DiscGolfDisc]', ModificationGuid: guid, Favorited: favorited },
|
||||||
|
// A guid the caller doesn't own is silently skipped, not inserted.
|
||||||
|
{ PrefabName: '[Basketball]', ModificationGuid: 'not-owned', Favorited: true },
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
expect((await update(true)).status).toBe(200)
|
||||||
|
let after = await unlocked()
|
||||||
|
expect(after).toHaveLength(1)
|
||||||
|
expect(after[0].Favorited).toBe(true)
|
||||||
|
|
||||||
|
// …and un-favouriting flips it back.
|
||||||
|
expect((await update(false)).status).toBe(200)
|
||||||
|
after = await unlocked()
|
||||||
|
expect(after[0].Favorited).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('PUT /api/equipment/v1/update 401s without a token, 400s on a non-array body', async () => {
|
||||||
|
const anon = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: '[]',
|
||||||
|
})
|
||||||
|
expect(anon.status).toBe(401)
|
||||||
|
|
||||||
|
const bad = await exports.default.fetch(`${ORIGIN}/api/equipment/v1/update`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { ...(await bearer('32')), 'Content-Type': 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
})
|
||||||
|
expect(bad.status).toBe(400)
|
||||||
})
|
})
|
||||||
|
|
||||||
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 () => {
|
||||||
@@ -1127,8 +1160,8 @@ describe('econ endpoints', () => {
|
|||||||
'POST /api/consumables/v1/consume',
|
'POST /api/consumables/v1/consume',
|
||||||
'POST /api/gamerewards/v1/request',
|
'POST /api/gamerewards/v1/request',
|
||||||
'POST /api/objectives/v1/cleargroup',
|
'POST /api/objectives/v1/cleargroup',
|
||||||
'POST /api/settings/v2/set',
|
|
||||||
'POST /api/storefronts/v2/buyItem',
|
'POST /api/storefronts/v2/buyItem',
|
||||||
|
'PUT /api/equipment/v1/update',
|
||||||
])
|
])
|
||||||
|
|
||||||
// Every operation carries a summary — a path present but undescribed is not
|
// Every operation carries a summary — a path present but undescribed is not
|
||||||
|
|||||||
Reference in New Issue
Block a user