mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[econ] room consumable token/currency purchase
This commit is contained in:
@@ -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
@@ -115,6 +115,7 @@ import {
|
||||
ErrorResponse,
|
||||
form,
|
||||
GameRewardRequest,
|
||||
HeldRoomKeyDto,
|
||||
InfluencerIdsResponse,
|
||||
IsOnWishlistBulkRequest,
|
||||
IsOnWishlistBulkResponse,
|
||||
@@ -131,6 +132,8 @@ import {
|
||||
OwnsRoomKeysRequest,
|
||||
OwnsRoomKeysResponse,
|
||||
PurchasableItemsRequest,
|
||||
PurchaseRoomConsumableRequest,
|
||||
PurchaseRoomConsumableResponse,
|
||||
PurchaseRoomCurrencyEnvelope,
|
||||
PurchaseRoomCurrencyRequest,
|
||||
PurchaseRoomKeyWithCurrencyRequest,
|
||||
@@ -140,6 +143,7 @@ import {
|
||||
RemoveInfluencerBody,
|
||||
RoomConsumableDto,
|
||||
RoomConsumableEnvelope,
|
||||
RoomConsumableInventoryItemDto,
|
||||
RoomCurrencyBalance,
|
||||
RoomCurrencyDto,
|
||||
RoomCurrencyEnvelope,
|
||||
@@ -147,7 +151,6 @@ import {
|
||||
RoomCurrencyPurchaseOfferEnvelope,
|
||||
RoomCurrencyPurchaseOffersDto,
|
||||
RoomEconConfig,
|
||||
HeldRoomKeyDto,
|
||||
RoomKeyDto,
|
||||
RoomKeyEnvelope,
|
||||
RoomPurchasableList,
|
||||
@@ -176,6 +179,8 @@ import {
|
||||
awardRoomConsumable,
|
||||
getRoomConsumable,
|
||||
getRoomConsumables,
|
||||
getRoomInventory,
|
||||
toInventoryItem,
|
||||
upsertRoomConsumable,
|
||||
} from './room-consumable-db'
|
||||
import {
|
||||
@@ -195,8 +200,8 @@ import {
|
||||
awardRoomKey,
|
||||
claimRoomKey,
|
||||
createRoomKey,
|
||||
getRoomKey,
|
||||
getHeldRoomKeys,
|
||||
getRoomKey,
|
||||
getRoomKeys,
|
||||
getRoomOwnerId,
|
||||
ownsRoomKeys,
|
||||
@@ -234,7 +239,7 @@ import type { ConsumeResult } from './consumables-db'
|
||||
import type { App, Env } from './context'
|
||||
import type { Equipment } from './equipment-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 { RoomKey, RoomKeyHolding } from './room-key-db'
|
||||
import type { WishlistItem } from './wishlist-db'
|
||||
@@ -3851,7 +3856,15 @@ const app = new Hono<App>({ strict: false })
|
||||
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({
|
||||
ConsumableId: consumableId,
|
||||
Success: true,
|
||||
@@ -3859,7 +3872,7 @@ const app = new Hono<App>({ strict: false })
|
||||
Response: {
|
||||
PlayerId: accountId,
|
||||
ConsumableId: consumableId,
|
||||
Quantity: owned,
|
||||
Quantity: owned.quantity,
|
||||
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)
|
||||
// and gated to the room's creator or a co-owner (403) — a shop is the room's to stock.
|
||||
.put(
|
||||
@@ -3968,10 +4177,48 @@ const app = new Hono<App>({ strict: false })
|
||||
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(
|
||||
'/api/roomconsumables/v1/roomConsumable/room/:roomId/me',
|
||||
listRoute('The caller’s room consumables', 'Empty stub'),
|
||||
(c) => c.json([])
|
||||
describeRoute({
|
||||
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
|
||||
// currencies are shown to every player who walks into it, so the list is not a secret
|
||||
|
||||
@@ -1079,6 +1079,86 @@ export const AwardRoomConsumableResult = z.object({
|
||||
|
||||
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
|
||||
* `{ Value, Success, Error, error_id }` the room-currency writes use.
|
||||
|
||||
@@ -11,14 +11,16 @@
|
||||
* names an existing `RoomConsumableId`
|
||||
* - `GET /api/roomconsumables/v1/roomConsumable/room/{roomId}` lists a room's
|
||||
* - `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
|
||||
* 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
|
||||
* been deleted still lists, at a price nobody can pay.
|
||||
*
|
||||
* Nothing BUYS one yet. There is no purchase endpoint, so `MaximumCountPerPurchase` is stored
|
||||
* and served and read by nothing.
|
||||
* A null `PurchaseCurrencyId` is priced in TOKENS, which the purchase pays to the room's
|
||||
* owner; `MaximumCountPerPurchase` caps a purchase's `Quantity` (0 means no cap).
|
||||
*
|
||||
* This worker (`econ`) owns the tables and their migrations — see apps/econ/migrations/,
|
||||
* 0021 (the listings) and 0022 (what players own of them).
|
||||
@@ -72,7 +74,7 @@ export interface RoomConsumable {
|
||||
PurchaseCurrencyId: string | null
|
||||
/** ISO-8601 UTC. */
|
||||
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
|
||||
}
|
||||
|
||||
@@ -201,7 +203,7 @@ export async function getRoomConsumables(
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* writing: an inventory row naming nothing would be a thing a player owns that cannot be
|
||||
* 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[] = [
|
||||
`CREATE TABLE IF NOT EXISTS room_inventory (
|
||||
player_id INTEGER NOT NULL,
|
||||
consumable_id TEXT NOT NULL,
|
||||
quantity INTEGER NOT NULL DEFAULT 0,
|
||||
concurrency_code TEXT,
|
||||
modified_at TEXT,
|
||||
PRIMARY KEY (player_id, consumable_id)
|
||||
)`,
|
||||
// "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)`,
|
||||
]
|
||||
|
||||
/** What a player owns of one consumable. */
|
||||
export interface RoomInventoryItem {
|
||||
PlayerId: number
|
||||
ConsumableId: string
|
||||
Quantity: number
|
||||
/**
|
||||
* The listing as it rides inside a {@link RoomConsumableInventoryItem}: the catalogue's
|
||||
* {@link RoomConsumable} plus `IsGiftable` and `Visibility`, and `ImageName` as '' rather
|
||||
* than null — observed from the live client, and NOT what the room's catalogue
|
||||
* (`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
|
||||
* deduction and a concurrent award racing it below zero. Returns the RESULTING total, never
|
||||
@@ -249,48 +309,80 @@ export async function awardRoomConsumable(
|
||||
db: D1Database,
|
||||
playerId: number,
|
||||
consumableId: string,
|
||||
quantity: number
|
||||
): Promise<number> {
|
||||
quantity: number,
|
||||
concurrencyCode: string | null = null
|
||||
): Promise<RoomInventoryHolding> {
|
||||
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),
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO room_inventory (player_id, consumable_id, quantity)
|
||||
VALUES (?1, ?2, MAX(0, ?3))
|
||||
ON CONFLICT (player_id, consumable_id)
|
||||
DO UPDATE SET quantity = MAX(0, room_inventory.quantity + ?3)`
|
||||
`INSERT INTO room_inventory (player_id, consumable_id, quantity, concurrency_code, modified_at)
|
||||
VALUES (?1, ?2, MAX(0, ?3), ?4, ?5)
|
||||
ON CONFLICT (player_id, consumable_id) DO UPDATE SET
|
||||
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),
|
||||
])
|
||||
|
||||
// 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(
|
||||
db: D1Database,
|
||||
roomId: number,
|
||||
playerId: number
|
||||
): Promise<RoomInventoryItem[]> {
|
||||
): Promise<RoomConsumableInventoryItem[]> {
|
||||
const { results } = await db
|
||||
.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
|
||||
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`
|
||||
)
|
||||
.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) => ({
|
||||
PlayerId: r.playerId,
|
||||
ConsumableId: r.consumableId,
|
||||
Quantity: r.quantity,
|
||||
}))
|
||||
return results.map((row) =>
|
||||
toInventoryItem(
|
||||
playerId,
|
||||
{
|
||||
quantity: row.quantity,
|
||||
concurrencyCode: row.concurrency_code,
|
||||
modifiedAt: row.inventory_modified_at,
|
||||
},
|
||||
toRoomConsumable(row)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
expect(await shopOf(99999)).toEqual([])
|
||||
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 () => {
|
||||
const res = await exports.default.fetch(
|
||||
`${ORIGIN}/api/roomcurrencies/v1/getAllBalances?roomId=1`
|
||||
@@ -7761,6 +8047,7 @@ describe('econ endpoints', () => {
|
||||
'POST /api/ugcPurchasables/v1/items/bulk',
|
||||
'PUT /api/equipment/v1/update',
|
||||
'PUT /api/roomconsumables/v1/roomConsumable',
|
||||
'PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/{via}',
|
||||
'PUT /api/roomkeys/v1/updateAll',
|
||||
])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user