mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 15:09:18 -07:00
[econ] consumables WIP
This commit is contained in:
@@ -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
@@ -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<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,
|
||||
* 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
|
||||
// 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: `{ "<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(
|
||||
'/api/roomconsumables/v1/roomConsumable/room/:roomId/me',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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<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.
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user