[econ] add getBalance endpoint

This commit is contained in:
Devin Zuczek
2026-10-15 13:04:39 -04:00
parent e7ee9ed444
commit 2a9ff1337a
3 changed files with 98 additions and 7 deletions
+54
View File
@@ -139,6 +139,7 @@ import {
RemoveInfluencerBody, RemoveInfluencerBody,
RoomConsumableDto, RoomConsumableDto,
RoomConsumableEnvelope, RoomConsumableEnvelope,
RoomCurrencyBalance,
RoomCurrencyDto, RoomCurrencyDto,
RoomCurrencyEnvelope, RoomCurrencyEnvelope,
RoomCurrencyPurchaseOfferDto, RoomCurrencyPurchaseOfferDto,
@@ -4586,6 +4587,59 @@ const app = new Hono<App>({ strict: false })
c.json([]) 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 // 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 // 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. // amount and price the client rendered, both of which must still match the offer.
+11
View File
@@ -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, * 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. * inside the `{ Value, Success, Error, error_id }` envelope the room-currency writes use.
+33 -7
View File
@@ -1888,6 +1888,31 @@ describe('econ endpoints', () => {
expect(await resultsOf(res)).toEqual([]) expect(await resultsOf(res)).toEqual([])
} }
expect(await resultsOf(await award([], await bearer('1')))).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<string, string>) =>
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<string, unknown>
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 () => { 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. // ConfigurationData is JSON escaped into a string (or null), never a nested object.
for (const item of body) { 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 // first activity of the day, so another activity doesn't claim it either, and writes
// no row of its own. // no row of its own.
expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200) expect((await request('rewardType=FirstActivityOfDay&Message=again')).status).toBe(200)
expect( expect((await request('rewardType=FirstActivityOfDay&giftContext=Soccer')).status).toBe(200)
(await request('rewardType=FirstActivityOfDay&giftContext=Soccer')).status
).toBe(200)
expect(await statusOf('FirstActivityOfDay')).toEqual(claimed) expect(await statusOf('FirstActivityOfDay')).toEqual(claimed)
expect(await statusOf('FirstActivityOfDay', 'Soccer')).toBeNull() 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. // An activity with a prize still hands it over, in a box carrying no XP.
expect( expect((await request('rewardType=PostQuestActivity&giftContext=Quest_Goblin_S')).status).toBe(
(await request('rewardType=PostQuestActivity&giftContext=Quest_Goblin_S')).status 200
).toBe(200) )
const quest = (await rewardBoxes()).at(-1) const quest = (await rewardBoxes()).at(-1)
expect(quest).toMatchObject({ GiftContext: 4003, Xp: 0 }) expect(quest).toMatchObject({ GiftContext: 4003, Xp: 0 })
expect(quest?.AvatarItemDesc).not.toBe('') expect(quest?.AvatarItemDesc).not.toBe('')
@@ -7491,6 +7516,7 @@ describe('econ endpoints', () => {
'GET /api/roomconsumables/v1/roomConsumable/room/{roomId}/me', 'GET /api/roomconsumables/v1/roomConsumable/room/{roomId}/me',
'GET /api/roomcurrencies/v1/currencies', 'GET /api/roomcurrencies/v1/currencies',
'GET /api/roomcurrencies/v1/getAllBalances', 'GET /api/roomcurrencies/v1/getAllBalances',
'GET /api/roomcurrencies/v1/getBalance',
'GET /api/roomcurrencies/v1/getPurchaseOffersBatch', 'GET /api/roomcurrencies/v1/getPurchaseOffersBatch',
'GET /api/roomkeys/v1/mine', 'GET /api/roomkeys/v1/mine',
'GET /api/roomkeys/v1/room', 'GET /api/roomkeys/v1/room',