mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-26 23:48:52 -07:00
[econ] key purchasing
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user