[econ] room key wip

This commit is contained in:
Devin Zuczek
2026-09-25 13:54:03 -04:00
parent 979e8bc595
commit bbdc061d05
9 changed files with 639 additions and 11 deletions
@@ -935,6 +935,13 @@ describe('public endpoints', () => {
) )
}) })
test('the config carries the room key limit', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/config/v2`)
expect(res.status).toBe(200)
const config = (await res.json()) as { RoomKeyConfig: { MaxKeysPerRoom: number } }
expect(config.RoomKeyConfig).toEqual({ MaxKeysPerRoom: 10 })
})
test('the level rewards match the published reward table', async () => { test('the level rewards match the published reward table', async () => {
// Rec Room's published level-reward table, spot-checked at the points where it turns: // Rec Room's published level-reward table, spot-checked at the points where it turns:
// consumables early, then clothing at a rising star rating (2★ = 10, 3★ = 20, 4★ = 30, // consumables early, then clothing at a rising star rating (2★ = 10, 3★ = 20, 4★ = 30,
+3
View File
@@ -382,5 +382,8 @@
"CloudRegion": "us", "CloudRegion": "us",
"CrcCheckEnabled": false, "CrcCheckEnabled": false,
"EnableServerTracingAfterDisconnect": false "EnableServerTracingAfterDisconnect": false
},
"RoomKeyConfig": {
"MaxKeysPerRoom": 10
} }
} }
+2 -1
View File
@@ -49,7 +49,8 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
| GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) | | GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) |
| POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 5 XP + gift box | | POST | `/api/gamerewards/v1/request` | ✓ | Claim a game reward → 5 XP + gift box |
| GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) | | GET | `/api/roomkeys/v1/mine` | | The player's room keys (stub `[]`) |
| GET | `/api/roomkeys/v1/room` | | Room keys for a room (stub `[]`) | | GET | `/api/roomkeys/v1/room` | | The keys a room has listed |
| POST | `/api/roomkeys/v1/create` | ✓ | List a key for a room (owner/co-owner) |
| POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | ~ | Gold year for `developer`s, else `{}` | | POST | `/api/CampusCard/v1/UpdateAndGetSubscription` | ~ | Gold year for `developer`s, else `{}` |
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) | | GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
+36
View File
@@ -0,0 +1,36 @@
-- Room keys — a room's own admission tickets.
--
-- A creator lists a key for their room (`POST /api/roomkeys/v1/create`, form-encoded
-- `Type=Key&RoomId=1162&Name=my%20key&Description=…&Price=50`): a named, priced thing a
-- player can hold to get through a door the room locks. This is the LISTING — its name, its
-- price, which room it opens. Nothing SELLS a key yet, so there is no table of who holds one.
-- Owned by the `econ` worker; generated from src/room-key-db.ts (ROOM_KEY_SCHEMA_DDL) — keep
-- in sync.
--
-- `room_key_id` is an AUTOINCREMENT rather than a GUID, unlike a room currency's or a
-- consumable's: the client's own model (`LocalRoomKeyCreated`'s payload, which names a key by
-- a numeric `RoomKeyId`) says so. `replication_id` is the GUID beside it that the same
-- payload carries; it is minted here.
--
-- `key_type` is the body's `Type`, stored as the enum NAME it arrives as (`Key`) and served
-- back as its ordinal (`Type: 0`); only `Key` has been observed.
--
-- `price` is the body's `Price`, and `purchase_currency_id` the `room_currency` it is charged
-- in — nullable, `Guid?` in the client's model, and the create body names none, so it is null
-- until an edit can set it. `image_name` is '' in the row and served as a null `ImageName`
-- (what the client reads) until a key can carry art.
CREATE TABLE IF NOT EXISTS room_key (
room_key_id INTEGER PRIMARY KEY AUTOINCREMENT,
replication_id TEXT NOT NULL,
room_id INTEGER NOT NULL,
key_type TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
price INTEGER NOT NULL,
purchase_currency_id TEXT,
image_name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
-- Every read is "this room's keys" — the whole access pattern.
CREATE INDEX IF NOT EXISTS idx_room_key_room ON room_key (room_id);
+186 -4
View File
@@ -100,6 +100,7 @@ import {
ConsumeGiftRequest, ConsumeGiftRequest,
CreatePurchaseOfferRequest, CreatePurchaseOfferRequest,
CreateRoomCurrencyRequest, CreateRoomCurrencyRequest,
CreateRoomKeyRequest,
CustomAvatarItemsResponse, CustomAvatarItemsResponse,
EquipmentUpdateRequest, EquipmentUpdateRequest,
ErrorResponse, ErrorResponse,
@@ -126,6 +127,8 @@ import {
RoomCurrencyPurchaseOfferEnvelope, RoomCurrencyPurchaseOfferEnvelope,
RoomCurrencyPurchaseOffersDto, RoomCurrencyPurchaseOffersDto,
RoomEconConfig, RoomEconConfig,
RoomKeyDto,
RoomKeyEnvelope,
RRPlusSignUpBonus, RRPlusSignUpBonus,
SaveOutfitRequest, SaveOutfitRequest,
SaveOutfitV4Response, SaveOutfitV4Response,
@@ -154,6 +157,7 @@ import {
getRoomCurrency, getRoomCurrency,
updateRoomCurrency, updateRoomCurrency,
} from './room-currency-db' } from './room-currency-db'
import { createRoomKey, getRoomKeys } from './room-key-db'
import type { Context } from 'hono' import type { Context } from 'hono'
import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain' import type { GiftContent, Outfit, Progression, StoredGift, XpGrant } from '@repo/domain'
@@ -161,6 +165,7 @@ import type { CustomAvatarItem } from '../../api/src/custom-avatar-items-db'
import type { SavedInvention } from '../../api/src/inventions-db' import type { SavedInvention } from '../../api/src/inventions-db'
import type { import type {
BalanceResponsePayload, BalanceResponsePayload,
LocalRoomKeyPayload,
PurchaseBalanceModificationPayload, PurchaseBalanceModificationPayload,
RoomCurrencyPayload, RoomCurrencyPayload,
} from '../../notify/src/notification-payloads' } from '../../notify/src/notification-payloads'
@@ -177,6 +182,7 @@ import type { Equipment } from './equipment-db'
import type { AvatarItem } from './inventory-db' import type { AvatarItem } from './inventory-db'
import type { RoomConsumable } from './room-consumable-db' import type { RoomConsumable } from './room-consumable-db'
import type { RoomCurrency, RoomCurrencyPurchaseOffer } from './room-currency-db' import type { RoomCurrency, RoomCurrencyPurchaseOffer } from './room-currency-db'
import type { RoomKey } from './room-key-db'
// Invention storage (owned by the `api` worker, on this same `recflare` database). // Invention storage (owned by the `api` worker, on this same `recflare` database).
// Imported directly rather than copied: these are plain D1 helpers with no bindings of // Imported directly rather than copied: these are plain D1 helpers with no bindings of
@@ -270,6 +276,19 @@ const CREATE_CURRENCY_FAILED = 'Failed to create currency'
const UPDATE_CURRENCY_FAILED = 'Failed to update currency' const UPDATE_CURRENCY_FAILED = 'Failed to update currency'
const CREATE_OFFER_FAILED = 'Failed to create purchase offer' const CREATE_OFFER_FAILED = 'Failed to create purchase offer'
const SAVE_CONSUMABLE_FAILED = 'Failed to save consumable' const SAVE_CONSUMABLE_FAILED = 'Failed to save consumable'
/**
* `Status` on the room-key create's `{ Status, RoomKey }` reply. 0 is the observed success
* code; the refusal code has not been observed, so 1 is an assumption.
*/
const ROOM_KEY_STATUS_OK = 0
const ROOM_KEY_STATUS_FAILED = 1
/**
* The most keys one room may list — the `RoomKeyConfig.MaxKeysPerRoom` the `api` worker's
* config (`apps/api/static/api-config-v2.json`) tells the client, restated here so the server
* refuses what the client already knows not to ask for.
*/
const MAX_KEYS_PER_ROOM = 10
/** /**
* The envelope the create endpoint answers in: `{ Value, Success, Error, error_id }`. * The envelope the create endpoint answers in: `{ Value, Success, Error, error_id }`.
@@ -288,6 +307,19 @@ function roomCurrencyEnvelope(c: Context<App>, value: RoomCurrency | null, error
}) })
} }
/**
* The room-key create's envelope: `{ Status, RoomKey }`, as the live client was observed
* reading it — NOT the `{ Value, Success, Error, error_id }` the room-currency and consumable
* writes beside it answer in. `Status` 0 carries the key; a refusal is a non-zero `Status`
* with a null `RoomKey` (the refusal code is an assumption — only success has been observed).
*/
function roomKeyEnvelope(c: Context<App>, key: RoomKey | null) {
return c.json({
Status: key === null ? ROOM_KEY_STATUS_FAILED : ROOM_KEY_STATUS_OK,
RoomKey: key,
})
}
/** /**
* The consumable write's envelope — the same `{ Value, Success, Error, error_id }` shape the * The consumable write's envelope — the same `{ Value, Success, Error, error_id }` shape the
* room-currency writes answer in, carrying one listing. * room-currency writes answer in, carrying one listing.
@@ -387,6 +419,56 @@ async function pushRoomCurrencyChange(
) )
} }
/**
* Push `LocalRoomKeyCreated` (120) for a new room key to everyone standing in the room, plus
* whoever listed it — the same audience, for the same reason, as a room currency's push: a
* key is something every client in the room may offer for sale, and the settings UI that
* lists one opens from outside the room, so its author is added separately.
*
* The frame is typed as the hub's {@link LocalRoomKeyPayload} — the client's own decoder —
* and is the stored record verbatim, so the HTTP response and the frame cannot drift apart.
* Best-effort: the key has already committed, and a hub hiccup must not fail the request.
*/
async function pushRoomKeyCreated(
c: Context<App>,
key: RoomKey,
createdByAccountId: number
): Promise<void> {
const frame: LocalRoomKeyPayload = key
let occupants: number[] = []
try {
occupants = await getPlayerIdsInRoom(c.env.DB, key.RoomId)
} catch (err) {
logger.error('failed to read room presence for a room-key push', {
roomId: key.RoomId,
error: err instanceof Error ? err.message : String(err),
})
}
const playerIds = new Set(occupants)
playerIds.add(createdByAccountId)
await Promise.all(
[...playerIds].map(async (playerId) => {
try {
await c.env.RECFLARE_NOTIFICATIONS_HUB.getByName(HUB_INSTANCE).notifyPlayer(
playerId,
NotificationType.LocalRoomKeyCreated,
{ ...frame }
)
} catch (err) {
logger.error('failed to push a room-key notification', {
playerId,
roomId: key.RoomId,
roomKeyId: key.RoomKeyId,
error: err instanceof Error ? err.message : String(err),
})
}
})
)
}
/** /**
* Push a ConsumableMappingRemoved notification to a player after they consume a * Push a ConsumableMappingRemoved notification to a player after they consume a
* consumable, mirroring the reference's * consumable, mirroring the reference's
@@ -5426,13 +5508,113 @@ const app = new Hono<App>({ strict: false })
} }
) )
// The player's room keys. Returns "[]". // The player's room keys. Returns "[]": nothing sells a key yet, so nobody holds one.
.get('/api/roomkeys/v1/mine', listRoute('The player’s room keys', 'Empty for now'), (c) => .get('/api/roomkeys/v1/mine', listRoute('The player’s room keys', 'Empty for now'), (c) =>
c.json([]) c.json([])
) )
// Room keys for a given room (client calls this on the econ host). [] with no DB. // The keys a room has listed. Public, like the room's currencies: a key is offered to
.get('/api/roomkeys/v1/room', listRoute('Room keys for a room', 'Empty for now'), (c) => // whoever walks up to the door it opens. A room with none, an unknown room and a missing
c.json([]) // `roomId` are all the same empty list.
.get(
'/api/roomkeys/v1/room',
describeRoute({
tags: ['Econ'],
summary: 'A room’s keys',
description: [
'Every key the room named by `roomId` has listed, oldest first.',
'',
'Public, like `GET /api/roomcurrencies/v1/currencies`. A room with none, an unknown',
'room and a missing `roomId` are all the same empty list.',
].join('\n'),
parameters: [
{
name: 'roomId',
in: 'query',
required: false,
description: 'The room whose keys to list',
schema: { type: 'string' },
},
],
responses: { 200: json(RoomKeyDto.array(), 'The room’s keys, oldest first') },
}),
async (c) => {
const roomId = Number.parseInt(c.req.query('roomId') ?? '', 10)
if (!Number.isInteger(roomId)) return c.json([])
return c.json(await getRoomKeys(c.env.DB, roomId))
}
)
// List a key for a room. Auth-gated (401) and gated to the room's creator or a co-owner
// (403) — the same owner-level check the room-currency writes apply.
.post(
'/api/roomkeys/v1/create',
describeRoute({
tags: ['Econ'],
summary: 'Create a room key',
description: [
'Lists a key for one room — its name, its description and its price. Form-encoded',
'(`Type=Key&RoomId=1162&Name=my%20key&Description=…&Price=50`).',
'',
'Gated to the room’s CREATOR or a CO-OWNER, like minting a currency. 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. A room may list at most 10 keys — the `RoomKeyConfig.MaxKeysPerRoom`',
'the `api` config tells the client — and the eleventh is refused.',
'',
'Answers `{ Status, RoomKey }` — `Status` 0 and the created key, as the live client',
'reads it — not the `{ Value, Success, Error, error_id }` envelope the room-currency',
'writes use. A recoverable refusal (an unusable `RoomId`, an empty `Name`, an unknown',
'room, a full room) is a 200 carrying a non-zero `Status` and a null `RoomKey`; only',
'the auth gates answer with an HTTP status of their own.',
'',
'Pushes `LocalRoomKeyCreated` (120), carrying the same object, to everyone in the',
'room and to the caller.',
].join('\n'),
security: AUTHED,
requestBody: form(CreateRoomKeyRequest, 'The key to list'),
responses: {
200: json(RoomKeyEnvelope, 'The key as created, or a refusal with a non-zero `Status`'),
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 roomKeyEnvelope(c, null)
const name = str(body.Name).trim()
if (name === '') return roomKeyEnvelope(c, null)
const canManage = await canManageRoomById(c.env.DB, roomId, accountId)
if (canManage === null) return roomKeyEnvelope(c, null)
if (!canManage) return c.body(null, 403)
if ((await getRoomKeys(c.env.DB, roomId)).length >= MAX_KEYS_PER_ROOM) {
return roomKeyEnvelope(c, null)
}
const key = await createRoomKey(c.env.DB, {
RoomId: roomId,
Type: str(body.Type).trim() || 'Key',
Name: censorSwears(name),
Description: censorSwears(str(body.Description)),
// A key cannot cost less than nothing.
Price: Math.max(0, int(body.Price, 0)),
})
await pushRoomKeyCreated(c, key, accountId)
return roomKeyEnvelope(c, key)
}
) )
// The Rec Room Plus sign-up bonus: which bonus is running and the token price window // The Rec Room Plus sign-up bonus: which bonus is running and the token price window
+51
View File
@@ -624,6 +624,57 @@ export const CreateRoomCurrencyRequest = z.object({
Color: z.string().optional().describe('Coin colour index; defaults to 0'), Color: z.string().optional().describe('Coin colour index; defaults to 0'),
}) })
/**
* A room key — a named, priced admission ticket a room lists. The shape is the `RoomKey` the
* live client was observed reading out of the create response, member for member and in its
* order; the `LocalRoomKeyCreated` frame (the `notify` worker's `LocalRoomKeyPayload`) carries
* the same object.
*
* `RoomKeyId` is a NUMBER, unlike a room currency's or consumable's GUID — the client's model
* says so — with the `ReplicationId` GUID beside it. The create body's `Type` arrives as an
* enum NAME (`Key`) and is served as the ORDINAL (0). `ImageName` is null, not '', until a key
* can carry art.
*/
export const RoomKeyDto = z.object({
RoomKeyId: z.int().describe('Autoincrement — how the client names this key afterwards'),
ReplicationId: z.string().describe('GUID, minted alongside the id'),
RoomId: z.int(),
Name: z.string(),
Description: z.string(),
Price: z.int(),
PurchaseCurrencyId: z
.string()
.nullable()
.describe('A `room_currency` id, or null — the create body names none'),
CreatedAt: z.string().describe('ISO-8601 UTC'),
ImageName: z.string().nullable().describe('Null — a key cannot carry art yet'),
Type: z.int().describe('The key type’s ordinal: 0 `Key`, the only one seen'),
})
/**
* The envelope `POST /api/roomkeys/v1/create` answers in: `{ Status, RoomKey }`, observed from
* the live client — NOT the `{ Value, Success, Error, error_id }` the room-currency and
* consumable writes use, though they live in the same worker. `Status` is 0 on success. A
* refusal answers a non-zero `Status` with a null `RoomKey`; only the success shape has been
* observed, so the refusal code (1) is an assumption.
*/
export const RoomKeyEnvelope = z.object({
Status: z.int().describe('0 on success; 1 on a refusal (the refusal code is an assumption)'),
RoomKey: RoomKeyDto.nullable().describe('The key, or null on a refusal'),
})
/** `POST /api/roomkeys/v1/create` — form-encoded. */
export const CreateRoomKeyRequest = z.object({
Type: z
.string()
.optional()
.describe('The key type, as an enum NAME. Only `Key` has been seen; defaults to it'),
RoomId: z.string().describe('The room the key opens'),
Name: z.string().describe('Shown to players; profanity-masked like every typed string'),
Description: z.string().optional().describe('Defaults to empty'),
Price: z.string().optional().describe('What the key costs; defaults to 0'),
})
/** /**
* One purchase offer on a room currency — a way to BUY that currency, priced in another * 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 * ("5 SuperTokens for 500 Rec Center Tokens"). The client's own model, member for member and
+148
View File
@@ -0,0 +1,148 @@
/**
* Room keys — a room's own admission tickets.
*
* A creator lists a key for their room: a named, priced thing a player can hold to get
* through a door the room locks. This module is the LISTING — its name, its price, which room
* it opens. Nothing sells a key yet, so who holds one is not recorded anywhere. The endpoints:
* - `POST /api/roomkeys/v1/create` lists one
* - `GET /api/roomkeys/v1/room?roomId=` lists a room's
*
* This worker (`econ`) owns the table and its migrations — see apps/econ/migrations/0024.
*/
/** Schema DDL — also builds the table in tests. Mirrors migration 0024. */
export const ROOM_KEY_SCHEMA_DDL: string[] = [
// `room_key_id` is an AUTOINCREMENT rather than a GUID, unlike a room currency's: the
// client's own model names a key by a numeric `RoomKeyId` (see the `notify` worker's
// `LocalRoomKeyPayload`). `replication_id` is the GUID beside it, minted here.
//
// `key_type` is the body's `Type`, stored as the enum NAME it arrives as (`Key`) and served
// back as the ORDINAL (`Type: 0`) — see {@link ROOM_KEY_TYPE_ORDINALS}. Only `Key` has
// been observed.
//
// `purchase_currency_id` is the `room_currency` the key is charged in — nullable, and the
// create body names none. `image_name` is '' in the row (NOT NULL), but the client reads
// `ImageName` as null until a key carries art, so an empty column is served as null.
`CREATE TABLE IF NOT EXISTS room_key (
room_key_id INTEGER PRIMARY KEY AUTOINCREMENT,
replication_id TEXT NOT NULL,
room_id INTEGER NOT NULL,
key_type TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
price INTEGER NOT NULL,
purchase_currency_id TEXT,
image_name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`,
// Every read is "this room's keys" — the whole access pattern.
`CREATE INDEX IF NOT EXISTS idx_room_key_room ON room_key (room_id)`,
]
/**
* The `Type` a key is served with: the ordinal of the client's key-type enum, keyed by the
* NAME the create body posts (`Type=Key`). Only `Key` has been observed; an unrecognised name
* is stored as posted and served as 0.
*/
export const ROOM_KEY_TYPE_ORDINALS: Readonly<Record<string, number>> = { Key: 0 }
/**
* A room key as the client reads it — the `RoomKey` inside the create response's
* `{ Status, RoomKey }`, member for member and in its order (observed from the live client),
* and the `notify` worker's `LocalRoomKeyPayload`. The create endpoint answers this object and
* the `LocalRoomKeyCreated` frame carries it, which is why `econ.app.ts` assigns one of these
* straight to a `LocalRoomKeyPayload`: that assignment is what stops the two drifting apart.
*
* `Type` is the body's enum NAME served back as its ORDINAL (`Key` → 0), and `ImageName` is
* NULL rather than '' until a key can carry art — both as the client sends and reads them.
*/
export interface RoomKey {
RoomKeyId: number
ReplicationId: string
RoomId: number
Name: string
Description: string
Price: number
/** A `room_currency` id, or null — the create body names none. */
PurchaseCurrencyId: string | null
/** ISO-8601 UTC. */
CreatedAt: string
/** Null until a key can carry art. */
ImageName: string | null
/** The key type's ordinal — 0 `Key`. See {@link ROOM_KEY_TYPE_ORDINALS}. */
Type: number
}
/** What the create endpoint supplies; the ids and the timestamp are minted here. */
export interface NewRoomKey {
RoomId: number
/** The body's `Type`, an enum name (`Key`). Stored verbatim, served as its ordinal. */
Type: string
Name: string
Description: string
Price: number
}
interface RoomKeyRow {
room_key_id: number
replication_id: string
room_id: number
key_type: string
name: string
description: string
price: number
purchase_currency_id: string | null
image_name: string
created_at: string
}
const SELECT_COLUMNS = `room_key_id, replication_id, room_id, key_type, name, description, price, purchase_currency_id, image_name, created_at`
const toRoomKey = (row: RoomKeyRow): RoomKey => ({
RoomKeyId: row.room_key_id,
ReplicationId: row.replication_id,
RoomId: row.room_id,
Name: row.name,
Description: row.description,
Price: row.price,
PurchaseCurrencyId: row.purchase_currency_id,
CreatedAt: row.created_at,
ImageName: row.image_name === '' ? null : row.image_name,
Type: ROOM_KEY_TYPE_ORDINALS[row.key_type] ?? 0,
})
/** List a room key, returning it as the client reads it back. */
export async function createRoomKey(db: D1Database, key: NewRoomKey): Promise<RoomKey> {
const row = await db
.prepare(
`INSERT INTO room_key
(replication_id, room_id, key_type, name, description, price, purchase_currency_id, image_name, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, '', ?7)
RETURNING ${SELECT_COLUMNS}`
)
.bind(
crypto.randomUUID(),
key.RoomId,
key.Type,
key.Name,
key.Description,
key.Price,
new Date().toISOString()
)
.first<RoomKeyRow>()
return toRoomKey(row!)
}
/**
* Every key a room has listed, oldest first — the order its owner built them up in. The
* autoincrement id IS creation order, so it is the sort key.
*/
export async function getRoomKeys(db: D1Database, roomId: number): Promise<RoomKey[]> {
const { results } = await db
.prepare(`SELECT ${SELECT_COLUMNS} FROM room_key WHERE room_id = ?1 ORDER BY room_key_id`)
.bind(roomId)
.all<RoomKeyRow>()
return results.map(toRoomKey)
}
+198 -4
View File
@@ -90,6 +90,7 @@ import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db' import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
import { ROOM_CONSUMABLE_SCHEMA_DDL, ROOM_INVENTORY_SCHEMA_DDL } from '../../room-consumable-db' import { ROOM_CONSUMABLE_SCHEMA_DDL, ROOM_INVENTORY_SCHEMA_DDL } from '../../room-consumable-db'
import { ROOM_BALANCE_SCHEMA_DDL, ROOM_CURRENCY_SCHEMA_DDL } from '../../room-currency-db' import { ROOM_BALANCE_SCHEMA_DDL, ROOM_CURRENCY_SCHEMA_DDL } from '../../room-currency-db'
import { ROOM_KEY_SCHEMA_DDL } from '../../room-key-db'
import type { CatalogLoadRow, CatalogRow, CatalogValue, StoreListing } from '../../catalog-db' import type { CatalogLoadRow, CatalogRow, CatalogValue, StoreListing } from '../../catalog-db'
import type { Env } from '../../context' import type { Env } from '../../context'
@@ -196,6 +197,7 @@ beforeAll(async () => {
for (const stmt of ROOM_BALANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of ROOM_BALANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of ROOM_CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of ROOM_CONSUMABLE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of ROOM_INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of ROOM_INVENTORY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of ROOM_KEY_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Presence (owned by the `match` worker) — a new room currency is pushed to everyone // Presence (owned by the `match` worker) — a new room currency is pushed to everyone
// standing in the room, which is read from here. // standing in the room, which is read from here.
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
@@ -1922,10 +1924,201 @@ describe('econ endpoints', () => {
}) })
}) })
test('GET /api/roomkeys/v1/room returns []', async () => { // Room 2511 is seeded as account 1's, with account 2 as co-owner.
const res = await exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/room?roomId=1`) describe('room keys', () => {
expect(res.status).toBe(200) type RoomKey = {
expect(await res.json()).toEqual([]) RoomKeyId: number
ReplicationId: string
RoomId: number
Name: string
Description: string
Price: number
PurchaseCurrencyId: string | null
CreatedAt: string
ImageName: string | null
Type: number
}
const create = async (fields: Record<string, string>, headers?: Record<string, string>) =>
exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/create`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...headers },
body: new URLSearchParams(fields),
})
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 create's reply, as the live client reads it — NOT the `{ Value, Success, Error,
// error_id }` envelope the currency and consumable writes answer in.
const envOf = async (res: Response) =>
(await res.json()) as {
Status: number
RoomKey: RoomKey | null
}
const keyOf = async (res: Response) => (await envOf(res)).RoomKey!
const keysOf = async (roomId: number) =>
(await (
await exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/room?roomId=${roomId}`)
).json()) as RoomKey[]
// The body exactly as the client posts it.
const body = {
Type: 'Key',
RoomId: '2511',
Name: 'my key',
Description: 'testsdfsdfsdf',
Price: '50',
}
test('GET /api/roomkeys/v1/room is public and empty for a room with none', async () => {
expect(await keysOf(2511)).toEqual([])
expect(await keysOf(99999)).toEqual([])
const res = await exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/room`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('POST create is gated to the room’s creator or a co-owner', async () => {
expect((await create(body)).status).toBe(401)
expect((await create(body, await bearer('999'))).status).toBe(403)
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({ Status: 1, RoomKey: null })
}
expect(await keysOf(2511)).toEqual([])
})
test('POST create lists the key and the room read serves it back', async () => {
const res = await create(body, await bearer('1'))
expect(res.status).toBe(200)
const envelope = await envOf(res)
expect(Object.keys(envelope)).toEqual(['Status', 'RoomKey'])
expect(envelope.Status).toBe(0)
const created = envelope.RoomKey!
// The key as the live client reads it, member for member and in its order.
expect(Object.keys(created)).toEqual([
'RoomKeyId',
'ReplicationId',
'RoomId',
'Name',
'Description',
'Price',
'PurchaseCurrencyId',
'CreatedAt',
'ImageName',
'Type',
])
expect(created).toMatchObject({
RoomId: 2511,
Name: 'my key',
Description: 'testsdfsdfsdf',
Price: 50,
PurchaseCurrencyId: null,
// Null, not '', until a key carries art.
ImageName: null,
// The posted enum NAME (`Key`) comes back as its ordinal.
Type: 0,
})
// A NUMERIC id, with a GUID beside it.
expect(Number.isInteger(created.RoomKeyId)).toBe(true)
expect(created.ReplicationId).toMatch(/^[0-9a-f-]{36}$/)
expect(new Date(created.CreatedAt).toISOString()).toBe(created.CreatedAt)
// The body's Type is stored as the name it arrived as.
const row = await env.DB.prepare('SELECT key_type FROM room_key WHERE room_key_id = ?1')
.bind(created.RoomKeyId)
.first<{ key_type: string }>()
expect(row?.key_type).toBe('Key')
expect(await keysOf(2511)).toEqual([created])
// A co-owner may list one too, and the list is oldest first.
const second = await keyOf(await create({ ...body, Name: 'second' }, await bearer('2')))
expect(second.RoomKeyId).toBeGreaterThan(created.RoomKeyId)
expect((await keysOf(2511)).map((k) => k.Name)).toEqual(['my key', 'second'])
})
test('POST create pushes LocalRoomKeyCreated to the room and the creator', async () => {
const now = Math.floor(Date.now() / 1000)
const putInRoom = async (accountId: number, roomId: number) =>
env.DB.prepare('INSERT OR REPLACE INTO presence (data) VALUES (?1)')
.bind(
JSON.stringify({
accountId,
roomInstance: { roomInstanceId: 1000000 + roomId, roomId, subRoomId: roomId },
expiresAt: now + 900,
})
)
.run()
await putInRoom(320, 2511)
await putInRoom(321, 1)
await drainFrames()
const created = await keyOf(await create({ ...body, Name: 'pushed' }, await bearer('1')))
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.LocalRoomKeyCreated)).toBe(
true
)
// The frame IS the create response.
expect(frames[0].payload).toEqual(created)
for (const id of [320, 321]) {
await env.DB.prepare('DELETE FROM presence WHERE account_id = ?1').bind(id).run()
}
})
test('POST create masks a name players will see, and defaults the rest', async () => {
const res = await create({ RoomId: '2511', Name: 'shit key' }, await bearer('1'))
const created = await keyOf(res)
expect(created.Name).toBe('**** key')
expect(created).toMatchObject({ Description: '', Price: 0, Type: 0 })
const row = await env.DB.prepare('SELECT key_type FROM room_key WHERE room_key_id = ?1')
.bind(created.RoomKeyId)
.first<{ key_type: string }>()
expect(row?.key_type).toBe('Key')
// A negative price is floored at nothing.
const free = await keyOf(await create({ ...body, Price: '-5' }, await bearer('1')))
expect(free.Price).toBe(0)
})
test('POST create refuses the eleventh key in a room', async () => {
// MaxKeysPerRoom is 10 (apps/api/static/api-config-v2.json). Fill up to it…
while ((await keysOf(2511)).length < 10) {
const res = await create({ ...body, Name: 'filler' }, await bearer('1'))
expect((await envOf(res)).Status).toBe(0)
}
// …and the next is refused, leaving the room at ten.
const res = await create({ ...body, Name: 'one too many' }, await bearer('1'))
expect(res.status).toBe(200)
expect(await envOf(res)).toEqual({ Status: 1, RoomKey: null })
expect((await keysOf(2511)).length).toBe(10)
await env.DB.prepare('DELETE FROM room_key').run()
})
}) })
test('GET /api/roomconsumables/v1/roomConsumable/room/:id/me returns []', async () => { test('GET /api/roomconsumables/v1/roomConsumable/room/:id/me returns []', async () => {
@@ -5745,6 +5938,7 @@ describe('econ endpoints', () => {
'POST /api/roomcurrencies/v1/createCurrency', 'POST /api/roomcurrencies/v1/createCurrency',
'POST /api/roomcurrencies/v1/createPurchaseOffer', 'POST /api/roomcurrencies/v1/createPurchaseOffer',
'POST /api/roomcurrencies/v1/updateCurrency', 'POST /api/roomcurrencies/v1/updateCurrency',
'POST /api/roomkeys/v1/create',
'POST /api/storefronts/v2/buyItem', 'POST /api/storefronts/v2/buyItem',
'POST /api/storefronts/v3/buyInvention', 'POST /api/storefronts/v3/buyInvention',
'POST /api/ugcPurchasables/v1/items/bulk', 'POST /api/ugcPurchasables/v1/items/bulk',
+8 -2
View File
@@ -405,7 +405,10 @@ export interface RoomCurrencyDeletedPayload {
CurrencyId: string CurrencyId: string
} }
/** `LocalRoomKeyCreated` (120). */ /**
* `LocalRoomKeyCreated` (120) — the same `RoomKey` object the create endpoint answers inside
* its `{ Status, RoomKey }` envelope; `econ` sends the one record on both.
*/
export interface LocalRoomKeyPayload { export interface LocalRoomKeyPayload {
RoomKeyId: number RoomKeyId: number
ReplicationId: string ReplicationId: string
@@ -416,7 +419,10 @@ export interface LocalRoomKeyPayload {
PurchaseCurrencyId: string | null PurchaseCurrencyId: string | null
/** ISO-8601. */ /** ISO-8601. */
CreatedAt: string CreatedAt: string
ImageName: string /** Null until a key carries art. */
ImageName: string | null
/** The key type's ordinal — 0 `Key`. */
Type: number
} }
/** `LocalRoomKeyDeleted` (121). */ /** `LocalRoomKeyDeleted` (121). */