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,
|
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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user