[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
+356
View File
@@ -93,6 +93,7 @@ import {
BuyInventionV3Response,
BuyItemRequest,
BuyItemResponse,
BuyRoomKeyResponse,
ChallengeProgressRequest,
ChallengeProgressResponse,
ChecklistCompleteResponse,
@@ -123,6 +124,10 @@ import {
OPTIONAL_AUTHED,
OwnsRoomKeysRequest,
OwnsRoomKeysResponse,
PurchaseRoomCurrencyEnvelope,
PurchaseRoomCurrencyRequest,
PurchaseRoomKeyWithCurrencyRequest,
PurchaseRoomKeyWithCurrencyResponse,
ReferralProgressResponse,
RoomConsumableDto,
RoomConsumableEnvelope,
@@ -158,17 +163,23 @@ import {
awardRoomCurrency,
createPurchaseOffer,
createRoomCurrency,
findPurchaseOffer,
getPurchaseOffers,
getRoomBalance,
getRoomCurrencies,
getRoomCurrency,
spendRoomCurrency,
updateRoomCurrency,
} from './room-currency-db'
import {
awardRoomKey,
claimRoomKey,
createRoomKey,
getRoomKey,
getRoomKeys,
getRoomOwnerId,
ownsRoomKeys,
releaseRoomKey,
updateRoomKey,
} from './room-key-db'
@@ -4277,6 +4288,134 @@ const app = new Hono<App>({ strict: false })
c.json([])
)
// 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.
.post(
'/api/roomCurrencies/v2/purchase',
describeRoute({
tags: ['Econ'],
summary: 'Buy room currency with tokens',
description: [
'Buys from a room currency’s shop. Form-encoded',
'(`PurchaseOfferId=ffab4157-…&RequestedAmount=444&RequestedPrice=555`): the caller',
'pays the offer’s `Price` in RecCenterTokens and is given its `CurrencyAmount` of the',
'currency the offer belongs to (`room_balance`).',
'',
'`PurchaseOfferId` is a `CurrencyPurchaseOfferId` out of a currency’s purchase offers,',
'and is all that says which currency is bought. `RequestedAmount` and `RequestedPrice`',
'must EQUAL the offer’s `CurrencyAmount` and `Price` — an offer edited since the client',
'drew it is refused rather than sold at numbers the player did not see.',
'',
'The tokens are paid to the room’s OWNER (its `CreatorAccountId`), as a room-key sale',
'is; an owner buying from their own shop, and a free offer, move no tokens.',
'',
'Answers the `{ Value, Success, Error, error_id }` envelope the room-currency writes',
'use. `Value` is BOTH balances the purchase moved, each a RESULTING total:',
'`CurrencyBalanceResponse` (`AccountId`, `CurrencyId`, `Balance`, `ModifiedAt`) is',
'what the buyer now holds of the room currency, and `TokenBalanceResponse`',
'(`Balance`, `CurrencyType`, `Platform`) their tokens. `Platform` is -2, the one',
'bucket every balance surface here names — any other value shows the buyer a phantom',
'second token balance. A refusal (no such offer, a mismatch, not enough tokens) is a',
'200 with `Success: false` and a null `Value` — that form is an assumption — and',
'nothing moved.',
'',
'When tokens moved, both players get a socket push carrying their resulting token',
'total — the buyer a StorefrontBalancePurchase, the owner a StorefrontBalanceUpdate.',
].join('\n'),
security: AUTHED,
requestBody: form(PurchaseRoomCurrencyRequest, 'The offer, and the numbers the client saw'),
responses: {
200: json(PurchaseRoomCurrencyEnvelope, 'The buyer’s new holding, or a refusal'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '')
const int = (v: unknown): number => (/^-?\d+$/.test(str(v)) ? Number(str(v)) : Number.NaN)
const refuse = (error: string) =>
c.json({ Value: null, Success: false, Error: error, error_id: null })
const offerId = str(body.PurchaseOfferId)
const found = offerId === '' ? null : await findPurchaseOffer(c.env.DB, offerId)
if (found === null) return refuse('No such purchase offer')
const { Offer: offer, RoomId: roomId } = found
// An offer that hands over nothing, or costs less than nothing, is not for sale.
if (!Number.isInteger(offer.CurrencyAmount) || offer.CurrencyAmount <= 0) {
return refuse('Purchase offer is not available')
}
if (!Number.isInteger(offer.Price) || offer.Price < 0) {
return refuse('Purchase offer is not available')
}
if (int(body.RequestedAmount) !== offer.CurrencyAmount) {
return refuse('Requested amount does not match')
}
if (int(body.RequestedPrice) !== offer.Price) return refuse('Requested price does not match')
const ownerId = await getRoomOwnerId(c.env.DB, roomId)
if (ownerId === null) return refuse('Purchase offer is not available')
// The owner paying themselves would be a debit and a credit of the same tokens.
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
const tokens = CurrencyType.RecCenterTokens
const charged = offer.Price > 0 && ownerId !== id
// Charged FIRST, and atomically: a buyer who cannot pay is handed nothing.
if (charged && !(await spendCurrency(c.env.DB, id, tokens, offer.Price, startingTokens))) {
return refuse('Not enough tokens')
}
const { Balance: currencyBalance } = await awardRoomCurrency(
c.env.DB,
offer.CurrencyId,
id,
offer.CurrencyAmount
)
if (charged) {
// Seed the owner's signup grant BEFORE crediting them — `creditCurrency` upserts
// the row, which would otherwise cost a never-seen owner their starting tokens.
await ensureStartingBalances(c.env.DB, ownerId, startingTokens)
const ownerBalance = await creditCurrency(
c.env.DB,
ownerId,
tokens,
offer.Price,
startingTokens
)
// Both frames carry a RESULTING total — see the frame rule above pushBalanceUpdate.
await pushBalanceUpdate(c, ownerId, tokens, ownerBalance)
}
const tokenBalance = await getBalance(c.env.DB, id, tokens, startingTokens)
if (charged) await pushBalancePurchase(c, id, tokens, -offer.Price, tokenBalance)
// Both balances are RESULTING totals. `Platform` names the one account-wide bucket,
// like every other token balance this worker reports.
return c.json({
Value: {
CurrencyBalanceResponse: {
AccountId: id,
CurrencyId: offer.CurrencyId,
Balance: currencyBalance,
ModifiedAt: new Date().toISOString(),
},
TokenBalanceResponse: {
Balance: tokenBalance,
CurrencyType: tokens,
Platform: ALL_PLATFORMS,
},
},
Success: true,
Error: null,
error_id: null,
})
}
)
// The room-economy surface the client asks for on entering a room: the room's own
// inventory/offers/gift-drop shops and the caller's slice of them. Nothing here is
// stored yet, so every one is an empty list — the client reads that as "this room
@@ -5642,6 +5781,223 @@ const app = new Hono<App>({ strict: false })
}
)
// Buy a room key with tokens. A GET with query params — that is how the client sends it.
// The key's price leaves the buyer and is paid to the room's owner; holding the key is a
// `room_key_player` row. Every refusal is a 200 in the same shape, told apart by the
// entry's `UpdateResponse` — only a missing token answers with a status of its own.
.get(
'/api/storefronts/v1/buyRoomKey',
describeRoute({
tags: ['Storefront'],
summary: 'Buy a room key',
description: [
'Looks the key up by `RoomKeyId`, confirms the client’s `RequestedPrice` matches its',
'stored `Price`, debits the buyer that price in RecCenterTokens, pays it to the',
'room’s OWNER (its `CreatorAccountId`), and records the buyer as holding the key',
'(`room_key_player`). A free key, and an owner buying their own room’s key, move no',
'tokens. A GET because that is how the client sends it.',
'',
'Answers the client’s `RoomKeyPurchaseResponseDTO`: `RoomKeyResponse` is the',
'`{ Status, RoomKey }` the create answers in, and `BalanceUpdateResponse` carries one',
'entry (`UpdateResponse`, `Data` = the key) and the buyer’s RESULTING `Balance`.',
'`Platform` is -2, the one bucket every balance surface here names — any other value',
'shows the buyer a phantom second balance.',
'',
'A refusal is a 200 in the same shape — `Status` 1, a null `RoomKey`, the balance',
'untouched — with the reason in the entry’s `UpdateResponse`: 6 the price does not',
'match, 3 already held, 2 not enough tokens, 4 no such key (or no such room, or a key',
'priced in a ROOM CURRENCY, which this token route does not sell).',
'',
'When tokens moved, both players get a socket push carrying their resulting total —',
'the buyer a StorefrontBalancePurchase, the owner a StorefrontBalanceUpdate.',
].join('\n'),
security: AUTHED,
parameters: [
{
name: 'RoomKeyId',
in: 'query',
required: true,
description: 'The key to buy — a `room_key` id',
schema: { type: 'integer' },
},
{
name: 'RequestedPrice',
in: 'query',
required: false,
description: 'The price the client rendered; must equal the key’s. Defaults to 0',
schema: { type: 'integer' },
},
],
responses: {
200: json(BuyRoomKeyResponse, 'The purchase result, or a refusal in the same shape'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const startingTokens = intVar(c.env.STARTING_TOKENS, DEFAULT_STARTING_TOKENS)
const currencyType = CurrencyType.RecCenterTokens
const roomKeyId = Number.parseInt(c.req.query('RoomKeyId') ?? '', 10)
// Absent/non-numeric reads as 0, which only matches a free key — a priced one then
// fails the confirmation below rather than selling for nothing.
const requestedPrice = Number.parseInt(c.req.query('RequestedPrice') ?? '0', 10) || 0
const key = Number.isInteger(roomKeyId) ? await getRoomKey(c.env.DB, roomKeyId) : null
// Every key is always sent: the client reads an omitted one as its enum's 0 member,
// which is success for `Status`/`UpdateResponse` and SteamPurchased for `Platform`.
const answer = async (code: number) =>
c.json({
RoomKeyResponse: {
Status: code === UpdateResponse.OK ? ROOM_KEY_STATUS_OK : ROOM_KEY_STATUS_FAILED,
RoomKey: code === UpdateResponse.OK ? key : null,
},
BalanceUpdateResponse: {
BalanceUpdates: [{ UpdateResponse: code, Data: key }],
Balance: await getBalance(c.env.DB, id, currencyType, startingTokens),
CurrencyType: currencyType,
Platform: ALL_PLATFORMS,
},
})
// A key priced in a room currency is not for sale in tokens.
if (key === null || key.PurchaseCurrencyId !== null) {
return answer(UpdateResponse.NoItemAvailable)
}
if (requestedPrice !== key.Price) return answer(UpdateResponse.RequestedPriceDoesNotMatch)
const ownerId = await getRoomOwnerId(c.env.DB, key.RoomId)
if (ownerId === null) return answer(UpdateResponse.NoItemAvailable)
// Claim the key BEFORE charging: the insert is atomic, so of two concurrent buys only
// one gets past here and only one is charged. A buyer who cannot pay gives it back.
if (!(await claimRoomKey(c.env.DB, id, key.RoomKeyId))) {
return answer(UpdateResponse.AlreadyOwned)
}
// The owner paying themselves would be a debit and a credit of the same tokens.
if (key.Price > 0 && ownerId !== id) {
if (!(await spendCurrency(c.env.DB, id, currencyType, key.Price, startingTokens))) {
await releaseRoomKey(c.env.DB, id, key.RoomKeyId)
return answer(UpdateResponse.NotEnoughCredit)
}
// Seed the owner's signup grant BEFORE crediting them — `creditCurrency` upserts
// the row, which would otherwise cost a never-seen owner their starting tokens.
await ensureStartingBalances(c.env.DB, ownerId, startingTokens)
const ownerBalance = await creditCurrency(
c.env.DB,
ownerId,
currencyType,
key.Price,
startingTokens
)
// Both frames carry a RESULTING total — see the frame rule above pushBalanceUpdate.
await pushBalanceUpdate(c, ownerId, currencyType, ownerBalance)
await pushBalancePurchase(
c,
id,
currencyType,
-key.Price,
await getBalance(c.env.DB, id, currencyType, startingTokens)
)
}
return answer(UpdateResponse.OK)
}
)
// Buy a room key with the room currency it is priced in — buyRoomKey's mirror image, and
// almost nothing shared on the wire: a POST with a form body, a bare `{ Balance,
// RoomKeyResponse }`, and a `Balance` that is an OBJECT. Nobody is paid: room currency is
// the room's own play money, so the price simply leaves the buyer.
.post(
'/api/storefronts/v1/PurchaseRoomKeyWithCurrency',
describeRoute({
tags: ['Storefront'],
summary: 'Buy a room key with room currency',
description: [
'Buys a key priced in a room currency. Form-encoded',
'(`RoomKeyId=43&RequestedPrice=1000&RequestedPurchaseCurrencyId=b9f41a7c-…`): the',
'key’s `Price` is subtracted from what the caller holds of that currency',
'(`room_balance`) and they are recorded as holding the key (`room_key_player`).',
'NOBODY IS PAID — unlike the token sale, the price does not reach the room’s owner.',
'',
'`RequestedPurchaseCurrencyId` must be the key’s own `PurchaseCurrencyId` and',
'`RequestedPrice` its `Price`. A key priced in TOKENS (a null `PurchaseCurrencyId`)',
'is not sold here — that is `GET /api/storefronts/v1/buyRoomKey`.',
'',
'Answers the client’s `RoomKeyPurchaseWithCurrencyResponseDTO`, a bare object with no',
'envelope: `Balance` is an OBJECT — `{ AccountId, CurrencyId, Balance, ModifiedAt }`,',
'the buyer’s RESULTING holding — and `RoomKeyResponse` the `{ Status, RoomKey }` the',
'create answers in. Not buyRoomKey’s shape; there is no `Platform` here.',
'',
'A refusal (no such key, a currency or price that is not the key’s, a key already',
'held, too little of the currency) is a 200 in the same shape: `Status` 1, a null',
'`RoomKey`, and the holding as it stands. That form is an assumption — only success',
'has been observed.',
].join('\n'),
security: AUTHED,
requestBody: form(PurchaseRoomKeyWithCurrencyRequest, 'The key, and what the client saw'),
responses: {
200: json(
PurchaseRoomKeyWithCurrencyResponse,
'The purchase result, or a refusal in the same shape'
),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const body = (await c.req.parseBody().catch(() => ({}))) as Record<string, unknown>
const str = (v: unknown): string => (typeof v === 'string' ? v.trim() : '')
const int = (v: unknown): number => (/^-?\d+$/.test(str(v)) ? Number(str(v)) : Number.NaN)
const requestedCurrencyId = str(body.RequestedPurchaseCurrencyId)
const roomKeyId = int(body.RoomKeyId)
const key = Number.isInteger(roomKeyId) ? await getRoomKey(c.env.DB, roomKeyId) : null
// The currency the answer reports on: the key's own once it is known to be the one
// asked for, the requested one on a refusal that never got that far.
const currencyId = key?.PurchaseCurrencyId ?? requestedCurrencyId
// `Status` is always sent: the client reads an omitted one as 0, which is success.
const answer = async (sold: boolean) =>
c.json({
Balance: {
AccountId: id,
CurrencyId: currencyId,
Balance: await getRoomBalance(c.env.DB, currencyId, id),
ModifiedAt: new Date().toISOString(),
},
RoomKeyResponse: {
Status: sold ? ROOM_KEY_STATUS_OK : ROOM_KEY_STATUS_FAILED,
RoomKey: sold ? key : null,
},
})
// A key priced in tokens is buyRoomKey's to sell, and a key is paid for in its own
// currency only — never whichever one the request would rather spend.
if (key === null || key.PurchaseCurrencyId === null) return answer(false)
if (requestedCurrencyId.toLowerCase() !== key.PurchaseCurrencyId.toLowerCase()) {
return answer(false)
}
if (int(body.RequestedPrice) !== key.Price) return answer(false)
// Claim the key BEFORE charging, as the token sale does: the insert is atomic, so of
// two concurrent buys only one is charged. A buyer who cannot pay gives it back.
if (!(await claimRoomKey(c.env.DB, id, key.RoomKeyId))) return answer(false)
if (
key.Price > 0 &&
!(await spendRoomCurrency(c.env.DB, key.PurchaseCurrencyId, id, key.Price))
) {
await releaseRoomKey(c.env.DB, id, key.RoomKeyId)
return answer(false)
}
return answer(true)
}
)
// Edit a room key. "All" is the key's FIELDS, not the room's keys: the body names one
// `RoomKeyId` and carries everything editable about it. Gated like the create — the room
// comes off the KEY, never the request.