mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 15:11:29 -07:00
add rooms
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
import type { HonoApp } from '@repo/hono-helpers'
|
||||
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
|
||||
|
||||
export type Env = SharedHonoEnv & {
|
||||
// add additional Bindings here
|
||||
}
|
||||
|
||||
/** Variables can be extended */
|
||||
export type Variables = SharedHonoVariables
|
||||
|
||||
export interface App extends HonoApp {
|
||||
Bindings: Env
|
||||
Variables: Variables
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Hono } from 'hono'
|
||||
import { useWorkersLogger } from 'workers-tagged-logger'
|
||||
|
||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||
|
||||
import type { App } from './context'
|
||||
|
||||
/**
|
||||
* Ported from the C# `RoomsController`. The C# backs these with EF Core
|
||||
* (`AppDbContext`); there's no DB binding yet, so room lookups synthesize the
|
||||
* same shape `BuildRoomResponse` produces. Responses are PascalCase — the C#
|
||||
* sets `PropertyNamingPolicy = null` and the anonymous response object uses
|
||||
* PascalCase member names (see JSON/ownedrooms.json).
|
||||
*
|
||||
* The class `[Route("rooms")]` prefix maps to this worker's subdomain, so the
|
||||
* method routes are served bare (e.g. `/rooms/{id}`, not `/rooms/rooms/{id}`).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build the full room payload the C# `BuildRoomResponse` returns. With no DB,
|
||||
* room 1 is the dorm (matching JSON/ownedrooms.json) and any other id gets a
|
||||
* generic published room so the client can still resolve and load it.
|
||||
*/
|
||||
/** Unity scene id for the dorm (also the matchmake/heartbeat instance location). */
|
||||
const DORM_SCENE_ID = '76d98498-60a1-430c-ab76-b54a29b7a163'
|
||||
|
||||
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 },
|
||||
// The client needs a SubRoom (UnitySceneId + DataBlob) to load the scene.
|
||||
// The dorm points at the dorm scene; an empty DataBlob loads the default
|
||||
// build. Generic rooms have no known scene yet.
|
||||
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: [],
|
||||
}
|
||||
}
|
||||
|
||||
const app = new Hono<App>()
|
||||
.use(
|
||||
'*',
|
||||
// middleware
|
||||
(c, next) =>
|
||||
useWorkersLogger(c.env.NAME, {
|
||||
environment: c.env.ENVIRONMENT,
|
||||
release: c.env.SENTRY_RELEASE,
|
||||
})(c, next)
|
||||
)
|
||||
|
||||
.onError(withOnError())
|
||||
.notFound(withNotFound())
|
||||
|
||||
.get('/', (c) => c.json({ service: 'rooms', status: 'ok' }))
|
||||
|
||||
// Room lookup by `id` (comma-separated; first match wins) or `name`. The C#
|
||||
// 400s when neither is supplied and returns `{}` when nothing matches.
|
||||
.get('/rooms', (c) => {
|
||||
const idParam = c.req.query('id')
|
||||
const nameParam = c.req.query('name')
|
||||
if (!idParam && !nameParam) {
|
||||
return c.json("Either 'id' or 'name' query parameter is required", 400)
|
||||
}
|
||||
if (idParam) {
|
||||
const firstId = idParam
|
||||
.split(',')
|
||||
.map((s) => Number.parseInt(s.trim(), 10))
|
||||
.find((n) => !Number.isNaN(n))
|
||||
if (firstId === undefined) return c.json({})
|
||||
return c.json(buildRoomResponse(firstId))
|
||||
}
|
||||
// Looked up by name — no DB to resolve it, so synthesize a room 1 (dorm).
|
||||
// TODO: resolve the named room once a DB binding exists.
|
||||
return c.json(buildRoomResponse(1))
|
||||
})
|
||||
|
||||
// Single room by id. The C# 404s when the row is missing; with no DB we
|
||||
// synthesize the room so the client can load it (ignores the include/
|
||||
// unityAsset* query params, same as the C#).
|
||||
.get('/rooms/:roomId{[0-9]+}', (c) => {
|
||||
const roomId = Number.parseInt(c.req.param('roomId'), 10)
|
||||
return c.json(buildRoomResponse(roomId))
|
||||
})
|
||||
|
||||
// Rooms created by the caller. The C# serves JSON/ownedrooms.json (the dorm).
|
||||
.get('/roomserver/rooms/createdby/me', (c) => c.json([buildRoomResponse(1)]))
|
||||
|
||||
export default app
|
||||
@@ -0,0 +1,57 @@
|
||||
import { SELF } from 'cloudflare:test'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import '../../rooms.app'
|
||||
|
||||
const ORIGIN = 'https://rooms.rec.djdevin.net'
|
||||
|
||||
describe('rooms endpoints', () => {
|
||||
it('GET / reports service status', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toEqual({ service: 'rooms', status: 'ok' })
|
||||
})
|
||||
|
||||
it('GET /rooms/1 returns the dorm room (ignoring include/unityAsset params)', async () => {
|
||||
const res = await SELF.fetch(
|
||||
`${ORIGIN}/rooms/1?include=1325&unityAssetTarget=0&unityAssetVersion=1`
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as {
|
||||
RoomId: number
|
||||
Name: string
|
||||
IsDorm: boolean
|
||||
Stats: object
|
||||
}
|
||||
expect(body).toMatchObject({ RoomId: 1, Name: 'DormRoom', IsDorm: true })
|
||||
expect(body).toHaveProperty('Stats')
|
||||
const subRooms = (body as unknown as { SubRooms: Array<{ UnitySceneId: string }> }).SubRooms
|
||||
expect(subRooms[0].UnitySceneId).toBe('76d98498-60a1-430c-ab76-b54a29b7a163')
|
||||
})
|
||||
|
||||
it('GET /rooms/:id synthesizes a generic room for other ids', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms/42`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { RoomId: number; Name: string; IsDorm: boolean }
|
||||
expect(body).toMatchObject({ RoomId: 42, Name: 'Room42', IsDorm: false })
|
||||
})
|
||||
|
||||
it('GET /rooms?id=1 returns the matching room', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms?id=1`)
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toMatchObject({ RoomId: 1, Name: 'DormRoom' })
|
||||
})
|
||||
|
||||
it('GET /rooms with no id or name returns 400', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/rooms`)
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('GET /roomserver/rooms/createdby/me returns the owned rooms array', async () => {
|
||||
const res = await SELF.fetch(`${ORIGIN}/roomserver/rooms/createdby/me`)
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as Array<{ RoomId: number }>
|
||||
expect(Array.isArray(body)).toBe(true)
|
||||
expect(body[0]).toMatchObject({ RoomId: 1, Name: 'DormRoom' })
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user