[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
+186 -4
View File
@@ -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