[econ] consumables WIP

This commit is contained in:
Devin Zuczek
2026-09-14 16:33:47 -04:00
parent ba8423e438
commit bacd359b61
6 changed files with 1045 additions and 5 deletions
@@ -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);
@@ -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);
+258 -4
View File
@@ -80,6 +80,8 @@ import {
AUTHED, AUTHED,
AvatarItemV4Dto, AvatarItemV4Dto,
AvatarV2Dto, AvatarV2Dto,
AwardRoomConsumableResultList,
AwardRoomConsumablesRequest,
AwardRoomCurrencyRequest, AwardRoomCurrencyRequest,
AwardRoomCurrencyResultList, AwardRoomCurrencyResultList,
BalanceEntry, BalanceEntry,
@@ -118,6 +120,8 @@ import {
OpaqueJsonBody, OpaqueJsonBody,
OPTIONAL_AUTHED, OPTIONAL_AUTHED,
ReferralProgressResponse, ReferralProgressResponse,
RoomConsumableDto,
RoomConsumableEnvelope,
RoomCurrencyDto, RoomCurrencyDto,
RoomCurrencyEnvelope, RoomCurrencyEnvelope,
RoomCurrencyPurchaseOfferDto, RoomCurrencyPurchaseOfferDto,
@@ -134,8 +138,15 @@ import {
UpdateObjectiveRequest, UpdateObjectiveRequest,
UpdateObjectiveResponse, UpdateObjectiveResponse,
UpdateRoomCurrencyRequest, UpdateRoomCurrencyRequest,
UpsertRoomConsumableRequest,
} from './openapi' } from './openapi'
import { claimReward } from './reward-db' import { claimReward } from './reward-db'
import {
awardRoomConsumable,
getRoomConsumable,
getRoomConsumables,
upsertRoomConsumable,
} from './room-consumable-db'
import { import {
awardRoomCurrency, awardRoomCurrency,
createPurchaseOffer, createPurchaseOffer,
@@ -166,6 +177,7 @@ import type { ConsumeResult } from './consumables-db'
import type { App } from './context' import type { App } from './context'
import type { Equipment } from './equipment-db' import type { Equipment } from './equipment-db'
import type { AvatarItem } from './inventory-db' import type { AvatarItem } from './inventory-db'
import type { RoomConsumable } from './room-consumable-db'
import type { RoomCurrency, RoomCurrencyPurchaseOffer } from './room-currency-db' import type { RoomCurrency, RoomCurrencyPurchaseOffer } from './room-currency-db'
// Invention storage (owned by the `api` worker, on this same `recflare` database). // 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 CREATE_CURRENCY_FAILED = 'Failed to create currency'
const UPDATE_CURRENCY_FAILED = 'Failed to update currency' const UPDATE_CURRENCY_FAILED = 'Failed to update currency'
const CREATE_OFFER_FAILED = 'Failed to create purchase offer' 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 }`. * The envelope the create endpoint answers in: `{ Value, Success, Error, error_id }`.
@@ -277,6 +290,19 @@ function roomCurrencyEnvelope(c: Context<App>, 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<App>, 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, * The create-offer envelope — the same `{ Value, Success, Error, error_id }` shape again,
* carrying one purchase offer. * carrying one purchase offer.
@@ -3036,12 +3062,240 @@ const app = new Hono<App>({ strict: false })
} }
) )
// Room consumables/currencies for a given room. Stubbed as empty lists so the // A room's shop — the things it sells for its own currency. Public, like the room's
// client doesn't 404. // currencies: a shop is shown to everyone who walks in.
.get( .get(
'/api/roomconsumables/v1/roomConsumable/room/:roomId', '/api/roomconsumables/v1/roomConsumable/room/:roomId',
listRoute('Room consumables', 'Empty stub so the client doesn’t 404'), describeRoute({
(c) => c.json([]) 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: `{ "<id>": { "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<string, unknown>)) {
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<Record<string, unknown>>().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<string, unknown>
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( .get(
'/api/roomconsumables/v1/roomConsumable/room/:roomId/me', '/api/roomconsumables/v1/roomConsumable/room/:roomId/me',
+107
View File
@@ -629,6 +629,113 @@ export const RoomCurrencyPurchaseOfferDto = z.object({
ModifiedAt: z.string().describe('ISO-8601 UTC'), 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 currency's shop, as `GET /api/roomcurrencies/v1/getPurchaseOffersBatch` groups them —
* one entry per currency asked for, the whole answer an array of these. * one entry per currency asked for, the whole answer an array of these.
+296
View File
@@ -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<RoomConsumable | null> {
const row = await db
.prepare(`SELECT ${SELECT_COLUMNS} FROM room_consumable WHERE room_consumable_id = ?1`)
.bind(roomConsumableId)
.first<RoomConsumableRow>()
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<RoomConsumable> {
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<RoomConsumable[]> {
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<RoomConsumableRow>()
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<number> {
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<RoomInventoryItem[]> {
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,
}))
}
+317 -1
View File
@@ -73,6 +73,7 @@ import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db' import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db'
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db' import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-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 { ROOM_BALANCE_SCHEMA_DDL, ROOM_CURRENCY_SCHEMA_DDL } from '../../room-currency-db'
import type { CatalogLoadRow, CatalogRow, CatalogValue } from '../../catalog-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 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_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_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 // Presence (owned by the `match` worker) — a new room currency is pushed to everyone
// standing in the room, which is read from here. // standing in the room, which is read from here.
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() 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([]) 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( const res = await exports.default.fetch(
`${ORIGIN}/api/roomconsumables/v1/roomConsumable/room/1` `${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<string, string>) =>
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<string, string>) =>
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. // Room 2511 is seeded as account 1's, with account 2 as co-owner.
describe('room currencies', () => { describe('room currencies', () => {
type RoomCurrency = { type RoomCurrency = {
@@ -4801,6 +5115,7 @@ describe('econ endpoints', () => {
'POST /api/items/purchaseInfos', 'POST /api/items/purchaseInfos',
'POST /api/objectives/v1/cleargroup', 'POST /api/objectives/v1/cleargroup',
'POST /api/objectives/v1/updateobjective', 'POST /api/objectives/v1/updateobjective',
'POST /api/roomconsumables/v1/roomConsumable/awardBulk',
'POST /api/roomcurrencies/v1/awardCurrency/bulk', 'POST /api/roomcurrencies/v1/awardCurrency/bulk',
'POST /api/roomcurrencies/v1/createCurrency', 'POST /api/roomcurrencies/v1/createCurrency',
'POST /api/roomcurrencies/v1/createPurchaseOffer', 'POST /api/roomcurrencies/v1/createPurchaseOffer',
@@ -4809,6 +5124,7 @@ describe('econ endpoints', () => {
'POST /api/storefronts/v3/buyInvention', 'POST /api/storefronts/v3/buyInvention',
'POST /api/ugcPurchasables/v1/items/bulk', 'POST /api/ugcPurchasables/v1/items/bulk',
'PUT /api/equipment/v1/update', 'PUT /api/equipment/v1/update',
'PUT /api/roomconsumables/v1/roomConsumable',
]) ])
// Every operation carries a summary — a path present but undescribed is not // Every operation carries a summary — a path present but undescribed is not