diff --git a/apps/econ/migrations/0021_room_consumable.sql b/apps/econ/migrations/0021_room_consumable.sql new file mode 100644 index 0000000..8c0efac --- /dev/null +++ b/apps/econ/migrations/0021_room_consumable.sql @@ -0,0 +1,41 @@ +-- Room consumables — the things a room sells. +-- +-- The other half of a room's economy: `room_currency` (migration 0017) is the money a room +-- mints and hands out, and this is the shop that takes it back — a Health Potion for 25 +-- tokens, a custom shirt for 500. A row here is the LISTING: its name, its picture, what it +-- costs and in which of the room's currencies. Written by +-- `PUT /api/roomconsumables/v1/roomConsumable` and read by +-- `GET /api/roomconsumables/v1/roomConsumable/room/{roomId}`. Owned by the `econ` worker; +-- generated from src/room-consumable-db.ts (ROOM_CONSUMABLE_SCHEMA_DDL) — keep in sync. +-- +-- `room_consumable_id` is a GUID rather than an autoincrement, like a room currency's: it is +-- how the client names this listing afterwards, and a room's consumables are created +-- independently of every other room's. +-- +-- The write body's `PriceAndCurrency` object is COLLAPSED here into `price` and +-- `purchase_currency_id`. It is one price in one currency, and a nested object would buy +-- nothing but a JSON blob to query through. Note the client READS them flat, so the write +-- shape and the read shape genuinely differ — do not unify them. +-- +-- `purchase_currency_id` points at a `room_currency` and is nullable (`Guid?` in the client's +-- model), for a listing that names no currency. Nothing resolves or validates it yet: a +-- consumable priced in a currency that has since been deleted still lists, at a price nobody +-- can pay. `image_name` is nullable for the same reason — a listing with no picture. `price` +-- is not: a listing always has one, even if it is zero. +-- +-- Nothing BUYS a consumable yet. There is no purchase endpoint, so +-- `maximum_count_per_purchase` is stored and served and read by nothing. +CREATE TABLE IF NOT EXISTS room_consumable ( + room_consumable_id TEXT PRIMARY KEY, + room_id INTEGER NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + image_name TEXT, + price INTEGER NOT NULL, + purchase_currency_id TEXT, + modified_at TEXT NOT NULL, + maximum_count_per_purchase INTEGER NOT NULL DEFAULT 0 + ); + +-- Every read is "this room's shop" — the whole access pattern. +CREATE INDEX IF NOT EXISTS idx_room_consumable_room ON room_consumable (room_id); diff --git a/apps/econ/migrations/0022_room_inventory.sql b/apps/econ/migrations/0022_room_inventory.sql new file mode 100644 index 0000000..512c2c0 --- /dev/null +++ b/apps/econ/migrations/0022_room_inventory.sql @@ -0,0 +1,26 @@ +-- What players own of a room's consumables — the other side of a room's shop. `room_consumable` +-- (migration 0021) is what a room sells; this is who has one. Written by +-- `POST /api/roomconsumables/v1/roomConsumable/awardBulk`. Owned by the `econ` worker; +-- generated from src/room-consumable-db.ts (ROOM_INVENTORY_SCHEMA_DDL) — keep in sync. +-- +-- 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. It is the +-- same reasoning as `room_balance` (0019), which holds a currency id rather than a room id. +-- +-- A missing row is a quantity of zero. Nothing is inserted until a player is first given one, +-- so a room's shop costs a row per player who actually owns something rather than one per +-- player who has looked at it. +-- +-- `consumable_id` is not a foreign key — nothing in this database is — 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. +CREATE TABLE IF NOT EXISTS room_inventory ( + player_id INTEGER NOT NULL, + consumable_id TEXT NOT NULL, + quantity INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (player_id, consumable_id) + ); + +-- "What does this player own?" is the read the client makes on entering a room, so the player +-- half of the key earns its own index for the lookups that start there. +CREATE INDEX IF NOT EXISTS idx_room_inventory_player ON room_inventory (player_id); diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index a87992c..d976d0c 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -80,6 +80,8 @@ import { AUTHED, AvatarItemV4Dto, AvatarV2Dto, + AwardRoomConsumableResultList, + AwardRoomConsumablesRequest, AwardRoomCurrencyRequest, AwardRoomCurrencyResultList, BalanceEntry, @@ -118,6 +120,8 @@ import { OpaqueJsonBody, OPTIONAL_AUTHED, ReferralProgressResponse, + RoomConsumableDto, + RoomConsumableEnvelope, RoomCurrencyDto, RoomCurrencyEnvelope, RoomCurrencyPurchaseOfferDto, @@ -134,8 +138,15 @@ import { UpdateObjectiveRequest, UpdateObjectiveResponse, UpdateRoomCurrencyRequest, + UpsertRoomConsumableRequest, } from './openapi' import { claimReward } from './reward-db' +import { + awardRoomConsumable, + getRoomConsumable, + getRoomConsumables, + upsertRoomConsumable, +} from './room-consumable-db' import { awardRoomCurrency, createPurchaseOffer, @@ -166,6 +177,7 @@ import type { ConsumeResult } from './consumables-db' import type { App } from './context' import type { Equipment } from './equipment-db' import type { AvatarItem } from './inventory-db' +import type { RoomConsumable } from './room-consumable-db' import type { RoomCurrency, RoomCurrencyPurchaseOffer } from './room-currency-db' // Invention storage (owned by the `api` worker, on this same `recflare` database). @@ -259,6 +271,7 @@ const HUB_INSTANCE = 'global' const CREATE_CURRENCY_FAILED = 'Failed to create currency' const UPDATE_CURRENCY_FAILED = 'Failed to update currency' const CREATE_OFFER_FAILED = 'Failed to create purchase offer' +const SAVE_CONSUMABLE_FAILED = 'Failed to save consumable' /** * The envelope the create endpoint answers in: `{ Value, Success, Error, error_id }`. @@ -277,6 +290,19 @@ function roomCurrencyEnvelope(c: Context, value: RoomCurrency | null, error }) } +/** + * The consumable write's envelope — the same `{ Value, Success, Error, error_id }` shape the + * room-currency writes answer in, carrying one listing. + */ +function roomConsumableEnvelope(c: Context, value: RoomConsumable | null, error?: string) { + return c.json({ + Value: value, + Success: error === undefined, + Error: error ?? null, + error_id: null, + }) +} + /** * The create-offer envelope — the same `{ Value, Success, Error, error_id }` shape again, * carrying one purchase offer. @@ -3036,12 +3062,240 @@ const app = new Hono({ strict: false }) } ) - // Room consumables/currencies for a given room. Stubbed as empty lists so the - // client doesn't 404. + // A room's shop — the things it sells for its own currency. Public, like the room's + // currencies: a shop is shown to everyone who walks in. .get( '/api/roomconsumables/v1/roomConsumable/room/:roomId', - listRoute('Room consumables', 'Empty stub so the client doesn’t 404'), - (c) => c.json([]) + describeRoute({ + tags: ['Econ'], + summary: 'A room’s consumables', + description: [ + 'Everything the room sells — a Health Potion for 25 tokens, a custom shirt for 500 —', + 'oldest first, which is the order its owner built the shop up in.', + '', + '`Price` and `PurchaseCurrencyId` are FLAT here, against the nested', + '`PriceAndCurrency` the write takes: the client sends them nested and reads them', + 'flat, so the two shapes genuinely differ. `PurchaseCurrencyId` names one of the', + 'room’s own currencies and is not resolved or validated — a listing priced in a', + 'currency that has since been deleted still shows, at a price nobody can pay.', + '', + 'Public, like `GET /api/roomcurrencies/v1/currencies`. A room that sells nothing, and', + 'an unknown room, are both an empty list — which the client reads as "no shop here"', + 'where a 404 would stall the room load.', + ].join('\n'), + parameters: [ + { + name: 'roomId', + in: 'path', + required: true, + description: 'The room whose shop to list', + schema: { type: 'string' }, + }, + ], + responses: { 200: json(RoomConsumableDto.array(), 'The room’s consumables, oldest first') }, + }), + async (c) => { + const roomId = Number.parseInt(c.req.param('roomId'), 10) + if (!Number.isInteger(roomId)) return c.json([]) + return c.json(await getRoomConsumables(c.env.DB, roomId)) + } + ) + + // Give the CALLER some of a room's consumables. Auth-gated (401); everything else is + // reported per entry, like the room-currency award. + .post( + '/api/roomconsumables/v1/roomConsumable/awardBulk', + describeRoute({ + tags: ['Econ'], + summary: 'Award room consumables to the caller', + description: [ + 'Adds to what the CALLER owns of a room’s consumables — the token says who is being', + 'given them, and there is no recipient in the body.', + '', + '`Requests` is a MAP keyed by consumable id, not a list: `{ "": { "Quantity": 1,', + '"ConcurrencyCodes": { … } } }`. `ConcurrencyCodes` is accepted and IGNORED — no', + 'optimistic concurrency is implemented, so `NewConcurrencyCode` is neither stored nor', + 'echoed and a stale `CurrentConcurrencyCode` does not refuse the write.', + '', + 'The answer is one result per entry, in the order the keys appeared, each standing', + 'alone: an id naming no listing fails that entry and leaves the rest to land. Shaped', + 'after the room-currency bulk award rather than observed.', + '', + '`Quantity` may be negative, which takes items away; a player’s count floors at zero.', + '`Quantity` on the result is the RESULTING total owned, never the change.', + '', + 'NOTE: this awards to whoever is asking, and nothing is spent for it — no room', + 'currency is debited and no room membership is checked. It is a self-service faucet', + 'until a purchase flow sits in front of it.', + ].join('\n'), + security: AUTHED, + requestBody: jsonBody(AwardRoomConsumablesRequest, 'The consumables to give the caller'), + responses: { + 200: json( + AwardRoomConsumableResultList, + 'One result per entry, in the order the keys appeared' + ), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const accountId = await authedId(c) + if (accountId === null) return unauthorized(c) + + const body = await c.req.json<{ Requests?: unknown }>().catch(() => null) + const requests = body?.Requests + // A body carrying no map of requests has no entries to report on, and the answer is + // a list of entry results — so there is nothing to say but "none". + if (typeof requests !== 'object' || requests === null || Array.isArray(requests)) { + return c.json([]) + } + + const results = [] + for (const [consumableId, request] of Object.entries(requests as Record)) { + const quantity = Number( + (typeof request === 'object' && request !== null + ? (request as { Quantity?: unknown }).Quantity + : Number.NaN) ?? Number.NaN + ) + if (!Number.isInteger(quantity)) { + results.push({ + ConsumableId: consumableId, + Success: false, + Error: 'Invalid quantity', + Response: null, + }) + continue + } + + // Checked before writing: an inventory row naming no listing would be a thing a + // player owns that cannot be described. + const consumable = await getRoomConsumable(c.env.DB, consumableId) + if (!consumable) { + results.push({ + ConsumableId: consumableId, + Success: false, + Error: 'No such consumable', + Response: null, + }) + continue + } + + const owned = await awardRoomConsumable(c.env.DB, accountId, consumableId, quantity) + results.push({ + ConsumableId: consumableId, + Success: true, + Error: null, + Response: { + PlayerId: accountId, + ConsumableId: consumableId, + Quantity: owned, + AwardedAt: new Date().toISOString(), + }, + }) + } + + return c.json(results) + } + ) + + // 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( + '/api/roomconsumables/v1/roomConsumable', + describeRoute({ + tags: ['Econ'], + summary: 'Create or replace a room consumable', + description: [ + 'Puts a thing in the room’s shop. JSON, with the price nested:', + '`{ "RoomConsumableId": null, "RoomId": 1162, "Name": "…", "Description": "…",', + '"ImageName": null, "PriceAndCurrency": { "Price": 50, "CurrencyId": "…" } }`.', + '', + '`RoomConsumableId` null (or absent) CREATES a listing and mints its id; naming an', + 'existing listing REPLACES it. A replace is a replace, not a merge — the client', + 'sends the whole form back, so an absent field is a cleared one. (The currency edit', + 'is the opposite: its body is genuinely partial.)', + '', + 'On a create the room is the body’s `RoomId`. On a replace it is the STORED', + 'listing’s, and the body’s is ignored — an edit cannot move a listing into another', + 'room, and the permission check must not be pointed somewhere friendlier than where', + 'the thing actually lives.', + '', + 'Gated to the room’s creator or a co-owner; a valid token from anyone else is a 403.', + '`PriceAndCurrency` is collapsed into the flat `Price`/`PurchaseCurrencyId` the read', + 'serves. Answers the `{ Value, Success, Error, error_id }` envelope the room-currency', + 'writes use, or a 200 carrying `Success: false` when the body is unusable or the', + 'listing is unknown.', + ].join('\n'), + security: AUTHED, + requestBody: jsonBody(UpsertRoomConsumableRequest, 'The listing to create or replace'), + responses: { + 200: json( + RoomConsumableEnvelope, + 'The listing as stored, or a rejection with `Success: false`' + ), + 401: UNAUTHORIZED_RESPONSE, + 403: { description: 'Not the room’s creator or a co-owner (empty body)' }, + }, + }), + async (c) => { + const accountId = await authedId(c) + if (accountId === null) return unauthorized(c) + + const body = (await c.req.json>().catch(() => null)) as Record< + string, + unknown + > | null + if (!body) return roomConsumableEnvelope(c, null, SAVE_CONSUMABLE_FAILED) + + const str = (v: unknown): string | null => (typeof v === 'string' ? v : null) + const int = (v: unknown, fallback: number): number => + typeof v === 'number' && Number.isInteger(v) ? v : fallback + + const name = str(body.Name)?.trim() ?? '' + if (name === '') return roomConsumableEnvelope(c, null, SAVE_CONSUMABLE_FAILED) + + // On a replace the room comes off the STORED listing, never the body — the same rule + // the currency edit follows. Otherwise a caller could name a listing in one room and + // a room they happen to own, and have the check pass against the wrong one. + const roomConsumableId = str(body.RoomConsumableId) + let roomId: number + if (roomConsumableId === null) { + roomId = int(body.RoomId, Number.NaN) + if (!Number.isInteger(roomId)) { + return roomConsumableEnvelope(c, null, SAVE_CONSUMABLE_FAILED) + } + } else { + const existing = await getRoomConsumable(c.env.DB, roomConsumableId) + if (!existing) return roomConsumableEnvelope(c, null, SAVE_CONSUMABLE_FAILED) + roomId = existing.RoomId + } + + const canManage = await canManageRoomById(c.env.DB, roomId, accountId) + if (canManage === null) return roomConsumableEnvelope(c, null, SAVE_CONSUMABLE_FAILED) + if (!canManage) return c.body(null, 403) + + // One price in one currency, flattened out of the body's nested object. + const priceAndCurrency = ( + typeof body.PriceAndCurrency === 'object' && body.PriceAndCurrency !== null + ? body.PriceAndCurrency + : {} + ) as Record + + const consumable = await upsertRoomConsumable(c.env.DB, roomConsumableId, { + RoomId: roomId, + // Masked like every other player-typed string: a shop's contents are shown to + // everyone who walks into the room. + Name: censorSwears(name), + Description: censorSwears(str(body.Description) ?? ''), + ImageName: str(body.ImageName), + Price: int(priceAndCurrency.Price, 0), + PurchaseCurrencyId: str(priceAndCurrency.CurrencyId), + // The client has not been seen sending this, so it falls back to the CLR default + // for an int rather than to a guess at what the shop should allow. + MaximumCountPerPurchase: int(body.MaximumCountPerPurchase, 0), + }) + return roomConsumableEnvelope(c, consumable) + } ) .get( '/api/roomconsumables/v1/roomConsumable/room/:roomId/me', diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 0c995f5..1477032 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -629,6 +629,113 @@ export const RoomCurrencyPurchaseOfferDto = z.object({ ModifiedAt: z.string().describe('ISO-8601 UTC'), }) +/** + * One room consumable — a thing a room sells, priced in the room's own currency. The client's + * own model, member for member and in its order. + * + * Note `Price`/`PurchaseCurrencyId` here against the nested `PriceAndCurrency` the WRITE + * takes: the client sends them nested and reads them flat, so the two shapes genuinely differ. + */ +export const RoomConsumableDto = z.object({ + RoomConsumableId: z.string().describe('GUID — how the client names this listing afterwards'), + RoomId: z.int(), + Name: z.string(), + Description: z.string(), + ImageName: z.string().nullable().describe('Null for a listing with no picture'), + Price: z.int(), + PurchaseCurrencyId: z + .string() + .nullable() + .describe('A `room_currency` id — the room’s own money. Not resolved or validated yet'), + ModifiedAt: z.string().describe('ISO-8601 UTC'), + MaximumCountPerPurchase: z + .int() + .describe('How many may be bought at once. Stored and served; nothing buys one yet'), +}) + +/** + * `PUT /api/roomconsumables/v1/roomConsumable` — JSON, creating a listing or replacing one. + * + * `RoomConsumableId` null (or absent) creates; naming an existing listing replaces it. An edit + * REPLACES rather than merges — the client sends the whole form back — which is the opposite + * of the currency edit, whose body is genuinely partial. + */ +export const UpsertRoomConsumableRequest = z.object({ + RoomConsumableId: z.string().nullable().optional().describe('Null to create; an id to replace'), + RoomId: z.int().describe('The room whose shop this is. Ignored when editing — see the route'), + Name: z.string(), + Description: z.string().optional().describe('Defaults to empty'), + ImageName: z.string().nullable().optional(), + PriceAndCurrency: z + .object({ + Price: z.int(), + CurrencyId: z.string().nullable().optional().describe('A `room_currency` id'), + }) + .optional() + .describe('Collapsed into `Price`/`PurchaseCurrencyId` on the stored listing'), + MaximumCountPerPurchase: z + .int() + .optional() + .describe('The client has not been seen sending this; defaults to 0'), +}) + +/** + * `POST /api/roomconsumables/v1/roomConsumable/awardBulk` — the consumables to give the + * CALLER. `Requests` is a MAP keyed by consumable id, not a list: the id is the key, and each + * value is what to do with it. + */ +export const AwardRoomConsumablesRequest = z.object({ + Requests: z + .record( + z.string(), + z.object({ + Quantity: z.int().describe('How many to give; negative takes them away'), + ConcurrencyCodes: z + .object({ + CurrentConcurrencyCode: z.string().nullable().optional(), + NewConcurrencyCode: z.string().nullable().optional(), + }) + .optional() + .describe('Accepted and IGNORED — no optimistic concurrency is implemented yet'), + }) + ) + .describe('Keyed by consumable id'), +}) + +/** + * One entry's result from the consumable award, in the order its key appeared in `Requests`. + * + * Shaped after the room-currency bulk award, which is the one response in this family the + * client has been seen reading: success is PER ENTRY, so one bad id neither fails the others + * nor the call, and `Response` is null when the entry failed. + */ +export const AwardRoomConsumableResult = z.object({ + ConsumableId: z.string(), + Success: z.boolean(), + Error: z.string().nullable().describe('Null on success'), + Response: z + .object({ + PlayerId: z.int(), + ConsumableId: z.string(), + Quantity: z.int().describe('The RESULTING total owned, never the change'), + AwardedAt: z.string().describe('ISO-8601 UTC'), + }) + .nullable(), +}) + +export const AwardRoomConsumableResultList = z.array(AwardRoomConsumableResult) + +/** + * The envelope the consumable write answers in — the same + * `{ Value, Success, Error, error_id }` the room-currency writes use. + */ +export const RoomConsumableEnvelope = z.object({ + Value: RoomConsumableDto.nullable(), + Success: z.boolean(), + Error: z.string().nullable(), + error_id: z.null(), +}) + /** * One currency's shop, as `GET /api/roomcurrencies/v1/getPurchaseOffersBatch` groups them — * one entry per currency asked for, the whole answer an array of these. diff --git a/apps/econ/src/room-consumable-db.ts b/apps/econ/src/room-consumable-db.ts new file mode 100644 index 0000000..aae85c3 --- /dev/null +++ b/apps/econ/src/room-consumable-db.ts @@ -0,0 +1,296 @@ +/** + * Room consumables — the things a room sells. + * + * The other half of a room's economy: `room-currency-db.ts` mints the money ("MySpecialTokens") + * and hands it out, and this is the shop that takes it back — a Health Potion for 25 tokens, + * a custom shirt for 500. A consumable is the LISTING: its name, its picture, what it costs + * and in which of the room's currencies. + * + * The endpoints: + * - `PUT /api/roomconsumables/v1/roomConsumable` creates one, or edits it when the body + * 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 + * + * `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. + * + * This worker (`econ`) owns the tables and their migrations — see apps/econ/migrations/, + * 0021 (the listings) and 0022 (what players own of them). + */ + +/** Schema DDL (mirror of migrations 0021_room_consumable.sql) — also builds the table in tests. */ +export const ROOM_CONSUMABLE_SCHEMA_DDL: string[] = [ + // `room_consumable_id` is a GUID rather than an autoincrement, like a room currency's: it + // is how the client names this listing afterwards, and a room's consumables are created + // independently of every other room's. + // + // `image_name` and `purchase_currency_id` are nullable because the client's model has them + // so — a listing with no picture, or none priced in a room currency. `price` is not: a + // listing always has a price, even if it is zero. + // + // The body's `PriceAndCurrency` object is COLLAPSED here into `price` and + // `purchase_currency_id`. It is one price in one currency, and a nested object would buy + // nothing but a JSON blob to query through. + `CREATE TABLE IF NOT EXISTS room_consumable ( + room_consumable_id TEXT PRIMARY KEY, + room_id INTEGER NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + image_name TEXT, + price INTEGER NOT NULL, + purchase_currency_id TEXT, + modified_at TEXT NOT NULL, + maximum_count_per_purchase INTEGER NOT NULL DEFAULT 0 + )`, + // Every read is "this room's shop" — the whole access pattern. + `CREATE INDEX IF NOT EXISTS idx_room_consumable_room ON room_consumable (room_id)`, +] + +/** + * One room consumable as the client reads it — its own model, member for member and in its + * order, taken from the client's field layout. + * + * Note `Price`/`PurchaseCurrencyId` here against the `PriceAndCurrency` object the write + * takes: the client sends them nested and reads them flat, so the two shapes are not the same + * and must not be unified. + */ +export interface RoomConsumable { + RoomConsumableId: string + RoomId: number + Name: string + Description: string + /** Null for a listing with no picture. */ + ImageName: string | null + Price: number + /** A `room_currency` id — the room's own money. Nullable in the client's model. */ + PurchaseCurrencyId: string | null + /** ISO-8601 UTC. */ + ModifiedAt: string + /** How many may be bought at once. Stored and served; nothing buys a consumable yet. */ + MaximumCountPerPurchase: number +} + +/** What the write supplies. The id is minted on a create and kept on an edit. */ +export interface NewRoomConsumable { + RoomId: number + Name: string + Description: string + ImageName: string | null + Price: number + PurchaseCurrencyId: string | null + MaximumCountPerPurchase: number +} + +interface RoomConsumableRow { + room_consumable_id: string + room_id: number + name: string + description: string + image_name: string | null + price: number + purchase_currency_id: string | null + modified_at: string + maximum_count_per_purchase: number +} + +const SELECT_COLUMNS = `room_consumable_id, room_id, name, description, image_name, price, purchase_currency_id, modified_at, maximum_count_per_purchase` + +const toRoomConsumable = (row: RoomConsumableRow): RoomConsumable => ({ + RoomConsumableId: row.room_consumable_id, + RoomId: row.room_id, + Name: row.name, + Description: row.description, + ImageName: row.image_name, + Price: row.price, + PurchaseCurrencyId: row.purchase_currency_id, + ModifiedAt: row.modified_at, + MaximumCountPerPurchase: row.maximum_count_per_purchase, +}) + +/** One consumable by its id, or null when there is no such row. */ +export async function getRoomConsumable( + db: D1Database, + roomConsumableId: string +): Promise { + const row = await db + .prepare(`SELECT ${SELECT_COLUMNS} FROM room_consumable WHERE room_consumable_id = ?1`) + .bind(roomConsumableId) + .first() + + return row ? toRoomConsumable(row) : null +} + +/** + * Create a listing, or replace one when `roomConsumableId` names an existing row. + * + * One function because the client uses one call for both — a `PUT` whose `RoomConsumableId` + * is null on a create and the listing's id on an edit — and because the two write the same + * columns. An edit REPLACES rather than merges: the client sends the whole form back, so an + * absent field is a cleared field, not an unmentioned one. (That is the opposite of the + * currency edit, whose body is genuinely partial.) + * + * `ModifiedAt` is set on both. `RoomId` is written from the caller's already-checked value, + * so an edit cannot move a listing into another room — the room comes off the STORED listing + * on an edit, never the body. + */ +export async function upsertRoomConsumable( + db: D1Database, + roomConsumableId: string | null, + consumable: NewRoomConsumable +): Promise { + const row: RoomConsumableRow = { + room_consumable_id: roomConsumableId ?? crypto.randomUUID(), + room_id: consumable.RoomId, + name: consumable.Name, + description: consumable.Description, + image_name: consumable.ImageName, + price: consumable.Price, + purchase_currency_id: consumable.PurchaseCurrencyId, + modified_at: new Date().toISOString(), + maximum_count_per_purchase: consumable.MaximumCountPerPurchase, + } + + await db + .prepare( + `INSERT INTO room_consumable (${SELECT_COLUMNS}) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT (room_consumable_id) DO UPDATE SET + name = ?3, description = ?4, image_name = ?5, price = ?6, + purchase_currency_id = ?7, modified_at = ?8, maximum_count_per_purchase = ?9` + ) + .bind( + row.room_consumable_id, + row.room_id, + row.name, + row.description, + row.image_name, + row.price, + row.purchase_currency_id, + row.modified_at, + row.maximum_count_per_purchase + ) + .run() + + return toRoomConsumable(row) +} + +/** + * A room's shop, oldest first — the order the owner built it up in, which is the order they + * expect to see it in. A room that sells nothing is an empty list. + */ +export async function getRoomConsumables( + db: D1Database, + roomId: number +): Promise { + const { results } = await db + .prepare( + `SELECT ${SELECT_COLUMNS} FROM room_consumable + WHERE room_id = ?1 ORDER BY modified_at, room_consumable_id` + ) + .bind(roomId) + .all() + + return results.map(toRoomConsumable) +} + +/** + * What a player owns of a room's consumables — the shop's other side, filled by + * `POST /api/roomconsumables/v1/roomConsumable/awardBulk`. + * + * 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 + * row is a quantity of zero — nothing is inserted until a player is first given one, so a + * room's shop costs a row per player who actually owns something rather than one per player + * who has looked at it. + * + * `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. + */ +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, + PRIMARY KEY (player_id, consumable_id) + )`, + // "What does this player own?" is the read the client makes on entering a room, so the + // player half of the key earns its own index for the lookups that start there. + `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 +} + +/** + * Give a player some of a consumable, returning what they own afterwards. + * + * 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 + * the change — the same rule the currency balances follow, and for the same reason: every + * surface that shows an inventory shows the total. + * + * Read BACK rather than taken from a `RETURNING`, for the reason {@link awardRoomCurrency} + * spells out: an empty RETURNING is indistinguishable from an award that changed nothing, so + * a write that didn't land would report a plausible-looking but wrong total. All three + * statements are one `batch`, which D1 runs as a transaction. + */ +export async function awardRoomConsumable( + db: D1Database, + playerId: number, + consumableId: string, + quantity: number +): Promise { + const read = db.prepare( + 'SELECT quantity FROM room_inventory WHERE player_id = ?1 AND consumable_id = ?2' + ) + const [, , after] = await db.batch<{ quantity: number }>([ + 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)` + ) + .bind(playerId, consumableId, quantity), + 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 +} + +/** Everything a player owns of one room's consumables. A listing they own none of is absent. */ +export async function getRoomInventory( + db: D1Database, + roomId: number, + playerId: number +): Promise { + const { results } = await db + .prepare( + `SELECT i.player_id AS playerId, i.consumable_id AS consumableId, i.quantity AS quantity + 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 + ORDER BY c.modified_at, c.room_consumable_id` + ) + .bind(roomId, playerId) + .all<{ playerId: number; consumableId: string; quantity: number }>() + + return results.map((r) => ({ + PlayerId: r.playerId, + ConsumableId: r.consumableId, + Quantity: r.quantity, + })) +} diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index a838a71..7e70c5e 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -73,6 +73,7 @@ import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db' import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db' import { INVENTORY_SCHEMA_DDL } from '../../inventory-db' import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db' +import { ROOM_CONSUMABLE_SCHEMA_DDL, ROOM_INVENTORY_SCHEMA_DDL } from '../../room-consumable-db' import { ROOM_BALANCE_SCHEMA_DDL, ROOM_CURRENCY_SCHEMA_DDL } from '../../room-currency-db' import type { CatalogLoadRow, CatalogRow, CatalogValue } from '../../catalog-db' @@ -163,6 +164,8 @@ beforeAll(async () => { for (const stmt of CATALOG_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of ROOM_CURRENCY_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of ROOM_BALANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of ROOM_CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run() + for (const stmt of ROOM_INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run() // Presence (owned by the `match` worker) — a new room currency is pushed to everyone // standing in the room, which is read from here. for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() @@ -771,7 +774,7 @@ describe('econ endpoints', () => { expect(await res.json()).toEqual([]) }) - test('GET /api/roomconsumables/v1/roomConsumable/room/:id returns []', async () => { + test('GET /api/roomconsumables/v1/roomConsumable/room/:id returns [] for a room with no shop', async () => { const res = await exports.default.fetch( `${ORIGIN}/api/roomconsumables/v1/roomConsumable/room/1` ) @@ -795,6 +798,317 @@ describe('econ endpoints', () => { } }) + // Room 2511 is seeded as account 1's, with account 2 as co-owner. + describe('room consumables', () => { + type Consumable = { + RoomConsumableId: string + RoomId: number + Name: string + Description: string + ImageName: string | null + Price: number + PurchaseCurrencyId: string | null + ModifiedAt: string + MaximumCountPerPurchase: number + } + + const save = async (payload: unknown, headers?: Record) => + exports.default.fetch(`${ORIGIN}/api/roomconsumables/v1/roomConsumable`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(payload), + }) + const envOf = async (res: Response) => + (await res.json()) as { + Value: Consumable | null + Success: boolean + Error: string | null + error_id: null + } + const shopOf = async (roomId: number) => + (await ( + await exports.default.fetch( + `${ORIGIN}/api/roomconsumables/v1/roomConsumable/room/${roomId}` + ) + ).json()) as Consumable[] + + const body = { + RoomConsumableId: null, + RoomId: 2511, + Name: 'test', + Description: 'test444444', + ImageName: null, + PriceAndCurrency: { Price: 50, CurrencyId: 'b9f41a7c-32f1-4456-b84f-fdb94b240955' }, + } + + test('PUT roomConsumable stocks the shop, gated to the room', async () => { + // No token → 401; a valid token with no standing in the room → 403. + expect((await save(body)).status).toBe(401) + expect((await save(body, await bearer('999'))).status).toBe(403) + + // An unusable body is the failure envelope, and stocks nothing. + const badBodies: unknown[] = [ + { ...body, RoomId: 'nope' }, + { ...body, Name: ' ' }, + { ...body, RoomConsumableId: 'a3f1e8d2-0000-4000-8000-000000000000' }, + 'not an object', + ] + for (const bad of badBodies) { + const res = await save(bad, await bearer('1')) + expect(res.status).toBe(200) + expect(await envOf(res)).toEqual({ + Value: null, + Success: false, + Error: 'Failed to save consumable', + error_id: null, + }) + } + expect(await shopOf(2511)).toEqual([]) + + // The owner stocks it. `PriceAndCurrency` collapses to the flat pair the read serves. + const res = await save(body, await bearer('1')) + expect(res.status).toBe(200) + const env = await envOf(res) + expect(env).toMatchObject({ Success: true, Error: null, error_id: null }) + const created = env.Value! + expect(created).toMatchObject({ + RoomId: 2511, + Name: 'test', + Description: 'test444444', + ImageName: null, + Price: 50, + PurchaseCurrencyId: 'b9f41a7c-32f1-4456-b84f-fdb94b240955', + // The client doesn't send this, so it falls back rather than landing as NaN. + MaximumCountPerPurchase: 0, + }) + expect(created.RoomConsumableId).toMatch(/^[0-9a-f-]{36}$/) + expect(Date.parse(created.ModifiedAt)).not.toBeNaN() + // The client's own model, member for member and in its order. + expect(Object.keys(created)).toEqual([ + 'RoomConsumableId', + 'RoomId', + 'Name', + 'Description', + 'ImageName', + 'Price', + 'PurchaseCurrencyId', + 'ModifiedAt', + 'MaximumCountPerPurchase', + ]) + + // The list serves it back, and it belongs to this room alone. + expect(await shopOf(2511)).toEqual([created]) + expect(await shopOf(1)).toEqual([]) + }) + + test('PUT roomConsumable replaces when the body names a listing', async () => { + const created = (await envOf(await save({ ...body, Name: 'Before' }, await bearer('1')))) + .Value! + + // Naming the listing REPLACES it rather than adding a second, and a co-owner may. + const edited = ( + await envOf( + await save( + { + RoomConsumableId: created.RoomConsumableId, + // Deliberately another room: an edit must not move a listing, and the + // permission check must not follow the body. + RoomId: 1, + Name: 'After', + Description: 'edited', + ImageName: 'potion.png', + PriceAndCurrency: { Price: 25, CurrencyId: null }, + MaximumCountPerPurchase: 10, + }, + await bearer('2') + ) + ) + ).Value! + + expect(edited).toMatchObject({ + RoomConsumableId: created.RoomConsumableId, + // Still room 2511 — the body's RoomId is ignored on a replace. + RoomId: 2511, + Name: 'After', + Description: 'edited', + ImageName: 'potion.png', + Price: 25, + PurchaseCurrencyId: null, + MaximumCountPerPurchase: 10, + }) + const shop = await shopOf(2511) + expect(shop.filter((it) => it.RoomConsumableId === created.RoomConsumableId)).toEqual([ + edited, + ]) + + // A replace is a REPLACE, not a merge: a field left out is cleared, since the client + // sends the whole form back. + const cleared = ( + await envOf( + await save( + { RoomConsumableId: created.RoomConsumableId, Name: 'Bare' }, + await bearer('1') + ) + ) + ).Value! + expect(cleared).toMatchObject({ + Name: 'Bare', + Description: '', + ImageName: null, + Price: 0, + PurchaseCurrencyId: null, + MaximumCountPerPurchase: 0, + }) + }) + + test('PUT roomConsumable masks a name players will see, and a room can stock several', async () => { + const masked = (await envOf(await save({ ...body, Name: 'shit potion' }, await bearer('1')))) + .Value! + expect(masked.Name).toBe('**** potion') + + await save({ ...body, Name: 'Second' }, await bearer('1')) + const shop = await shopOf(2511) + expect(shop.length).toBeGreaterThanOrEqual(2) + // Each listing has its own id — two things in a shop are not one thing. + expect(new Set(shop.map((it) => it.RoomConsumableId)).size).toBe(shop.length) + }) + + test('POST roomConsumable/awardBulk gives them to the CALLER, reporting each entry', async () => { + type AwardResult = { + ConsumableId: string + Success: boolean + Error: string | null + Response: { + PlayerId: number + ConsumableId: string + Quantity: number + AwardedAt: string + } | null + } + const potion = (await envOf(await save({ ...body, Name: 'Potion' }, await bearer('1')))) + .Value! + const elixir = (await envOf(await save({ ...body, Name: 'Elixir' }, await bearer('1')))) + .Value! + + const awardBulk = async (payload: unknown, headers?: Record) => + exports.default.fetch(`${ORIGIN}/api/roomconsumables/v1/roomConsumable/awardBulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(payload), + }) + const resultsOf = async (res: Response) => (await res.json()) as AwardResult[] + const ownedBy = 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 request = (id: string, Quantity: number) => ({ + Requests: { + [id]: { + Quantity, + ConcurrencyCodes: { + CurrentConcurrencyCode: null, + NewConcurrencyCode: '8792f49a-703d-4906-8320-dc9c5f616e21', + }, + }, + }, + }) + + // No token → 401. Nothing else is answered at the call level. + expect((await awardBulk(request(potion.RoomConsumableId, 1))).status).toBe(401) + + // The award goes to whoever is ASKING — account 42 here, not the room's owner. + const res = await awardBulk(request(potion.RoomConsumableId, 1), await bearer('42')) + expect(res.status).toBe(200) + const results = await resultsOf(res) + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ + ConsumableId: potion.RoomConsumableId, + Success: true, + Error: null, + Response: { + PlayerId: 42, + ConsumableId: potion.RoomConsumableId, + Quantity: 1, + }, + }) + expect(Date.parse(results[0].Response!.AwardedAt)).not.toBeNaN() + expect(await ownedBy(42, potion.RoomConsumableId)).toBe(1) + // Nobody else got one. + expect(await ownedBy(1, potion.RoomConsumableId)).toBe(0) + + // Awards accumulate, and `Quantity` on the result is the RESULTING total. + expect( + ( + await resultsOf(await awardBulk(request(potion.RoomConsumableId, 4), await bearer('42'))) + )[0].Response + ).toMatchObject({ Quantity: 5 }) + + // Negative takes them away, and the count floors at zero. + expect( + ( + await resultsOf(await awardBulk(request(potion.RoomConsumableId, -2), await bearer('42'))) + )[0].Response + ).toMatchObject({ Quantity: 3 }) + expect( + ( + await resultsOf( + await awardBulk(request(potion.RoomConsumableId, -99), await bearer('42')) + ) + )[0].Response + ).toMatchObject({ Quantity: 0 }) + + // Several keys in one call: each entry stands alone, in the order the keys appeared, + // so an id naming no listing leaves the rest to land. + const mixed = await awardBulk( + { + Requests: { + [elixir.RoomConsumableId]: { Quantity: 2 }, + 'a3f1e8d2-0000-4000-8000-000000000000': { Quantity: 9 }, + [potion.RoomConsumableId]: { Quantity: 'lots' }, + }, + }, + await bearer('42') + ) + const mixedResults = await resultsOf(mixed) + expect(mixedResults.map((r) => r.ConsumableId)).toEqual([ + elixir.RoomConsumableId, + 'a3f1e8d2-0000-4000-8000-000000000000', + potion.RoomConsumableId, + ]) + expect(mixedResults.map((r) => r.Success)).toEqual([true, false, false]) + expect(mixedResults.map((r) => r.Error)).toEqual([ + null, + 'No such consumable', + 'Invalid quantity', + ]) + expect(await ownedBy(42, elixir.RoomConsumableId)).toBe(2) + // The bad entries wrote nothing. + expect(await ownedBy(42, 'a3f1e8d2-0000-4000-8000-000000000000')).toBe(0) + expect(await ownedBy(42, potion.RoomConsumableId)).toBe(0) + + // A body with no `Requests` map has no entries to report on. + for (const bad of [{}, { Requests: null }, { Requests: [] }, 'nope']) { + const res = await awardBulk(bad, await bearer('42')) + expect(res.status).toBe(200) + expect(await resultsOf(res)).toEqual([]) + } + }) + + 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( + `${ORIGIN}/api/roomconsumables/v1/roomConsumable/room/nope` + ) + expect(res.status).toBe(200) + expect(await res.json()).toEqual([]) + }) + }) + // Room 2511 is seeded as account 1's, with account 2 as co-owner. describe('room currencies', () => { type RoomCurrency = { @@ -4801,6 +5115,7 @@ describe('econ endpoints', () => { 'POST /api/items/purchaseInfos', 'POST /api/objectives/v1/cleargroup', 'POST /api/objectives/v1/updateobjective', + 'POST /api/roomconsumables/v1/roomConsumable/awardBulk', 'POST /api/roomcurrencies/v1/awardCurrency/bulk', 'POST /api/roomcurrencies/v1/createCurrency', 'POST /api/roomcurrencies/v1/createPurchaseOffer', @@ -4809,6 +5124,7 @@ describe('econ endpoints', () => { 'POST /api/storefronts/v3/buyInvention', 'POST /api/ugcPurchasables/v1/items/bulk', 'PUT /api/equipment/v1/update', + 'PUT /api/roomconsumables/v1/roomConsumable', ]) // Every operation carries a summary — a path present but undescribed is not