diff --git a/apps/econ/migrations/0017_room_currency.sql b/apps/econ/migrations/0017_room_currency.sql new file mode 100644 index 0000000..07211f1 --- /dev/null +++ b/apps/econ/migrations/0017_room_currency.sql @@ -0,0 +1,51 @@ +-- Room currencies — a room's own money. +-- +-- A creator mints one for their room ("MySpecialTokens"), hands it out for doing things in +-- there, and sells things for it: finish the obstacle course, earn 50 tokens, spend them on a +-- custom shirt at the shop by the door. Play money for one room, meaning nothing outside it. +-- +-- A row here is the CURRENCY — its name, its coin art, how fast the room may hand it out. +-- What a player has actually earned is `room_balance` (migration 0019). Owned by the `econ` +-- worker; generated from src/room-currency-db.ts (ROOM_CURRENCY_SCHEMA_DDL) — keep in sync. +-- +-- These are NOT balances and must never reach the `balance` table, which is keyed by +-- (account_id, currency_type) alone. That key cannot say WHICH room's currency a row is, so +-- one room's tokens would spend in every other room — see CurrencyType in src/balance-db.ts, +-- where RoomCurrency is left out of the spendable set for exactly this reason. +-- +-- Every currency here is CurrencyType 300 (RoomCurrency) — that is what the type MEANS — and +-- is told apart from the others by its own `currency_id`, so the type is not a column. +-- +-- `currency_id` is a GUID rather than an autoincrement: it is how the client names this +-- currency everywhere afterwards, and a room's currencies are minted independently of every +-- other room's, so an id that says nothing about ordering is the honest one. +-- +-- `limit_amount`, not `limit` — LIMIT is a SQL keyword, and a column that has to be quoted at +-- every use is a column that will eventually not be. It holds the most of this currency the +-- room may award PER DAY: a faucet rate, not a cap on what a player may hold. Nothing enforces +-- it yet — no day's awards are tracked anywhere — so it is stored and served and otherwise +-- inert. +-- +-- `shape` and `color` are the client's own indices into its coin art (the create body sends +-- `Shape=0&Color=19`); nothing interprets them, they are stored and served back. `image_name` +-- is separate and stays empty until a room can upload custom coin art — the client's model +-- carries both, so the column is here rather than in a later migration. +-- +-- NOTE: `image_name` was NOT NULL DEFAULT '' here, and 0018 makes it nullable — the client +-- serves null, not an empty string, for a currency with no custom coin art. This file is left +-- as it shipped; the current shape of the table is 0017 + 0018. +CREATE TABLE IF NOT EXISTS room_currency ( + currency_id TEXT PRIMARY KEY, + room_id INTEGER NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + limit_amount INTEGER NOT NULL, + shape INTEGER NOT NULL, + color INTEGER NOT NULL, + image_name TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + modified_at TEXT NOT NULL + ); + +-- Every read is "this room's currencies" — the whole access pattern. +CREATE INDEX IF NOT EXISTS idx_room_currency_room ON room_currency (room_id); diff --git a/apps/econ/migrations/0018_room_currency_image_name.sql b/apps/econ/migrations/0018_room_currency_image_name.sql new file mode 100644 index 0000000..db851ef --- /dev/null +++ b/apps/econ/migrations/0018_room_currency_image_name.sql @@ -0,0 +1,45 @@ +-- Make `room_currency.image_name` nullable, and turn the empty strings already stored into +-- nulls. Owned by the `econ` worker; 0017 created the table with `image_name TEXT NOT NULL +-- DEFAULT ''`. Generated from src/room-currency-db.ts (ROOM_CURRENCY_SCHEMA_DDL, which mirrors +-- the table as it stands AFTER this migration) — keep in sync. +-- +-- The client's own model has `ImageName` as a nullable string and serves NULL for a currency +-- with no custom coin art; an empty string is a different value to its decoder, so a currency +-- created before this read back as having art named "". Nothing can upload coin art yet, so +-- every row is affected and every one of them should be null. +-- +-- A rebuild rather than an ALTER: SQLite cannot drop a NOT NULL constraint in place. This is +-- the standard four-step swap — build the new shape, copy across, drop the old, rename — and +-- the index goes with the dropped table, so it is recreated at the end rather than left to be +-- noticed later by a slow query. `NULLIF(image_name, '')` is the conversion: it maps '' to +-- NULL and leaves a real name alone, so a row that somehow already carries art keeps it. +-- +-- Safe to re-run in spirit but not in fact — it is a migration, applied once, and the rename +-- would fail on a second pass because `room_currency_new` no longer exists. +CREATE TABLE room_currency_new ( + currency_id TEXT PRIMARY KEY, + room_id INTEGER NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + limit_amount INTEGER NOT NULL, + shape INTEGER NOT NULL, + color INTEGER NOT NULL, + image_name TEXT, + created_at TEXT NOT NULL, + modified_at TEXT NOT NULL + ); + +INSERT INTO room_currency_new ( + currency_id, room_id, name, description, limit_amount, shape, color, image_name, created_at, modified_at + ) +SELECT + currency_id, room_id, name, description, limit_amount, shape, color, NULLIF(image_name, ''), created_at, modified_at +FROM room_currency; + +DROP TABLE room_currency; + +ALTER TABLE room_currency_new RENAME TO room_currency; + +-- Recreated because dropping the old table took its index with it. Same index 0017 defined: +-- every read is "this room's currencies". +CREATE INDEX IF NOT EXISTS idx_room_currency_room ON room_currency (room_id); diff --git a/apps/econ/migrations/0019_room_balance.sql b/apps/econ/migrations/0019_room_balance.sql new file mode 100644 index 0000000..754a833 --- /dev/null +++ b/apps/econ/migrations/0019_room_balance.sql @@ -0,0 +1,28 @@ +-- How much of a room's own money each player has earned — the other half of room currencies +-- (the currencies themselves are `room_currency`, migration 0017). Written by +-- `POST /api/roomcurrencies/v1/awardCurrency/bulk`, which is how a room pays a player for +-- finishing its obstacle course. Owned by the `econ` worker; generated from +-- src/room-currency-db.ts (ROOM_BALANCE_SCHEMA_DDL) — keep in sync. +-- +-- Keyed by CURRENCY, not by currency type, and that difference is the whole point: the +-- account-scoped `balance` table's (account_id, currency_type) key cannot say WHICH room's +-- currency a row is, so one room's tokens would spend in every other room. Room currencies +-- are left out of that table for exactly this reason (see CurrencyType in src/balance-db.ts) +-- and live here instead, keyed by the `currency_id` that names one room's currency uniquely. +-- +-- The room is not a column: `room_currency` already says which room a currency belongs to, +-- and repeating it here would be a second place for that to be wrong. +-- +-- A missing row is a balance of zero. Nothing is inserted until a player is first awarded +-- some, so a room's currency costs a row per player who has actually earned it rather than +-- one per player who has walked in. +CREATE TABLE IF NOT EXISTS room_balance ( + currency_id TEXT NOT NULL, + player_id INTEGER NOT NULL, + amount INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (currency_id, player_id) + ); + +-- "What does this player hold across the room?" walks the room's currencies, so the reverse +-- lookup earns its own index rather than a scan of every holder of every currency. +CREATE INDEX IF NOT EXISTS idx_room_balance_player ON room_balance (player_id); diff --git a/apps/econ/migrations/0020_room_currency_purchase_offers.sql b/apps/econ/migrations/0020_room_currency_purchase_offers.sql new file mode 100644 index 0000000..33ec58d --- /dev/null +++ b/apps/econ/migrations/0020_room_currency_purchase_offers.sql @@ -0,0 +1,19 @@ +-- The purchase offers a room sells its own currency through — "5 SuperTokens for 500 Rec +-- Center Tokens" — served by `GET /api/roomcurrencies/v1/getPurchaseOffersBatch?ids=`. Owned +-- by the `econ` worker; 0017 created the table. Generated from src/room-currency-db.ts +-- (ROOM_CURRENCY_SCHEMA_DDL, which mirrors the table as it stands after 0017 + 0018 + this) +-- — keep in sync. +-- +-- A JSON column rather than a table of its own. Offers are a small, ordered list that is only +-- ever read, written and replaced AS A WHOLE, for one currency at a time — the client shows +-- them as that currency's shop and reorders them together. A table would buy per-offer +-- queries nothing asks for, and cost a join on the one read that matters. +-- +-- Each offer holds `CurrencyPurchaseOfferId`, `Order`, `Name`, `CurrencyAmount`, `Price` and +-- `ModifiedAt`. It does NOT hold `CurrencyId`: the row it lives on already says which currency +-- these are offers for, and the read projects it back in. Storing it would be a second place +-- for that to be wrong. +-- +-- NULL, not '[]', for a currency with no offers — a currency nobody has built a shop for has +-- no list, and the read treats null and an unparseable value alike as "no offers". +ALTER TABLE room_currency ADD COLUMN purchase_offers TEXT; diff --git a/apps/econ/src/balance-db.ts b/apps/econ/src/balance-db.ts index dd6e3a9..3cf2fc9 100644 --- a/apps/econ/src/balance-db.ts +++ b/apps/econ/src/balance-db.ts @@ -39,6 +39,9 @@ import { BalancePlatform } from '../../notify/src/notification-payloads' * room would spend in every other. `isSpendable` rejects them for that reason. * - `ProgressionEvent` (400): an XP/progression counter the client models as a * currency. Not spendable. + * - `RoomieCredits` (500): the newest member of the client's enum. Nothing here grants or + * spends it yet; it is listed so the enum matches the client's and a value arriving on + * the wire has a name rather than reading as an unknown number. */ export const CurrencyType = { Invalid: 0, @@ -50,6 +53,7 @@ export const CurrencyType = { RoomCurrency: 300, RoomInventoryItem: 301, ProgressionEvent: 400, + RoomieCredits: 500, } as const export type CurrencyTypeValue = (typeof CurrencyType)[keyof typeof CurrencyType] diff --git a/apps/econ/src/econ.app.ts b/apps/econ/src/econ.app.ts index 5a3cdd4..a87992c 100644 --- a/apps/econ/src/econ.app.ts +++ b/apps/econ/src/econ.app.ts @@ -4,12 +4,14 @@ import { useWorkersLogger } from 'workers-tagged-logger' import { addXp, + canManageRoomById, consumeGift, createGift, getAccount, getGift, getOutfits, getPendingGifts, + getPlayerIdsInRoom, grantInvention, levelReward, levelsReached, @@ -78,6 +80,8 @@ import { AUTHED, AvatarItemV4Dto, AvatarV2Dto, + AwardRoomCurrencyRequest, + AwardRoomCurrencyResultList, BalanceEntry, BulkPurchaseRequest, BulkPurchaseResponse, @@ -94,6 +98,8 @@ import { ConsumeConsumableRequest, ConsumeEnvelope, ConsumeGiftRequest, + CreatePurchaseOfferRequest, + CreateRoomCurrencyRequest, CustomAvatarItemsResponse, EquipmentUpdateRequest, ErrorResponse, @@ -112,6 +118,11 @@ import { OpaqueJsonBody, OPTIONAL_AUTHED, ReferralProgressResponse, + RoomCurrencyDto, + RoomCurrencyEnvelope, + RoomCurrencyPurchaseOfferDto, + RoomCurrencyPurchaseOfferEnvelope, + RoomCurrencyPurchaseOffersDto, RoomEconConfig, RRPlusSignUpBonus, SaveOutfitRequest, @@ -122,8 +133,18 @@ import { UNAUTHORIZED_RESPONSE, UpdateObjectiveRequest, UpdateObjectiveResponse, + UpdateRoomCurrencyRequest, } from './openapi' import { claimReward } from './reward-db' +import { + awardRoomCurrency, + createPurchaseOffer, + createRoomCurrency, + getPurchaseOffers, + getRoomCurrencies, + getRoomCurrency, + updateRoomCurrency, +} from './room-currency-db' import type { Context } from 'hono' import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain' @@ -132,6 +153,7 @@ import type { SavedInvention } from '../../api/src/inventions-db' import type { BalanceResponsePayload, PurchaseBalanceModificationPayload, + RoomCurrencyPayload, } from '../../notify/src/notification-payloads' import type { Avatar } from './avatar-db' import type { CatalogRow } from './catalog-db' @@ -144,6 +166,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 { RoomCurrency, RoomCurrencyPurchaseOffer } from './room-currency-db' // Invention storage (owned by the `api` worker, on this same `recflare` database). // Imported directly rather than copied: these are plain D1 helpers with no bindings of @@ -224,6 +247,122 @@ async function persistPostedOutfit(c: Context): Promise /** The notifications hub is a single global DO instance (see the `notify` worker). */ const HUB_INSTANCE = 'global' +/** + * The messages the room-currency failure paths carry. One string per endpoint, not per + * reason: the client unwraps the envelope and hands its callers `Value`, so a more specific + * message would reach nobody but a log. + * + * The create string is the reference's own. The update one is its obvious counterpart and has + * not been seen on the wire — if the real client ever shows it and the wording is wrong, this + * is the line to fix. + */ +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' + +/** + * The envelope the create endpoint answers in: `{ Value, Success, Error, error_id }`. + * + * PascalCase beside a lowercase `error_id` — the client's own mixed casing, not a typo, and + * NOT the lowercase `{ success, error, value }` the `rooms` worker's room mutations use. + * A failure answers 200 with `Success: false` and a null `Value`, the way the reference's + * failure path does; only the auth gates answer with a status of their own. + */ +function roomCurrencyEnvelope(c: Context, value: RoomCurrency | 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. + */ +function purchaseOfferEnvelope( + c: Context, + value: RoomCurrencyPurchaseOffer | null, + error?: string +) { + return c.json({ + Value: value, + Success: error === undefined, + Error: error ?? null, + error_id: null, + }) +} + +/** + * Push `RoomCurrencyCreated` or `RoomCurrencyModified` for a room currency to everyone + * standing in the room, plus whoever made the change. + * + * Room-wide because a room currency is room-wide: every client in there prices the room's + * shops in it and shows what the player holds of it, so all of them need the change, not just + * the owner who made it. An EDIT matters to them just as much as a creation — a renamed or + * re-capped currency that only the editor hears about leaves everyone else spending the old + * one. Read from live presence across the room's instances. The editor is added separately: + * the settings UI opens from outside the room, and their own client is the one certain to be + * showing the currency list right now. + * + * The frame is typed as the hub's {@link RoomCurrencyPayload} — the client's own model — so a + * renamed key fails the build here rather than vanishing on the wire. It is the stored record + * verbatim: the two are the same object, which is why the HTTP response and this frame cannot + * drift apart. + * + * Best-effort, like the other pushes here — the currency has already committed, so a hub + * hiccup must not fail the request. One unreachable player doesn't cost the rest theirs. + */ +async function pushRoomCurrencyChange( + c: Context, + currency: RoomCurrency, + changedByAccountId: number, + notificationType: + typeof NotificationType.RoomCurrencyCreated | typeof NotificationType.RoomCurrencyModified +): Promise { + const frame: RoomCurrencyPayload = currency + + // Presence is another worker's table, and this whole push is best-effort: a currency that + // has already committed must not fail the request because the audience couldn't be read. + // The editor still hears about it either way. + let occupants: number[] = [] + try { + occupants = await getPlayerIdsInRoom(c.env.DB, currency.RoomId) + } catch (err) { + logger.error('failed to read room presence for a room-currency push', { + notificationType, + roomId: currency.RoomId, + error: err instanceof Error ? err.message : String(err), + }) + } + + const playerIds = new Set(occupants) + playerIds.add(changedByAccountId) + + await Promise.all( + [...playerIds].map(async (playerId) => { + try { + await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer( + playerId, + notificationType, + // Spread because `notifyPlayer` takes an index-signature record, which an + // interface doesn't satisfy implicitly. + { ...frame } + ) + } catch (err) { + logger.error('failed to push a room-currency notification', { + notificationType, + playerId, + roomId: currency.RoomId, + currencyId: currency.CurrencyId, + error: err instanceof Error ? err.message : String(err), + }) + } + }) + ) +} + /** * Push a ConsumableMappingRemoved notification to a player after they consume a * consumable, mirroring the reference's @@ -2909,8 +3048,621 @@ const app = new Hono({ strict: false }) listRoute('The caller’s room consumables', 'Empty stub'), (c) => c.json([]) ) - .get('/api/roomcurrencies/v1/currencies', listRoute('Room currencies', 'Empty stub'), (c) => - c.json([]) + // The custom currencies a room has minted. Public, like the room itself: a room's + // currencies are shown to every player who walks into it, so the list is not a secret + // the way its ban list is. A room with none — or an unknown room — is an empty list. + .get( + '/api/roomcurrencies/v1/currencies', + describeRoute({ + tags: ['Econ'], + summary: 'A room’s currencies', + description: [ + 'Every custom currency the room named by `roomId` has minted, oldest first — the', + 'order its owner built them up in.', + '', + 'Public, like the room itself: a room’s currencies are shown to everyone who walks', + 'into it. A room with none, an unknown room, and a missing `roomId` are all the same', + 'empty list, which the client reads as "this room has no currency of its own".', + '', + 'These are DEFINITIONS, not balances — what a player holds of one is', + '`getAllBalances`, which is still a stub.', + ].join('\n'), + parameters: [ + { + name: 'roomId', + in: 'query', + required: false, + description: 'The room whose currencies to list', + schema: { type: 'string' }, + }, + ], + responses: { 200: json(RoomCurrencyDto.array(), 'The room’s currencies, oldest first') }, + }), + async (c) => { + const roomId = Number.parseInt(c.req.query('roomId') ?? '', 10) + if (!Number.isInteger(roomId)) return c.json([]) + return c.json(await getRoomCurrencies(c.env.DB, roomId)) + } + ) + + // Mint a custom currency for a room. Auth-gated (401) and gated to the room's creator or + // a co-owner (403) — the same owner-level check the `rooms` worker applies to its own + // room-admin writes, reading the same room blob. + .post( + '/api/roomcurrencies/v1/createCurrency', + describeRoute({ + tags: ['Econ'], + summary: 'Create a room currency', + description: [ + 'Mints a custom currency for one room — its name, its description, its coin art and', + 'the most of it the room may award per day. Form-encoded', + '(`RoomId=2511&Name=Devintokens&Description=…&Limit=100&Shape=0&Color=19`).', + '', + 'Gated to the room’s CREATOR or a CO-OWNER — minting a currency is room', + 'administration, so it takes the same standing as the room’s other settings. A valid', + 'token from anyone else is a 403.', + '', + '`Name` and `Description` are masked by the same word list as every other string a', + 'player types, since a currency’s name is shown to everyone in the room.', + '', + 'Answers the created currency inside the `{ Value, Success, Error, error_id }`', + 'envelope — PascalCase beside a lowercase `error_id`, which is the client’s own mixed', + 'casing. The client unwraps it and hands its callers `Value` alone. A recoverable', + 'refusal (an unusable `RoomId`, an empty `Name`, an unknown room) is a 200 carrying', + '`Success: false`, a null `Value` and the reference’s own message; only the auth', + 'gates answer with a status of their own.', + '', + '`CurrencyId` is how the client names this currency everywhere afterwards.', + '`CurrencyType` is always 300 (RoomCurrency) and `ImageName` is null until custom', + 'coin art can be uploaded.', + ].join('\n'), + security: AUTHED, + requestBody: form(CreateRoomCurrencyRequest, 'The currency to mint'), + responses: { + 200: json( + RoomCurrencyEnvelope, + 'The currency as created, 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.parseBody().catch(() => ({}))) as Record + const str = (v: unknown): string => (typeof v === 'string' ? v : '') + const int = (v: unknown, fallback: number): number => { + const parsed = Number.parseInt(str(v), 10) + return Number.isInteger(parsed) ? parsed : fallback + } + + const roomId = int(body.RoomId, Number.NaN) + if (!Number.isInteger(roomId)) return roomCurrencyEnvelope(c, null, CREATE_CURRENCY_FAILED) + const name = str(body.Name).trim() + if (name === '') return roomCurrencyEnvelope(c, null, CREATE_CURRENCY_FAILED) + + // The room is the `rooms` worker's, read here only to apply its owner check — the + // same `canManageRoom` that worker gates its own room-admin writes on, so the two + // can't drift into disagreeing about who runs a room. Null is no such room, which + // answers differently from a room somebody else runs. + const canManage = await canManageRoomById(c.env.DB, roomId, accountId) + if (canManage === null) return roomCurrencyEnvelope(c, null, CREATE_CURRENCY_FAILED) + if (!canManage) return c.body(null, 403) + + const currency = await createRoomCurrency(c.env.DB, { + RoomId: roomId, + // Masked like every other player-typed string (see the gift note above): a + // currency's name is shown to everyone who walks into the room. + Name: censorSwears(name), + Description: censorSwears(str(body.Description)), + Limit: int(body.Limit, 0), + Shape: int(body.Shape, 0), + Color: int(body.Color, 0), + }) + + await pushRoomCurrencyChange(c, currency, accountId, NotificationType.RoomCurrencyCreated) + return roomCurrencyEnvelope(c, currency) + } + ) + + // Edit a room currency. Auth-gated (401) and gated to the creator or a co-owner of the room + // that MINTED it (403) — the currency names the room, so the caller doesn't get to say + // which room's permissions apply. Answers the same envelope the create does. + .post( + '/api/roomcurrencies/v1/updateCurrency', + describeRoute({ + tags: ['Econ'], + summary: 'Edit a room currency', + description: [ + 'Changes a currency’s name, description, daily award limit or coin art. Form-encoded,', + 'naming the', + 'currency by `CurrencyId`', + '(`CurrencyId=b9f41a7c-\u2026&Name=\u2026&Description=\u2026&Limit=330&Shape=9&Color=5`).', + '', + 'The room is not in the body and cannot be: it is whichever room minted the currency,', + 'which is also the room whose creator/co-owner check applies. A valid token from', + 'anyone else is a 403.', + '', + 'Every field but `CurrencyId` is optional, and one left out is left ALONE rather than', + 'reset — the client sends the whole form, so this only bites a partial request, and', + 'blanking a description because it went unmentioned is the worse reading. What can', + 'never change: the id, the room, the `CurrencyType`, and `CreatedAt`. `ModifiedAt`', + 'moves to now, which is what the client’s model carries both for.', + '', + '`Name` and `Description` are masked by the same word list as every other string a', + 'player types. Answers the created-currency envelope: the updated currency in', + '`Value`, or a 200 carrying `Success: false` when there is no such currency or the', + 'body is unusable.', + '', + 'Everyone standing in the room gets a `RoomCurrencyModified` frame — a renamed or', + 'recapped currency that only the editor hears about leaves the rest of the room', + 'spending the old one.', + ].join('\n'), + security: AUTHED, + requestBody: form(UpdateRoomCurrencyRequest, 'The currency and the fields to change'), + responses: { + 200: json( + RoomCurrencyEnvelope, + 'The currency as it now stands, or a rejection with `Success: false`' + ), + 401: UNAUTHORIZED_RESPONSE, + 403: { + description: 'Not the creator or a co-owner of the room that minted it (empty body)', + }, + }, + }), + async (c) => { + const accountId = await authedId(c) + if (accountId === null) return unauthorized(c) + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined) + const int = (v: unknown): number | undefined => { + const raw = str(v) + if (raw === undefined) return undefined + const parsed = Number.parseInt(raw, 10) + return Number.isInteger(parsed) ? parsed : undefined + } + + const currencyId = str(body.CurrencyId)?.trim() ?? '' + if (currencyId === '') return roomCurrencyEnvelope(c, null, UPDATE_CURRENCY_FAILED) + + const existing = await getRoomCurrency(c.env.DB, currencyId) + if (!existing) return roomCurrencyEnvelope(c, null, UPDATE_CURRENCY_FAILED) + + // The room comes off the CURRENCY, never the request: the caller names what to edit, + // not whose permissions to check against. Same gate as minting one. + const canManage = await canManageRoomById(c.env.DB, existing.RoomId, accountId) + // A currency whose room has since been deleted is unowned and so uneditable by + // anyone — a rejection rather than a 403, since nobody is being turned away. + if (canManage === null) return roomCurrencyEnvelope(c, null, UPDATE_CURRENCY_FAILED) + if (!canManage) return c.body(null, 403) + + // A name sent but blank is a bad edit, not an instruction to leave it alone: the + // currency would be left nameless in every list that shows it. + const name = str(body.Name)?.trim() + if (name !== undefined && name === '') { + return roomCurrencyEnvelope(c, null, UPDATE_CURRENCY_FAILED) + } + + const updated = await updateRoomCurrency(c.env.DB, existing, { + Name: name === undefined ? undefined : censorSwears(name), + Description: + str(body.Description) === undefined ? undefined : censorSwears(str(body.Description)!), + Limit: int(body.Limit), + Shape: int(body.Shape), + Color: int(body.Color), + }) + + await pushRoomCurrencyChange(c, updated, accountId, NotificationType.RoomCurrencyModified) + return roomCurrencyEnvelope(c, updated) + } + ) + + // The purchase offers on one or more room currencies — a currency's shop, the ways a + // player can BUY it. Public, like the currencies themselves: a shop is shown to everyone + // who walks into the room. + .get( + '/api/roomcurrencies/v1/getPurchaseOffersBatch', + describeRoute({ + tags: ['Econ'], + summary: 'Purchase offers for room currencies', + description: [ + 'The offers on each currency named in `ids` — the ways a player can buy that', + 'currency, priced in another ("5 SuperTokens for 500 Rec Center Tokens").', + '', + '`ids` takes several currencies: comma-separated, repeated (`ids=a&ids=b`), or both.', + 'The answer is one GROUP per currency — `{ CurrencyId, PurchaseOffers }` — in the', + 'order the ids were given, each shop sorted by its offers’ `Order`. Grouped even', + 'though every offer carries its own `CurrencyId`, because the group is what says a', + 'currency was asked about: a currency with no shop still gets one, with an empty', + '`PurchaseOffers`.', + '', + 'An id naming no currency at all is omitted — there is no shop to report, empty or', + 'otherwise — the way the other batch lookups here treat an unknown id, and never an', + 'error for the whole call. No `ids` at all is an empty list.', + '', + 'Public, like `GET /api/roomcurrencies/v1/currencies`. Nothing writes offers yet, so', + 'every currency answers empty until there is an endpoint that builds a shop.', + ].join('\n'), + parameters: [ + { + name: 'ids', + in: 'query', + required: false, + description: 'Currency ids — comma-separated, repeated, or both', + schema: { type: 'string' }, + }, + ], + responses: { + 200: json( + RoomCurrencyPurchaseOffersDto.array(), + 'One group per currency asked for, in the order given' + ), + }, + }), + async (c) => { + // Both spellings, since nothing says which the client uses: `ids=a&ids=b` and + // `ids=a,b` reach the same list, and blanks from a trailing comma are dropped. + const ids = c.req + .queries('ids') + ?.flatMap((value) => value.split(',')) + .map((value) => value.trim()) + .filter((value) => value !== '') + + // De-duplicated: an id sent twice is one shop, not the same shop twice. + return c.json(await getPurchaseOffers(c.env.DB, [...new Set(ids ?? [])])) + } + ) + + // Add a purchase offer to a room currency's shop. Auth-gated (401) and gated to the + // creator or a co-owner of the room that minted the currency (403) — a shop is the room's + // to build, and the room comes off the currency rather than the request. + .post( + '/api/roomcurrencies/v1/createPurchaseOffer', + describeRoute({ + tags: ['Econ'], + summary: 'Add a purchase offer to a room currency', + description: [ + 'Appends an offer to the currency named by `CurrencyId` — a way for players to buy', + 'that currency ("5 SuperTokens for 500 Rec Center Tokens"). Form-encoded', + '(`CurrencyId=…&Name=…&Amount=5&Price=55&Order=0`).', + '', + 'Note `Amount` in the body against `CurrencyAmount` on the offer it becomes: the', + 'body and the client’s model spell the same number differently, and the answer uses', + 'the model’s spelling.', + '', + '`CurrencyPurchaseOfferId` and `ModifiedAt` are minted here — the offer’s id is how', + 'the client names it afterwards. `Order` places it in the shop and defaults to 0;', + 'offers are served sorted by it, so several at 0 keep the order they were added in.', + '', + 'Gated to the creator or a co-owner of the room that minted the currency. A valid', + 'token from anyone else is a 403. Answers the same', + '`{ Value, Success, Error, error_id }` envelope the currency writes use, with the', + 'offer in `Value`, or a 200 carrying `Success: false` when the currency is unknown', + 'or the body unusable.', + ].join('\n'), + security: AUTHED, + requestBody: form(CreatePurchaseOfferRequest, 'The offer to add'), + responses: { + 200: json( + RoomCurrencyPurchaseOfferEnvelope, + 'The offer as created, or a rejection with `Success: false`' + ), + 401: UNAUTHORIZED_RESPONSE, + 403: { description: 'Not the creator or a co-owner of the minting room (empty body)' }, + }, + }), + async (c) => { + const accountId = await authedId(c) + if (accountId === null) return unauthorized(c) + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const str = (v: unknown): string => (typeof v === 'string' ? v : '') + const int = (v: unknown, fallback: number): number => { + const parsed = Number.parseInt(str(v), 10) + return Number.isInteger(parsed) ? parsed : fallback + } + + const currencyId = str(body.CurrencyId).trim() + if (currencyId === '') return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED) + + const amount = int(body.Amount, Number.NaN) + const price = int(body.Price, Number.NaN) + if (!Number.isInteger(amount) || !Number.isInteger(price)) { + return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED) + } + + const currency = await getRoomCurrency(c.env.DB, currencyId) + if (!currency) return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED) + + // The room comes off the CURRENCY, never the request — the same gate minting and + // editing one take. A shop is the room's to build. + const canManage = await canManageRoomById(c.env.DB, currency.RoomId, accountId) + if (canManage === null) return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED) + if (!canManage) return c.body(null, 403) + + const offer = await createPurchaseOffer(c.env.DB, currencyId, { + // The body's `Amount` is the model's `CurrencyAmount`. + CurrencyAmount: amount, + Price: price, + // Stored verbatim, NOT profanity-masked like a currency's name: the client has + // been seen sending a GUID here, and the mask works on substrings — it would + // happily corrupt an identifier that happened to contain a word. If this turns + // out to be player-typed display text, it wants masking. + Name: str(body.Name), + Order: int(body.Order, 0), + }) + return purchaseOfferEnvelope(c, offer) + } + ) + + // Award room currency to players, several awards per call. Auth-gated (401); everything + // else is reported PER ENTRY, including whether the caller may award that currency at all. + .post( + '/api/roomcurrencies/v1/awardCurrency/bulk', + describeRoute({ + tags: ['Econ'], + summary: 'Award room currency in bulk', + description: [ + 'Adds to what players hold of a room currency. The body is a JSON ARRAY, each entry', + 'naming its own `CurrencyId` and `RecipientId`, so one call can pay several players', + 'or one player in several currencies.', + '', + 'The answer is a BARE ARRAY, one result per entry IN THE ORDER SENT, and each result', + 'stands alone: `Success` and `Error` are that entry’s, and `Response` carries the', + 'outcome or is null. So one entry failing neither fails the others nor the call —', + 'including an entry whose currency belongs to a room the caller cannot manage, which', + 'is that entry’s failure rather than a 403 for everything. Only a missing or invalid', + 'token is answered at the call level, with a 401.', + '', + 'Each entry is authorised against the room that MINTED its currency: the caller must', + 'be that room’s creator or a co-owner. The room comes off the currency, never the', + 'request — a faucet a caller could point at any room would let anyone print another', + 'room’s money.', + '', + '`Amount` may be negative, which deducts. A balance floors at zero in the write', + 'itself — a player cannot owe a room currency — and `AmountAwarded` reports what', + 'actually landed, which is less than was asked for only when a deduction ran out of', + 'balance to take. `Balance` is the RESULTING total.', + '', + 'The currency’s `Limit` is NOT applied here. It is the most a room may award PER DAY', + '— a faucet rate, not a ceiling on holdings — and nothing tracks a day’s awards yet,', + 'so no award is refused for it.', + '', + '`TransactionId` is the client’s id for an award so that a retry is the same award.', + 'It is accepted and validated, but NOT yet deduplicated — replaying a request', + 'currently awards twice. That needs a table of its own.', + ].join('\n'), + security: AUTHED, + requestBody: jsonBody( + AwardRoomCurrencyRequest.array(), + 'The awards to apply, as a bare JSON array' + ), + responses: { + 200: json(AwardRoomCurrencyResultList, 'One result per entry, in the order sent'), + 401: UNAUTHORIZED_RESPONSE, + }, + }), + async (c) => { + const accountId = await authedId(c) + if (accountId === null) return unauthorized(c) + + const parsed = await c.req.json().catch(() => null) + // A body that isn't a list of awards has no entries to report on, and the answer is + // a list of entry results — so there is nothing to say but "none". + if (!Array.isArray(parsed)) return c.json([]) + + // Cached across entries, so paying twenty players in one currency is one currency + // lookup and one permission check rather than twenty of each. `null` caches a + // currency that doesn't exist; `false` one the caller may not award. + const currencies = new Map() + const resolve = async (currencyId: string): Promise => { + const cached = currencies.get(currencyId) + if (cached !== undefined) return cached + + const found = await getRoomCurrency(c.env.DB, currencyId) + // The room comes off the CURRENCY, never the request — the same gate minting and + // editing one take. + const canManage = found && (await canManageRoomById(c.env.DB, found.RoomId, accountId)) + const resolved = !found || canManage === null ? null : canManage ? found : false + currencies.set(currencyId, resolved) + return resolved + } + + const results = [] + for (const entry of parsed as Array>) { + const currencyId = typeof entry?.CurrencyId === 'string' ? entry.CurrencyId.trim() : '' + const playerId = Number(entry?.RecipientId) + const amount = Number(entry?.Amount) + + // Every result names its entry even when the entry was unusable, so a caller can + // line the answer up with what it sent. + const fail = (error: string) => ({ + AccountId: Number.isInteger(playerId) ? playerId : 0, + CurrencyId: currencyId, + Success: false, + Error: error, + Response: null, + }) + + if (currencyId === '' || !Number.isInteger(playerId) || !Number.isInteger(amount)) { + results.push(fail('Invalid award')) + continue + } + + const currency = await resolve(currencyId) + if (currency === null) { + results.push(fail('No such currency')) + continue + } + if (currency === false) { + results.push(fail('Not permitted')) + continue + } + + const { Balance, AmountAwarded } = await awardRoomCurrency( + c.env.DB, + currency.CurrencyId, + playerId, + amount + ) + results.push({ + AccountId: playerId, + CurrencyId: currency.CurrencyId, + Success: true, + Error: null, + Response: { + AccountId: playerId, + CurrencyId: currency.CurrencyId, + Balance, + AmountAwarded, + AwardedAt: new Date().toISOString(), + }, + }) + } + + return c.json(results) + } + ) + + // The purchase offers on one or more room currencies — a currency's shop, the ways a + // player can BUY it. Public, like the currencies themselves: a shop is shown to everyone + // who walks into the room. + .get( + '/api/roomcurrencies/v1/getPurchaseOffersBatch', + describeRoute({ + tags: ['Econ'], + summary: 'Purchase offers for room currencies', + description: [ + 'The offers on each currency named in `ids` — the ways a player can buy that', + 'currency, priced in another ("5 SuperTokens for 500 Rec Center Tokens").', + '', + '`ids` takes several currencies: comma-separated, repeated (`ids=a&ids=b`), or both.', + 'The answer is FLAT rather than grouped, because every offer carries its own', + '`CurrencyId` — the client groups them itself, and one currency’s answer looks like', + 'twenty’s. Ordered by the ids as given, then by each offer’s `Order`, so a caller', + 'gets its own batch back in its own order with each shop in shop order.', + '', + 'An id naming no currency contributes nothing, the way the other batch lookups here', + 'treat an unknown id: a shop that does not exist is an empty one, not an error for', + 'the whole call. No `ids` at all is an empty list.', + '', + 'Public, like `GET /api/roomcurrencies/v1/currencies`. Nothing writes offers yet, so', + 'every currency answers empty until there is an endpoint that builds a shop.', + ].join('\n'), + parameters: [ + { + name: 'ids', + in: 'query', + required: false, + description: 'Currency ids — comma-separated, repeated, or both', + schema: { type: 'string' }, + }, + ], + responses: { + 200: json( + RoomCurrencyPurchaseOfferDto.array(), + 'The offers, flattened across the currencies asked for' + ), + }, + }), + async (c) => { + // Both spellings, since nothing says which the client uses: `ids=a&ids=b` and + // `ids=a,b` reach the same list, and blanks from a trailing comma are dropped. + const ids = c.req + .queries('ids') + ?.flatMap((value) => value.split(',')) + .map((value) => value.trim()) + .filter((value) => value !== '') + + // De-duplicated: an id sent twice is one shop, not the same shop twice. + return c.json(await getPurchaseOffers(c.env.DB, [...new Set(ids ?? [])])) + } + ) + + // Add a purchase offer to a room currency's shop. Auth-gated (401) and gated to the + // creator or a co-owner of the room that minted the currency (403) — a shop is the room's + // to build, and the room comes off the currency rather than the request. + .post( + '/api/roomcurrencies/v1/createPurchaseOffer', + describeRoute({ + tags: ['Econ'], + summary: 'Add a purchase offer to a room currency', + description: [ + 'Appends an offer to the currency named by `CurrencyId` — a way for players to buy', + 'that currency ("5 SuperTokens for 500 Rec Center Tokens"). Form-encoded', + '(`CurrencyId=…&Name=…&Amount=5&Price=55&Order=0`).', + '', + 'Note `Amount` in the body against `CurrencyAmount` on the offer it becomes: the', + 'body and the client’s model spell the same number differently, and the answer uses', + 'the model’s spelling.', + '', + '`CurrencyPurchaseOfferId` and `ModifiedAt` are minted here — the offer’s id is how', + 'the client names it afterwards. `Order` places it in the shop and defaults to 0;', + 'offers are served sorted by it, so several at 0 keep the order they were added in.', + '', + 'Gated to the creator or a co-owner of the room that minted the currency. A valid', + 'token from anyone else is a 403. Answers the same', + '`{ Value, Success, Error, error_id }` envelope the currency writes use, with the', + 'offer in `Value`, or a 200 carrying `Success: false` when the currency is unknown', + 'or the body unusable.', + ].join('\n'), + security: AUTHED, + requestBody: form(CreatePurchaseOfferRequest, 'The offer to add'), + responses: { + 200: json( + RoomCurrencyPurchaseOfferEnvelope, + 'The offer as created, or a rejection with `Success: false`' + ), + 401: UNAUTHORIZED_RESPONSE, + 403: { description: 'Not the creator or a co-owner of the minting room (empty body)' }, + }, + }), + async (c) => { + const accountId = await authedId(c) + if (accountId === null) return unauthorized(c) + + const body = (await c.req.parseBody().catch(() => ({}))) as Record + const str = (v: unknown): string => (typeof v === 'string' ? v : '') + const int = (v: unknown, fallback: number): number => { + const parsed = Number.parseInt(str(v), 10) + return Number.isInteger(parsed) ? parsed : fallback + } + + const currencyId = str(body.CurrencyId).trim() + if (currencyId === '') return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED) + + const amount = int(body.Amount, Number.NaN) + const price = int(body.Price, Number.NaN) + if (!Number.isInteger(amount) || !Number.isInteger(price)) { + return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED) + } + + const currency = await getRoomCurrency(c.env.DB, currencyId) + if (!currency) return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED) + + // The room comes off the CURRENCY, never the request — the same gate minting and + // editing one take. A shop is the room's to build. + const canManage = await canManageRoomById(c.env.DB, currency.RoomId, accountId) + if (canManage === null) return purchaseOfferEnvelope(c, null, CREATE_OFFER_FAILED) + if (!canManage) return c.body(null, 403) + + const offer = await createPurchaseOffer(c.env.DB, currencyId, { + // The body's `Amount` is the model's `CurrencyAmount`. + CurrencyAmount: amount, + Price: price, + // Stored verbatim, NOT profanity-masked like a currency's name: the client has + // been seen sending a GUID here, and the mask works on substrings — it would + // happily corrupt an identifier that happened to contain a word. If this turns + // out to be player-typed display text, it wants masking. + Name: str(body.Name), + Order: int(body.Order, 0), + }) + return purchaseOfferEnvelope(c, offer) + } ) .get('/api/roomcurrencies/v1/getAllBalances', listRoute('Room balances', 'Empty stub'), (c) => c.json([]) diff --git a/apps/econ/src/openapi.ts b/apps/econ/src/openapi.ts index 64aae09..0c995f5 100644 --- a/apps/econ/src/openapi.ts +++ b/apps/econ/src/openapi.ts @@ -554,6 +554,187 @@ export const LockedItemsBulkRequest = z.object({ export const ErrorResponse = z.object({ error: z.string() }) +/** + * A room currency — a custom currency minted for use inside one room. The client's own model, + * member for member and in its order (see the `notify` worker's `RoomCurrencyPayload`, which + * the `RoomCurrencyCreated`/`Modified` frames carry: the create response and those frames are + * the same object). `CurrencyType` is always 300 (RoomCurrency) — a room currency is told + * apart from the others by its `CurrencyId`, not by its type. + */ +export const RoomCurrencyDto = z.object({ + CurrencyId: z.string().describe('GUID — how the client names this currency afterwards'), + RoomId: z.int(), + Name: z.string(), + Description: z.string(), + CurrencyType: z + .literal(300) + .describe('Always 300 (RoomCurrency) — the same enum `buyInvention` prices in'), + Limit: z + .int() + .describe( + 'The most the room may award PER DAY — a faucet rate, not a holding cap. Stored and ' + + 'served, but not enforced: nothing tracks a day’s awards yet' + ), + Shape: z.int().describe('A byte — the client’s index into its coin shapes; uninterpreted'), + Color: z + .int() + .describe('The client’s index into its coin colours, signed — it sends -1 for none'), + ImageName: z.string().nullable().describe('Null — custom coin art is not uploadable yet'), + CreatedAt: z.string().describe('ISO-8601 UTC'), + ModifiedAt: z.string().describe('ISO-8601 UTC; equal to `CreatedAt` until an edit'), +}) + +/** + * The envelope `POST /api/roomcurrencies/v1/createCurrency` answers in. The client unwraps it + * and hands its callers `Value` alone, so the envelope exists on the wire but never reaches + * the code that uses the currency. + * + * PascalCase `Value`/`Success`/`Error` beside a lowercase `error_id` — the same mixed casing + * the client's other envelopes use (see the `rooms` worker's isBanned and publish-state + * envelopes). NOT the lowercase `{ success, error, value }` the room mutations answer with; + * the two live in different workers and must not be unified. + */ +export const RoomCurrencyEnvelope = z.object({ + Value: RoomCurrencyDto.nullable().describe('The currency, or null on a failure'), + Success: z.boolean(), + Error: z.string().nullable().describe('Null on success; the failure message otherwise'), + error_id: z.null().describe('Always null. Present as a key, and lowercase'), +}) + +/** `POST /api/roomcurrencies/v1/createCurrency` — form-encoded. */ +export const CreateRoomCurrencyRequest = z.object({ + RoomId: z.string().describe('The room the currency belongs to'), + Name: z.string().describe('Shown to players; profanity-masked like every typed string'), + Description: z.string().optional().describe('Defaults to empty'), + Limit: z + .string() + .optional() + .describe('The most the room may award per day; defaults to 0. Not enforced yet'), + Shape: z.string().optional().describe('Coin shape index; defaults to 0'), + Color: z.string().optional().describe('Coin colour index; defaults to 0'), +}) + +/** + * One purchase offer on a room currency — a way to BUY that currency, priced in another + * ("5 SuperTokens for 500 Rec Center Tokens"). The client's own model, member for member and + * in its order. + */ +export const RoomCurrencyPurchaseOfferDto = z.object({ + CurrencyPurchaseOfferId: z.string().describe('GUID — names this offer'), + CurrencyId: z.string().describe('GUID of the currency being SOLD'), + Order: z.int().describe('Where it sits in the currency’s shop; the list is sorted by it'), + Name: z.string(), + CurrencyAmount: z.int().describe('How much of the currency the offer hands over'), + Price: z.int().describe('What it costs'), + ModifiedAt: z.string().describe('ISO-8601 UTC'), +}) + +/** + * 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. + * + * Grouped rather than flat even though every offer already carries its own `CurrencyId`: the + * group is what says a currency was ASKED ABOUT, which a flat list cannot. A currency with no + * shop still gets a group with an empty `PurchaseOffers`. + */ +export const RoomCurrencyPurchaseOffersDto = z.object({ + CurrencyId: z.string(), + PurchaseOffers: z + .array(RoomCurrencyPurchaseOfferDto) + .describe('Sorted by `Order`; empty for a currency that sells nothing yet'), +}) + +/** + * `POST /api/roomcurrencies/v1/createPurchaseOffer` — form-encoded. + * + * Note `Amount` here against `CurrencyAmount` on the offer it becomes: the body and the model + * spell the same number differently, and the route maps between them rather than serving the + * body’s spelling back. + */ +export const CreatePurchaseOfferRequest = z.object({ + CurrencyId: z.string().describe('The currency this offer sells'), + Name: z.string().describe('The offer’s name; the client has been seen sending a GUID'), + Amount: z.string().describe('How much of the currency the offer hands over (`CurrencyAmount`)'), + Price: z.string().describe('What it costs'), + Order: z.string().optional().describe('Where it sits in the shop; defaults to 0'), +}) + +/** + * The envelope the create-offer endpoint answers in — the same + * `{ Value, Success, Error, error_id }` the currency writes use, with the offer in `Value`. + */ +export const RoomCurrencyPurchaseOfferEnvelope = z.object({ + Value: RoomCurrencyPurchaseOfferDto.nullable(), + Success: z.boolean(), + Error: z.string().nullable(), + error_id: z.null(), +}) + +/** + * One entry of `POST /api/roomcurrencies/v1/awardCurrency/bulk` — a JSON ARRAY of these, + * not an object wrapping one. Each names its own currency and recipient, so a single call can + * pay several players, or one player in several currencies. + */ +export const AwardRoomCurrencyRequest = z.object({ + CurrencyId: z.string().describe('The room currency being awarded'), + RecipientId: z.int().describe('The player receiving it'), + Amount: z.int().describe('How much to add; negative deducts, and a balance floors at 0'), + TransactionId: z + .string() + .optional() + .describe( + 'The client’s id for this award, so a retry is the same award. NOT YET DEDUPLICATED — ' + + 'accepted and validated, but a replay currently awards twice' + ), +}) + +/** What one award actually did, on the result that reports it. */ +export const AwardRoomCurrencyResponse = z.object({ + AccountId: z.int().describe('The player paid'), + CurrencyId: z.string(), + Balance: z.int().describe('The RESULTING total, never the change'), + AmountAwarded: z + .int() + .describe( + 'What actually landed — less than asked for when the `Limit` or the zero floor clamped it' + ), + AwardedAt: z.string().describe('ISO-8601 UTC'), +}) + +/** + * One entry's result from `POST /api/roomcurrencies/v1/awardCurrency/bulk`, in the order sent. + * + * Success is PER ENTRY: `Success`/`Error` are this entry's alone and `Response` is null when + * it failed, so one bad entry neither fails the others nor the call. That includes an entry + * whose currency belongs to a room the caller cannot manage — a rejection here rather than a + * 403 over everything. `AccountId` and `CurrencyId` are repeated outside `Response` so a + * caller can line a failure up with what it sent, where there is no `Response` to read them + * from. + */ +export const AwardRoomCurrencyResult = z.object({ + AccountId: z.int(), + CurrencyId: z.string(), + Success: z.boolean(), + Error: z.string().nullable().describe('Null on success'), + Response: AwardRoomCurrencyResponse.nullable().describe('Null when this entry failed'), +}) + +export const AwardRoomCurrencyResultList = z.array(AwardRoomCurrencyResult) + +/** + * `POST /api/roomcurrencies/v1/updateCurrency` — form-encoded. Names the currency by id + * rather than by room: the room it belongs to is whichever one minted it, and an edit cannot + * move it. Every field but `CurrencyId` is optional and an absent one is left as it was. + */ +export const UpdateRoomCurrencyRequest = z.object({ + CurrencyId: z.string().describe('The currency to edit, as the create response named it'), + Name: z.string().optional().describe('Profanity-masked like every typed string'), + Description: z.string().optional(), + Limit: z.string().optional().describe('The most the room may award per day. Not enforced yet'), + Shape: z.string().optional().describe('Coin shape index'), + Color: z.string().optional().describe('Coin colour index, signed'), +}) + // ---- Request schemas ------------------------------------------------------- /** diff --git a/apps/econ/src/room-currency-db.ts b/apps/econ/src/room-currency-db.ts new file mode 100644 index 0000000..eda549d --- /dev/null +++ b/apps/econ/src/room-currency-db.ts @@ -0,0 +1,549 @@ +/** + * Room currencies — a room's own money. + * + * A creator mints one for their room ("MySpecialTokens"), hands it out for doing things in + * there, and sells things for it: finish the obstacle course, earn 50 tokens, spend them on a + * custom shirt at the shop by the door. It is play money for one room, and it means nothing + * outside it. + * + * This module is the CURRENCY ITSELF — its name, its coin art, how fast the room may hand it + * out. What a player has actually earned is a separate thing, in `room_balance` further down. + * The endpoints: + * - `POST /api/roomcurrencies/v1/createCurrency` mints one + * - `POST /api/roomcurrencies/v1/updateCurrency` edits it + * - `GET /api/roomcurrencies/v1/currencies?roomId=` lists a room's + * - `POST /api/roomcurrencies/v1/awardCurrency/bulk` pays players + * - the purchase offers below are the shop that sells the currency for other money + * + * Two things to know before touching it: + * + * These must never reach the `balance` table, which is keyed by `(account, currency_type)` + * alone. That key cannot say WHICH room's currency a row is, so one room's tokens would spend + * in every other room — see `CurrencyType` in balance-db.ts, where `RoomCurrency` is left out + * of the spendable set for exactly this reason. + * + * And every currency here carries `CurrencyType.RoomCurrency` (300), because that is what the + * type MEANS: "some room's currency". One is told apart from another by its own `CurrencyId`, + * never by its type — so the type is a constant on the DTO rather than a column. + * + * This worker (`econ`) owns the table and its migrations — see apps/econ/migrations/, 0017, + * 0018 and 0020. + */ + +import { CurrencyType } from './balance-db' + +/** + * Schema DDL — also builds the table in tests. Mirrors the table as it STANDS: 0017 created + * it, 0018 made `image_name` nullable and 0020 added `purchase_offers`, so this is the three + * of them together rather than any one file. + */ +export const ROOM_CURRENCY_SCHEMA_DDL: string[] = [ + // `currency_id` is a GUID rather than an autoincrement: it is how the client names this + // currency everywhere afterwards (its balances, its storefront prices), and a room's + // currencies are created independently of every other room's, so an id that says nothing + // about ordering is the honest one. + // + // `limit_amount`, not `limit` — `LIMIT` is a SQL keyword, and a column that has to be + // quoted at every use is a column that will eventually not be. It is the most of this + // currency the room may award PER DAY, a faucet rate rather than a cap on what a player + // may hold, and nothing enforces it yet: no day's awards are tracked anywhere. + // + // `shape` and `color` are the client's own indices into its coin art (the body sends + // `Shape=0&Color=19`); nothing here interprets them, they are stored and served back. + // `color` is signed — the client sends -1 for "no colour chosen". + // + // `image_name` is separate and NULLABLE, not empty-string: the client serves null for a + // currency with no custom coin art, and null is what its model carries. It stays null + // until a room can upload art — the column is here rather than in a later migration + // because the client's model has the member either way. + // + // `purchase_offers` is the currency's shop, as a JSON array (see + // {@link RoomCurrencyPurchaseOffer}). A column rather than a table because offers are + // read, written and replaced as a WHOLE, for one currency at a time. + `CREATE TABLE IF NOT EXISTS room_currency ( + currency_id TEXT PRIMARY KEY, + room_id INTEGER NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + limit_amount INTEGER NOT NULL, + shape INTEGER NOT NULL, + color INTEGER NOT NULL, + image_name TEXT, + created_at TEXT NOT NULL, + modified_at TEXT NOT NULL, + purchase_offers TEXT + )`, + // Every read is "this room's currencies" — the whole access pattern. + `CREATE INDEX IF NOT EXISTS idx_room_currency_room ON room_currency (room_id)`, +] + +/** + * A room currency as the client reads it — its own model, member for member and IN ITS + * ORDER, taken from the client's field layout rather than inferred. The same object the + * create endpoint answers with and the `RoomCurrencyCreated`/`Modified` frames carry, which + * is why `econ.app.ts` assigns one of these straight to a `RoomCurrencyPayload`: that + * assignment is what stops the two drifting apart. + * + * `CurrencyType` is always {@link CurrencyType.RoomCurrency} — see the module doc. `RoomId` + * is `long?` in the client's model but is never null here: a room currency belongs to a + * room by definition, and the column is NOT NULL. + */ +export interface RoomCurrency { + CurrencyId: string + RoomId: number + Name: string + Description: string + CurrencyType: number + /** + * The most of this currency the room may award PER DAY — a faucet rate, not a ceiling on + * what a player may hold. Stored and served; nothing enforces it yet, because nothing + * tracks a day's awards. + */ + Limit: number + /** A byte in the client's model — its index into the coin shapes. */ + Shape: number + Color: number + /** Null for a currency with no custom coin art, which is every one of them so far. */ + ImageName: string | null + /** ISO-8601 UTC. */ + CreatedAt: string + /** ISO-8601 UTC. */ + ModifiedAt: string +} + +/** What the create endpoint supplies; the id and timestamps are minted here. */ +export interface NewRoomCurrency { + RoomId: number + Name: string + Description: string + Limit: number + Shape: number + Color: number +} + +interface RoomCurrencyRow { + currency_id: string + room_id: number + name: string + description: string + limit_amount: number + shape: number + color: number + image_name: string | null + created_at: string + modified_at: string +} + +const SELECT_COLUMNS = `currency_id, room_id, name, description, limit_amount, shape, color, image_name, created_at, modified_at` + +const toRoomCurrency = (row: RoomCurrencyRow): RoomCurrency => ({ + CurrencyId: row.currency_id, + RoomId: row.room_id, + Name: row.name, + Description: row.description, + CurrencyType: CurrencyType.RoomCurrency, + Limit: row.limit_amount, + Shape: row.shape, + Color: row.color, + ImageName: row.image_name, + CreatedAt: row.created_at, + ModifiedAt: row.modified_at, +}) + +/** + * Mint a room currency, returning it as the client reads it back. + * + * `CreatedAt` and `ModifiedAt` start equal — the client's model carries both and an edit + * will move only the second, so a never-edited currency reads as created-and-untouched + * rather than as having no modification time at all. + */ +export async function createRoomCurrency( + db: D1Database, + currency: NewRoomCurrency +): Promise { + const now = new Date().toISOString() + const row = await db + .prepare( + `INSERT INTO room_currency + (currency_id, room_id, name, description, limit_amount, shape, color, image_name, created_at, modified_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?8) + RETURNING ${SELECT_COLUMNS}` + ) + .bind( + crypto.randomUUID(), + currency.RoomId, + currency.Name, + currency.Description, + currency.Limit, + currency.Shape, + currency.Color, + now + ) + .first() + + return toRoomCurrency(row!) +} + +/** One currency by its id, or null when there is no such row. */ +export async function getRoomCurrency( + db: D1Database, + currencyId: string +): Promise { + const row = await db + .prepare(`SELECT ${SELECT_COLUMNS} FROM room_currency WHERE currency_id = ?1`) + .bind(currencyId) + .first() + + return row ? toRoomCurrency(row) : null +} + +/** + * What an update may change. Everything else about a currency is fixed: `CurrencyId` names + * it, `RoomId` is the room that minted it (moving one between rooms would strand whatever + * players hold of it), `CurrencyType` is what it IS, and `CreatedAt` already happened. + * + * Every field is optional and an absent one is LEFT ALONE rather than reset. The client sends + * the whole form, so this only bites a partial request — and there, blanking a description + * because it wasn't mentioned is the worse of the two readings. + */ +export interface RoomCurrencyEdit { + Name?: string + Description?: string + Limit?: number + Shape?: number + Color?: number +} + +/** + * Apply an edit to a currency, returning it as the client reads it back. + * + * `ModifiedAt` moves; `CreatedAt` does not. That is the whole point of the client's model + * carrying both, and it is why the two start equal on a currency that has never been edited. + * + * The caller supplies the already-loaded currency (after its room's owner check) both to + * avoid a re-read and because the merge needs the current values to leave absent fields alone. + */ +export async function updateRoomCurrency( + db: D1Database, + currency: RoomCurrency, + edit: RoomCurrencyEdit +): Promise { + const merged: RoomCurrency = { + ...currency, + Name: edit.Name ?? currency.Name, + Description: edit.Description ?? currency.Description, + Limit: edit.Limit ?? currency.Limit, + Shape: edit.Shape ?? currency.Shape, + Color: edit.Color ?? currency.Color, + ModifiedAt: new Date().toISOString(), + } + + await db + .prepare( + `UPDATE room_currency + SET name = ?2, description = ?3, limit_amount = ?4, shape = ?5, color = ?6, modified_at = ?7 + WHERE currency_id = ?1` + ) + .bind( + merged.CurrencyId, + merged.Name, + merged.Description, + merged.Limit, + merged.Shape, + merged.Color, + merged.ModifiedAt + ) + .run() + + return merged +} + +/** + * Every currency a room has minted, oldest first — the order they were created in, which is + * the order the room's owner built them up in and so the one they expect to see. + */ +export async function getRoomCurrencies(db: D1Database, roomId: number): Promise { + const { results } = await db + .prepare( + `SELECT ${SELECT_COLUMNS} FROM room_currency WHERE room_id = ?1 ORDER BY created_at, currency_id` + ) + .bind(roomId) + .all() + + return results.map(toRoomCurrency) +} + +/** + * One purchase offer on a room currency — a way to BUY that currency with other money, rather + * than earning it. A room that sells its tokens rather than only handing them out lists them + * here: "5 MySpecialTokens for 500 Rec Center Tokens". The client's own model, member for + * member and in its order. + * + * `CurrencyId` names the currency being SOLD, so an offer knows its own shop. It is not part + * of what gets stored: the row the offers live on already says which currency they belong to, + * and {@link getPurchaseOffers} projects it back in. `Price` is in whatever currency the room + * charges in, which nothing here records yet — the client's model has no field for it either. + */ +export interface RoomCurrencyPurchaseOffer { + CurrencyPurchaseOfferId: string + CurrencyId: string + Order: number + Name: string + CurrencyAmount: number + Price: number + /** ISO-8601 UTC. */ + ModifiedAt: string +} + +/** + * An offer as it is STORED in `room_currency.purchase_offers` — everything above except + * `CurrencyId`, which the owning row already says. + */ +export type StoredPurchaseOffer = Omit + +/** + * Read a JSON offers column into whole offers, stamping each with the currency it belongs to. + * + * Null, an empty column and anything that doesn't parse as an array are all "no offers" — a + * currency nobody has built a shop for simply has none, and a column that has somehow been + * corrupted should show an empty shop rather than fail the whole batch. + */ +function parsePurchaseOffers(currencyId: string, json: string | null): RoomCurrencyPurchaseOffer[] { + if (!json) return [] + let parsed: unknown + try { + parsed = JSON.parse(json) + } catch { + return [] + } + if (!Array.isArray(parsed)) return [] + + return (parsed as StoredPurchaseOffer[]).map((offer) => ({ + CurrencyPurchaseOfferId: String(offer.CurrencyPurchaseOfferId ?? ''), + CurrencyId: currencyId, + Order: Number(offer.Order ?? 0), + Name: String(offer.Name ?? ''), + CurrencyAmount: Number(offer.CurrencyAmount ?? 0), + Price: Number(offer.Price ?? 0), + ModifiedAt: String(offer.ModifiedAt ?? ''), + })) +} + +/** What the create-offer endpoint supplies; the id and `ModifiedAt` are minted here. */ +export interface NewPurchaseOffer { + Name: string + CurrencyAmount: number + Price: number + Order: number +} + +/** + * Append a purchase offer to a currency's shop, returning it as the client reads it back. + * + * Appended IN SQL, with `json_insert(…, '$[#]', …)`, rather than read-modify-written here: + * two offers added at once would otherwise race, and the second read would overwrite the + * first's append with a list that never had it. `COALESCE(…, '[]')` covers the null column a + * currency starts life with, so the first offer needs no separate "create the array" step. + * + * The stored object deliberately omits `CurrencyId` — the row it lands on already says which + * currency these are offers for (see {@link StoredPurchaseOffer}) — but the returned offer + * carries it, because the client's model does. + */ +export async function createPurchaseOffer( + db: D1Database, + currencyId: string, + offer: NewPurchaseOffer +): Promise { + const stored: StoredPurchaseOffer = { + CurrencyPurchaseOfferId: crypto.randomUUID(), + Order: offer.Order, + Name: offer.Name, + CurrencyAmount: offer.CurrencyAmount, + Price: offer.Price, + ModifiedAt: new Date().toISOString(), + } + + await db + .prepare( + `UPDATE room_currency + SET purchase_offers = json_insert(COALESCE(purchase_offers, '[]'), '$[#]', json(?2)) + WHERE currency_id = ?1` + ) + .bind(currencyId, JSON.stringify(stored)) + .run() + + // Built out member by member rather than spread: the client's model puts `CurrencyId` + // SECOND, and a spread would land it at the end. + return { + CurrencyPurchaseOfferId: stored.CurrencyPurchaseOfferId, + CurrencyId: currencyId, + Order: stored.Order, + Name: stored.Name, + CurrencyAmount: stored.CurrencyAmount, + Price: stored.Price, + ModifiedAt: stored.ModifiedAt, + } +} + +/** One currency's shop — the group the batch read answers in, one per currency asked for. */ +export interface RoomCurrencyPurchaseOffers { + CurrencyId: string + PurchaseOffers: RoomCurrencyPurchaseOffer[] +} + +/** + * The purchase offers of several currencies at once, GROUPED BY CURRENCY — what + * `getPurchaseOffersBatch` serves. + * + * Grouped rather than flat even though every offer already carries its own `CurrencyId`: the + * group is what says a currency was ASKED ABOUT, which a flat list cannot. A currency with no + * shop yet still gets a group, with an empty `PurchaseOffers` — "this currency sells nothing" + * and "you didn't ask about this currency" are different answers. + * + * Groups come back in the order the ids were given, each shop sorted by its offers' `Order`, + * so a caller gets its own batch back in its own order. An id naming no currency at all is + * omitted, the way the other batch lookups here treat an unknown id — there is no shop to + * report, empty or otherwise. + */ +export async function getPurchaseOffers( + db: D1Database, + currencyIds: string[] +): Promise { + if (currencyIds.length === 0) return [] + + const placeholders = currencyIds.map((_, i) => `?${i + 1}`).join(', ') + const { results } = await db + .prepare( + `SELECT currency_id, purchase_offers FROM room_currency WHERE currency_id IN (${placeholders})` + ) + .bind(...currencyIds) + .all<{ currency_id: string; purchase_offers: string | null }>() + + const byId = new Map(results.map((r) => [r.currency_id, r.purchase_offers])) + + return currencyIds + .filter((currencyId) => byId.has(currencyId)) + .map((currencyId) => ({ + CurrencyId: currencyId, + PurchaseOffers: parsePurchaseOffers(currencyId, byId.get(currencyId) ?? null).sort( + (a, b) => a.Order - b.Order + ), + })) +} + +/** + * How much of a room's own money each player has earned — the other half of the feature, the + * currencies themselves being above. + * + * Keyed by CURRENCY, not by currency type, and that difference is the whole point: the + * account-scoped `balance` table's `(account, currency_type)` key cannot say which room's + * currency a row is, so one room's tokens would spend in every other room. Room currencies + * are left out of that table for exactly this reason (see `CurrencyType` in balance-db.ts). + * + * A missing row is a balance of zero. Nothing is inserted until a player is first paid, so a + * room's currency costs a row per player who has actually earned some rather than one per + * player who has walked in. + */ +export const ROOM_BALANCE_SCHEMA_DDL: string[] = [ + `CREATE TABLE IF NOT EXISTS room_balance ( + currency_id TEXT NOT NULL, + player_id INTEGER NOT NULL, + amount INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (currency_id, player_id) + )`, + // "What does this player hold across the room?" walks the currencies, so the reverse + // lookup earns its own index rather than a scan of every holder of every currency. + `CREATE INDEX IF NOT EXISTS idx_room_balance_player ON room_balance (player_id)`, +] + +/** One player's holding of one room currency. */ +export interface RoomBalance { + CurrencyId: string + PlayerId: number + Amount: number +} + +/** What an award did: the resulting total, and how much actually landed. */ +export interface RoomAwardResult { + /** The RESULTING total, never the change. */ + Balance: number + /** + * What was actually added. Equal to the delta except where the zero floor bit — a + * deduction larger than the balance takes it to zero, not below, and this reports the part + * that had somewhere to come from. + */ + AmountAwarded: number +} + +/** + * Move a player's holding of a room currency by `delta`, returning what they hold afterwards + * and how much of the delta actually landed. + * + * Floored at zero in the statement itself, so a deduction and a concurrent award can't race + * a balance negative between a read and a write: a negative `delta` is a deduction, and a + * player cannot owe a room currency. + * + * NOT capped at the currency's `Limit`. `Limit` is the most of a currency a room may award + * PER DAY — a faucet rate, not a ceiling on what a player may hold — and nothing tracks a + * day's awards yet, so it is stored and served but enforced nowhere. Clamping a balance to it + * (as this once did) silently refuses awards a player was entitled to: a `Limit` of 3 left + * everyone stuck at 3 coins forever. + * + * The before-reading, the write and the after-reading go in ONE `batch`, which D1 runs as a + * transaction, so nothing can change between the two reads but the write between them. + */ +export async function awardRoomCurrency( + db: D1Database, + currencyId: string, + playerId: number, + delta: number +): Promise { + const read = db.prepare( + 'SELECT amount FROM room_balance WHERE currency_id = ?1 AND player_id = ?2' + ) + const [before, , after] = await db.batch<{ amount: number }>([ + read.bind(currencyId, playerId), + db + .prepare( + `INSERT INTO room_balance (currency_id, player_id, amount) + VALUES (?1, ?2, MAX(0, ?3)) + ON CONFLICT (currency_id, player_id) + DO UPDATE SET amount = MAX(0, room_balance.amount + ?3)` + ) + .bind(currencyId, playerId, delta), + read.bind(currencyId, playerId), + ]) + + // Read BACK rather than taken from a `RETURNING` on the upsert. The two differ in how they + // fail: a RETURNING that came back empty is indistinguishable from an award that changed + // nothing, so a write that didn't land would report `AmountAwarded: 0` on a balance that + // had in fact moved — a plausible-looking answer that is simply wrong. A read is either a + // row or no row, and no row is a real, correct zero. + const previous = before.results[0]?.amount ?? 0 + const balance = after.results[0]?.amount ?? 0 + return { Balance: balance, AmountAwarded: balance - previous } +} + +/** What a player holds of each of a room's currencies. A currency they've never earned is absent. */ +export async function getRoomBalances( + db: D1Database, + roomId: number, + playerId: number +): Promise { + const { results } = await db + .prepare( + `SELECT b.currency_id AS currencyId, b.player_id AS playerId, b.amount AS amount + FROM room_balance AS b + JOIN room_currency AS c ON c.currency_id = b.currency_id + WHERE c.room_id = ?1 AND b.player_id = ?2 + ORDER BY c.created_at, c.currency_id` + ) + .bind(roomId, playerId) + .all<{ currencyId: string; playerId: number; amount: number }>() + + return results.map((r) => ({ + CurrencyId: r.currencyId, + PlayerId: r.playerId, + Amount: r.amount, + })) +} diff --git a/apps/econ/src/test/integration/api.test.ts b/apps/econ/src/test/integration/api.test.ts index 95b5c2e..a838a71 100644 --- a/apps/econ/src/test/integration/api.test.ts +++ b/apps/econ/src/test/integration/api.test.ts @@ -9,8 +9,10 @@ import { getProgression, INVENTORY_INVENTION_SCHEMA_DDL, OUTFIT_SCHEMA_DDL, + PRESENCE_SCHEMA_DDL, PROGRESSION_SCHEMA_DDL, RECEIVED_GIFT_SCHEMA_DDL, + ROOM_SCHEMA_DDL, } from '@repo/domain' // The `invention` table belongs to the `api` worker; buyInvention reads it, so its DDL @@ -71,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_BALANCE_SCHEMA_DDL, ROOM_CURRENCY_SCHEMA_DDL } from '../../room-currency-db' import type { CatalogLoadRow, CatalogRow, CatalogValue } from '../../catalog-db' import type { Env } from '../../context' @@ -158,6 +161,29 @@ beforeAll(async () => { for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of CUSTOM_AVATAR_ITEM_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_BALANCE_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() + // The rooms table (owned by the `rooms` worker) — creating a room currency reads the room + // blob to apply its creator/co-owner check (the blob alone: `canManageRoomById` skips the + // hydration that would drag in subrooms/tags/stats). Room 2511 is account 1's, with 2 as + // co-owner. + for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run() + await env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)') + .bind( + JSON.stringify({ + RoomId: 2511, + Name: 'CurrencyRoom', + CreatorAccountId: 1, + Roles: [ + { AccountId: 1, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 }, + { AccountId: 2, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 }, + ], + }) + ) + .run() // A few equipment skins, which is where the WEEKLY CHALLENGE gift pool comes from now that // skins are awarded rather than sold and no storefront lists one. Without these the pool is // empty and the week has nothing to be themed on. @@ -753,10 +779,688 @@ describe('econ endpoints', () => { expect(await res.json()).toEqual([]) }) - test('GET /api/roomcurrencies/v1/currencies returns []', async () => { + test('GET /api/roomcurrencies/v1/currencies returns [] for a room with none', async () => { const res = await exports.default.fetch(`${ORIGIN}/api/roomcurrencies/v1/currencies?roomId=1`) expect(res.status).toBe(200) expect(await res.json()).toEqual([]) + + // An unknown room and a missing roomId read the same way — the client takes any of + // them as "this room has no currency of its own". + for (const query of ['?roomId=99999', '', '?roomId=nope']) { + const other = await exports.default.fetch( + `${ORIGIN}/api/roomcurrencies/v1/currencies${query}` + ) + expect(other.status).toBe(200) + expect(await other.json()).toEqual([]) + } + }) + + // Room 2511 is seeded as account 1's, with account 2 as co-owner. + describe('room currencies', () => { + type RoomCurrency = { + CurrencyId: string + RoomId: number + Name: string + Description: string + CurrencyType: number + Limit: number + Shape: number + Color: number + ImageName: string | null + CreatedAt: string + ModifiedAt: string + } + + const create = async (fields: Record, headers?: Record) => + exports.default.fetch(`${ORIGIN}/api/roomcurrencies/v1/createCurrency`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers }, + body: new URLSearchParams(fields), + }) + + /** Everything the stub hub was pushed since the last call (see vitest.config). */ + const drainFrames = async (): Promise< + Array<{ + accountId: number + notificationType: string | number + payload: Record + }> + > => + ( + env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') as unknown as { + drainFrames(): Promise< + Array<{ + accountId: number + notificationType: string | number + payload: Record + }> + > + } + ).drainFrames() + + /** The `{ Value, Success, Error, error_id }` envelope the create endpoint answers in. */ + const envOf = async (res: Response) => + (await res.json()) as { + Value: RoomCurrency | null + Success: boolean + Error: string | null + error_id: null + } + + const currenciesOf = async (roomId: number) => + (await ( + await exports.default.fetch(`${ORIGIN}/api/roomcurrencies/v1/currencies?roomId=${roomId}`) + ).json()) as RoomCurrency[] + + const body = { + RoomId: '2511', + Name: 'Devintokens', + Description: 'this is my currency', + Limit: '100', + Shape: '0', + Color: '19', + } + + test('POST createCurrency is gated to the room’s creator or a co-owner', async () => { + // Only the auth gates answer with a status of their own: no token → 401, a valid + // token with no standing in the room → 403. + expect((await create(body)).status).toBe(401) + expect((await create(body, await bearer('999'))).status).toBe(403) + + // Everything else is a 200 carrying the failure envelope — an unknown room, an + // unusable RoomId, an empty Name. None of them writes anything. + for (const bad of [ + { ...body, RoomId: '99999' }, + { ...body, RoomId: 'nope' }, + { ...body, Name: ' ' }, + ]) { + const res = await create(bad, await bearer('1')) + expect(res.status).toBe(200) + expect(await envOf(res)).toEqual({ + Value: null, + Success: false, + Error: 'Failed to create currency', + error_id: null, + }) + } + expect(await currenciesOf(2511)).toEqual([]) + }) + + test('POST createCurrency mints the currency and the list reads it back', async () => { + const res = await create(body, await bearer('1')) + expect(res.status).toBe(200) + + // Enveloped: the client unwraps it and hands its callers `Value` alone. + const env = await envOf(res) + expect(env).toMatchObject({ Success: true, Error: null, error_id: null }) + const created = env.Value! + // The client's own model, member for member and in its order. + expect(Object.keys(created)).toEqual([ + 'CurrencyId', + 'RoomId', + 'Name', + 'Description', + 'CurrencyType', + 'Limit', + 'Shape', + 'Color', + 'ImageName', + 'CreatedAt', + 'ModifiedAt', + ]) + expect(created).toMatchObject({ + RoomId: 2511, + Name: 'Devintokens', + Description: 'this is my currency', + // A room currency is told apart by its id, not its type — the type is always 300. + CurrencyType: 300, + Limit: 100, + Shape: 0, + Color: 19, + // Null until custom coin art can be uploaded — null, not empty string. + ImageName: null, + }) + // A GUID of its own, and timestamps that start equal. + expect(created.CurrencyId).toMatch(/^[0-9a-f-]{36}$/) + expect(created.ModifiedAt).toBe(created.CreatedAt) + expect(Date.parse(created.CreatedAt)).not.toBeNaN() + + // The list serves it back, and it belongs to this room alone. + expect(await currenciesOf(2511)).toEqual([created]) + expect(await currenciesOf(1)).toEqual([]) + + // A co-owner may mint one too, and a room can hold several — oldest first. + const second = await create({ ...body, Name: 'Coowner coins', Limit: '5' }, await bearer('2')) + expect(second.status).toBe(200) + const list = await currenciesOf(2511) + expect(list.map((c) => c.Name)).toEqual(['Devintokens', 'Coowner coins']) + // Each carries its own id — two currencies in one room are not one currency. + expect(new Set(list.map((c) => c.CurrencyId)).size).toBe(2) + expect(list[1]).toMatchObject({ Limit: 5, CurrencyType: 300 }) + }) + + test('POST updateCurrency edits it, gated by the room that minted it', async () => { + const made = (await envOf(await create({ ...body, Name: 'Before' }, await bearer('1')))) + .Value! + + const update = async (fields: Record, headers?: Record) => + exports.default.fetch(`${ORIGIN}/api/roomcurrencies/v1/updateCurrency`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers }, + body: new URLSearchParams(fields), + }) + + // No token → 401. A valid token belonging to nobody in the room → 403: the room + // comes off the CURRENCY, so the caller can't point the gate somewhere friendlier. + expect((await update({ CurrencyId: made.CurrencyId, Name: 'X' })).status).toBe(401) + expect( + (await update({ CurrencyId: made.CurrencyId, Name: 'X' }, await bearer('999'))).status + ).toBe(403) + + // An unknown currency, a missing id and a blank name are the failure envelope — a + // name sent but empty is a bad edit, not "leave it alone". + const badEdits: Array> = [ + { CurrencyId: 'a3f1e8d2-0000-4000-8000-000000000000', Name: 'X' }, + { Name: 'X' }, + { CurrencyId: made.CurrencyId, Name: ' ' }, + ] + for (const bad of badEdits) { + const res = await update(bad, await bearer('1')) + expect(res.status).toBe(200) + expect(await envOf(res)).toEqual({ + Value: null, + Success: false, + Error: 'Failed to update currency', + error_id: null, + }) + } + // None of that touched it. + expect((await currenciesOf(2511)).find((cur) => cur.CurrencyId === made.CurrencyId)).toEqual( + made + ) + + // The owner edits every mutable field. + const res = await update( + { + CurrencyId: made.CurrencyId, + Name: 'After', + Description: 'edited', + Limit: '330', + Shape: '9', + Color: '5', + }, + await bearer('1') + ) + expect(res.status).toBe(200) + const updated = (await envOf(res)).Value! + expect(updated).toMatchObject({ + CurrencyId: made.CurrencyId, + Name: 'After', + Description: 'edited', + Limit: 330, + Shape: 9, + Color: 5, + // What an edit may never change. + RoomId: 2511, + CurrencyType: 300, + CreatedAt: made.CreatedAt, + }) + // `ModifiedAt` moves; `CreatedAt` doesn't. That is what the client carries both for. + expect(Date.parse(updated.ModifiedAt)).toBeGreaterThanOrEqual(Date.parse(made.CreatedAt)) + // Persisted, and still one row rather than a second currency. + const listed = (await currenciesOf(2511)).filter((cur) => cur.CurrencyId === made.CurrencyId) + expect(listed).toEqual([updated]) + + // A co-owner may edit too, and a field left out is left ALONE rather than reset. + const partial = await update({ CurrencyId: made.CurrencyId, Limit: '7' }, await bearer('2')) + expect(partial.status).toBe(200) + expect((await envOf(partial)).Value).toMatchObject({ + Limit: 7, + Name: 'After', + Description: 'edited', + Shape: 9, + Color: 5, + }) + + // And a name is masked on the way in, like it is on create. + const masked = await update( + { CurrencyId: made.CurrencyId, Name: 'shit bucks' }, + await bearer('1') + ) + expect((await envOf(masked)).Value?.Name).toBe('**** bucks') + }) + + test('POST updateCurrency pushes RoomCurrencyModified to the room and the editor', async () => { + const made = (await envOf(await create({ ...body, Name: 'Watched' }, await bearer('1')))) + .Value! + + const now = Math.floor(Date.now() / 1000) + await env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId: 320, + roomInstance: { roomInstanceId: 1002511, roomId: 2511, subRoomId: 2511 }, + expiresAt: now + 900, + }) + ) + .run() + await drainFrames() + + const res = await exports.default.fetch(`${ORIGIN}/api/roomcurrencies/v1/updateCurrency`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(await bearer('1')), + }, + body: new URLSearchParams({ CurrencyId: made.CurrencyId, Name: 'Renamed' }), + }) + expect(res.status).toBe(200) + + // An edit reaches the room, not just the editor — otherwise everyone else keeps + // spending the currency under its old name and cap. + const frames = await drainFrames() + expect(frames.map((f) => f.accountId).sort((a, b) => a - b)).toEqual([1, 320]) + expect( + frames.every((f) => f.notificationType === NotificationType.RoomCurrencyModified) + ).toBe(true) + expect(frames[0].payload).toMatchObject({ + CurrencyId: made.CurrencyId, + Name: 'Renamed', + RoomId: 2511, + CurrencyType: 300, + }) + + await env.DB.prepare('DELETE FROM presence WHERE account_id = 320').run() + }) + + test('POST awardCurrency/bulk credits players, reporting each entry on its own', async () => { + const coin = ( + await envOf(await create({ ...body, Name: 'Payable', Limit: '10' }, await bearer('1'))) + ).Value! + + type AwardResult = { + AccountId: number + CurrencyId: string + Success: boolean + Error: string | null + Response: { + AccountId: number + CurrencyId: string + Balance: number + AmountAwarded: number + AwardedAt: string + } | null + } + const award = async (awards: unknown, headers?: Record) => + exports.default.fetch(`${ORIGIN}/api/roomcurrencies/v1/awardCurrency/bulk`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...headers }, + body: JSON.stringify(awards), + }) + const resultsOf = async (res: Response) => (await res.json()) as AwardResult[] + + const one = [ + { + CurrencyId: coin.CurrencyId, + RecipientId: 205, + Amount: 1, + TransactionId: '6f523936-7aa8-4660-b02e-cfd388902402', + }, + ] + + // Only the token is answered at the call level. + expect((await award(one)).status).toBe(401) + + // A caller with no standing in the minting room is refused PER ENTRY, not with a + // 403 over the whole call — and nothing is awarded. + const outsider = await award(one, await bearer('999')) + expect(outsider.status).toBe(200) + expect(await resultsOf(outsider)).toEqual([ + { + AccountId: 205, + CurrencyId: coin.CurrencyId, + Success: false, + Error: 'Not permitted', + Response: null, + }, + ]) + + // The owner awards: the result names the entry and carries what the award did. + const res = await award(one, await bearer('1')) + expect(res.status).toBe(200) + const results = await resultsOf(res) + expect(results).toHaveLength(1) + expect(results[0]).toMatchObject({ + AccountId: 205, + CurrencyId: coin.CurrencyId, + Success: true, + Error: null, + Response: { + AccountId: 205, + CurrencyId: coin.CurrencyId, + Balance: 1, + AmountAwarded: 1, + }, + }) + expect(Date.parse(results[0].Response!.AwardedAt)).not.toBeNaN() + expect(Object.keys(results[0])).toEqual([ + 'AccountId', + 'CurrencyId', + 'Success', + 'Error', + 'Response', + ]) + expect(Object.keys(results[0].Response!)).toEqual([ + 'AccountId', + 'CurrencyId', + 'Balance', + 'AmountAwarded', + 'AwardedAt', + ]) + + // Awards accumulate, and a co-owner may award too. + const more = await award( + [{ CurrencyId: coin.CurrencyId, RecipientId: 205, Amount: 4 }], + await bearer('2') + ) + expect((await resultsOf(more))[0].Response).toMatchObject({ Balance: 5, AmountAwarded: 4 }) + + // A mixed batch: the good entries land and the bad ones are reported in place, in + // the order sent, so nothing is lost and nothing is silently skipped. + const mixed = await award( + [ + { CurrencyId: coin.CurrencyId, RecipientId: 206, Amount: 2 }, + { CurrencyId: 'a3f1e8d2-0000-4000-8000-000000000000', RecipientId: 206, Amount: 5 }, + { CurrencyId: coin.CurrencyId, RecipientId: 'nope', Amount: 1 }, + { CurrencyId: coin.CurrencyId, RecipientId: 205, Amount: 1 }, + ], + await bearer('1') + ) + const mixedResults = await resultsOf(mixed) + expect(mixedResults.map((r) => r.Success)).toEqual([true, false, false, true]) + expect(mixedResults.map((r) => r.Error)).toEqual([ + null, + 'No such currency', + 'Invalid award', + null, + ]) + expect(mixedResults[0].Response).toMatchObject({ Balance: 2, AmountAwarded: 2 }) + // The two entries for 205 in this batch and the one before it ran in order. + expect(mixedResults[3].Response).toMatchObject({ Balance: 6, AmountAwarded: 1 }) + + // A body that is not a list of awards has no entries to report on. + for (const bad of [{ CurrencyId: coin.CurrencyId }, 'nope', null]) { + const res = await award(bad, await bearer('1')) + expect(res.status).toBe(200) + expect(await resultsOf(res)).toEqual([]) + } + expect(await resultsOf(await award([], await bearer('1')))).toEqual([]) + }) + + test('POST awardCurrency/bulk floors at zero and ignores the Limit', async () => { + // `Limit` is the most the room may award PER DAY — a faucet rate, not a ceiling on + // holdings — and nothing tracks a day's awards yet, so it must not bound a balance. + // It once did, which left every player of a Limit-3 currency stuck at 3 coins. + const capped = ( + await envOf(await create({ ...body, Name: 'Limited', Limit: '3' }, await bearer('1'))) + ).Value! + const award = async (currencyId: string, recipientId: number, amount: number) => + ( + (await ( + await exports.default.fetch(`${ORIGIN}/api/roomcurrencies/v1/awardCurrency/bulk`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(await bearer('1')), + }, + body: JSON.stringify([ + { CurrencyId: currencyId, RecipientId: recipientId, Amount: amount }, + ]), + }) + ).json()) as Array<{ Response: { Balance: number; AmountAwarded: number } }> + )[0].Response + const landed = async (currencyId: string, recipientId: number, amount: number) => { + const { Balance, AmountAwarded } = await award(currencyId, recipientId, amount) + return { Balance, AmountAwarded } + } + + // Straight past the `Limit` of 3, and on past it again. + expect(await landed(capped.CurrencyId, 210, 3)).toEqual({ Balance: 3, AmountAwarded: 3 }) + expect(await landed(capped.CurrencyId, 210, 5)).toEqual({ Balance: 8, AmountAwarded: 5 }) + expect(await landed(capped.CurrencyId, 210, 999)).toEqual({ + Balance: 1007, + AmountAwarded: 999, + }) + + // Negative deducts, and floors at zero — a player cannot owe a room currency, and + // `AmountAwarded` reports the part that had somewhere to come from. + expect(await landed(capped.CurrencyId, 211, 10)).toEqual({ Balance: 10, AmountAwarded: 10 }) + expect(await landed(capped.CurrencyId, 211, -3)).toEqual({ Balance: 7, AmountAwarded: -3 }) + expect(await landed(capped.CurrencyId, 211, -99)).toEqual({ Balance: 0, AmountAwarded: -7 }) + + // A `Limit` of 0 is no different from any other — it bounds nothing either way. + const unlimited = ( + await envOf(await create({ RoomId: '2511', Name: 'Unlimited' }, await bearer('1'))) + ).Value! + expect(await landed(unlimited.CurrencyId, 212, 50_000)).toEqual({ + Balance: 50_000, + AmountAwarded: 50_000, + }) + }) + + test('GET getPurchaseOffersBatch groups each currency’s shop', async () => { + type Offer = { + CurrencyPurchaseOfferId: string + CurrencyId: string + Order: number + Name: string + CurrencyAmount: number + Price: number + ModifiedAt: string + } + type Group = { CurrencyId: string; PurchaseOffers: Offer[] } + const groupsFor = async (query: string) => + (await ( + await exports.default.fetch( + `${ORIGIN}/api/roomcurrencies/v1/getPurchaseOffersBatch${query}` + ) + ).json()) as Group[] + + const shopped = (await envOf(await create({ ...body, Name: 'Shopped' }, await bearer('1')))) + .Value! + const alsoShopped = ( + await envOf(await create({ ...body, Name: 'AlsoShopped' }, await bearer('1'))) + ).Value! + const unshopped = ( + await envOf(await create({ ...body, Name: 'Unshopped' }, await bearer('1'))) + ).Value! + + // Seeded directly, deliberately out of `Order`, to prove the read sorts them. + const setOffers = (currencyId: string, offers: unknown[]) => + env.DB.prepare('UPDATE room_currency SET purchase_offers = ?2 WHERE currency_id = ?1') + .bind(currencyId, JSON.stringify(offers)) + .run() + + await setOffers(shopped.CurrencyId, [ + { + CurrencyPurchaseOfferId: 'ffffffff-0000-4000-8000-000000000002', + Order: 2, + Name: 'Handful', + CurrencyAmount: 5, + Price: 500, + ModifiedAt: '2026-09-14T12:00:00Z', + }, + { + CurrencyPurchaseOfferId: 'ffffffff-0000-4000-8000-000000000001', + Order: 1, + Name: 'Pile', + CurrencyAmount: 50, + Price: 4000, + ModifiedAt: '2026-09-14T12:00:00Z', + }, + ]) + await setOffers(alsoShopped.CurrencyId, [ + { + CurrencyPurchaseOfferId: 'ffffffff-0000-4000-8000-000000000003', + Order: 1, + Name: 'Single', + CurrencyAmount: 1, + Price: 100, + ModifiedAt: '2026-09-14T12:00:00Z', + }, + ]) + + // One currency: one group, its shop in `Order` rather than stored order, and + // `CurrencyId` on every offer projected in from the row that owns it. + const one = await groupsFor(`?ids=${shopped.CurrencyId}`) + expect(one).toHaveLength(1) + expect(Object.keys(one[0])).toEqual(['CurrencyId', 'PurchaseOffers']) + expect(one[0].CurrencyId).toBe(shopped.CurrencyId) + expect(one[0].PurchaseOffers.map((o) => o.Name)).toEqual(['Pile', 'Handful']) + expect(one[0].PurchaseOffers[0]).toEqual({ + CurrencyPurchaseOfferId: 'ffffffff-0000-4000-8000-000000000001', + CurrencyId: shopped.CurrencyId, + Order: 1, + Name: 'Pile', + CurrencyAmount: 50, + Price: 4000, + ModifiedAt: '2026-09-14T12:00:00Z', + }) + // The client's own model, member for member and in its order. + expect(Object.keys(one[0].PurchaseOffers[0])).toEqual([ + 'CurrencyPurchaseOfferId', + 'CurrencyId', + 'Order', + 'Name', + 'CurrencyAmount', + 'Price', + 'ModifiedAt', + ]) + + // Several: a group each, in the order the ids were given. Both spellings of `ids` + // reach the same answer, and a repeated id is one group. + for (const query of [ + `?ids=${shopped.CurrencyId},${alsoShopped.CurrencyId}`, + `?ids=${shopped.CurrencyId}&ids=${alsoShopped.CurrencyId}`, + `?ids=${shopped.CurrencyId},${alsoShopped.CurrencyId},${shopped.CurrencyId}`, + ]) { + const groups = await groupsFor(query) + expect(groups.map((g) => g.CurrencyId)).toEqual([ + shopped.CurrencyId, + alsoShopped.CurrencyId, + ]) + expect(groups.flatMap((g) => g.PurchaseOffers.map((o) => o.Name))).toEqual([ + 'Pile', + 'Handful', + 'Single', + ]) + } + // Reversing the ids reverses the groups. + expect( + (await groupsFor(`?ids=${alsoShopped.CurrencyId},${shopped.CurrencyId}`)).map( + (g) => g.CurrencyId + ) + ).toEqual([alsoShopped.CurrencyId, shopped.CurrencyId]) + + // A currency with no shop still gets a GROUP, with an empty list — "sells nothing" + // and "you didn't ask about it" are different answers. + expect(await groupsFor(`?ids=${unshopped.CurrencyId}`)).toEqual([ + { CurrencyId: unshopped.CurrencyId, PurchaseOffers: [] }, + ]) + + // An id naming no currency at all is omitted, and doesn't fail the batch it was + // sent in. No ids is an empty list. + expect(await groupsFor('?ids=a3f1e8d2-0000-4000-8000-000000000000')).toEqual([]) + expect(await groupsFor('')).toEqual([]) + expect(await groupsFor('?ids=')).toEqual([]) + expect( + ( + await groupsFor(`?ids=a3f1e8d2-0000-4000-8000-000000000000,${alsoShopped.CurrencyId}`) + ).map((g) => g.CurrencyId) + ).toEqual([alsoShopped.CurrencyId]) + + // A column that doesn't parse shows an empty shop rather than failing the call. + await env.DB.prepare('UPDATE room_currency SET purchase_offers = ?2 WHERE currency_id = ?1') + .bind(unshopped.CurrencyId, 'not json') + .run() + expect(await groupsFor(`?ids=${unshopped.CurrencyId}`)).toEqual([ + { CurrencyId: unshopped.CurrencyId, PurchaseOffers: [] }, + ]) + }) + + test('POST createCurrency pushes RoomCurrencyCreated to the room and the creator', async () => { + // Two players standing in 2511, one somewhere else, and one whose presence lapsed. + const now = Math.floor(Date.now() / 1000) + const putInRoom = async (accountId: number, roomId: number, expired = false) => + env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)') + .bind( + JSON.stringify({ + accountId, + roomInstance: { roomInstanceId: 1000000 + roomId, roomId, subRoomId: roomId }, + expiresAt: expired ? now - 1 : now + 900, + }) + ) + .run() + await putInRoom(310, 2511) + await putInRoom(311, 2511) + await putInRoom(312, 1) + await putInRoom(313, 2511, true) + await drainFrames() + + // Account 1 owns the room but is NOT standing in it — the settings UI opens from + // anywhere, so the creator is added to the audience separately. + const res = await create({ ...body, Name: 'Broadcast bucks' }, await bearer('1')) + expect(res.status).toBe(200) + const created = (await envOf(res)).Value! + + const frames = await drainFrames() + expect(frames.map((f) => f.accountId).sort((a, b) => a - b)).toEqual([1, 310, 311]) + expect(frames.every((f) => f.notificationType === NotificationType.RoomCurrencyCreated)).toBe( + true + ) + // The frame IS the create response — same object, same member order, so the two + // cannot drift apart. + expect(Object.keys(frames[0].payload)).toEqual(Object.keys(created)) + expect(frames[0].payload).toEqual({ + CurrencyId: created.CurrencyId, + RoomId: 2511, + Name: 'Broadcast bucks', + Description: 'this is my currency', + CurrencyType: 300, + Limit: 100, + ImageName: null, + CreatedAt: created.CreatedAt, + ModifiedAt: created.ModifiedAt, + Shape: 0, + Color: 19, + }) + + for (const id of [310, 311, 312, 313]) { + await env.DB.prepare('DELETE FROM presence WHERE account_id = ?1').bind(id).run() + } + }) + + test('POST createCurrency masks a name players will see, and defaults the rest', async () => { + const res = await create({ RoomId: '2511', Name: 'shit bucks' }, await bearer('1')) + expect(res.status).toBe(200) + const created = (await envOf(res)).Value! + // Masked per character like every other player-typed string (see the gift message + // above) — a currency's name is shown to everyone who walks into the room. + expect(created.Name).toBe('**** bucks') + // Everything the body left out falls back rather than landing as NaN. + expect(created).toMatchObject({ Description: '', Limit: 0, Shape: 0, Color: 0 }) + }) + + test('POST createCurrency keeps a negative Color and a large Limit', async () => { + // `Color` is signed — the client sends -1 for "no colour chosen" — and `Limit` is a + // long. Neither may be clamped or dropped on the way through. + const res = await create( + { RoomId: '2511', Name: 'Uncoloured', Limit: '1000000', Color: '-1' }, + await bearer('1') + ) + expect(res.status).toBe(200) + expect((await envOf(res)).Value).toMatchObject({ Limit: 1000000, Color: -1 }) + }) }) test('GET /api/roomkeys/v1/room returns []', async () => { @@ -4064,6 +4768,7 @@ describe('econ endpoints', () => { 'GET /api/roomconsumables/v1/roomConsumable/room/{roomId}/me', 'GET /api/roomcurrencies/v1/currencies', 'GET /api/roomcurrencies/v1/getAllBalances', + 'GET /api/roomcurrencies/v1/getPurchaseOffersBatch', 'GET /api/roomkeys/v1/mine', 'GET /api/roomkeys/v1/room', 'GET /api/storefronts/v1/adcarouselitems', @@ -4096,6 +4801,10 @@ describe('econ endpoints', () => { 'POST /api/items/purchaseInfos', 'POST /api/objectives/v1/cleargroup', 'POST /api/objectives/v1/updateobjective', + 'POST /api/roomcurrencies/v1/awardCurrency/bulk', + 'POST /api/roomcurrencies/v1/createCurrency', + 'POST /api/roomcurrencies/v1/createPurchaseOffer', + 'POST /api/roomcurrencies/v1/updateCurrency', 'POST /api/storefronts/v2/buyItem', 'POST /api/storefronts/v3/buyInvention', 'POST /api/ugcPurchasables/v1/items/bulk', diff --git a/apps/notify/src/notification-payloads.ts b/apps/notify/src/notification-payloads.ts index 3452e97..6a12314 100644 --- a/apps/notify/src/notification-payloads.ts +++ b/apps/notify/src/notification-payloads.ts @@ -76,7 +76,9 @@ export enum CurrencyType { DraculaSilver = 101, RecRoyaleSeason1 = 200, RoomCurrency = 300, + RoomInventoryItem = 301, ProgressionEvent = 400, + RoomieCredits = 500, } /** Why a player was kicked/banned/warned. Shared by ModerationKick and ModerationUnkick. */ @@ -369,15 +371,29 @@ export interface CreatorClubSubscriptionPayload { MembershipType: number } -/** `RoomCurrencyCreated` / `RoomCurrencyModified`. */ +/** + * `RoomCurrencyCreated` / `RoomCurrencyModified` — and the same object + * `POST /api/roomcurrencies/v1/createCurrency` answers with. + * + * Members are in the CLIENT'S OWN ORDER, taken from its field layout rather than guessed: + * `Shape` (a byte) and `Color` (an int) sit between `Limit` and `ImageName`. They were + * missing from this interface while it was recovered from a frame alone, and econ spread + * them onto the end of the payload instead — which carried them, but put them somewhere the + * client's model doesn't have them. + */ export interface RoomCurrencyPayload { CurrencyId: string + /** Nullable in the client's model (`long?`), though a room currency always names one. */ RoomId: number | null Name: string Description: string CurrencyType: CurrencyType Limit: number - ImageName: string + /** A byte in the client's model — its index into the coin shapes. */ + Shape: number + Color: number + /** Null for a currency with no custom coin art. */ + ImageName: string | null /** ISO-8601. */ CreatedAt: string /** ISO-8601. */