mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[econ] currency WIP
This commit is contained in:
@@ -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;
|
||||
@@ -39,6 +39,9 @@ import { BalancePlatform } from '../../notify/src/notification-payloads'
|
||||
* room would spend in every other. `isSpendable` rejects them for that reason.
|
||||
* - `ProgressionEvent` (400): an XP/progression counter the client models as a
|
||||
* currency. Not spendable.
|
||||
* - `RoomieCredits` (500): the newest member of the client's enum. Nothing here grants or
|
||||
* spends it yet; it is listed so the enum matches the client's and a value arriving on
|
||||
* the wire has a name rather than reading as an unknown number.
|
||||
*/
|
||||
export const CurrencyType = {
|
||||
Invalid: 0,
|
||||
@@ -50,6 +53,7 @@ export const CurrencyType = {
|
||||
RoomCurrency: 300,
|
||||
RoomInventoryItem: 301,
|
||||
ProgressionEvent: 400,
|
||||
RoomieCredits: 500,
|
||||
} as const
|
||||
|
||||
export type CurrencyTypeValue = (typeof CurrencyType)[keyof typeof CurrencyType]
|
||||
|
||||
+754
-2
@@ -4,12 +4,14 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import {
|
||||
addXp,
|
||||
canManageRoomById,
|
||||
consumeGift,
|
||||
createGift,
|
||||
getAccount,
|
||||
getGift,
|
||||
getOutfits,
|
||||
getPendingGifts,
|
||||
getPlayerIdsInRoom,
|
||||
grantInvention,
|
||||
levelReward,
|
||||
levelsReached,
|
||||
@@ -78,6 +80,8 @@ import {
|
||||
AUTHED,
|
||||
AvatarItemV4Dto,
|
||||
AvatarV2Dto,
|
||||
AwardRoomCurrencyRequest,
|
||||
AwardRoomCurrencyResultList,
|
||||
BalanceEntry,
|
||||
BulkPurchaseRequest,
|
||||
BulkPurchaseResponse,
|
||||
@@ -94,6 +98,8 @@ import {
|
||||
ConsumeConsumableRequest,
|
||||
ConsumeEnvelope,
|
||||
ConsumeGiftRequest,
|
||||
CreatePurchaseOfferRequest,
|
||||
CreateRoomCurrencyRequest,
|
||||
CustomAvatarItemsResponse,
|
||||
EquipmentUpdateRequest,
|
||||
ErrorResponse,
|
||||
@@ -112,6 +118,11 @@ import {
|
||||
OpaqueJsonBody,
|
||||
OPTIONAL_AUTHED,
|
||||
ReferralProgressResponse,
|
||||
RoomCurrencyDto,
|
||||
RoomCurrencyEnvelope,
|
||||
RoomCurrencyPurchaseOfferDto,
|
||||
RoomCurrencyPurchaseOfferEnvelope,
|
||||
RoomCurrencyPurchaseOffersDto,
|
||||
RoomEconConfig,
|
||||
RRPlusSignUpBonus,
|
||||
SaveOutfitRequest,
|
||||
@@ -122,8 +133,18 @@ import {
|
||||
UNAUTHORIZED_RESPONSE,
|
||||
UpdateObjectiveRequest,
|
||||
UpdateObjectiveResponse,
|
||||
UpdateRoomCurrencyRequest,
|
||||
} from './openapi'
|
||||
import { claimReward } from './reward-db'
|
||||
import {
|
||||
awardRoomCurrency,
|
||||
createPurchaseOffer,
|
||||
createRoomCurrency,
|
||||
getPurchaseOffers,
|
||||
getRoomCurrencies,
|
||||
getRoomCurrency,
|
||||
updateRoomCurrency,
|
||||
} from './room-currency-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
|
||||
@@ -132,6 +153,7 @@ import type { SavedInvention } from '../../api/src/inventions-db'
|
||||
import type {
|
||||
BalanceResponsePayload,
|
||||
PurchaseBalanceModificationPayload,
|
||||
RoomCurrencyPayload,
|
||||
} from '../../notify/src/notification-payloads'
|
||||
import type { Avatar } from './avatar-db'
|
||||
import type { CatalogRow } from './catalog-db'
|
||||
@@ -144,6 +166,7 @@ import type { ConsumeResult } from './consumables-db'
|
||||
import type { App } from './context'
|
||||
import type { Equipment } from './equipment-db'
|
||||
import type { AvatarItem } from './inventory-db'
|
||||
import type { RoomCurrency, RoomCurrencyPurchaseOffer } from './room-currency-db'
|
||||
|
||||
// Invention storage (owned by the `api` worker, on this same `recflare` database).
|
||||
// Imported directly rather than copied: these are plain D1 helpers with no bindings of
|
||||
@@ -224,6 +247,122 @@ async function persistPostedOutfit(c: Context<App>): Promise<Outfit | Response>
|
||||
/** The notifications hub is a single global DO instance (see the `notify` worker). */
|
||||
const HUB_INSTANCE = 'global'
|
||||
|
||||
/**
|
||||
* The messages the room-currency failure paths carry. One string per endpoint, not per
|
||||
* reason: the client unwraps the envelope and hands its callers `Value`, so a more specific
|
||||
* message would reach nobody but a log.
|
||||
*
|
||||
* The create string is the reference's own. The update one is its obvious counterpart and has
|
||||
* not been seen on the wire — if the real client ever shows it and the wording is wrong, this
|
||||
* is the line to fix.
|
||||
*/
|
||||
const CREATE_CURRENCY_FAILED = 'Failed to create currency'
|
||||
const UPDATE_CURRENCY_FAILED = 'Failed to update currency'
|
||||
const CREATE_OFFER_FAILED = 'Failed to create purchase offer'
|
||||
|
||||
/**
|
||||
* The envelope the create endpoint answers in: `{ Value, Success, Error, error_id }`.
|
||||
*
|
||||
* PascalCase beside a lowercase `error_id` — the client's own mixed casing, not a typo, and
|
||||
* NOT the lowercase `{ success, error, value }` the `rooms` worker's room mutations use.
|
||||
* A failure answers 200 with `Success: false` and a null `Value`, the way the reference's
|
||||
* failure path does; only the auth gates answer with a status of their own.
|
||||
*/
|
||||
function roomCurrencyEnvelope(c: Context<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
|
||||
* consumable, mirroring the reference's
|
||||
@@ -2909,8 +3048,621 @@ const app = new Hono<App>({ strict: false })
|
||||
listRoute('The caller’s room consumables', 'Empty stub'),
|
||||
(c) => c.json([])
|
||||
)
|
||||
.get('/api/roomcurrencies/v1/currencies', listRoute('Room currencies', 'Empty stub'), (c) =>
|
||||
c.json([])
|
||||
// The custom currencies a room has minted. Public, like the room itself: a room's
|
||||
// currencies are shown to every player who walks into it, so the list is not a secret
|
||||
// the way its ban list is. A room with none — or an unknown room — is an empty list.
|
||||
.get(
|
||||
'/api/roomcurrencies/v1/currencies',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'A room’s currencies',
|
||||
description: [
|
||||
'Every custom currency the room named by `roomId` has minted, oldest first — the',
|
||||
'order its owner built them up in.',
|
||||
'',
|
||||
'Public, like the room itself: a room’s currencies are shown to everyone who walks',
|
||||
'into it. A room with none, an unknown room, and a missing `roomId` are all the same',
|
||||
'empty list, which the client reads as "this room has no currency of its own".',
|
||||
'',
|
||||
'These are DEFINITIONS, not balances — what a player holds of one is',
|
||||
'`getAllBalances`, which is still a stub.',
|
||||
].join('\n'),
|
||||
parameters: [
|
||||
{
|
||||
name: 'roomId',
|
||||
in: 'query',
|
||||
required: false,
|
||||
description: 'The room whose currencies to list',
|
||||
schema: { type: 'string' },
|
||||
},
|
||||
],
|
||||
responses: { 200: json(RoomCurrencyDto.array(), 'The room’s currencies, oldest first') },
|
||||
}),
|
||||
async (c) => {
|
||||
const roomId = Number.parseInt(c.req.query('roomId') ?? '', 10)
|
||||
if (!Number.isInteger(roomId)) return c.json([])
|
||||
return c.json(await getRoomCurrencies(c.env.DB, roomId))
|
||||
}
|
||||
)
|
||||
|
||||
// Mint a custom currency for a room. Auth-gated (401) and gated to the room's creator or
|
||||
// a co-owner (403) — the same owner-level check the `rooms` worker applies to its own
|
||||
// room-admin writes, reading the same room blob.
|
||||
.post(
|
||||
'/api/roomcurrencies/v1/createCurrency',
|
||||
describeRoute({
|
||||
tags: ['Econ'],
|
||||
summary: 'Create a room currency',
|
||||
description: [
|
||||
'Mints a custom currency for one room — its name, its description, its coin art and',
|
||||
'the most of it the room may award per day. Form-encoded',
|
||||
'(`RoomId=2511&Name=Devintokens&Description=…&Limit=100&Shape=0&Color=19`).',
|
||||
'',
|
||||
'Gated to the room’s CREATOR or a CO-OWNER — minting a currency is room',
|
||||
'administration, so it takes the same standing as the room’s other settings. A valid',
|
||||
'token from anyone else is a 403.',
|
||||
'',
|
||||
'`Name` and `Description` are masked by the same word list as every other string a',
|
||||
'player types, since a currency’s name is shown to everyone in the room.',
|
||||
'',
|
||||
'Answers the created currency inside the `{ Value, Success, Error, error_id }`',
|
||||
'envelope — PascalCase beside a lowercase `error_id`, which is the client’s own mixed',
|
||||
'casing. The client unwraps it and hands its callers `Value` alone. A recoverable',
|
||||
'refusal (an unusable `RoomId`, an empty `Name`, an unknown room) is a 200 carrying',
|
||||
'`Success: false`, a null `Value` and the reference’s own message; only the auth',
|
||||
'gates answer with a status of their own.',
|
||||
'',
|
||||
'`CurrencyId` is how the client names this currency everywhere afterwards.',
|
||||
'`CurrencyType` is always 300 (RoomCurrency) and `ImageName` is null until custom',
|
||||
'coin art can be uploaded.',
|
||||
].join('\n'),
|
||||
security: AUTHED,
|
||||
requestBody: form(CreateRoomCurrencyRequest, 'The currency to mint'),
|
||||
responses: {
|
||||
200: json(
|
||||
RoomCurrencyEnvelope,
|
||||
'The currency as created, or a rejection with `Success: false`'
|
||||
),
|
||||
401: UNAUTHORIZED_RESPONSE,
|
||||
403: { description: 'Not the room’s creator or a co-owner (empty body)' },
|
||||
},
|
||||
}),
|
||||
async (c) => {
|
||||
const accountId = await authedId(c)
|
||||
if (accountId === null) return unauthorized(c)
|
||||
|
||||
const body = (await c.req.parseBody().catch(() => ({}))) as Record<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) =>
|
||||
c.json([])
|
||||
|
||||
@@ -554,6 +554,187 @@ export const LockedItemsBulkRequest = z.object({
|
||||
|
||||
export const ErrorResponse = z.object({ error: z.string() })
|
||||
|
||||
/**
|
||||
* A room currency — a custom currency minted for use inside one room. The client's own model,
|
||||
* member for member and in its order (see the `notify` worker's `RoomCurrencyPayload`, which
|
||||
* the `RoomCurrencyCreated`/`Modified` frames carry: the create response and those frames are
|
||||
* the same object). `CurrencyType` is always 300 (RoomCurrency) — a room currency is told
|
||||
* apart from the others by its `CurrencyId`, not by its type.
|
||||
*/
|
||||
export const RoomCurrencyDto = z.object({
|
||||
CurrencyId: z.string().describe('GUID — how the client names this currency afterwards'),
|
||||
RoomId: z.int(),
|
||||
Name: z.string(),
|
||||
Description: z.string(),
|
||||
CurrencyType: z
|
||||
.literal(300)
|
||||
.describe('Always 300 (RoomCurrency) — the same enum `buyInvention` prices in'),
|
||||
Limit: z
|
||||
.int()
|
||||
.describe(
|
||||
'The most the room may award PER DAY — a faucet rate, not a holding cap. Stored and ' +
|
||||
'served, but not enforced: nothing tracks a day’s awards yet'
|
||||
),
|
||||
Shape: z.int().describe('A byte — the client’s index into its coin shapes; uninterpreted'),
|
||||
Color: z
|
||||
.int()
|
||||
.describe('The client’s index into its coin colours, signed — it sends -1 for none'),
|
||||
ImageName: z.string().nullable().describe('Null — custom coin art is not uploadable yet'),
|
||||
CreatedAt: z.string().describe('ISO-8601 UTC'),
|
||||
ModifiedAt: z.string().describe('ISO-8601 UTC; equal to `CreatedAt` until an edit'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The envelope `POST /api/roomcurrencies/v1/createCurrency` answers in. The client unwraps it
|
||||
* and hands its callers `Value` alone, so the envelope exists on the wire but never reaches
|
||||
* the code that uses the currency.
|
||||
*
|
||||
* PascalCase `Value`/`Success`/`Error` beside a lowercase `error_id` — the same mixed casing
|
||||
* the client's other envelopes use (see the `rooms` worker's isBanned and publish-state
|
||||
* envelopes). NOT the lowercase `{ success, error, value }` the room mutations answer with;
|
||||
* the two live in different workers and must not be unified.
|
||||
*/
|
||||
export const RoomCurrencyEnvelope = z.object({
|
||||
Value: RoomCurrencyDto.nullable().describe('The currency, or null on a failure'),
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable().describe('Null on success; the failure message otherwise'),
|
||||
error_id: z.null().describe('Always null. Present as a key, and lowercase'),
|
||||
})
|
||||
|
||||
/** `POST /api/roomcurrencies/v1/createCurrency` — form-encoded. */
|
||||
export const CreateRoomCurrencyRequest = z.object({
|
||||
RoomId: z.string().describe('The room the currency belongs to'),
|
||||
Name: z.string().describe('Shown to players; profanity-masked like every typed string'),
|
||||
Description: z.string().optional().describe('Defaults to empty'),
|
||||
Limit: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('The most the room may award per day; defaults to 0. Not enforced yet'),
|
||||
Shape: z.string().optional().describe('Coin shape index; defaults to 0'),
|
||||
Color: z.string().optional().describe('Coin colour index; defaults to 0'),
|
||||
})
|
||||
|
||||
/**
|
||||
* One purchase offer on a room currency — a way to BUY that currency, priced in another
|
||||
* ("5 SuperTokens for 500 Rec Center Tokens"). The client's own model, member for member and
|
||||
* in its order.
|
||||
*/
|
||||
export const RoomCurrencyPurchaseOfferDto = z.object({
|
||||
CurrencyPurchaseOfferId: z.string().describe('GUID — names this offer'),
|
||||
CurrencyId: z.string().describe('GUID of the currency being SOLD'),
|
||||
Order: z.int().describe('Where it sits in the currency’s shop; the list is sorted by it'),
|
||||
Name: z.string(),
|
||||
CurrencyAmount: z.int().describe('How much of the currency the offer hands over'),
|
||||
Price: z.int().describe('What it costs'),
|
||||
ModifiedAt: z.string().describe('ISO-8601 UTC'),
|
||||
})
|
||||
|
||||
/**
|
||||
* One currency's shop, as `GET /api/roomcurrencies/v1/getPurchaseOffersBatch` groups them —
|
||||
* one entry per currency asked for, the whole answer an array of these.
|
||||
*
|
||||
* Grouped rather than flat even though every offer already carries its own `CurrencyId`: the
|
||||
* group is what says a currency was ASKED ABOUT, which a flat list cannot. A currency with no
|
||||
* shop still gets a group with an empty `PurchaseOffers`.
|
||||
*/
|
||||
export const RoomCurrencyPurchaseOffersDto = z.object({
|
||||
CurrencyId: z.string(),
|
||||
PurchaseOffers: z
|
||||
.array(RoomCurrencyPurchaseOfferDto)
|
||||
.describe('Sorted by `Order`; empty for a currency that sells nothing yet'),
|
||||
})
|
||||
|
||||
/**
|
||||
* `POST /api/roomcurrencies/v1/createPurchaseOffer` — form-encoded.
|
||||
*
|
||||
* Note `Amount` here against `CurrencyAmount` on the offer it becomes: the body and the model
|
||||
* spell the same number differently, and the route maps between them rather than serving the
|
||||
* body’s spelling back.
|
||||
*/
|
||||
export const CreatePurchaseOfferRequest = z.object({
|
||||
CurrencyId: z.string().describe('The currency this offer sells'),
|
||||
Name: z.string().describe('The offer’s name; the client has been seen sending a GUID'),
|
||||
Amount: z.string().describe('How much of the currency the offer hands over (`CurrencyAmount`)'),
|
||||
Price: z.string().describe('What it costs'),
|
||||
Order: z.string().optional().describe('Where it sits in the shop; defaults to 0'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The envelope the create-offer endpoint answers in — the same
|
||||
* `{ Value, Success, Error, error_id }` the currency writes use, with the offer in `Value`.
|
||||
*/
|
||||
export const RoomCurrencyPurchaseOfferEnvelope = z.object({
|
||||
Value: RoomCurrencyPurchaseOfferDto.nullable(),
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable(),
|
||||
error_id: z.null(),
|
||||
})
|
||||
|
||||
/**
|
||||
* One entry of `POST /api/roomcurrencies/v1/awardCurrency/bulk` — a JSON ARRAY of these,
|
||||
* not an object wrapping one. Each names its own currency and recipient, so a single call can
|
||||
* pay several players, or one player in several currencies.
|
||||
*/
|
||||
export const AwardRoomCurrencyRequest = z.object({
|
||||
CurrencyId: z.string().describe('The room currency being awarded'),
|
||||
RecipientId: z.int().describe('The player receiving it'),
|
||||
Amount: z.int().describe('How much to add; negative deducts, and a balance floors at 0'),
|
||||
TransactionId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The client’s id for this award, so a retry is the same award. NOT YET DEDUPLICATED — ' +
|
||||
'accepted and validated, but a replay currently awards twice'
|
||||
),
|
||||
})
|
||||
|
||||
/** What one award actually did, on the result that reports it. */
|
||||
export const AwardRoomCurrencyResponse = z.object({
|
||||
AccountId: z.int().describe('The player paid'),
|
||||
CurrencyId: z.string(),
|
||||
Balance: z.int().describe('The RESULTING total, never the change'),
|
||||
AmountAwarded: z
|
||||
.int()
|
||||
.describe(
|
||||
'What actually landed — less than asked for when the `Limit` or the zero floor clamped it'
|
||||
),
|
||||
AwardedAt: z.string().describe('ISO-8601 UTC'),
|
||||
})
|
||||
|
||||
/**
|
||||
* One entry's result from `POST /api/roomcurrencies/v1/awardCurrency/bulk`, in the order sent.
|
||||
*
|
||||
* Success is PER ENTRY: `Success`/`Error` are this entry's alone and `Response` is null when
|
||||
* it failed, so one bad entry neither fails the others nor the call. That includes an entry
|
||||
* whose currency belongs to a room the caller cannot manage — a rejection here rather than a
|
||||
* 403 over everything. `AccountId` and `CurrencyId` are repeated outside `Response` so a
|
||||
* caller can line a failure up with what it sent, where there is no `Response` to read them
|
||||
* from.
|
||||
*/
|
||||
export const AwardRoomCurrencyResult = z.object({
|
||||
AccountId: z.int(),
|
||||
CurrencyId: z.string(),
|
||||
Success: z.boolean(),
|
||||
Error: z.string().nullable().describe('Null on success'),
|
||||
Response: AwardRoomCurrencyResponse.nullable().describe('Null when this entry failed'),
|
||||
})
|
||||
|
||||
export const AwardRoomCurrencyResultList = z.array(AwardRoomCurrencyResult)
|
||||
|
||||
/**
|
||||
* `POST /api/roomcurrencies/v1/updateCurrency` — form-encoded. Names the currency by id
|
||||
* rather than by room: the room it belongs to is whichever one minted it, and an edit cannot
|
||||
* move it. Every field but `CurrencyId` is optional and an absent one is left as it was.
|
||||
*/
|
||||
export const UpdateRoomCurrencyRequest = z.object({
|
||||
CurrencyId: z.string().describe('The currency to edit, as the create response named it'),
|
||||
Name: z.string().optional().describe('Profanity-masked like every typed string'),
|
||||
Description: z.string().optional(),
|
||||
Limit: z.string().optional().describe('The most the room may award per day. Not enforced yet'),
|
||||
Shape: z.string().optional().describe('Coin shape index'),
|
||||
Color: z.string().optional().describe('Coin colour index, signed'),
|
||||
})
|
||||
|
||||
// ---- Request schemas -------------------------------------------------------
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
getProgression,
|
||||
INVENTORY_INVENTION_SCHEMA_DDL,
|
||||
OUTFIT_SCHEMA_DDL,
|
||||
PRESENCE_SCHEMA_DDL,
|
||||
PROGRESSION_SCHEMA_DDL,
|
||||
RECEIVED_GIFT_SCHEMA_DDL,
|
||||
ROOM_SCHEMA_DDL,
|
||||
} from '@repo/domain'
|
||||
|
||||
// The `invention` table belongs to the `api` worker; buyInvention reads it, so its DDL
|
||||
@@ -71,6 +73,7 @@ import { CONSUMABLE_SCHEMA_DDL, grantConsumable } from '../../consumables-db'
|
||||
import { EQUIPMENT_SCHEMA_DDL, grantEquipment } from '../../equipment-db'
|
||||
import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
|
||||
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
|
||||
import { ROOM_BALANCE_SCHEMA_DDL, ROOM_CURRENCY_SCHEMA_DDL } from '../../room-currency-db'
|
||||
|
||||
import type { CatalogLoadRow, CatalogRow, CatalogValue } from '../../catalog-db'
|
||||
import type { Env } from '../../context'
|
||||
@@ -158,6 +161,29 @@ beforeAll(async () => {
|
||||
for (const stmt of INVENTION_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of CUSTOM_AVATAR_ITEM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of CATALOG_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of ROOM_CURRENCY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
for (const stmt of ROOM_BALANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Presence (owned by the `match` worker) — a new room currency is pushed to everyone
|
||||
// standing in the room, which is read from here.
|
||||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// The rooms table (owned by the `rooms` worker) — creating a room currency reads the room
|
||||
// blob to apply its creator/co-owner check (the blob alone: `canManageRoomById` skips the
|
||||
// hydration that would drag in subrooms/tags/stats). Room 2511 is account 1's, with 2 as
|
||||
// co-owner.
|
||||
for (const stmt of ROOM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
await env.DB.prepare('INSERT OR IGNORE INTO room (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
RoomId: 2511,
|
||||
Name: 'CurrencyRoom',
|
||||
CreatorAccountId: 1,
|
||||
Roles: [
|
||||
{ AccountId: 1, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||
{ AccountId: 2, Role: 30, LastChangedByAccountId: null, InvitedRole: 0 },
|
||||
],
|
||||
})
|
||||
)
|
||||
.run()
|
||||
// A few equipment skins, which is where the WEEKLY CHALLENGE gift pool comes from now that
|
||||
// skins are awarded rather than sold and no storefront lists one. Without these the pool is
|
||||
// empty and the week has nothing to be themed on.
|
||||
@@ -753,10 +779,688 @@ describe('econ endpoints', () => {
|
||||
expect(await res.json()).toEqual([])
|
||||
})
|
||||
|
||||
test('GET /api/roomcurrencies/v1/currencies returns []', async () => {
|
||||
test('GET /api/roomcurrencies/v1/currencies returns [] for a room with none', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/roomcurrencies/v1/currencies?roomId=1`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
|
||||
// An unknown room and a missing roomId read the same way — the client takes any of
|
||||
// them as "this room has no currency of its own".
|
||||
for (const query of ['?roomId=99999', '', '?roomId=nope']) {
|
||||
const other = await exports.default.fetch(
|
||||
`${ORIGIN}/api/roomcurrencies/v1/currencies${query}`
|
||||
)
|
||||
expect(other.status).toBe(200)
|
||||
expect(await other.json()).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
// Room 2511 is seeded as account 1's, with account 2 as co-owner.
|
||||
describe('room currencies', () => {
|
||||
type RoomCurrency = {
|
||||
CurrencyId: string
|
||||
RoomId: number
|
||||
Name: string
|
||||
Description: string
|
||||
CurrencyType: number
|
||||
Limit: number
|
||||
Shape: number
|
||||
Color: number
|
||||
ImageName: string | null
|
||||
CreatedAt: string
|
||||
ModifiedAt: string
|
||||
}
|
||||
|
||||
const create = async (fields: Record<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 () => {
|
||||
@@ -4064,6 +4768,7 @@ describe('econ endpoints', () => {
|
||||
'GET /api/roomconsumables/v1/roomConsumable/room/{roomId}/me',
|
||||
'GET /api/roomcurrencies/v1/currencies',
|
||||
'GET /api/roomcurrencies/v1/getAllBalances',
|
||||
'GET /api/roomcurrencies/v1/getPurchaseOffersBatch',
|
||||
'GET /api/roomkeys/v1/mine',
|
||||
'GET /api/roomkeys/v1/room',
|
||||
'GET /api/storefronts/v1/adcarouselitems',
|
||||
@@ -4096,6 +4801,10 @@ describe('econ endpoints', () => {
|
||||
'POST /api/items/purchaseInfos',
|
||||
'POST /api/objectives/v1/cleargroup',
|
||||
'POST /api/objectives/v1/updateobjective',
|
||||
'POST /api/roomcurrencies/v1/awardCurrency/bulk',
|
||||
'POST /api/roomcurrencies/v1/createCurrency',
|
||||
'POST /api/roomcurrencies/v1/createPurchaseOffer',
|
||||
'POST /api/roomcurrencies/v1/updateCurrency',
|
||||
'POST /api/storefronts/v2/buyItem',
|
||||
'POST /api/storefronts/v3/buyInvention',
|
||||
'POST /api/ugcPurchasables/v1/items/bulk',
|
||||
|
||||
@@ -76,7 +76,9 @@ export enum CurrencyType {
|
||||
DraculaSilver = 101,
|
||||
RecRoyaleSeason1 = 200,
|
||||
RoomCurrency = 300,
|
||||
RoomInventoryItem = 301,
|
||||
ProgressionEvent = 400,
|
||||
RoomieCredits = 500,
|
||||
}
|
||||
|
||||
/** Why a player was kicked/banned/warned. Shared by ModerationKick and ModerationUnkick. */
|
||||
@@ -369,15 +371,29 @@ export interface CreatorClubSubscriptionPayload {
|
||||
MembershipType: number
|
||||
}
|
||||
|
||||
/** `RoomCurrencyCreated` / `RoomCurrencyModified`. */
|
||||
/**
|
||||
* `RoomCurrencyCreated` / `RoomCurrencyModified` — and the same object
|
||||
* `POST /api/roomcurrencies/v1/createCurrency` answers with.
|
||||
*
|
||||
* Members are in the CLIENT'S OWN ORDER, taken from its field layout rather than guessed:
|
||||
* `Shape` (a byte) and `Color` (an int) sit between `Limit` and `ImageName`. They were
|
||||
* missing from this interface while it was recovered from a frame alone, and econ spread
|
||||
* them onto the end of the payload instead — which carried them, but put them somewhere the
|
||||
* client's model doesn't have them.
|
||||
*/
|
||||
export interface RoomCurrencyPayload {
|
||||
CurrencyId: string
|
||||
/** Nullable in the client's model (`long?`), though a room currency always names one. */
|
||||
RoomId: number | null
|
||||
Name: string
|
||||
Description: string
|
||||
CurrencyType: CurrencyType
|
||||
Limit: number
|
||||
ImageName: string
|
||||
/** A byte in the client's model — its index into the coin shapes. */
|
||||
Shape: number
|
||||
Color: number
|
||||
/** Null for a currency with no custom coin art. */
|
||||
ImageName: string | null
|
||||
/** ISO-8601. */
|
||||
CreatedAt: string
|
||||
/** ISO-8601. */
|
||||
|
||||
Reference in New Issue
Block a user