[econ] fix missing owned keys endpoint

This commit is contained in:
Devin Zuczek
2026-10-22 12:51:53 -04:00
parent 208178494a
commit 76db89c30c
5 changed files with 137 additions and 7 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
| POST | `/api/challenge/v2/updateProgress` | ✓ | Report challenge progress |
| GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) |
| POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 10 XP + gift box |
| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) |
| GET | `/api/roomkeys/v1/mine` | ✓ | The player's held room keys |
| GET | `/api/roomkeys/v1/room` | | The keys a room has listed |
| POST | `/api/roomkeys/v1/create` | ✓ | List a key for a room (owner/co-owner) |
| PUT | `/api/roomkeys/v1/updateAll` | ✓ | Edit one key (owner/co-owner) |
+27 -3
View File
@@ -147,6 +147,7 @@ import {
RoomCurrencyPurchaseOfferEnvelope,
RoomCurrencyPurchaseOffersDto,
RoomEconConfig,
HeldRoomKeyDto,
RoomKeyDto,
RoomKeyEnvelope,
RoomPurchasableList,
@@ -195,6 +196,7 @@ import {
claimRoomKey,
createRoomKey,
getRoomKey,
getHeldRoomKeys,
getRoomKeys,
getRoomOwnerId,
ownsRoomKeys,
@@ -6391,9 +6393,31 @@ const app = new Hono<App>({ strict: false })
}
)
// The player's room keys. Returns "[]": nothing sells a key yet, so nobody holds one.
.get('/api/roomkeys/v1/mine', listRoute('The player’s room keys', 'Empty for now'), (c) =>
c.json([])
// The caller's room keys: every key they hold (`room_key_player`), across all rooms.
.get(
'/api/roomkeys/v1/mine',
describeRoute({
tags: ['Econ'],
summary: 'The player’s room keys',
description: [
'Every key the caller holds — bought, awarded, or taken free as a room manager —',
'across every room, oldest key first. No parameters.',
'',
'Each entry is the room key plus `IsGiftable` (false) and `Visibility` (0), with',
'`ImageName` as "" rather than null when the key has no art — the shape observed',
'from the live client, which differs from `/api/roomkeys/v1/room`’s.',
].join('\n'),
security: AUTHED,
responses: {
200: json(HeldRoomKeyDto.array(), 'The caller’s keys, oldest first'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
return c.json(await getHeldRoomKeys(c.env.DB, id))
}
)
// The keys a room has listed. Public, like the room's currencies: a key is offered to
// whoever walks up to the door it opens. A room with none, an unknown room and a missing
+7
View File
@@ -779,6 +779,13 @@ export const RoomKeyDto = z.object({
Type: z.int().describe('The key type’s ordinal: 0 `Key`, the only one seen'),
})
/** A held key as `GET /api/roomkeys/v1/mine` serves it — the room key plus two fields. */
export const HeldRoomKeyDto = RoomKeyDto.extend({
ImageName: z.string().describe('The key’s art; "" when it has none (NOT null, unlike the list)'),
IsGiftable: z.boolean().describe('Always false — gifting keys is not modelled'),
Visibility: z.int().describe('Always 0 — not modelled'),
})
/**
* The envelope `POST /api/roomkeys/v1/create` answers in: `{ Status, RoomKey }`, observed from
* the live client — NOT the `{ Value, Success, Error, error_id }` the room-currency and
+32
View File
@@ -213,6 +213,38 @@ export async function getRoomKeys(db: D1Database, roomId: number): Promise<RoomK
return results.map(toRoomKey)
}
/**
* A key as `GET /api/roomkeys/v1/mine` serves it — observed from the live client: the
* {@link RoomKey} plus `IsGiftable` and `Visibility`, and `ImageName` as '' (not null) on a
* key without art. Neither extra field is modelled here, so both are served as their 0/false.
*/
export interface HeldRoomKey extends Omit<RoomKey, 'ImageName'> {
ImageName: string
IsGiftable: boolean
Visibility: number
}
/** Every key `accountId` holds, across all rooms, in creation order. */
export async function getHeldRoomKeys(db: D1Database, accountId: number): Promise<HeldRoomKey[]> {
const { results } = await db
.prepare(
`SELECT ${SELECT_COLUMNS.split(', ')
.map((col) => `k.${col}`)
.join(', ')}
FROM room_key_player p JOIN room_key k ON k.room_key_id = p.room_key_id
WHERE p.account_id = ?1 ORDER BY k.room_key_id`
)
.bind(accountId)
.all<RoomKeyRow>()
return results.map((row) => ({
...toRoomKey(row),
ImageName: row.image_name,
IsGiftable: false,
Visibility: 0,
}))
}
/**
* Award a player a room key, answering when they came to hold it — now, or the first award's
* time when they already did (a key is held once; re-awarding is a no-op). Null when
+70 -3
View File
@@ -7211,10 +7211,77 @@ describe('econ endpoints', () => {
expect(rows?.count).toBe(0)
})
test('GET /api/roomkeys/v1/mine returns []', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/mine`)
test('GET /api/roomkeys/v1/mine lists the keys the caller holds, across rooms', async () => {
const mine = async (sub: string | null) =>
exports.default.fetch(
`${ORIGIN}/api/roomkeys/v1/mine`,
sub === null ? undefined : { headers: await bearer(sub) }
)
expect((await mine(null)).status).toBe(401)
expect(await (await mine('7601')).json()).toEqual([])
const insert = async (roomId: number, name: string, imageName: string) =>
(await env.DB.prepare(
`INSERT INTO room_key
(replication_id, room_id, key_type, name, description, price, purchase_currency_id, image_name, created_at)
VALUES ('0f8c3a7e-1d42-4b90-9c11-7a6e5d2b8f03', ?1, 'Key', ?2, '', 0, NULL, ?3, '2026-10-14T12:00:00Z')
RETURNING room_key_id`
)
.bind(roomId, name, imageName)
.first<{ room_key_id: number }>())!.room_key_id
const a = await insert(12, 'VIP Key', '')
const b = await insert(2511, 'Art Key', 'key.png')
const notHeld = await insert(12, 'Other', '')
for (const [key, account] of [
[b, 7601],
[a, 7601],
[notHeld, 7602],
]) {
await env.DB.prepare(
"INSERT INTO room_key_player (room_key_id, account_id, awarded_at) VALUES (?1, ?2, '')"
)
.bind(key, account)
.run()
}
const res = await mine('7601')
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
const keys = (await res.json()) as Array<Record<string, unknown>>
expect(keys).toEqual([
{
RoomKeyId: a,
ReplicationId: '0f8c3a7e-1d42-4b90-9c11-7a6e5d2b8f03',
RoomId: 12,
Name: 'VIP Key',
Description: '',
Price: 0,
PurchaseCurrencyId: null,
CreatedAt: '2026-10-14T12:00:00Z',
ImageName: '',
Type: 0,
IsGiftable: false,
Visibility: 0,
},
expect.objectContaining({ RoomKeyId: b, RoomId: 2511, ImageName: 'key.png' }),
])
// Member order as the client was observed receiving it.
expect(Object.keys(keys[0]!)).toEqual([
'RoomKeyId',
'ReplicationId',
'RoomId',
'Name',
'Description',
'Price',
'PurchaseCurrencyId',
'CreatedAt',
'ImageName',
'Type',
'IsGiftable',
'Visibility',
])
await env.DB.prepare('DELETE FROM room_key_player').run()
await env.DB.prepare('DELETE FROM room_key').run()
})
test('GET /api/subscriptionseasons/v1/seasons/current returns []', async () => {