custom dorm rooms

This commit is contained in:
Devin Zuczek
2026-07-06 00:04:52 -04:00
parent 3b9d59239e
commit 48001d8bf4
4 changed files with 181 additions and 24 deletions
+46 -10
View File
@@ -4,8 +4,12 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from './jwt'
import { createRoomInstance, getJoinableInstance } from './room-instance-db'
import { getRoomById, getRoomByName } from './rooms-db'
import {
createRoomInstance,
getJoinableInstance,
getRoomInstancesByRoom,
} from './room-instance-db'
import { getOrCreateDormRoom, getRoomById, getRoomByName } from './rooms-db'
import type { Context } from 'hono'
import type { App } from './context'
@@ -172,16 +176,17 @@ function instanceFieldsFromRoom(room: Room) {
Record<string, unknown> | undefined
const str = (v: unknown, fallback = '') => (typeof v === 'string' ? v : fallback)
const num = (v: unknown, fallback: number) => (typeof v === 'number' ? v : fallback)
// All room instance names are prefixed with `^` (the username prefix `@` is a
// separate thing, e.g. a dorm is `^@user's Dorm`). The client uses this prefix
// to resolve the instance; without it the new scene won't load. Matches Stella.
// Room instance names are prefixed with `^` so the client resolves the instance
// (without it the new scene won't load). Personal dorms are the exception: they
// carry the owner prefix `@<user>'s Dorm` and must NOT also get a `^`.
const rawName = str(room.Name, 'Room')
const name = rawName.startsWith('^') || rawName.startsWith('@') ? rawName : `^${rawName}`
return {
roomId: num(room.RoomId, 1),
subRoomId: num(sub?.SubRoomId, 1),
location: str(sub?.UnitySceneId),
dataBlob: str(sub?.DataBlob),
name: rawName.startsWith('^') ? rawName : `^${rawName}`,
name,
maxCapacity: num(sub?.MaxPlayers, 4),
roomInstanceType: room.IsDorm === true ? 2 : 0,
isDorm: room.IsDorm === true,
@@ -266,6 +271,35 @@ async function resolveRoomInstance(
return roomInstanceFromRoom(room, isPrivate, instance.roomInstanceId, instance.photonRoomId)
}
/**
* The authed player's personal dorm instance. Gets-or-creates their dorm room,
* then backs it with a single persistent private `room_instance` so the dorm has
* a stable, unique Photon room id (dorms are isolated from each other) that
* survives re-entry. The room's current scene/saved data is re-read each time, so
* edits show up on the next visit.
*/
async function playerDormInstance(c: Context<App>, accountId: number): Promise<RoomInstance> {
const room = await getOrCreateDormRoom(c.env.DB, accountId)
const f = instanceFieldsFromRoom(room)
// Reuse the dorm's one instance (private, so getJoinableInstance won't find it).
let instance = (await getRoomInstancesByRoom(c.env.DB, f.roomId))[0]
if (!instance) {
instance = await createRoomInstance(c.env.DB, {
ownerAccountId: accountId,
roomId: f.roomId,
subRoomId: f.subRoomId,
location: f.location,
dataBlob: f.dataBlob,
photonRoomId: crypto.randomUUID(),
name: f.name,
maxCapacity: f.maxCapacity,
isPrivate: true,
roomInstanceType: f.roomInstanceType,
})
}
return roomInstanceFromRoom(room, true, instance.roomInstanceId, instance.photonRoomId)
}
const app = new Hono<App>()
.use(
'*',
@@ -388,7 +422,7 @@ const app = new Hono<App>()
const joinMode = await readJoinMode(c)
const instance =
room.toLowerCase() === 'dormroom'
? dormRoomInstance()
? await playerDormInstance(c, id)
: await resolveRoomInstance(c, room, joinMode === 2, id)
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
await enterRoom(c, id, instance)
@@ -410,7 +444,8 @@ const app = new Hono<App>()
return c.json({ errorCode: 0, roomInstance: presence.roomInstance })
}
}
const instance = dormRoomInstance()
// Authed but no presence → their personal dorm; unauthenticated → offline dorm.
const instance = id !== null ? await playerDormInstance(c, id) : dormRoomInstance()
if (id !== null) await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
})
@@ -434,7 +469,7 @@ const app = new Hono<App>()
// The dorm check here is "dorm" (goto/room uses "dormroom").
const instance =
room.toLowerCase() === 'dorm'
? dormRoomInstance()
? await playerDormInstance(c, id)
: await resolveRoomInstance(c, room, joinMode === 2, id)
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
await enterRoom(c, id, instance)
@@ -444,7 +479,8 @@ const app = new Hono<App>()
// Offline dorm — also persisted as presence so the heartbeat stays in sync.
.post('/goto/none', async (c) => {
const id = await authedId(c)
const instance = dormRoomInstance()
// Authed → their personal dorm; unauthenticated → the offline dorm.
const instance = id !== null ? await playerDormInstance(c, id) : dormRoomInstance()
if (id !== null) await enterRoom(c, id, instance)
return c.json({ errorCode: 0, roomInstance: instance })
})
+67
View File
@@ -29,3 +29,70 @@ export async function getRoomByName(db: D1Database, name: string): Promise<Room
.first<RoomRow>()
)
}
/** The seeded template dorm (RoomId 1) that personal dorms are cloned from. */
const DORM_TEMPLATE_ROOM_ID = 1
/** A player's username from the shared accounts table (for naming their dorm), or null. */
export async function getUsername(db: D1Database, accountId: number): Promise<string | null> {
const row = await db
.prepare('SELECT data FROM accounts WHERE account_id = ?1')
.bind(accountId)
.first<{ data: string }>()
if (!row) return null
const account = JSON.parse(row.data) as { username?: string }
return typeof account.username === 'string' ? account.username : null
}
/** A player's personal dorm room (owned by them, IsDorm), or null if none yet. */
export async function getDormRoom(db: D1Database, accountId: number): Promise<Room | null> {
return parseOne(
await db
.prepare('SELECT data FROM rooms WHERE creator_account_id = ?1 AND is_dorm = 1 LIMIT 1')
.bind(accountId)
.first<RoomRow>()
)
}
/**
* The player's personal dorm room, created on first access. Cloned from the
* seeded template dorm (RoomId 1) but owned by the player and flagged IsDorm — so
* matchmaking routes them into their own dorm and they can save it via the
* owner-gated room-save. Idempotent: returns the existing dorm once created.
*
* NOTE: this is the one place the match worker writes to the rooms table (the
* `rooms` worker otherwise owns the schema).
*/
export async function getOrCreateDormRoom(db: D1Database, accountId: number): Promise<Room> {
const existing = await getDormRoom(db, accountId)
if (existing) return existing
const template = await getRoomById(db, DORM_TEMPLATE_ROOM_ID)
const idRow = await db
.prepare('SELECT COALESCE(MAX(room_id), 1) + 1 AS next FROM rooms')
.first<{ next: number }>()
const roomId = idRow?.next ?? 2
// Reuse the template's subroom (scene/capacity), owned by the player, starting
// from a clean save. Fall back to the base dorm scene if the template is absent.
const templateSub =
template && Array.isArray(template.SubRooms) && template.SubRooms.length > 0
? (template.SubRooms[0] as Record<string, unknown>)
: { SubRoomId: 1, UnitySceneId: '76d98498-60a1-430c-ab76-b54a29b7a163', MaxPlayers: 4 }
// Named after the owner: `@<username>'s Dorm` (falls back to the account id).
const username = (await getUsername(db, accountId)) ?? `Player${accountId}`
const room: Room = {
...(template ?? { Accessibility: 2 }),
RoomId: roomId,
Name: `@${username}'s Dorm`,
CreatorAccountId: accountId,
IsDorm: true,
Roles: [{ AccountId: accountId, Role: 255, LastChangedByAccountId: null, InvitedRole: 0 }],
SubRooms: [{ ...templateSub, CreatorAccountId: accountId }],
CreatedAt: new Date().toISOString(),
}
await db.prepare('INSERT INTO rooms (data) VALUES (?1)').bind(JSON.stringify(room)).run()
return room
}
+65 -12
View File
@@ -39,13 +39,29 @@ beforeAll(async () => {
`CREATE TABLE IF NOT EXISTS rooms (
data TEXT NOT NULL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL
name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL,
creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL,
is_dorm INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsDorm')) VIRTUAL
)`
).run()
const insert = env.DB.prepare('INSERT OR IGNORE INTO rooms (data) VALUES (?1)')
await env.DB.batch(TEST_ROOMS.map((r) => insert.bind(JSON.stringify(r))))
// Room instances (owned by the rooms worker) — matchmaking finds/creates here.
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
// Accounts table (owned by the auth worker) — dorm creation reads the username
// to name the room. Seed the players the dorm tests authenticate as.
await env.DB.prepare(
`CREATE TABLE IF NOT EXISTS accounts (
data TEXT NOT NULL,
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.accountId')) VIRTUAL
)`
).run()
const insertAccount = env.DB.prepare('INSERT OR IGNORE INTO accounts (data) VALUES (?1)')
await env.DB.batch([
insertAccount.bind(JSON.stringify({ accountId: 42, username: 'Tester' })),
insertAccount.bind(JSON.stringify({ accountId: 43, username: 'Roomie' })),
])
})
// Mint a token the way the `auth` worker does, using the same dev secret, so the
@@ -221,23 +237,39 @@ describe('auth-gated endpoints', () => {
expect(res.status).toBe(401)
})
test('POST /goto/room/dormroom returns the dorm instance', async () => {
test('POST /goto/room/dormroom creates and returns the players personal dorm', async () => {
const res = await exports.default.fetch(`${ORIGIN}/goto/room/dormroom`, {
method: 'POST',
headers: await bearer(),
headers: await bearer('42'),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
errorCode: number
roomInstance: { name: string; location: string; isPrivate: boolean; roomId: number }
roomInstance: {
name: string
location: string
isPrivate: boolean
roomId: number
photonRoomId: string
}
}
expect(body.errorCode).toBe(0)
expect(body.roomInstance).toMatchObject({
name: '^DormRoom',
// Named after the owner: `@<username>'s Dorm` (no `^` — the `@` is its prefix).
name: "@Tester's Dorm",
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
isPrivate: true,
roomId: 1,
})
// The dorm gets its own unique Photon room id (isolated from other dorms).
expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
// A personal dorm room was created (not the seeded template RoomId 1)…
const roomId = body.roomInstance.roomId
expect(roomId).toBeGreaterThan(2)
// …owned by the player and flagged IsDorm so they can save it.
const row = await env.DB.prepare('SELECT data FROM rooms WHERE room_id = ?1')
.bind(roomId)
.first<{ data: string }>()
expect(JSON.parse(row!.data)).toMatchObject({ CreatorAccountId: 42, IsDorm: true })
})
test('POST /goto/room/:id resolves a real room scene from D1', async () => {
@@ -300,22 +332,41 @@ describe('auth-gated endpoints', () => {
expect(res.status).toBe(401)
})
test('POST /matchmake/dorm returns the dorm instance', async () => {
test('POST /matchmake/dorm returns the same personal dorm (idempotent)', async () => {
// First entry (fresh account 43) creates the dorm; a second returns the same one.
const first = (await (
await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
method: 'POST',
headers: await bearer('43'),
})
).json()) as { roomInstance: { roomId: number; photonRoomId: string; roomInstanceId: number } }
expect(first.roomInstance.roomId).toBeGreaterThan(2)
const res = await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
method: 'POST',
headers: await bearer(),
headers: await bearer('43'),
})
expect(res.status).toBe(200)
const body = (await res.json()) as {
errorCode: number
roomInstance: { name: string; location: string; isPrivate: boolean; roomId: number }
roomInstance: {
name: string
location: string
isPrivate: boolean
roomId: number
photonRoomId: string
roomInstanceId: number
}
}
expect(body.errorCode).toBe(0)
expect(body.roomInstance).toMatchObject({
name: '^DormRoom',
name: "@Roomie's Dorm",
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
isPrivate: true,
roomId: 1,
// Same dorm room + reused instance (stable id + Photon room), not a new one.
roomId: first.roomInstance.roomId,
photonRoomId: first.roomInstance.photonRoomId,
roomInstanceId: first.roomInstance.roomInstanceId,
})
})
@@ -410,7 +461,9 @@ describe('auth-gated endpoints', () => {
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers })
).json()) as { roomInstance: { name: string } | null; isOnline: boolean }
expect(hb.isOnline).toBe(true)
expect(hb.roomInstance?.name).toBe('^DormRoom')
// Presence is preserved: the heartbeat replays their personal dorm. Account 9
// has no seeded username, so the name falls back to `@Player9's Dorm`.
expect(hb.roomInstance?.name).toBe("@Player9's Dorm")
})
test('GET /player?id reports stored presence per id', async () => {
+3 -2
View File
@@ -14,8 +14,9 @@
"id": "local"
}
],
// Shared `recflare` DB (bound read-only here). The "local" placeholder is replaced
// with the real id from RECFLARE_D1 (see .env) at deploy time.
// Shared `recflare` DB. Mostly read (room scenes for matchmaking); also writes a
// player's personal dorm room on first dorm entry (see getOrCreateDormRoom). The
// "local" placeholder is replaced with the real id from RECFLARE_D1 at deploy time.
"d1_databases": [
{
"binding": "DB",