[econ] room consumable token/currency purchase

This commit is contained in:
Devin Zuczek
2026-10-22 14:16:18 -04:00
parent 1d0e71d651
commit fbf7c923a6
5 changed files with 764 additions and 45 deletions
@@ -0,0 +1,13 @@
-- What a player holds of a room consumable now remembers its last write: `concurrency_code`
-- (the request's `NewConcurrencyCode`) and `modified_at`. Owned by the `econ` worker; generated
-- from src/room-consumable-db.ts (ROOM_INVENTORY_SCHEMA_DDL, which mirrors the table as it
-- stands AFTER this migration) — keep in sync.
--
-- Both are served on the client's `RoomConsumableInventoryItem` (`GET
-- …/roomConsumable/room/{roomId}/me`, and the purchase's `InventoryItem`) as `ConcurrencyCode`
-- and `ModifiedAt`. The code is stored to be echoed, not checked — no optimistic concurrency is
-- implemented. Nullable: rows written before this have neither, and are served a null code and
-- the listing's own `ModifiedAt`.
ALTER TABLE room_inventory ADD COLUMN concurrency_code TEXT;
ALTER TABLE room_inventory ADD COLUMN modified_at TEXT;
+254 -7
View File
@@ -115,6 +115,7 @@ import {
ErrorResponse, ErrorResponse,
form, form,
GameRewardRequest, GameRewardRequest,
HeldRoomKeyDto,
InfluencerIdsResponse, InfluencerIdsResponse,
IsOnWishlistBulkRequest, IsOnWishlistBulkRequest,
IsOnWishlistBulkResponse, IsOnWishlistBulkResponse,
@@ -131,6 +132,8 @@ import {
OwnsRoomKeysRequest, OwnsRoomKeysRequest,
OwnsRoomKeysResponse, OwnsRoomKeysResponse,
PurchasableItemsRequest, PurchasableItemsRequest,
PurchaseRoomConsumableRequest,
PurchaseRoomConsumableResponse,
PurchaseRoomCurrencyEnvelope, PurchaseRoomCurrencyEnvelope,
PurchaseRoomCurrencyRequest, PurchaseRoomCurrencyRequest,
PurchaseRoomKeyWithCurrencyRequest, PurchaseRoomKeyWithCurrencyRequest,
@@ -140,6 +143,7 @@ import {
RemoveInfluencerBody, RemoveInfluencerBody,
RoomConsumableDto, RoomConsumableDto,
RoomConsumableEnvelope, RoomConsumableEnvelope,
RoomConsumableInventoryItemDto,
RoomCurrencyBalance, RoomCurrencyBalance,
RoomCurrencyDto, RoomCurrencyDto,
RoomCurrencyEnvelope, RoomCurrencyEnvelope,
@@ -147,7 +151,6 @@ import {
RoomCurrencyPurchaseOfferEnvelope, RoomCurrencyPurchaseOfferEnvelope,
RoomCurrencyPurchaseOffersDto, RoomCurrencyPurchaseOffersDto,
RoomEconConfig, RoomEconConfig,
HeldRoomKeyDto,
RoomKeyDto, RoomKeyDto,
RoomKeyEnvelope, RoomKeyEnvelope,
RoomPurchasableList, RoomPurchasableList,
@@ -176,6 +179,8 @@ import {
awardRoomConsumable, awardRoomConsumable,
getRoomConsumable, getRoomConsumable,
getRoomConsumables, getRoomConsumables,
getRoomInventory,
toInventoryItem,
upsertRoomConsumable, upsertRoomConsumable,
} from './room-consumable-db' } from './room-consumable-db'
import { import {
@@ -195,8 +200,8 @@ import {
awardRoomKey, awardRoomKey,
claimRoomKey, claimRoomKey,
createRoomKey, createRoomKey,
getRoomKey,
getHeldRoomKeys, getHeldRoomKeys,
getRoomKey,
getRoomKeys, getRoomKeys,
getRoomOwnerId, getRoomOwnerId,
ownsRoomKeys, ownsRoomKeys,
@@ -234,7 +239,7 @@ import type { ConsumeResult } from './consumables-db'
import type { App, Env } from './context' import type { App, Env } from './context'
import type { Equipment } from './equipment-db' import type { Equipment } from './equipment-db'
import type { AvatarItem } from './inventory-db' import type { AvatarItem } from './inventory-db'
import type { RoomConsumable } from './room-consumable-db' import type { RoomConsumable, RoomConsumableInventoryItem } from './room-consumable-db'
import type { RoomCurrency, RoomCurrencyPurchaseOffer } from './room-currency-db' import type { RoomCurrency, RoomCurrencyPurchaseOffer } from './room-currency-db'
import type { RoomKey, RoomKeyHolding } from './room-key-db' import type { RoomKey, RoomKeyHolding } from './room-key-db'
import type { WishlistItem } from './wishlist-db' import type { WishlistItem } from './wishlist-db'
@@ -3851,7 +3856,15 @@ const app = new Hono<App>({ strict: false })
continue continue
} }
const owned = await awardRoomConsumable(c.env.DB, accountId, consumableId, quantity) const code = (request as { ConcurrencyCodes?: { NewConcurrencyCode?: unknown } })
.ConcurrencyCodes?.NewConcurrencyCode
const owned = await awardRoomConsumable(
c.env.DB,
accountId,
consumableId,
quantity,
typeof code === 'string' ? code : null
)
results.push({ results.push({
ConsumableId: consumableId, ConsumableId: consumableId,
Success: true, Success: true,
@@ -3859,7 +3872,7 @@ const app = new Hono<App>({ strict: false })
Response: { Response: {
PlayerId: accountId, PlayerId: accountId,
ConsumableId: consumableId, ConsumableId: consumableId,
Quantity: owned, Quantity: owned.quantity,
AwardedAt: new Date().toISOString(), AwardedAt: new Date().toISOString(),
}, },
}) })
@@ -3869,6 +3882,202 @@ const app = new Hono<App>({ strict: false })
} }
) )
// Buy some of a room's consumables, for the caller or as a gift. One route for both kinds of
// price: a listing in TOKENS pays the room's owner, as a room key does; a listing in a ROOM
// CURRENCY pays nobody — that money is the room's own invention. What is bought lands in
// `room_inventory`, the same holding `awardBulk` adds to.
.put(
// Two paths, one sale: the client posts a token listing to `/purchase/tokens` and a
// room-currency one to `/purchase/currency`, with the same body. The body's
// `ExpectedPriceAndCurrency` is what is checked against the listing, not the path.
'/api/roomconsumables/v1/roomconsumable/:id/purchase/:via{currency|tokens}',
describeRoute({
tags: ['Storefront'],
summary: 'Buy a room consumable',
description: [
'Buys `Quantity` of the consumable named in the path, at its listed price EACH.',
'The client posts a listing priced in tokens to `…/purchase/tokens` and one priced',
'in a room currency to `…/purchase/currency`; both take the same body and answer',
'the same way, and it is the body’s `ExpectedPriceAndCurrency` that must match.',
'',
'`ExpectedPriceAndCurrency` must be the listing’s own: `Price` its unit price and',
'`CurrencyId` its `PurchaseCurrencyId` — null for a listing priced in tokens.',
'`Quantity` must be at least 1 and, when the listing sets one, at most its',
'`MaximumCountPerPurchase`. `ConcurrencyCodes` is accepted and IGNORED.',
'',
'- In TOKENS: the total leaves the buyer and is paid to the room’s OWNER, who gets a',
' StorefrontBalanceUpdate; the buyer gets a StorefrontBalancePurchase. Both carry',
' a resulting total.',
'- In a ROOM CURRENCY: the total leaves the buyer’s holding of it (`room_balance`)',
' and NOBODY is paid.',
'',
'The room’s creator and co-owners pay nothing: the client asks them `Price` 0 (the',
'listed price is accepted too).',
'',
'`GiftRecipientAccountId` sends what is bought to that player; the buyer still pays.',
'Null (or the buyer’s own id) keeps it. Either way the items are added to the',
'recipient’s `room_inventory`.',
'',
'Answers the client’s purchase result, observed from the live client:',
'`BalanceUpdateResult` (an UpdateResponse — 0 OK), `CurrencyBalanceResponse` (what',
'the BUYER now holds of what they paid in: `{ AccountId, CurrencyId, Balance,',
'ModifiedAt }`, `CurrencyId` null and `Balance` their token total for a token',
'listing) and `OperationResult` — `{ Status, InventoryItem }`, the item being what',
'the RECIPIENT now owns — the same `RoomConsumableInventoryItem` that',
'`…/roomConsumable/room/{roomId}/me` lists: `Count` the resulting total,',
'`ConcurrencyCode` the request’s `NewConcurrencyCode` (stored, not checked),',
'`Consumable` the listing plus `IsGiftable`/`Visibility`.',
'',
'A refusal is a 200 in the same shape — `Status` 1, a null `InventoryItem`, the',
'balance as it stands — with the reason in `BalanceUpdateResult`: 4 no such',
'consumable or room, 7 a bad `Quantity`, 8 a bad gift recipient, 6 a price or',
'currency that is not the listing’s, 2 not enough to pay. Nothing is charged or',
'given. The refusal form is an ASSUMPTION — only success has been observed.',
].join('\n'),
security: AUTHED,
parameters: [
{
name: 'id',
in: 'path',
required: true,
description: 'The consumable to buy — a `room_consumable` id',
schema: { type: 'string' },
},
{
name: 'via',
in: 'path',
required: true,
description: '`tokens` for a listing priced in tokens, `currency` for a room currency',
schema: { type: 'string', enum: ['tokens', 'currency'] },
},
],
requestBody: jsonBody(PurchaseRoomConsumableRequest, 'What to buy, and what the client saw'),
responses: {
200: json(PurchaseRoomConsumableResponse, 'What the recipient now owns, or a refusal'),
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 body = await c.req
.json<{
ConcurrencyCodes?: { NewConcurrencyCode?: unknown } | null
ExpectedPriceAndCurrency?: { Price?: unknown; CurrencyId?: unknown } | null
Quantity?: unknown
GiftRecipientAccountId?: unknown
}>()
.catch(() => null)
const request = body !== null && typeof body === 'object' ? body : {}
const expected = request.ExpectedPriceAndCurrency
const expectedCurrency =
typeof expected?.CurrencyId === 'string' && expected.CurrencyId !== ''
? expected.CurrencyId
: null
const consumable = await getRoomConsumable(c.env.DB, c.req.param('id'))
// The balance the answer reports on: what the listing is priced in once it is known,
// what the request named on a refusal that never got that far. Null is tokens.
const balanceCurrency = consumable ? consumable.PurchaseCurrencyId : expectedCurrency
// Every key is always sent: the client reads an omitted `Status` or
// `BalanceUpdateResult` as its enum's 0 member, which is success.
const answer = async (code: number, item: RoomConsumableInventoryItem | null) =>
c.json({
BalanceUpdateResult: code,
CurrencyBalanceResponse: {
AccountId: id,
CurrencyId: balanceCurrency,
Balance:
balanceCurrency === null
? await getBalance(c.env.DB, id, currencyType, startingTokens)
: await getRoomBalance(c.env.DB, balanceCurrency, id),
ModifiedAt: new Date().toISOString(),
},
OperationResult: {
Status: item === null ? ROOM_KEY_STATUS_FAILED : ROOM_KEY_STATUS_OK,
InventoryItem: item,
},
})
if (!consumable) return answer(UpdateResponse.NoItemAvailable, null)
const quantity = request.Quantity
if (
typeof quantity !== 'number' ||
!Number.isInteger(quantity) ||
quantity < 1 ||
(consumable.MaximumCountPerPurchase > 0 && quantity > consumable.MaximumCountPerPurchase)
) {
return answer(UpdateResponse.RequestedAmountNotAllowed, null)
}
const gift = request.GiftRecipientAccountId
if (
gift !== null &&
gift !== undefined &&
!(Number.isInteger(gift) && (gift as number) > 0)
) {
return answer(UpdateResponse.PlayerNotEligible, null)
}
const recipientId = (gift as number | null | undefined) ?? id
// Paid in the listing's own currency only — never whichever the request would rather.
if (expectedCurrency?.toLowerCase() !== consumable.PurchaseCurrencyId?.toLowerCase()) {
return answer(UpdateResponse.RequestedPriceDoesNotMatch, null)
}
const canManage = await canManageRoomById(c.env.DB, consumable.RoomId, id)
if (canManage === null) return answer(UpdateResponse.NoItemAvailable, null)
const expectedPrice = expected?.Price
if (expectedPrice !== consumable.Price && !(canManage && expectedPrice === 0)) {
return answer(UpdateResponse.RequestedPriceDoesNotMatch, null)
}
const total = canManage ? 0 : consumable.Price * quantity
if (total > 0 && consumable.PurchaseCurrencyId !== null) {
if (!(await spendRoomCurrency(c.env.DB, consumable.PurchaseCurrencyId, id, total))) {
return answer(UpdateResponse.NotEnoughCredit, null)
}
} else if (total > 0) {
const ownerId = await getRoomOwnerId(c.env.DB, consumable.RoomId)
if (ownerId === null) return answer(UpdateResponse.NoItemAvailable, null)
if (!(await spendCurrency(c.env.DB, id, currencyType, total, startingTokens))) {
return answer(UpdateResponse.NotEnoughCredit, null)
}
// Seed the owner's signup grant BEFORE crediting them, as the key sale does.
await ensureStartingBalances(c.env.DB, ownerId, startingTokens)
const ownerBalance = await creditCurrency(
c.env.DB,
ownerId,
currencyType,
total,
startingTokens
)
await pushBalanceUpdate(c, ownerId, currencyType, ownerBalance)
await pushBalancePurchase(
c,
id,
currencyType,
-total,
await getBalance(c.env.DB, id, currencyType, startingTokens)
)
}
const newCode = request.ConcurrencyCodes?.NewConcurrencyCode
const owned = await awardRoomConsumable(
c.env.DB,
recipientId,
consumable.RoomConsumableId,
quantity,
typeof newCode === 'string' ? newCode : null
)
return answer(UpdateResponse.OK, toInventoryItem(recipientId, owned, consumable))
}
)
// Create a consumable, or replace one when the body names an existing id. Auth-gated (401) // Create a consumable, or replace one when the body names an existing id. Auth-gated (401)
// and gated to the room's creator or a co-owner (403) — a shop is the room's to stock. // and gated to the room's creator or a co-owner (403) — a shop is the room's to stock.
.put( .put(
@@ -3968,10 +4177,48 @@ const app = new Hono<App>({ strict: false })
return roomConsumableEnvelope(c, consumable) return roomConsumableEnvelope(c, consumable)
} }
) )
// What the CALLER owns of one room's consumables. NOT the catalogue the same path without
// `/me` serves: a different element type (`RoomConsumableInventoryItem`), so a separate handler.
.get( .get(
'/api/roomconsumables/v1/roomConsumable/room/:roomId/me', '/api/roomconsumables/v1/roomConsumable/room/:roomId/me',
listRoute('The caller’s room consumables', 'Empty stub'), describeRoute({
(c) => c.json([]) tags: ['Econ'],
summary: 'The caller’s room consumables',
description: [
'A bare array of what the caller owns of the room’s consumables, in the shop’s order —',
'the client’s `RoomConsumableInventoryItem`, observed from the live client:',
'`{ RoomConsumableId, AccountId, Count, ConcurrencyCode, ModifiedAt, Consumable }`.',
'',
'`Count` is what they hold now; a consumable they hold none of (never had, or spent',
'down to zero) is absent. `ConcurrencyCode` is the last `NewConcurrencyCode` written',
'(stored, not checked). `Consumable` is the listing plus `IsGiftable` (false) and',
'`Visibility` (0), with `ImageName` as "" rather than null — NOT the catalogue',
'element `…/room/{roomId}` serves.',
'',
'An unknown room, or one the caller owns nothing of, is an empty list.',
].join('\n'),
security: AUTHED,
parameters: [
{
name: 'roomId',
in: 'path',
required: true,
description: 'The room whose consumables to list',
schema: { type: 'integer' },
},
],
responses: {
200: json(RoomConsumableInventoryItemDto.array(), 'What the caller owns, shop order'),
401: UNAUTHORIZED_RESPONSE,
},
}),
async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
const roomId = Number.parseInt(c.req.param('roomId'), 10)
if (!Number.isInteger(roomId)) return c.json([])
return c.json(await getRoomInventory(c.env.DB, roomId, id))
}
) )
// The custom currencies a room has minted. Public, like the room itself: a room's // The custom currencies a room has minted. Public, like the room itself: a room's
// currencies are shown to every player who walks into it, so the list is not a secret // currencies are shown to every player who walks into it, so the list is not a secret
+80
View File
@@ -1079,6 +1079,86 @@ export const AwardRoomConsumableResult = z.object({
export const AwardRoomConsumableResultList = z.array(AwardRoomConsumableResult) export const AwardRoomConsumableResultList = z.array(AwardRoomConsumableResult)
/**
* The client's `RoomConsumableInventoryItem` — what a player owns of one consumable. The
* element of `GET …/roomConsumable/room/{roomId}/me` and the purchase's `InventoryItem`.
*/
export const RoomConsumableInventoryItemDto = z.object({
RoomConsumableId: z.string(),
AccountId: z.int().describe('Who owns them'),
Count: z.int().describe('The RESULTING total owned, never the change'),
ConcurrencyCode: z
.string()
.nullable()
.describe('The last `NewConcurrencyCode` written — stored, not checked'),
ModifiedAt: z.string().describe('ISO-8601 UTC — the last write'),
Consumable: RoomConsumableDto.extend({
ImageName: z.string().describe('"" when the listing has no picture (NOT null)'),
IsGiftable: z.boolean().describe('Always false — not modelled'),
Visibility: z.int().describe('Always 0 — not modelled'),
}).describe('The listing — the catalogue element plus two fields'),
})
/**
* `PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/{tokens|currency}` — the client's
* `RoomConsumablePurchaseRequest`, four keys in declaration order (from its formatter). One
* route for both kinds of price: `ExpectedPriceAndCurrency.CurrencyId` null is TOKENS.
*/
export const PurchaseRoomConsumableRequest = z.object({
ConcurrencyCodes: z
.object({
CurrentConcurrencyCode: z.string().nullable().optional(),
NewConcurrencyCode: z.string().nullable().optional(),
})
.optional()
.describe('Accepted and IGNORED — no optimistic concurrency is implemented yet'),
ExpectedPriceAndCurrency: z
.object({
Price: z.int().describe('The UNIT price the client rendered; must equal the listing’s'),
CurrencyId: z
.string()
.nullable()
.describe('The listing’s room currency, or null for a listing priced in tokens'),
})
.describe('What the client showed — refused unless it is the listing’s own'),
Quantity: z.int().describe('How many to buy; at least 1, at most `MaximumCountPerPurchase`'),
GiftRecipientAccountId: z
.int()
.nullable()
.optional()
.describe('Who receives them; null for the buyer. The buyer pays either way'),
})
/**
* The consumable purchase's answer, observed from the live client. The refusal form (`Status`
* 1, a null `InventoryItem`) is an assumption — only success has been observed.
*/
export const PurchaseRoomConsumableResponse = z.object({
BalanceUpdateResult: z
.int()
.describe(
'An UpdateResponse: 0 OK, 2 NotEnoughCredit, 4 NoItemAvailable, ' +
'6 RequestedPriceDoesNotMatch, 7 RequestedAmountNotAllowed, 8 PlayerNotEligible'
),
CurrencyBalanceResponse: z
.object({
AccountId: z.int().describe('The BUYER — who paid, even for a gift'),
CurrencyId: z
.string()
.nullable()
.describe('The room currency paid in; null for a listing priced in tokens'),
Balance: z.int().describe('What the buyer holds of it afterwards — the RESULTING total'),
ModifiedAt: z.string().describe('ISO-8601 UTC'),
})
.describe('What the buyer now holds of what they paid in'),
OperationResult: z.object({
Status: z.int().describe('0 on success; 1 on a refusal. Always sent — omitted reads as 0'),
InventoryItem: RoomConsumableInventoryItemDto.nullable().describe(
'What the recipient now owns of the consumable; null on a refusal'
),
}),
})
/** /**
* The envelope the consumable write answers in — the same * The envelope the consumable write answers in — the same
* `{ Value, Success, Error, error_id }` the room-currency writes use. * `{ Value, Success, Error, error_id }` the room-currency writes use.
+122 -30
View File
@@ -11,14 +11,16 @@
* names an existing `RoomConsumableId` * names an existing `RoomConsumableId`
* - `GET /api/roomconsumables/v1/roomConsumable/room/{roomId}` lists a room's * - `GET /api/roomconsumables/v1/roomConsumable/room/{roomId}` lists a room's
* - `POST /api/roomconsumables/v1/roomConsumable/awardBulk` gives one to a player * - `POST /api/roomconsumables/v1/roomConsumable/awardBulk` gives one to a player
* - `PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/tokens` and `…/purchase/currency`
* sell one, in tokens or the listing's room currency
* *
* `PurchaseCurrencyId` is a `room_currency`, so a room prices its goods in its own money. It * `PurchaseCurrencyId` is a `room_currency`, so a room prices its goods in its own money. It
* is nullable — `Guid?` in the client's model — for a listing that names no currency, and * is nullable — `Guid?` in the client's model — for a listing that names no currency, and
* nothing here resolves or validates it yet: a consumable priced in a currency that has since * nothing here resolves or validates it yet: a consumable priced in a currency that has since
* been deleted still lists, at a price nobody can pay. * been deleted still lists, at a price nobody can pay.
* *
* Nothing BUYS one yet. There is no purchase endpoint, so `MaximumCountPerPurchase` is stored * A null `PurchaseCurrencyId` is priced in TOKENS, which the purchase pays to the room's
* and served and read by nothing. * owner; `MaximumCountPerPurchase` caps a purchase's `Quantity` (0 means no cap).
* *
* This worker (`econ`) owns the tables and their migrations — see apps/econ/migrations/, * This worker (`econ`) owns the tables and their migrations — see apps/econ/migrations/,
* 0021 (the listings) and 0022 (what players own of them). * 0021 (the listings) and 0022 (what players own of them).
@@ -72,7 +74,7 @@ export interface RoomConsumable {
PurchaseCurrencyId: string | null PurchaseCurrencyId: string | null
/** ISO-8601 UTC. */ /** ISO-8601 UTC. */
ModifiedAt: string ModifiedAt: string
/** How many may be bought at once. Stored and served; nothing buys a consumable yet. */ /** How many may be bought at once; 0 is no cap. */
MaximumCountPerPurchase: number MaximumCountPerPurchase: number
} }
@@ -201,7 +203,7 @@ export async function getRoomConsumables(
/** /**
* What a player owns of a room's consumables — the shop's other side, filled by * What a player owns of a room's consumables — the shop's other side, filled by
* `POST /api/roomconsumables/v1/roomConsumable/awardBulk`. * `POST /api/roomconsumables/v1/roomConsumable/awardBulk` and by the purchase.
* *
* Keyed by CONSUMABLE, not by room: `room_consumable` already says which room a listing * Keyed by CONSUMABLE, not by room: `room_consumable` already says which room a listing
* belongs to, and repeating it here would be a second place for that to be wrong. A missing * belongs to, and repeating it here would be a second place for that to be wrong. A missing
@@ -212,12 +214,18 @@ export async function getRoomConsumables(
* `consumable_id` is not a foreign key, but the award route checks the listing exists before * `consumable_id` is not a foreign key, but the award route checks the listing exists before
* writing: an inventory row naming nothing would be a thing a player owns that cannot be * writing: an inventory row naming nothing would be a thing a player owns that cannot be
* described. * described.
*
* `concurrency_code` is the last `NewConcurrencyCode` a write sent, served back as the item's
* `ConcurrencyCode`; nothing checks it — no optimistic concurrency is implemented. It and
* `modified_at` came in migration 0029, so a row older than that has both null.
*/ */
export const ROOM_INVENTORY_SCHEMA_DDL: string[] = [ export const ROOM_INVENTORY_SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS room_inventory ( `CREATE TABLE IF NOT EXISTS room_inventory (
player_id INTEGER NOT NULL, player_id INTEGER NOT NULL,
consumable_id TEXT NOT NULL, consumable_id TEXT NOT NULL,
quantity INTEGER NOT NULL DEFAULT 0, quantity INTEGER NOT NULL DEFAULT 0,
concurrency_code TEXT,
modified_at TEXT,
PRIMARY KEY (player_id, consumable_id) PRIMARY KEY (player_id, consumable_id)
)`, )`,
// "What does this player own?" is the read the client makes on entering a room, so the // "What does this player own?" is the read the client makes on entering a room, so the
@@ -225,15 +233,67 @@ export const ROOM_INVENTORY_SCHEMA_DDL: string[] = [
`CREATE INDEX IF NOT EXISTS idx_room_inventory_player ON room_inventory (player_id)`, `CREATE INDEX IF NOT EXISTS idx_room_inventory_player ON room_inventory (player_id)`,
] ]
/** What a player owns of one consumable. */ /**
export interface RoomInventoryItem { * The listing as it rides inside a {@link RoomConsumableInventoryItem}: the catalogue's
PlayerId: number * {@link RoomConsumable} plus `IsGiftable` and `Visibility`, and `ImageName` as '' rather
ConsumableId: string * than null — observed from the live client, and NOT what the room's catalogue
Quantity: number * (`GET …/roomConsumable/room/{roomId}`) serves. Neither extra field is modelled here, so
* both are their false/0.
*/
export interface InventoryConsumable extends Omit<RoomConsumable, 'ImageName'> {
ImageName: string
IsGiftable: boolean
Visibility: number
} }
/** /**
* Give a player some of a consumable, returning what they own afterwards. * What a player owns of one consumable, as the client reads it — `RoomConsumableInventoryItem`,
* observed from the live client. The element of `GET …/roomConsumable/room/{roomId}/me` and
* the `InventoryItem` a purchase answers with.
*/
export interface RoomConsumableInventoryItem {
RoomConsumableId: string
AccountId: number
/** The RESULTING total owned, never the change. */
Count: number
/** The last `NewConcurrencyCode` written; null on a row older than migration 0029. */
ConcurrencyCode: string | null
/** ISO-8601 UTC — the last write; the listing's own time on a row older than 0029. */
ModifiedAt: string
Consumable: InventoryConsumable
}
/** What a player holds of one consumable — the row, before it is joined to its listing. */
export interface RoomInventoryHolding {
quantity: number
concurrencyCode: string | null
modifiedAt: string | null
}
/** Project a holding and its listing onto the client's inventory item. */
export function toInventoryItem(
accountId: number,
holding: RoomInventoryHolding,
consumable: RoomConsumable
): RoomConsumableInventoryItem {
return {
RoomConsumableId: consumable.RoomConsumableId,
AccountId: accountId,
Count: holding.quantity,
ConcurrencyCode: holding.concurrencyCode,
ModifiedAt: holding.modifiedAt ?? consumable.ModifiedAt,
Consumable: {
...consumable,
ImageName: consumable.ImageName ?? '',
IsGiftable: false,
Visibility: 0,
},
}
}
/**
* Give a player some of a consumable, returning what they hold afterwards. `concurrencyCode`
* is the request's `NewConcurrencyCode`, stored to be served back; null leaves the stored one.
* *
* Floored at zero in the statement itself, so a negative `quantity` deducts without a * Floored at zero in the statement itself, so a negative `quantity` deducts without a
* deduction and a concurrent award racing it below zero. Returns the RESULTING total, never * deduction and a concurrent award racing it below zero. Returns the RESULTING total, never
@@ -249,48 +309,80 @@ export async function awardRoomConsumable(
db: D1Database, db: D1Database,
playerId: number, playerId: number,
consumableId: string, consumableId: string,
quantity: number quantity: number,
): Promise<number> { concurrencyCode: string | null = null
): Promise<RoomInventoryHolding> {
const read = db.prepare( const read = db.prepare(
'SELECT quantity FROM room_inventory WHERE player_id = ?1 AND consumable_id = ?2' `SELECT quantity, concurrency_code, modified_at FROM room_inventory
WHERE player_id = ?1 AND consumable_id = ?2`
) )
const [, , after] = await db.batch<{ quantity: number }>([ const [, , after] = await db.batch<{
quantity: number
concurrency_code: string | null
modified_at: string | null
}>([
read.bind(playerId, consumableId), read.bind(playerId, consumableId),
db db
.prepare( .prepare(
`INSERT INTO room_inventory (player_id, consumable_id, quantity) `INSERT INTO room_inventory (player_id, consumable_id, quantity, concurrency_code, modified_at)
VALUES (?1, ?2, MAX(0, ?3)) VALUES (?1, ?2, MAX(0, ?3), ?4, ?5)
ON CONFLICT (player_id, consumable_id) ON CONFLICT (player_id, consumable_id) DO UPDATE SET
DO UPDATE SET quantity = MAX(0, room_inventory.quantity + ?3)` quantity = MAX(0, room_inventory.quantity + ?3),
concurrency_code = COALESCE(?4, room_inventory.concurrency_code),
modified_at = ?5`
) )
.bind(playerId, consumableId, quantity), .bind(playerId, consumableId, quantity, concurrencyCode, new Date().toISOString()),
read.bind(playerId, consumableId), read.bind(playerId, consumableId),
]) ])
// No row means a quantity of zero — nothing is inserted until a player is first given one. // No row means a quantity of zero — nothing is inserted until a player is first given one.
return after.results[0]?.quantity ?? 0 const row = after.results[0]
return {
quantity: row?.quantity ?? 0,
concurrencyCode: row?.concurrency_code ?? null,
modifiedAt: row?.modified_at ?? null,
}
} }
/** Everything a player owns of one room's consumables. A listing they own none of is absent. */ /**
* Everything a player owns of one room's consumables, in the shop's order. A listing they own
* none of is absent — including one they held and spent down to zero.
*/
export async function getRoomInventory( export async function getRoomInventory(
db: D1Database, db: D1Database,
roomId: number, roomId: number,
playerId: number playerId: number
): Promise<RoomInventoryItem[]> { ): Promise<RoomConsumableInventoryItem[]> {
const { results } = await db const { results } = await db
.prepare( .prepare(
`SELECT i.player_id AS playerId, i.consumable_id AS consumableId, i.quantity AS quantity `SELECT ${SELECT_COLUMNS.split(', ')
.map((col) => `c.${col}`)
.join(', ')},
i.quantity AS quantity, i.concurrency_code AS concurrency_code,
i.modified_at AS inventory_modified_at
FROM room_inventory AS i FROM room_inventory AS i
JOIN room_consumable AS c ON c.room_consumable_id = i.consumable_id JOIN room_consumable AS c ON c.room_consumable_id = i.consumable_id
WHERE c.room_id = ?1 AND i.player_id = ?2 WHERE c.room_id = ?1 AND i.player_id = ?2 AND i.quantity > 0
ORDER BY c.modified_at, c.room_consumable_id` ORDER BY c.modified_at, c.room_consumable_id`
) )
.bind(roomId, playerId) .bind(roomId, playerId)
.all<{ playerId: number; consumableId: string; quantity: number }>() .all<
RoomConsumableRow & {
quantity: number
concurrency_code: string | null
inventory_modified_at: string | null
}
>()
return results.map((r) => ({ return results.map((row) =>
PlayerId: r.playerId, toInventoryItem(
ConsumableId: r.consumableId, playerId,
Quantity: r.quantity, {
})) quantity: row.quantity,
concurrencyCode: row.concurrency_code,
modifiedAt: row.inventory_modified_at,
},
toRoomConsumable(row)
)
)
} }
+295 -8
View File
@@ -1479,6 +1479,300 @@ describe('econ endpoints', () => {
} }
}) })
test('PUT roomconsumable/:id/purchase/tokens|currency sells for tokens (paying the owner) or room currency (paying nobody)', async () => {
const coin = 'b9f41a7c-32f1-4456-b84f-fdb94b240955'
// Room 2511 is account 1's, with 2 as co-owner. One listing in tokens (max 3 a
// purchase), one in the room's coin.
const tokenItem = (
await envOf(
await save(
{
...body,
Name: 'Token potion',
PriceAndCurrency: { Price: 5, CurrencyId: null },
MaximumCountPerPurchase: 3,
},
await bearer('1')
)
)
).Value!
const coinItem = (
await envOf(await save({ ...body, Name: 'Coin potion' }, await bearer('1')))
).Value!
expect(tokenItem).toMatchObject({ Price: 5, PurchaseCurrencyId: null })
expect(coinItem).toMatchObject({ Price: 50, PurchaseCurrencyId: coin })
const purchase = async (
sub: string | null,
consumableId: string,
price: number,
currencyId: string | null,
quantity = 1,
gift: number | null = null
) =>
exports.default.fetch(
// As the client does: a token price to `/tokens`, a room currency to `/currency`.
`${ORIGIN}/api/roomconsumables/v1/roomconsumable/${consumableId}/purchase/` +
(currencyId === null ? 'tokens' : 'currency'),
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(sub === null ? {} : await bearer(sub)),
},
body: JSON.stringify({
ConcurrencyCodes: {
CurrentConcurrencyCode: null,
NewConcurrencyCode: '1ea4a2dd-b643-406d-99b7-bd1b936e678b',
},
ExpectedPriceAndCurrency: { Price: price, CurrencyId: currencyId },
Quantity: quantity,
GiftRecipientAccountId: gift,
}),
}
)
type InventoryItem = {
RoomConsumableId: string
AccountId: number
Count: number
ConcurrencyCode: string | null
ModifiedAt: string
Consumable: Consumable & { IsGiftable: boolean; Visibility: number }
}
type Purchase = {
BalanceUpdateResult: number
CurrencyBalanceResponse: {
AccountId: number
CurrencyId: string | null
Balance: number
ModifiedAt: string
}
OperationResult: {
Status: number
InventoryItem: InventoryItem | null
}
}
const result = async (res: Response) => {
expect(res.status).toBe(200)
return (await res.json()) as Purchase
}
// A refusal: the same shape, nothing given, the buyer's balance as it stands.
const refused = (
code: number,
buyer: number,
currencyId: string | null,
balance: number
) => ({
BalanceUpdateResult: code,
CurrencyBalanceResponse: {
AccountId: buyer,
CurrencyId: currencyId,
Balance: balance,
ModifiedAt: expect.any(String),
},
OperationResult: { Status: 1, InventoryItem: null },
})
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 setCoins = (playerId: number, amount: number) =>
env.DB.prepare(
'INSERT OR REPLACE INTO room_balance (currency_id, player_id, amount) VALUES (?1, ?2, ?3)'
)
.bind(coin, playerId, amount)
.run()
const coins = async (playerId: number) =>
(
await env.DB.prepare(
'SELECT amount FROM room_balance WHERE currency_id = ?1 AND player_id = ?2'
)
.bind(coin, playerId)
.first<{ amount: number }>()
)?.amount ?? 0
const owned = async (playerId: number, consumableId: string) =>
(
await env.DB.prepare(
'SELECT quantity FROM room_inventory WHERE player_id = ?1 AND consumable_id = ?2'
)
.bind(playerId, consumableId)
.first<{ quantity: number }>()
)?.quantity ?? 0
const drainFrames = async () =>
(
env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') as unknown as {
drainFrames(): Promise<Array<{ accountId: number; payload: Record<string, unknown> }>>
}
).drainFrames()
await setTokens(1, 1000)
await setTokens(2, 40)
await setTokens(7701, 100)
await setTokens(7702, 4)
await setCoins(7701, 120)
await drainFrames()
expect((await purchase(null, tokenItem.RoomConsumableId, 5, null)).status).toBe(401)
// Only the two purchase paths exist.
const other = await exports.default.fetch(
`${ORIGIN}/api/roomconsumables/v1/roomconsumable/${tokenItem.RoomConsumableId}/purchase/gems`,
{ method: 'PUT', headers: await bearer('7701'), body: '{}' }
)
expect(other.status).toBe(404)
// Refusals charge and give nothing, and say why in `BalanceUpdateResult`.
const refusals: Array<[Promise<Response>, ReturnType<typeof refused>]> = [
[purchase('7701', 'nope', 5, null), refused(4, 7701, null, 100)],
[purchase('7701', tokenItem.RoomConsumableId, 5, null, 0), refused(7, 7701, null, 100)],
[purchase('7701', tokenItem.RoomConsumableId, 5, null, 4), refused(7, 7701, null, 100)],
[purchase('7701', tokenItem.RoomConsumableId, 5, null, 1, -3), refused(8, 7701, null, 100)],
[purchase('7701', tokenItem.RoomConsumableId, 0, null), refused(6, 7701, null, 100)],
[purchase('7701', tokenItem.RoomConsumableId, 5, coin), refused(6, 7701, null, 100)],
[purchase('7701', coinItem.RoomConsumableId, 50, null), refused(6, 7701, coin, 120)],
[purchase('7702', tokenItem.RoomConsumableId, 5, null), refused(2, 7702, null, 4)],
[purchase('7701', coinItem.RoomConsumableId, 50, coin, 3), refused(2, 7701, coin, 120)],
]
for (const [res, expected] of refusals) {
expect(await result(await res)).toEqual(expected)
}
expect([await tokens(1), await tokens(7701), await tokens(7702)]).toEqual([1000, 100, 4])
expect(await coins(7701)).toBe(120)
expect(await owned(7701, tokenItem.RoomConsumableId)).toBe(0)
expect(await drainFrames()).toEqual([])
// Tokens: 3 × 5 leaves the buyer and reaches the room's owner.
const sold = await result(await purchase('7701', tokenItem.RoomConsumableId, 5, null, 3))
expect(sold).toEqual({
BalanceUpdateResult: 0,
CurrencyBalanceResponse: {
AccountId: 7701,
CurrencyId: null,
Balance: 85,
ModifiedAt: expect.any(String),
},
OperationResult: {
Status: 0,
InventoryItem: {
RoomConsumableId: tokenItem.RoomConsumableId,
AccountId: 7701,
Count: 3,
ConcurrencyCode: '1ea4a2dd-b643-406d-99b7-bd1b936e678b',
ModifiedAt: expect.any(String),
// The listing plus two fields, with no picture as '' — not the catalogue's.
Consumable: { ...tokenItem, ImageName: '', IsGiftable: false, Visibility: 0 },
},
},
})
// Member order as the client was observed receiving it.
expect(Object.keys(sold)).toEqual([
'BalanceUpdateResult',
'CurrencyBalanceResponse',
'OperationResult',
])
expect(Object.keys(sold.OperationResult.InventoryItem!)).toEqual([
'RoomConsumableId',
'AccountId',
'Count',
'ConcurrencyCode',
'ModifiedAt',
'Consumable',
])
expect([await tokens(1), await tokens(7701)]).toEqual([1015, 85])
const frames = await drainFrames()
expect(frames.find((f) => f.accountId === 1)?.payload).toMatchObject({
Balance: 1015,
Platform: -2,
})
expect(frames.find((f) => f.accountId === 7701)?.payload).toMatchObject({
Delta: -15,
Balance: 85,
Platform: -2,
})
// Room currency: 2 × 50 leaves the buyer's coin and pays nobody.
const bought = await result(await purchase('7701', coinItem.RoomConsumableId, 50, coin, 2))
expect(bought.CurrencyBalanceResponse).toMatchObject({
AccountId: 7701,
CurrencyId: coin,
Balance: 20,
})
expect(bought.OperationResult.InventoryItem).toMatchObject({ AccountId: 7701, Count: 2 })
expect(await coins(7701)).toBe(20)
expect(await coins(1)).toBe(0)
expect(await tokens(1)).toBe(1015)
expect(await drainFrames()).toEqual([])
// A gift: the buyer pays, the owner is paid, the recipient owns it.
const gift = await result(
await purchase('7701', tokenItem.RoomConsumableId, 5, null, 1, 7702)
)
// The balance is the BUYER's; the item is the RECIPIENT's.
expect(gift.CurrencyBalanceResponse).toMatchObject({ AccountId: 7701, Balance: 80 })
expect(gift.OperationResult.InventoryItem).toMatchObject({ AccountId: 7702, Count: 1 })
expect(await owned(7702, tokenItem.RoomConsumableId)).toBe(1)
expect(await owned(7701, tokenItem.RoomConsumableId)).toBe(3)
expect([await tokens(1), await tokens(7701), await tokens(7702)]).toEqual([1020, 80, 4])
await drainFrames()
// A co-owner gets it free at the 0 the client asks them, in either currency.
const free = await result(await purchase('2', tokenItem.RoomConsumableId, 0, null, 2))
expect(free.BalanceUpdateResult).toBe(0)
expect(free.OperationResult.InventoryItem).toMatchObject({ AccountId: 2, Count: 2 })
const freeCoin = await result(await purchase('2', coinItem.RoomConsumableId, 0, coin))
expect(freeCoin.OperationResult.Status).toBe(0)
expect([await tokens(1), await tokens(2), await coins(2)]).toEqual([1020, 40, 0])
expect(await drainFrames()).toEqual([])
// `/me` lists what the caller holds of the room, in the shop's order — the same item
// the purchase answered with, the stored concurrency code included.
const mine = async (sub: string | null, roomId: number | string = 2511) => {
const res = await exports.default.fetch(
`${ORIGIN}/api/roomconsumables/v1/roomConsumable/room/${roomId}/me`,
sub === null ? undefined : { headers: await bearer(sub) }
)
return {
status: res.status,
items: (await res.json().catch(() => null)) as InventoryItem[],
}
}
expect((await mine(null)).status).toBe(401)
const own = await mine('7701')
expect(own.status).toBe(200)
expect(own.items).toEqual([
{
RoomConsumableId: tokenItem.RoomConsumableId,
AccountId: 7701,
Count: 3,
ConcurrencyCode: '1ea4a2dd-b643-406d-99b7-bd1b936e678b',
ModifiedAt: expect.any(String),
Consumable: { ...tokenItem, ImageName: '', IsGiftable: false, Visibility: 0 },
},
expect.objectContaining({ RoomConsumableId: coinItem.RoomConsumableId, Count: 2 }),
])
expect((await mine('7702')).items).toEqual([
expect.objectContaining({ RoomConsumableId: tokenItem.RoomConsumableId, Count: 1 }),
])
// Another room, an unknown one, and a nonsense id are all empty.
expect((await mine('7701', 99999)).items).toEqual([])
expect((await mine('7701', 'nope')).items).toEqual([])
// Spent down to zero is absent, not a zero row.
await env.DB.prepare('UPDATE room_inventory SET quantity = 0 WHERE player_id = 7702').run()
expect((await mine('7702')).items).toEqual([])
await env.DB.prepare('DELETE FROM room_inventory').run()
await env.DB.prepare('DELETE FROM room_balance WHERE currency_id = ?1').bind(coin).run()
})
test('GET roomConsumable/room/:id is public and empty for an unknown room', async () => { test('GET roomConsumable/room/:id is public and empty for an unknown room', async () => {
expect(await shopOf(99999)).toEqual([]) expect(await shopOf(99999)).toEqual([])
const res = await exports.default.fetch( const res = await exports.default.fetch(
@@ -3267,14 +3561,6 @@ describe('econ endpoints', () => {
}) })
}) })
test('GET /api/roomconsumables/v1/roomConsumable/room/:id/me returns []', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/roomconsumables/v1/roomConsumable/room/1/me`
)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /api/roomcurrencies/v1/getAllBalances returns []', async () => { test('GET /api/roomcurrencies/v1/getAllBalances returns []', async () => {
const res = await exports.default.fetch( const res = await exports.default.fetch(
`${ORIGIN}/api/roomcurrencies/v1/getAllBalances?roomId=1` `${ORIGIN}/api/roomcurrencies/v1/getAllBalances?roomId=1`
@@ -7761,6 +8047,7 @@ describe('econ endpoints', () => {
'POST /api/ugcPurchasables/v1/items/bulk', 'POST /api/ugcPurchasables/v1/items/bulk',
'PUT /api/equipment/v1/update', 'PUT /api/equipment/v1/update',
'PUT /api/roomconsumables/v1/roomConsumable', 'PUT /api/roomconsumables/v1/roomConsumable',
'PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/{via}',
'PUT /api/roomkeys/v1/updateAll', 'PUT /api/roomkeys/v1/updateAll',
]) ])