diff --git a/apps/econ/migrations/0029_room_inventory_concurrency.sql b/apps/econ/migrations/0029_room_inventory_concurrency.sql new file mode 100644 index 0000000..b15c638 --- /dev/null +++ b/apps/econ/migrations/0029_room_inventory_concurrency.sql @@ -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; diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index f3bc11f..b9cf19b 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -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({ 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({ strict: false }) Response: { PlayerId: accountId, ConsumableId: consumableId, - Quantity: owned, + Quantity: owned.quantity, AwardedAt: new Date().toISOString(), }, }) @@ -3869,6 +3882,202 @@ const app = new Hono({ 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({ 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 diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 0b21737..2ce71f1 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -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. diff --git a/apps/econ/src/room-consumable-db.ts b/apps/econ/src/room-consumable-db.ts index aae85c3..cac705e 100644 --- a/apps/econ/src/room-consumable-db.ts +++ b/apps/econ/src/room-consumable-db.ts @@ -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 { + 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 { + quantity: number, + concurrencyCode: string | null = null +): Promise { 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 { +): Promise { 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) + ) + ) } diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 79d0108..c7de5cd 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -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 }>> + } + ).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, ReturnType]> = [ + [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', ])