mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-26 23:48:52 -07:00
[econ] room key wip
This commit is contained in:
+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
|
||||
|
||||
Reference in New Issue
Block a user