diff --git a/apps/econ/README.md b/apps/econ/README.md index 1ab5e7d..3f586a3 100644 --- a/apps/econ/README.md +++ b/apps/econ/README.md @@ -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) | diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 26561d3..f3bc11f 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -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({ 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 diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 308a14e..0b21737 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -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 diff --git a/apps/econ/src/room-key-db.ts b/apps/econ/src/room-key-db.ts index 230b746..93eaf0a 100644 --- a/apps/econ/src/room-key-db.ts +++ b/apps/econ/src/room-key-db.ts @@ -213,6 +213,38 @@ export async function getRoomKeys(db: D1Database, roomId: number): Promise { + 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 { + 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() + + 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 diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index ea8872e..79d0108 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -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> + 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 () => {