[econ] currency WIP

This commit is contained in:
Devin Zuczek
2026-09-14 15:45:07 -04:00
parent 295ec7bc16
commit ba8423e438
10 changed files with 2359 additions and 5 deletions
@@ -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);
@@ -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);
@@ -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);
@@ -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;
+4
View File
@@ -39,6 +39,9 @@ import { BalancePlatform } from '../../notify/src/notification-payloads'
* room would spend in every other. `isSpendable` rejects them for that reason. * room would spend in every other. `isSpendable` rejects them for that reason.
* - `ProgressionEvent` (400): an XP/progression counter the client models as a * - `ProgressionEvent` (400): an XP/progression counter the client models as a
* currency. Not spendable. * 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 = { export const CurrencyType = {
Invalid: 0, Invalid: 0,
@@ -50,6 +53,7 @@ export const CurrencyType = {
RoomCurrency: 300, RoomCurrency: 300,
RoomInventoryItem: 301, RoomInventoryItem: 301,
ProgressionEvent: 400, ProgressionEvent: 400,
RoomieCredits: 500,
} as const } as const
export type CurrencyTypeValue = (typeof CurrencyType)[keyof typeof CurrencyType] export type CurrencyTypeValue = (typeof CurrencyType)[keyof typeof CurrencyType]
+754 -2
View File
@@ -4,12 +4,14 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { import {
addXp, addXp,
canManageRoomById,
consumeGift, consumeGift,
createGift, createGift,
getAccount, getAccount,
getGift, getGift,
getOutfits, getOutfits,
getPendingGifts, getPendingGifts,
getPlayerIdsInRoom,
grantInvention, grantInvention,
levelReward, levelReward,
levelsReached, levelsReached,
@@ -78,6 +80,8 @@ import {
AUTHED, AUTHED,
AvatarItemV4Dto, AvatarItemV4Dto,
AvatarV2Dto, AvatarV2Dto,
AwardRoomCurrencyRequest,
AwardRoomCurrencyResultList,
BalanceEntry, BalanceEntry,
BulkPurchaseRequest, BulkPurchaseRequest,
BulkPurchaseResponse, BulkPurchaseResponse,
@@ -94,6 +98,8 @@ import {
ConsumeConsumableRequest, ConsumeConsumableRequest,
ConsumeEnvelope, ConsumeEnvelope,
ConsumeGiftRequest, ConsumeGiftRequest,
CreatePurchaseOfferRequest,
CreateRoomCurrencyRequest,
CustomAvatarItemsResponse, CustomAvatarItemsResponse,
EquipmentUpdateRequest, EquipmentUpdateRequest,
ErrorResponse, ErrorResponse,
@@ -112,6 +118,11 @@ import {
OpaqueJsonBody, OpaqueJsonBody,
OPTIONAL_AUTHED, OPTIONAL_AUTHED,
ReferralProgressResponse, ReferralProgressResponse,
RoomCurrencyDto,
RoomCurrencyEnvelope,
RoomCurrencyPurchaseOfferDto,
RoomCurrencyPurchaseOfferEnvelope,
RoomCurrencyPurchaseOffersDto,
RoomEconConfig, RoomEconConfig,
RRPlusSignUpBonus, RRPlusSignUpBonus,
SaveOutfitRequest, SaveOutfitRequest,
@@ -122,8 +133,18 @@ import {
UNAUTHORIZED_RESPONSE, UNAUTHORIZED_RESPONSE,
UpdateObjectiveRequest, UpdateObjectiveRequest,
UpdateObjectiveResponse, UpdateObjectiveResponse,
UpdateRoomCurrencyRequest,
} from './openapi' } from './openapi'
import { claimReward } from './reward-db' import { claimReward } from './reward-db'
import {
awardRoomCurrency,
createPurchaseOffer,
createRoomCurrency,
getPurchaseOffers,
getRoomCurrencies,
getRoomCurrency,
updateRoomCurrency,
} from './room-currency-db'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain' import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
@@ -132,6 +153,7 @@ import type { SavedInvention } from '../../api/src/inventions-db'
import type { import type {
BalanceResponsePayload, BalanceResponsePayload,
PurchaseBalanceModificationPayload, PurchaseBalanceModificationPayload,
RoomCurrencyPayload,
} from '../../notify/src/notification-payloads' } from '../../notify/src/notification-payloads'
import type { Avatar } from './avatar-db' import type { Avatar } from './avatar-db'
import type { CatalogRow } from './catalog-db' import type { CatalogRow } from './catalog-db'
@@ -144,6 +166,7 @@ import type { ConsumeResult } from './consumables-db'
import type { App } from './context' import type { App } from './context'
import type { Equipment } from './equipment-db' import type { Equipment } from './equipment-db'
import type { AvatarItem } from './inventory-db' import type { AvatarItem } from './inventory-db'
import type { RoomCurrency, RoomCurrencyPurchaseOffer } from './room-currency-db'
// Invention storage (owned by the `api` worker, on this same `recflare` database). // Invention storage (owned by the `api` worker, on this same `recflare` database).
// Imported directly rather than copied: these are plain D1 helpers with no bindings of // Imported directly rather than copied: these are plain D1 helpers with no bindings of
@@ -224,6 +247,122 @@ async function persistPostedOutfit(c: Context<App>): Promise<Outfit | Response>
/** The notifications hub is a single global DO instance (see the `notify` worker). */ /** The notifications hub is a single global DO instance (see the `notify` worker). */
const HUB_INSTANCE = 'global' 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<App>, 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<App>,
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<App>,
currency: RoomCurrency,
changedByAccountId: number,
notificationType:
typeof NotificationType.RoomCurrencyCreated | typeof NotificationType.RoomCurrencyModified
): Promise<void> {
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 * Push a ConsumableMappingRemoved notification to a player after they consume a
* consumable, mirroring the reference's * consumable, mirroring the reference's
@@ -2909,8 +3048,621 @@ const app = new Hono<App>({ strict: false })
listRoute('The caller’s room consumables', 'Empty stub'), listRoute('The caller’s room consumables', 'Empty stub'),
(c) => c.json([]) (c) => c.json([])
) )
.get('/api/roomcurrencies/v1/currencies', listRoute('Room currencies', 'Empty stub'), (c) => // The custom currencies a room has minted. Public, like the room itself: a room's
c.json([]) // 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<string, unknown>
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<string, unknown>
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<string, unknown>
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<unknown>().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<string, RoomCurrency | null | false>()
const resolve = async (currencyId: string): Promise<RoomCurrency | null | false> => {
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<Record<string, unknown>>) {
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<string, unknown>
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) => .get('/api/roomcurrencies/v1/getAllBalances', listRoute('Room balances', 'Empty stub'), (c) =>
c.json([]) c.json([])
+181
View File
@@ -554,6 +554,187 @@ export const LockedItemsBulkRequest = z.object({
export const ErrorResponse = z.object({ error: z.string() }) 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 ------------------------------------------------------- // ---- Request schemas -------------------------------------------------------
/** /**
+549
View File
@@ -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<RoomCurrency> {
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<RoomCurrencyRow>()
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<RoomCurrency | null> {
const row = await db
.prepare(`SELECT ${SELECT_COLUMNS} FROM room_currency WHERE currency_id = ?1`)
.bind(currencyId)
.first<RoomCurrencyRow>()
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<RoomCurrency> {
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<RoomCurrency[]> {
const { results } = await db
.prepare(
`SELECT ${SELECT_COLUMNS} FROM room_currency WHERE room_id = ?1 ORDER BY created_at, currency_id`
)
.bind(roomId)
.all<RoomCurrencyRow>()
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<RoomCurrencyPurchaseOffer, 'CurrencyId'>
/**
* 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<RoomCurrencyPurchaseOffer> {
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<RoomCurrencyPurchaseOffers[]> {
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<RoomAwardResult> {
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<RoomBalance[]> {
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,
}))
}
+710 -1
View File
@@ -9,8 +9,10 @@ import {
getProgression, getProgression,
INVENTORY_INVENTION_SCHEMA_DDL, INVENTORY_INVENTION_SCHEMA_DDL,
OUTFIT_SCHEMA_DDL, OUTFIT_SCHEMA_DDL,
PRESENCE_SCHEMA_DDL,
PROGRESSION_SCHEMA_DDL, PROGRESSION_SCHEMA_DDL,
RECEIVED_GIFT_SCHEMA_DDL, RECEIVED_GIFT_SCHEMA_DDL,
ROOM_SCHEMA_DDL,
} from '@repo/domain' } from '@repo/domain'
// The `invention` table belongs to the `api` worker; buyInvention reads it, so its DDL // 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 { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db'
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db' import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db' import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
import { ROOM_BALANCE_SCHEMA_DDL, ROOM_CURRENCY_SCHEMA_DDL } from '../../room-currency-db'
import type { CatalogLoadRow, CatalogRow, CatalogValue } from '../../catalog-db' import type { CatalogLoadRow, CatalogRow, CatalogValue } from '../../catalog-db'
import type { Env } from '../../context' 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 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 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 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 // 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 // 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. // empty and the week has nothing to be themed on.
@@ -753,10 +779,688 @@ describe('econ endpoints', () => {
expect(await res.json()).toEqual([]) 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`) const res = await exports.default.fetch(`${ORIGIN}/api/roomcurrencies/v1/currencies?roomId=1`)
expect(res.status).toBe(200) expect(res.status).toBe(200)
expect(await res.json()).toEqual([]) 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<string, string>, headers?: Record<string, string>) =>
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<string, unknown>
}>
> =>
(
env.RECFLARE_NOTIFICATIONS_HUB.getByName('global') as unknown as {
drainFrames(): Promise<
Array<{
accountId: number
notificationType: string | number
payload: Record<string, unknown>
}>
>
}
).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<string, string>, headers?: Record<string, string>) =>
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<Record<string, string>> = [
{ 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<string, string>) =>
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 () => { 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/roomconsumables/v1/roomConsumable/room/{roomId}/me',
'GET /api/roomcurrencies/v1/currencies', 'GET /api/roomcurrencies/v1/currencies',
'GET /api/roomcurrencies/v1/getAllBalances', 'GET /api/roomcurrencies/v1/getAllBalances',
'GET /api/roomcurrencies/v1/getPurchaseOffersBatch',
'GET /api/roomkeys/v1/mine', 'GET /api/roomkeys/v1/mine',
'GET /api/roomkeys/v1/room', 'GET /api/roomkeys/v1/room',
'GET /api/storefronts/v1/adcarouselitems', 'GET /api/storefronts/v1/adcarouselitems',
@@ -4096,6 +4801,10 @@ describe('econ endpoints', () => {
'POST /api/items/purchaseInfos', 'POST /api/items/purchaseInfos',
'POST /api/objectives/v1/cleargroup', 'POST /api/objectives/v1/cleargroup',
'POST /api/objectives/v1/updateobjective', 'POST /api/objectives/v1/updateobjective',
'POST /api/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/v2/buyItem',
'POST /api/storefronts/v3/buyInvention', 'POST /api/storefronts/v3/buyInvention',
'POST /api/ugcPurchasables/v1/items/bulk', 'POST /api/ugcPurchasables/v1/items/bulk',
+18 -2
View File
@@ -76,7 +76,9 @@ export enum CurrencyType {
DraculaSilver = 101, DraculaSilver = 101,
RecRoyaleSeason1 = 200, RecRoyaleSeason1 = 200,
RoomCurrency = 300, RoomCurrency = 300,
RoomInventoryItem = 301,
ProgressionEvent = 400, ProgressionEvent = 400,
RoomieCredits = 500,
} }
/** Why a player was kicked/banned/warned. Shared by ModerationKick and ModerationUnkick. */ /** Why a player was kicked/banned/warned. Shared by ModerationKick and ModerationUnkick. */
@@ -369,15 +371,29 @@ export interface CreatorClubSubscriptionPayload {
MembershipType: number 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 { export interface RoomCurrencyPayload {
CurrencyId: string CurrencyId: string
/** Nullable in the client's model (`long?`), though a room currency always names one. */
RoomId: number | null RoomId: number | null
Name: string Name: string
Description: string Description: string
CurrencyType: CurrencyType CurrencyType: CurrencyType
Limit: number 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. */ /** ISO-8601. */
CreatedAt: string CreatedAt: string
/** ISO-8601. */ /** ISO-8601. */