diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index b9cf19b..c71cec4 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -106,6 +106,8 @@ import { ConsumeConsumableRequest, ConsumeEnvelope, ConsumeGiftRequest, + ConsumeRoomConsumableRequest, + ConsumeRoomConsumableResponse, CreatePurchaseOfferRequest, CreateRoomCurrencyRequest, CreateRoomKeyRequest, @@ -177,6 +179,7 @@ import { import { claimAllowance, claimReward, DAILY_REWARD_COOLDOWN_MS } from './reward-db' import { awardRoomConsumable, + consumeRoomConsumable, getRoomConsumable, getRoomConsumables, getRoomInventory, @@ -4220,6 +4223,65 @@ const app = new Hono({ strict: false }) return c.json(await getRoomInventory(c.env.DB, roomId, id)) } ) + // Use up one of a room consumable the CALLER holds — drinking the Health Potion. Auth-gated + // (401); a refusal is a 200 in the same shape, as the purchase's is. + .put( + '/api/roomconsumables/v1/roomConsumable/:id/consume', + describeRoute({ + tags: ['Econ'], + summary: 'Consume a room consumable', + description: [ + 'Takes ONE of the consumable named in the path from what the caller holds. The body', + 'is only `{ CurrentConcurrencyCode, NewConcurrencyCode }`, at the top level; there is', + 'no quantity. `NewConcurrencyCode` is stored and served back as the item’s', + '`ConcurrencyCode`; `CurrentConcurrencyCode` is accepted and NOT checked.', + '', + 'Answers `{ Status, InventoryItem }` — the purchase’s `OperationResult` — with the', + '`RoomConsumableInventoryItem` the caller now holds: `Count` the RESULTING total, 0', + 'when they used their last (after which `…/room/{roomId}/me` omits it).', + '', + 'A consumable that does not exist, or one the caller holds none of, is `Status` 1', + 'with a null `InventoryItem`, and nothing is written. 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 use — a `room_consumable` id', + schema: { type: 'string' }, + }, + ], + requestBody: jsonBody(ConsumeRoomConsumableRequest, 'The concurrency pair'), + responses: { + 200: json(ConsumeRoomConsumableResponse, 'What the caller now holds, or a refusal'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const id = await authedId(c) + if (id === null) return unauthorized(c) + + const body = await c.req.json<{ NewConcurrencyCode?: unknown }>().catch(() => null) + const newCode = + body !== null && typeof body === 'object' && typeof body.NewConcurrencyCode === 'string' + ? body.NewConcurrencyCode + : null + // Every key is always sent: an omitted `Status` reads as its enum's 0, success. + const refused = () => c.json({ Status: ROOM_KEY_STATUS_FAILED, InventoryItem: null }) + + const consumable = await getRoomConsumable(c.env.DB, c.req.param('id')) + if (!consumable) return refused() + const held = await consumeRoomConsumable(c.env.DB, id, consumable.RoomConsumableId, newCode) + if (!held) return refused() + return c.json({ + Status: ROOM_KEY_STATUS_OK, + InventoryItem: toInventoryItem(id, held, consumable), + }) + } + ) // 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 // the way its ban list is. A room with none — or an unknown room — is an empty list. diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 2ce71f1..fbd6528 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -1159,6 +1159,27 @@ export const PurchaseRoomConsumableResponse = z.object({ }), }) +/** + * `PUT /api/roomconsumables/v1/roomConsumable/{id}/consume` — just the concurrency pair, at the + * TOP level (not nested under `ConcurrencyCodes` as the purchase sends it). Uses up one. + */ +export const ConsumeRoomConsumableRequest = z.object({ + CurrentConcurrencyCode: z.string().nullable().optional().describe('Accepted and NOT checked'), + NewConcurrencyCode: z + .string() + .nullable() + .optional() + .describe('Stored and echoed back as the item’s `ConcurrencyCode`'), +}) + +/** The consume's answer: the purchase's `OperationResult`, alone. */ +export const ConsumeRoomConsumableResponse = 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 caller holds afterwards (`Count` the resulting total, 0 included); 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 cac705e..0d12f48 100644 --- a/apps/econ/src/room-consumable-db.ts +++ b/apps/econ/src/room-consumable-db.ts @@ -13,6 +13,7 @@ * - `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 + * - `PUT /api/roomconsumables/v1/roomConsumable/{id}/consume` uses up one the caller holds * * `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 @@ -344,6 +345,42 @@ export async function awardRoomConsumable( } } +/** + * Use up ONE of a consumable a player holds, returning what they hold afterwards — or null + * when they hold none, and nothing was written. `concurrencyCode` is the request's + * `NewConcurrencyCode`, stored to be served back; null leaves the stored one. + * + * A single conditional UPDATE: the `quantity >= 1` guard is what refuses, so two consumes + * racing over the last one cannot both succeed. Here an empty `RETURNING` is exactly the + * answer wanted — "you had none" — unlike the award, where it would be ambiguous. + */ +export async function consumeRoomConsumable( + db: D1Database, + playerId: number, + consumableId: string, + concurrencyCode: string | null = null +): Promise { + const row = await db + .prepare( + `UPDATE room_inventory SET + quantity = quantity - 1, + concurrency_code = COALESCE(?3, concurrency_code), + modified_at = ?4 + WHERE player_id = ?1 AND consumable_id = ?2 AND quantity >= 1 + RETURNING quantity, concurrency_code, modified_at` + ) + .bind(playerId, consumableId, concurrencyCode, new Date().toISOString()) + .first<{ quantity: number; concurrency_code: string | null; modified_at: string | null }>() + + return row + ? { + quantity: row.quantity, + concurrencyCode: row.concurrency_code, + modifiedAt: row.modified_at, + } + : null +} + /** * 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. diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index c7de5cd..83f8d78 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -1773,6 +1773,77 @@ describe('econ endpoints', () => { await env.DB.prepare('DELETE FROM room_balance WHERE currency_id = ?1').bind(coin).run() }) + test('PUT roomConsumable/:id/consume uses up one the caller holds', async () => { + const potion = ( + await envOf(await save({ ...body, Name: 'Drinkable potion' }, await bearer('1'))) + ).Value! + await env.DB.prepare( + 'INSERT INTO room_inventory (player_id, consumable_id, quantity) VALUES (7801, ?1, 2)' + ) + .bind(potion.RoomConsumableId) + .run() + + const consume = async (sub: string | null, consumableId: string, code: string) => + exports.default.fetch( + `${ORIGIN}/api/roomconsumables/v1/roomConsumable/${consumableId}/consume`, + { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...(sub === null ? {} : await bearer(sub)), + }, + body: JSON.stringify({ + CurrentConcurrencyCode: '872adb91-b163-4f2c-aa8f-4f5d828078cb', + NewConcurrencyCode: code, + }), + } + ) + const held = async () => + ( + await env.DB.prepare( + 'SELECT quantity FROM room_inventory WHERE player_id = 7801 AND consumable_id = ?1' + ) + .bind(potion.RoomConsumableId) + .first<{ quantity: number }>() + )?.quantity + + expect((await consume(null, potion.RoomConsumableId, 'a')).status).toBe(401) + + const res = await consume('7801', potion.RoomConsumableId, 'code-1') + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + Status: 0, + InventoryItem: { + RoomConsumableId: potion.RoomConsumableId, + AccountId: 7801, + Count: 1, + ConcurrencyCode: 'code-1', + ModifiedAt: expect.any(String), + Consumable: { ...potion, ImageName: '', IsGiftable: false, Visibility: 0 }, + }, + }) + + // The last one answers Count 0; after that there is nothing to use. + const last = (await (await consume('7801', potion.RoomConsumableId, 'code-2')).json()) as { + Status: number + InventoryItem: { Count: number; ConcurrencyCode: string } | null + } + expect(last).toMatchObject({ + Status: 0, + InventoryItem: { Count: 0, ConcurrencyCode: 'code-2' }, + }) + const refusal = { Status: 1, InventoryItem: null } + expect(await (await consume('7801', potion.RoomConsumableId, 'code-3')).json()).toEqual( + refusal + ) + expect(await held()).toBe(0) + // Someone who never had one, and an unknown consumable, are refused too. + expect(await (await consume('7802', potion.RoomConsumableId, 'x')).json()).toEqual(refusal) + expect(await (await consume('7801', 'nope', 'x')).json()).toEqual(refusal) + + await env.DB.prepare('DELETE FROM room_inventory').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( @@ -8047,6 +8118,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}/consume', 'PUT /api/roomconsumables/v1/roomconsumable/{id}/purchase/{via}', 'PUT /api/roomkeys/v1/updateAll', ])