mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[econ] room key wip
This commit is contained in:
@@ -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 () => {
|
||||
// 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,
|
||||
|
||||
@@ -382,5 +382,8 @@
|
||||
"CloudRegion": "us",
|
||||
"CrcCheckEnabled": false,
|
||||
"EnableServerTracingAfterDisconnect": false
|
||||
},
|
||||
"RoomKeyConfig": {
|
||||
"MaxKeysPerRoom": 10
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -49,7 +49,8 @@ missing/invalid). `~` = optional auth: served to anyone, personalised for a vali
|
||||
| GET | `/api/gamerewards/v1/pending` | | Pending game rewards (stub `[]`) |
|
||||
| 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/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 `{}` |
|
||||
| GET | `/openapi.json` | | Generated OpenAPI 3.1 spec (see below) |
|
||||
|
||||
|
||||
@@ -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
@@ -100,6 +100,7 @@ import {
|
||||
ConsumeGiftRequest,
|
||||
CreatePurchaseOfferRequest,
|
||||
CreateRoomCurrencyRequest,
|
||||
CreateRoomKeyRequest,
|
||||
CustomAvatarItemsResponse,
|
||||
EquipmentUpdateRequest,
|
||||
ErrorResponse,
|
||||
@@ -126,6 +127,8 @@ import {
|
||||
RoomCurrencyPurchaseOfferEnvelope,
|
||||
RoomCurrencyPurchaseOffersDto,
|
||||
RoomEconConfig,
|
||||
RoomKeyDto,
|
||||
RoomKeyEnvelope,
|
||||
RRPlusSignUpBonus,
|
||||
SaveOutfitRequest,
|
||||
SaveOutfitV4Response,
|
||||
@@ -154,6 +157,7 @@ import {
|
||||
getRoomCurrency,
|
||||
updateRoomCurrency,
|
||||
} from './room-currency-db'
|
||||
import { createRoomKey, getRoomKeys } from './room-key-db'
|
||||
|
||||
import type { Context } from 'hono'
|
||||
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 {
|
||||
BalanceResponsePayload,
|
||||
LocalRoomKeyPayload,
|
||||
PurchaseBalanceModificationPayload,
|
||||
RoomCurrencyPayload,
|
||||
} from '../../notify/src/notification-payloads'
|
||||
@@ -177,6 +182,7 @@ import type { Equipment } from './equipment-db'
|
||||
import type { AvatarItem } from './inventory-db'
|
||||
import type { RoomConsumable } from './room-consumable-db'
|
||||
import type { RoomCurrency, RoomCurrencyPurchaseOffer } from './room-currency-db'
|
||||
import type { RoomKey } from './room-key-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
|
||||
@@ -270,6 +276,19 @@ const CREATE_CURRENCY_FAILED = 'Failed to create currency'
|
||||
const UPDATE_CURRENCY_FAILED = 'Failed to update currency'
|
||||
const CREATE_OFFER_FAILED = 'Failed to create purchase offer'
|
||||
const SAVE_CONSUMABLE_FAILED = 'Failed to save consumable'
|
||||
/**
|
||||
* `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 }`.
|
||||
@@ -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
|
||||
* 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
|
||||
* 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) =>
|
||||
c.json([])
|
||||
)
|
||||
// Room keys for a given room (client calls this on the econ host). [] with no DB.
|
||||
.get('/api/roomkeys/v1/room', listRoute('Room keys for a room', 'Empty for now'), (c) =>
|
||||
c.json([])
|
||||
// The keys a room has listed. Public, like the room's currencies: a key is offered to
|
||||
// whoever walks up to the door it opens. A room with none, an unknown room and a missing
|
||||
// `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
|
||||
|
||||
@@ -624,6 +624,57 @@ export const CreateRoomCurrencyRequest = z.object({
|
||||
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
|
||||
* ("5 SuperTokens for 500 Rec Center Tokens"). The client's own model, member for member and
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -90,6 +90,7 @@ import { INVENTORY_SCHEMA_DDL } from '../../inventory-db'
|
||||
import { REWARD_STATUS_SCHEMA_DDL } from '../../reward-db'
|
||||
import { ROOM_CONSUMABLE_SCHEMA_DDL, ROOM_INVENTORY_SCHEMA_DDL } from '../../room-consumable-db'
|
||||
import { ROOM_BALANCE_SCHEMA_DDL, ROOM_CURRENCY_SCHEMA_DDL } from '../../room-currency-db'
|
||||
import { ROOM_KEY_SCHEMA_DDL } from '../../room-key-db'
|
||||
|
||||
import type { CatalogLoadRow, CatalogRow, CatalogValue, StoreListing } from '../../catalog-db'
|
||||
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_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_KEY_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()
|
||||
@@ -1922,10 +1924,201 @@ describe('econ endpoints', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('GET /api/roomkeys/v1/room returns []', async () => {
|
||||
const res = await exports.default.fetch(`${ORIGIN}/api/roomkeys/v1/room?roomId=1`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual([])
|
||||
// Room 2511 is seeded as account 1's, with account 2 as co-owner.
|
||||
describe('room keys', () => {
|
||||
type RoomKey = {
|
||||
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 () => {
|
||||
@@ -5745,6 +5938,7 @@ describe('econ endpoints', () => {
|
||||
'POST /api/roomcurrencies/v1/createCurrency',
|
||||
'POST /api/roomcurrencies/v1/createPurchaseOffer',
|
||||
'POST /api/roomcurrencies/v1/updateCurrency',
|
||||
'POST /api/roomkeys/v1/create',
|
||||
'POST /api/storefronts/v2/buyItem',
|
||||
'POST /api/storefronts/v3/buyInvention',
|
||||
'POST /api/ugcPurchasables/v1/items/bulk',
|
||||
|
||||
@@ -405,7 +405,10 @@ export interface RoomCurrencyDeletedPayload {
|
||||
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 {
|
||||
RoomKeyId: number
|
||||
ReplicationId: string
|
||||
@@ -416,7 +419,10 @@ export interface LocalRoomKeyPayload {
|
||||
PurchaseCurrencyId: string | null
|
||||
/** ISO-8601. */
|
||||
CreatedAt: string
|
||||
ImageName: string
|
||||
/** Null until a key carries art. */
|
||||
ImageName: string | null
|
||||
/** The key type's ordinal — 0 `Key`. */
|
||||
Type: number
|
||||
}
|
||||
|
||||
/** `LocalRoomKeyDeleted` (121). */
|
||||
|
||||
Reference in New Issue
Block a user