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 () => {
|
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,
|
||||||
|
|||||||
@@ -382,5 +382,8 @@
|
|||||||
"CloudRegion": "us",
|
"CloudRegion": "us",
|
||||||
"CrcCheckEnabled": false,
|
"CrcCheckEnabled": false,
|
||||||
"EnableServerTracingAfterDisconnect": 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 `[]`) |
|
| 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) |
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 { 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',
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
Reference in New Issue
Block a user