[econ] key purchasing

This commit is contained in:
Devin Zuczek
2026-10-01 14:35:34 -04:00
parent b09f014b6d
commit 1458df1664
6 changed files with 1039 additions and 0 deletions
+455
View File
@@ -1941,6 +1941,163 @@ describe('econ endpoints', () => {
expect(res.status).toBe(200)
expect((await envOf(res)).Value).toMatchObject({ Limit: 1000000, Color: -1 })
})
test('POST roomCurrencies/v2/purchase sells an offer’s currency for tokens, paying the owner', async () => {
const form = async (path: string, fields: Record<string, string>, sub: string | null) =>
exports.default.fetch(`${ORIGIN}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(sub === null ? {} : await bearer(sub)),
},
body: new URLSearchParams(fields),
})
const purchase = async (sub: string | null, offerId: string, amount: number, price: number) =>
form(
'/api/roomCurrencies/v2/purchase',
{
PurchaseOfferId: offerId,
RequestedAmount: String(amount),
RequestedPrice: String(price),
},
sub
)
const addOffer = async (currencyId: string, amount: number, price: number) =>
(
(await (
await form(
'/api/roomcurrencies/v1/createPurchaseOffer',
{
CurrencyId: currencyId,
Name: 'pack',
Amount: String(amount),
Price: String(price),
},
'1'
)
).json()) as { Value: { CurrencyPurchaseOfferId: string } }
).Value.CurrencyPurchaseOfferId
const setTokens = (accountId: number, amount: number) =>
env.DB.prepare(
'INSERT OR REPLACE INTO balance (account_id, currency_type, amount) VALUES (?1, 2, ?2)'
)
.bind(accountId, amount)
.run()
const tokens = async (accountId: number) =>
(
await env.DB.prepare(
'SELECT amount FROM balance WHERE account_id = ?1 AND currency_type = 2'
)
.bind(accountId)
.first<{ amount: number }>()
)?.amount
const held = async (currencyId: string, playerId: number) =>
(
await env.DB.prepare(
'SELECT amount FROM room_balance WHERE currency_id = ?1 AND player_id = ?2'
)
.bind(currencyId, playerId)
.first<{ amount: number }>()
)?.amount ?? 0
const refused = (error: string) => ({
Value: null,
Success: false,
Error: error,
error_id: null,
})
// Two currencies in room 2511 (account 1's), so the offer has to pick the right one.
const other = (await envOf(await create({ ...body, Name: 'Other' }, await bearer('1'))))
.Value!
const sold = (await envOf(await create({ ...body, Name: 'Sold' }, await bearer('1')))).Value!
await addOffer(other.CurrencyId, 444, 1)
const offerId = await addOffer(sold.CurrencyId, 444, 555)
const freeOfferId = await addOffer(sold.CurrencyId, 5, 0)
await setTokens(1, 1000)
await setTokens(7401, 600)
await setTokens(7402, 100)
await drainFrames()
expect((await purchase(null, offerId, 444, 555)).status).toBe(401)
// Refusals: an unknown offer, numbers that are not the offer's, too few tokens.
const cases: Array<[Response, string]> = [
[await purchase('7401', crypto.randomUUID(), 444, 555), 'No such purchase offer'],
[await purchase('7401', '', 444, 555), 'No such purchase offer'],
[await purchase('7401', offerId, 445, 555), 'Requested amount does not match'],
[await purchase('7401', offerId, 444, 554), 'Requested price does not match'],
[await purchase('7401', offerId, 444, 0), 'Requested price does not match'],
[await purchase('7402', offerId, 444, 555), 'Not enough tokens'],
]
for (const [res, error] of cases) {
expect(res.status).toBe(200)
expect(await res.json()).toEqual(refused(error))
}
expect([await tokens(1), await tokens(7401), await tokens(7402)]).toEqual([1000, 600, 100])
expect(await held(sold.CurrencyId, 7401)).toBe(0)
expect(await drainFrames()).toEqual([])
// The sale, exactly as the client posts it: 555 tokens for 444 of the offer's currency.
const res = await purchase('7401', offerId, 444, 555)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
Value: {
CurrencyBalanceResponse: {
AccountId: 7401,
CurrencyId: sold.CurrencyId,
Balance: 444,
ModifiedAt: expect.any(String),
},
TokenBalanceResponse: { Balance: 45, CurrencyType: 2, Platform: -2 },
},
Success: true,
Error: null,
error_id: null,
})
expect([await tokens(1), await tokens(7401)]).toEqual([1555, 45])
expect(await held(sold.CurrencyId, 7401)).toBe(444)
expect(await held(other.CurrencyId, 7401)).toBe(0)
// Both hear their RESULTING token total, in the account-wide bucket.
const frames = await drainFrames()
expect(frames.find((f) => f.accountId === 1)?.payload).toEqual({
Balance: 1555,
CurrencyType: 2,
Platform: -2,
})
expect(frames.find((f) => f.accountId === 7401)?.payload).toMatchObject({
Delta: -555,
Balance: 45,
Platform: -2,
})
// A second purchase needs the tokens again; the holding is left where it was.
expect(await (await purchase('7401', offerId, 444, 555)).json()).toEqual(
refused('Not enough tokens')
)
expect(await held(sold.CurrencyId, 7401)).toBe(444)
// A free offer, and the owner buying from their own shop, move no tokens.
const gratis = (await (await purchase('7402', freeOfferId, 5, 0)).json()) as {
Value: {
CurrencyBalanceResponse: { Balance: number }
TokenBalanceResponse: { Balance: number }
}
}
expect(gratis.Value.CurrencyBalanceResponse.Balance).toBe(5)
expect(gratis.Value.TokenBalanceResponse.Balance).toBe(100)
const own = (await (await purchase('1', offerId, 444, 555)).json()) as typeof gratis
expect(own.Value.CurrencyBalanceResponse.Balance).toBe(444)
expect(own.Value.TokenBalanceResponse.Balance).toBe(1555)
expect([await tokens(1), await tokens(7402)]).toEqual([1555, 100])
expect(await drainFrames()).toEqual([])
await env.DB.prepare('DELETE FROM room_balance WHERE currency_id IN (?1, ?2)')
.bind(sold.CurrencyId, other.CurrencyId)
.run()
await env.DB.prepare('DELETE FROM room_currency WHERE currency_id IN (?1, ?2)')
.bind(sold.CurrencyId, other.CurrencyId)
.run()
})
})
// Room 2511 is seeded as account 1's, with account 2 as co-owner.
@@ -2415,6 +2572,301 @@ describe('econ endpoints', () => {
.bind(currencyId)
.run()
})
test('GET buyRoomKey moves the key’s price from the buyer to the room’s owner', async () => {
const buy = async (sub: string | null, roomKeyId: number | string, price?: number) =>
exports.default.fetch(
`${ORIGIN}/api/storefronts/v1/buyRoomKey?RoomKeyId=${roomKeyId}` +
(price === undefined ? '' : `&RequestedPrice=${price}`),
sub === null ? undefined : { headers: await bearer(sub) }
)
const setTokens = (accountId: number, amount: number) =>
env.DB.prepare(
'INSERT OR REPLACE INTO balance (account_id, currency_type, amount) VALUES (?1, 2, ?2)'
)
.bind(accountId, amount)
.run()
const tokens = async (accountId: number) =>
(
await env.DB.prepare(
'SELECT amount FROM balance WHERE account_id = ?1 AND currency_type = 2'
)
.bind(accountId)
.first<{ amount: number }>()
)?.amount
const holders = async (roomKeyId: number) =>
(
await env.DB.prepare(
'SELECT account_id FROM room_key_player WHERE room_key_id = ?1 ORDER BY account_id'
)
.bind(roomKeyId)
.all<{ account_id: number }>()
).results.map((r) => r.account_id)
// A refusal: the same shape, nothing sold, the balance as it stood.
const refused = (code: number, data: RoomKey | null, balance: number) => ({
RoomKeyResponse: { Status: 1, RoomKey: null },
BalanceUpdateResponse: {
BalanceUpdates: [{ UpdateResponse: code, Data: data }],
Balance: balance,
CurrencyType: 2,
Platform: -2,
},
})
// Room 2511 is account 1's. A 300-token key, and a free one.
const key = await keyOf(await create({ ...body, Price: '300' }, await bearer('1')))
const free = await keyOf(
await create({ ...body, Name: 'free', Price: '0' }, await bearer('1'))
)
await setTokens(1, 1000)
await setTokens(7301, 500)
await setTokens(7302, 100)
await drainFrames()
expect((await buy(null, key.RoomKeyId, 300)).status).toBe(401)
// The price must match — `RequestedPrice=0`, or none, does not buy a priced key.
for (const wrong of [0, 299, undefined]) {
const res = await buy('7301', key.RoomKeyId, wrong)
expect(res.status).toBe(200)
expect(await res.json()).toEqual(refused(6, key, 500))
}
// No such key; not enough tokens.
expect(await (await buy('7301', 999999, 300)).json()).toEqual(refused(4, null, 500))
expect(await (await buy('7301', 'abc', 300)).json()).toEqual(refused(4, null, 500))
expect(await (await buy('7302', key.RoomKeyId, 300)).json()).toEqual(refused(2, key, 100))
expect(await holders(key.RoomKeyId)).toEqual([])
expect([await tokens(1), await tokens(7301), await tokens(7302)]).toEqual([1000, 500, 100])
expect(await drainFrames()).toEqual([])
// The sale: 300 leaves the buyer, reaches the owner, and the buyer holds the key.
const sold = await buy('7301', key.RoomKeyId, 300)
expect(sold.status).toBe(200)
const soldBody = (await sold.json()) as {
BalanceUpdateResponse: Record<string, unknown>
}
expect(soldBody).toEqual({
RoomKeyResponse: { Status: 0, RoomKey: key },
BalanceUpdateResponse: {
BalanceUpdates: [{ UpdateResponse: 0, Data: key }],
Balance: 200,
CurrencyType: 2,
Platform: -2,
},
})
// Derived-first key order, as the client's formatter runs it.
expect(Object.keys(soldBody.BalanceUpdateResponse)).toEqual([
'BalanceUpdates',
'Balance',
'CurrencyType',
'Platform',
])
expect(await holders(key.RoomKeyId)).toEqual([7301])
expect([await tokens(1), await tokens(7301)]).toEqual([1300, 200])
// Both hear their RESULTING total, in the account-wide bucket.
const frames = await drainFrames()
expect(frames.find((f) => f.accountId === 1)?.payload).toEqual({
Balance: 1300,
CurrencyType: 2,
Platform: -2,
})
expect(frames.find((f) => f.accountId === 7301)?.payload).toMatchObject({
Delta: -300,
Balance: 200,
Platform: -2,
})
// Held once: buying it again is refused and charges nothing.
expect(await (await buy('7301', key.RoomKeyId, 300)).json()).toEqual(refused(3, key, 200))
expect([await tokens(1), await tokens(7301)]).toEqual([1300, 200])
// A free key moves nothing; nor does the owner buying their own room's key.
const gratis = (await (await buy('7302', free.RoomKeyId)).json()) as {
RoomKeyResponse: { Status: number }
BalanceUpdateResponse: { Balance: number }
}
expect(gratis.RoomKeyResponse.Status).toBe(0)
expect(gratis.BalanceUpdateResponse.Balance).toBe(100)
const own = (await (await buy('1', key.RoomKeyId, 300)).json()) as typeof gratis
expect(own.RoomKeyResponse.Status).toBe(0)
expect(own.BalanceUpdateResponse.Balance).toBe(1300)
expect(await holders(key.RoomKeyId)).toEqual([1, 7301])
expect(await drainFrames()).toEqual([])
// A key priced in a room currency is not sold for tokens.
await env.DB.prepare(
"UPDATE room_key SET purchase_currency_id = 'c0ffee' WHERE room_key_id = ?1"
)
.bind(free.RoomKeyId)
.run()
const inCurrency = { ...free, PurchaseCurrencyId: 'c0ffee' }
expect(await (await buy('7301', free.RoomKeyId, 0)).json()).toEqual(
refused(4, inCurrency, 200)
)
await env.DB.prepare('DELETE FROM room_key_player').run()
await env.DB.prepare('DELETE FROM room_key').run()
})
test('POST PurchaseRoomKeyWithCurrency subtracts the key’s price in its room currency and pays nobody', async () => {
const form = async (
path: string,
fields: Record<string, string>,
sub: string | null,
method = 'POST'
) =>
exports.default.fetch(`${ORIGIN}${path}`, {
method,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(sub === null ? {} : await bearer(sub)),
},
body: new URLSearchParams(fields),
})
const purchase = async (
sub: string | null,
roomKeyId: number,
price: number,
currency: string
) =>
form(
'/api/storefronts/v1/PurchaseRoomKeyWithCurrency',
{
RoomKeyId: String(roomKeyId),
RequestedPrice: String(price),
RequestedPurchaseCurrencyId: currency,
},
sub
)
const mint = async (name: string) =>
(
(await (
await form('/api/roomcurrencies/v1/createCurrency', { RoomId: '2511', Name: name }, '1')
).json()) as { Value: { CurrencyId: string } }
).Value.CurrencyId
const give = (currencyId: string, playerId: number, amount: number) =>
env.DB.prepare(
'INSERT OR REPLACE INTO room_balance (currency_id, player_id, amount) VALUES (?1, ?2, ?3)'
)
.bind(currencyId, playerId, amount)
.run()
const held = async (currencyId: string, playerId: number) =>
(
await env.DB.prepare(
'SELECT amount FROM room_balance WHERE currency_id = ?1 AND player_id = ?2'
)
.bind(currencyId, playerId)
.first<{ amount: number }>()
)?.amount ?? 0
const holders = async (roomKeyId: number) =>
(
await env.DB.prepare(
'SELECT account_id FROM room_key_player WHERE room_key_id = ?1 ORDER BY account_id'
)
.bind(roomKeyId)
.all<{ account_id: number }>()
).results.map((r) => r.account_id)
const tokens = async (accountId: number) =>
(
await env.DB.prepare(
'SELECT amount FROM balance WHERE account_id = ?1 AND currency_type = 2'
)
.bind(accountId)
.first<{ amount: number }>()
)?.amount
// A refusal: the same shape, nothing sold, the holding as it stands.
const refused = (accountId: number, currencyId: string, balance: number) => ({
Balance: {
AccountId: accountId,
CurrencyId: currencyId,
Balance: balance,
ModifiedAt: expect.any(String),
},
RoomKeyResponse: { Status: 1, RoomKey: null },
})
// A key priced at 1000 of the room's own currency; a second currency; a token key.
const coin = await mint('coin')
const other = await mint('other')
const made = await keyOf(await create(body, await bearer('1')))
const key = (
await envOf(
await form(
'/api/roomkeys/v1/updateAll',
{ RoomKeyId: String(made.RoomKeyId), Price: '1000', PurchaseCurrencyId: coin },
'1',
'PUT'
)
)
).RoomKey!
expect(key).toMatchObject({ Price: 1000, PurchaseCurrencyId: coin })
const tokenKey = await keyOf(await create({ ...body, Name: 'token key' }, await bearer('1')))
await give(coin, 7501, 1500)
await give(other, 7501, 5000)
await give(coin, 7502, 999)
const ownerTokens = await tokens(1)
await drainFrames()
expect((await purchase(null, key.RoomKeyId, 1000, coin)).status).toBe(401)
// Refusals: no such key, the wrong price, a currency that is not the key's, a key
// priced in tokens, and a buyer one coin short — who is NOT sold it for what they have.
const cases: Array<[Response, ReturnType<typeof refused>]> = [
[await purchase('7501', 999999, 1000, coin), refused(7501, coin, 1500)],
[await purchase('7501', key.RoomKeyId, 999, coin), refused(7501, coin, 1500)],
[await purchase('7501', key.RoomKeyId, 1000, other), refused(7501, coin, 1500)],
[await purchase('7501', tokenKey.RoomKeyId, 50, coin), refused(7501, coin, 1500)],
[await purchase('7502', key.RoomKeyId, 1000, coin), refused(7502, coin, 999)],
]
for (const [res, expected] of cases) {
expect(res.status).toBe(200)
expect(await res.json()).toEqual(expected)
}
expect(await holders(key.RoomKeyId)).toEqual([])
expect(await holders(tokenKey.RoomKeyId)).toEqual([])
expect([await held(coin, 7501), await held(other, 7501), await held(coin, 7502)]).toEqual([
1500, 5000, 999,
])
// The sale, exactly as the client posts it. `Balance` is an OBJECT holding the
// resulting total, and it comes first.
const res = await purchase('7501', key.RoomKeyId, 1000, coin)
expect(res.status).toBe(200)
const sold = (await res.json()) as Record<string, unknown>
expect(sold).toEqual({
Balance: {
AccountId: 7501,
CurrencyId: coin,
Balance: 500,
ModifiedAt: expect.any(String),
},
RoomKeyResponse: { Status: 0, RoomKey: key },
})
expect(Object.keys(sold)).toEqual(['Balance', 'RoomKeyResponse'])
expect(await holders(key.RoomKeyId)).toEqual([7501])
expect([await held(coin, 7501), await held(other, 7501)]).toEqual([500, 5000])
// Nobody is paid: the owner holds none of the coin, their tokens did not move, and
// no balance frame went out.
expect(await held(coin, 1)).toBe(0)
expect(await tokens(1)).toBe(ownerTokens)
expect(await drainFrames()).toEqual([])
// Held once: buying it again is refused and subtracts nothing.
expect(await (await purchase('7501', key.RoomKeyId, 1000, coin)).json()).toEqual(
refused(7501, coin, 500)
)
expect(await held(coin, 7501)).toBe(500)
await env.DB.prepare('DELETE FROM room_key_player').run()
await env.DB.prepare('DELETE FROM room_key').run()
await env.DB.prepare('DELETE FROM room_balance WHERE currency_id IN (?1, ?2)')
.bind(coin, other)
.run()
await env.DB.prepare('DELETE FROM room_currency WHERE currency_id IN (?1, ?2)')
.bind(coin, other)
.run()
})
})
test('GET /api/roomconsumables/v1/roomConsumable/room/:id/me returns []', async () => {
@@ -6205,6 +6657,7 @@ describe('econ endpoints', () => {
'GET /api/roomkeys/v1/mine',
'GET /api/roomkeys/v1/room',
'GET /api/storefronts/v1/adcarouselitems',
'GET /api/storefronts/v1/buyRoomKey',
'GET /api/storefronts/v2/buyInvention',
'GET /api/storefronts/v3/giftdropstore/{id}',
'GET /api/storefronts/v4/balance/{currencyType}',
@@ -6234,6 +6687,7 @@ describe('econ endpoints', () => {
'POST /api/items/purchaseInfos',
'POST /api/objectives/v1/cleargroup',
'POST /api/objectives/v1/updateobjective',
'POST /api/roomCurrencies/v2/purchase',
'POST /api/roomconsumables/v1/roomConsumable/awardBulk',
'POST /api/roomcurrencies/v1/awardCurrency/bulk',
'POST /api/roomcurrencies/v1/createCurrency',
@@ -6242,6 +6696,7 @@ describe('econ endpoints', () => {
'POST /api/roomkeys/v1/awardbulk',
'POST /api/roomkeys/v1/create',
'POST /api/roomkeys/v1/owns/bulk',
'POST /api/storefronts/v1/PurchaseRoomKeyWithCurrency',
'POST /api/storefronts/v2/buyItem',
'POST /api/storefronts/v3/buyInvention',
'POST /api/ugcPurchasables/v1/items/bulk',