add rooms

This commit is contained in:
Devin Zuczek
2026-06-14 18:59:35 -04:00
parent 767dd47bab
commit 768ca2a0a3
64 changed files with 87267 additions and 787 deletions
+105 -4
View File
@@ -56,6 +56,64 @@ async function parseFormIds(c: Context<App>): Promise<number[]> {
.filter((n) => !Number.isNaN(n))
}
/** Unity scene id for the dorm (matches the match worker's instance location). */
const DORM_SCENE_ID = '76d98498-60a1-430c-ab76-b54a29b7a163'
/**
* Full room payload (PascalCase), mirroring the C#'s `BuildRoomResponse` /
* `RoomserverRoomsBulk`. With no Rooms DB, room 1 is the dorm and other ids get
* a generic published room. The SubRoom carries the UnitySceneId/DataBlob the
* client needs to load the scene.
*/
function buildRoomResponse(roomId: number) {
const isDorm = roomId === 1
return {
RoomId: roomId,
Name: isDorm ? 'DormRoom' : `Room${roomId}`,
Description: isDorm ? 'Your private room' : '',
CreatorAccountId: 1,
ImageName: 'DefaultRoomImage.jpg',
State: 0,
Accessibility: 0,
SupportsLevelVoting: false,
IsRRO: false,
IsDorm: isDorm,
CloningAllowed: false,
SupportsVRLow: true,
SupportsQuest2: true,
SupportsMobile: true,
SupportsScreens: true,
SupportsWalkVR: true,
SupportsTeleportVR: true,
SupportsJuniors: true,
MinLevel: 0,
WarningMask: 0,
CustomWarning: null,
DisableMicAutoMute: false,
DisableRoomComments: false,
EncryptVoiceChat: false,
CreatedAt: '2026-01-18T02:31:37.6171131',
Stats: { CheerCount: 0, FavoriteCount: 0, VisitorCount: 1, VisitCount: 1 },
SubRooms: [
{
SubRoomId: 1,
Name: '',
DataBlob: '',
IsSandbox: false,
MaxPlayers: 4,
Accessibility: 0,
UnitySceneId: isDorm ? DORM_SCENE_ID : '',
DataSavedAt: '2026-01-18T02:31:37.6171131',
},
],
Roles: [],
LoadScreens: [],
PromoImages: [],
PromoExternalContent: [],
Tags: [],
}
}
/** Default reputation for an account — the fallback the C# fills with no DB. */
function defaultReputation(id: number) {
return {
@@ -95,6 +153,27 @@ const app = new Hono<App>({ strict: false })
UseRudderStack: false,
})
)
.get('/api/config/v1/azurespeech', (c) =>
c.json({
Key: 'dce8de5b297747d9b5bddcc7f19e8c5b',
Region: 'eastus',
Enabled: false,
})
)
.get('/api/config/v1/backtrace', (c) =>
c.json({
ReportBudget: 125,
FilterType: 0,
SampleRate: 0.025,
LogLineCount: 50,
CaptureNativeCrashes: 1,
AMRThresholdMS: 0,
MessageCount: 1000,
MessageRegex:
"^Cannot set the parent of the GameObject .* while its new parent|^\\\\>\\\\x2010x\\\\:\\\\x20|\\\\'LabelTheme\\\\' contains missing PaletteTheme reference on",
VersionRegex: '.*',
})
)
.get('/api/config/v2', (c) => c.json(apiConfigV2))
.get('/api/versioncheck/v4', (c) =>
c.json({
@@ -129,6 +208,11 @@ const app = new Hono<App>({ strict: false })
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
})
// C# v2 is identical to v1 — same ParseFormIds + PlayerProgressions query.
.post('/api/players/v2/progression/bulk', async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
})
.post('/api/v1/progression/bulk', async (c) => {
await parseFormIds(c) // TODO: query PlayerProgressions for these ids
return c.json([])
@@ -217,6 +301,17 @@ const app = new Hono<App>({ strict: false })
return c.json({ success: false, error: 'Gift not found' }, 404)
})
// Custom avatar item gates. None of these are in CannedNet — they're real Rec
// Room client endpoints the C# never implemented. Each returns a bare JSON
// boolean; we enable them. Flip to `false` to disable the corresponding flow.
.get('/api/customAvatarItems/v1/isCreationAllowedForAccount', (c) => c.json(true))
.get('/api/customAvatarItems/v1/isCreationEnabled', (c) => c.json(true))
.get('/api/customAvatarItems/v1/isRenderingEnabled', (c) => c.json(true))
// Voice chat config. Not in CannedNet; the client fetches it to set up voice.
// No reference shape, so return an empty object until the client needs fields.
.get('/voice/config', (c) => c.json({}))
// ---- Player reporting -----------------------------------------------------
.get('/api/PlayerReporting/v1/moderationBlockDetails', (c) =>
c.json({
@@ -330,19 +425,25 @@ const app = new Hono<App>({ strict: false })
if (!idParam && !nameParam) {
return c.text("Either 'id' or 'name' query parameter is required", 400)
}
return c.json([]) // TODO: query Rooms + related tables
// Synthesize a room per requested id (the client needs SubRooms to load).
// TODO: query Rooms + related tables once a DB binding exists.
const ids = (idParam ?? '')
.split(',')
.map((s) => Number.parseInt(s.trim(), 10))
.filter((n) => !Number.isNaN(n))
return c.json(ids.map(buildRoomResponse))
})
.get('/roomserver/rooms/hot', (c) => c.json({ Results: [], TotalResults: 0 }))
.get('/roomserver/roomsandplaylists/hot', (c) => c.json({ Results: [], TotalResults: 0 }))
.get('/roomserver/rooms/createdby/me', (c) => c.json([])) // TODO: hydrate from JSON/ownedrooms.json
.get('/roomserver/rooms/createdby/me', (c) => c.json([buildRoomResponse(1)]))
.get('/roomserver/rooms/:id/interactionby/me', (c) =>
c.json({ Cheered: false, Favorited: false })
)
.get('/roomserver/rooms/:id', (c) => {
const roomId = Number.parseInt(c.req.param('id'), 10)
if (Number.isNaN(roomId)) return c.notFound()
// No Rooms binding → room can never be found.
return c.notFound()
// No Rooms binding → synthesize the room so the client can load it.
return c.json(buildRoomResponse(roomId))
})
export default app
+67 -5
View File
@@ -46,6 +46,23 @@ describe('public endpoints', () => {
})
})
test('GET /api/config/v1/azurespeech', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/azurespeech`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({
Key: 'dce8de5b297747d9b5bddcc7f19e8c5b',
Region: 'eastus',
Enabled: false,
})
})
test('GET /api/config/v1/backtrace', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/config/v1/backtrace`)
expect(res.status).toBe(200)
const body = (await res.json()) as { ReportBudget: number; VersionRegex: string }
expect(body).toMatchObject({ ReportBudget: 125, VersionRegex: '.*' })
})
test('GET /api/versioncheck/v4', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/versioncheck/v4`)
expect(await res.json()).toMatchObject({ VersionStatus: 0 })
@@ -84,6 +101,42 @@ describe('public endpoints', () => {
const res = await exports.default.fetch(`${ORIGIN}/api/storefronts/v1/p2p/betaEnabled`)
expect(await res.json()).toBe(false)
})
test('POST /api/players/v2/progression/bulk returns an array', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/players/v2/progression/bulk`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ Ids: '1,2,3' }),
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
test('GET /api/customAvatarItems/v1/isCreationAllowedForAccount returns true', async () => {
const res = await exports.default.fetch(
`${ORIGIN}/api/customAvatarItems/v1/isCreationAllowedForAccount`
)
expect(res.status).toBe(200)
expect(await res.json()).toBe(true)
})
test('GET /api/customAvatarItems/v1/isCreationEnabled returns true', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/isCreationEnabled`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(true)
})
test('GET /api/customAvatarItems/v1/isRenderingEnabled returns true', async () => {
const res = await exports.default.fetch(`${ORIGIN}/api/customAvatarItems/v1/isRenderingEnabled`)
expect(res.status).toBe(200)
expect(await res.json()).toBe(true)
})
test('GET /voice/config returns an object', async () => {
const res = await exports.default.fetch(`${ORIGIN}/voice/config`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual({})
})
})
describe('auth-gated endpoints', () => {
@@ -129,10 +182,12 @@ describe('room server', () => {
expect(res.status).toBe(400)
})
test('GET /roomserver/rooms/bulk with id returns empty array', async () => {
test('GET /roomserver/rooms/bulk with id returns rooms with SubRooms', async () => {
const res = await exports.default.fetch(`${ORIGIN}/roomserver/rooms/bulk?id=1,2`)
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
const rooms = (await res.json()) as Array<{ RoomId: number; SubRooms: unknown[] }>
expect(rooms.map((r) => r.RoomId)).toEqual([1, 2])
expect(rooms[0].SubRooms).toHaveLength(1)
})
test('GET /roomserver/rooms/hot returns an empty result set', async () => {
@@ -140,9 +195,16 @@ describe('room server', () => {
expect(await res.json()).toEqual({ Results: [], TotalResults: 0 })
})
test('GET /roomserver/rooms/:id 404s without data', async () => {
const res = await exports.default.fetch(`${ORIGIN}/roomserver/rooms/5`)
expect(res.status).toBe(404)
test('GET /roomserver/rooms/:id synthesizes a room with a SubRoom', async () => {
const res = await exports.default.fetch(`${ORIGIN}/roomserver/rooms/1`)
expect(res.status).toBe(200)
const room = (await res.json()) as {
RoomId: number
IsDorm: boolean
SubRooms: Array<{ UnitySceneId: string }>
}
expect(room).toMatchObject({ RoomId: 1, IsDorm: true })
expect(room.SubRooms[0].UnitySceneId).toBe('76d98498-60a1-430c-ab76-b54a29b7a163')
})
test('GET /roomserver/rooms/:id/interactionby/me', async () => {
File diff suppressed because one or more lines are too long