From 2a9ff1337ab4237a877fb2ec0e73cb62134848b3 Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Thu, 15 Oct 2026 13:04:39 -0400 Subject: [PATCH] [econ] add getBalance endpoint --- apps/econ/src/econ.app.ts | 54 ++++++++++++++++++++++ apps/econ/src/openapi.ts | 11 +++++ apps/econ/src/test/integration/api.test.ts | 40 +++++++++++++--- 3 files changed, 98 insertions(+), 7 deletions(-) diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 72586f7..3321431 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -139,6 +139,7 @@ import { RemoveInfluencerBody, RoomConsumableDto, RoomConsumableEnvelope, + RoomCurrencyBalance, RoomCurrencyDto, RoomCurrencyEnvelope, RoomCurrencyPurchaseOfferDto, @@ -4586,6 +4587,59 @@ const app = new Hono({ strict: false }) c.json([]) ) + // What one player holds of one room currency. Any signed-in caller may read any player's, + // as the query names the player rather than taking the caller. + .get( + '/api/roomcurrencies/v1/getBalance', + describeRoute({ + tags: ['Econ'], + summary: 'A player’s balance of one room currency', + description: [ + 'What the player named by `accountId` holds of the room currency `currencyId`', + '(`room_balance`). A player who has never earned any — and a currency that does not', + 'exist — is a `Balance` of 0, not a 404.', + '', + '`ModifiedAt` is the time of the response: `room_balance` keeps no modification time.', + 'A missing `currencyId` or a non-integer `accountId` is a 400.', + ].join('\n'), + security: AUTHED, + parameters: [ + { + name: 'currencyId', + in: 'query', + required: true, + description: 'The room currency', + schema: { type: 'string' }, + }, + { + name: 'accountId', + in: 'query', + required: true, + description: 'The player whose balance to read', + schema: { type: 'integer' }, + }, + ], + responses: { + 200: json(RoomCurrencyBalance, 'The player’s balance'), + 400: { description: 'A missing `currencyId` or a non-integer `accountId`' }, + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + if ((await authedId(c)) === null) return unauthorized(c) + const currencyId = c.req.query('currencyId')?.trim() ?? '' + const rawAccountId = c.req.query('accountId')?.trim() ?? '' + if (currencyId === '' || !/^\d+$/.test(rawAccountId)) return c.body(null, 400) + const accountId = Number(rawAccountId) + return c.json({ + AccountId: accountId, + CurrencyId: currencyId, + Balance: await getRoomBalance(c.env.DB, currencyId, accountId), + ModifiedAt: new Date().toISOString(), + }) + } + ) + // Buy room currency with tokens, through one of the currency's purchase offers. The body // names only the OFFER — which currency it sells is whichever one lists it — and the // amount and price the client rendered, both of which must still match the offer. diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 5e1bcca..0cff378 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -1187,6 +1187,17 @@ export const PurchaseRoomCurrencyRequest = z.object({ ), }) +/** + * What a player holds of one room currency, as `GET /api/roomcurrencies/v1/getBalance` answers + * it. `ModifiedAt` is the time of the response: `room_balance` keeps no modification time. + */ +export const RoomCurrencyBalance = z.object({ + AccountId: z.int().describe('The player the balance belongs to'), + CurrencyId: z.string().describe('The room currency'), + Balance: z.int().describe('What the player holds of it; 0 when they have never earned any'), + ModifiedAt: z.string().describe('ISO-8601 UTC; the time of the response'), +}) + /** * What `POST /api/roomCurrencies/v2/purchase` answers: both balances the purchase moved, * inside the `{ Value, Success, Error, error_id }` envelope the room-currency writes use. diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 4d7b9e0..565c944 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -1888,6 +1888,31 @@ describe('econ endpoints', () => { expect(await resultsOf(res)).toEqual([]) } expect(await resultsOf(await award([], await bearer('1')))).toEqual([]) + + // getBalance reads back what the awards left, for any player the query names. + const balance = async (query: string, headers?: Record) => + exports.default.fetch(`${ORIGIN}/api/roomcurrencies/v1/getBalance?${query}`, { headers }) + expect((await balance(`currencyId=${coin.CurrencyId}&accountId=205`)).status).toBe(401) + const read = await balance(`currencyId=${coin.CurrencyId}&accountId=205`, await bearer('999')) + expect(read.status).toBe(200) + const body205 = (await read.json()) as Record + expect(Object.keys(body205)).toEqual(['AccountId', 'CurrencyId', 'Balance', 'ModifiedAt']) + expect(body205).toMatchObject({ AccountId: 205, CurrencyId: coin.CurrencyId, Balance: 6 }) + expect(Date.parse(body205.ModifiedAt as string)).not.toBeNaN() + // Never paid, and an unknown currency, are both a balance of 0. + for (const query of [ + `currencyId=${coin.CurrencyId}&accountId=207`, + 'currencyId=a3f1e8d2-0000-4000-8000-000000000000&accountId=205', + ]) { + expect(await (await balance(query, await bearer('1'))).json()).toMatchObject({ Balance: 0 }) + } + for (const query of [ + `currencyId=${coin.CurrencyId}`, + 'accountId=205', + `currencyId=${coin.CurrencyId}&accountId=x`, + ]) { + expect((await balance(query, await bearer('1'))).status).toBe(400) + } }) test('POST awardCurrency/bulk floors at zero and ignores the Limit', async () => { @@ -3784,7 +3809,9 @@ describe('econ endpoints', () => { }) // ConfigurationData is JSON escaped into a string (or null), never a nested object. for (const item of body) { - expect(item.ConfigurationData === null || typeof item.ConfigurationData === 'string').toBe(true) + expect(item.ConfigurationData === null || typeof item.ConfigurationData === 'string').toBe( + true + ) } }) @@ -6613,9 +6640,7 @@ describe('econ endpoints', () => { // first activity of the day, so another activity doesn't claim it either, and writes // no row of its own. expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200) - expect( - (await request('rewardType=FirstActivityOfDay&giftContext=Soccer')).status - ).toBe(200) + expect((await request('rewardType=FirstActivityOfDay&giftContext=Soccer')).status).toBe(200) expect(await statusOf('FirstActivityOfDay')).toEqual(claimed) expect(await statusOf('FirstActivityOfDay', 'Soccer')).toBeNull() @@ -7021,9 +7046,9 @@ describe('econ endpoints', () => { ) // An activity with a prize still hands it over, in a box carrying no XP. - expect( - (await request('rewardType=PostQuestActivity&giftContext=Quest_Goblin_S')).status - ).toBe(200) + expect((await request('rewardType=PostQuestActivity&giftContext=Quest_Goblin_S')).status).toBe( + 200 + ) const quest = (await rewardBoxes()).at(-1) expect(quest).toMatchObject({ GiftContext: 4003, Xp: 0 }) expect(quest?.AvatarItemDesc).not.toBe('') @@ -7491,6 +7516,7 @@ describe('econ endpoints', () => { 'GET /api/roomconsumables/v1/roomConsumable/room/{roomId}/me', 'GET /api/roomcurrencies/v1/currencies', 'GET /api/roomcurrencies/v1/getAllBalances', + 'GET /api/roomcurrencies/v1/getBalance', 'GET /api/roomcurrencies/v1/getPurchaseOffersBatch', 'GET /api/roomkeys/v1/mine', 'GET /api/roomkeys/v1/room',