From 48001d8bf4eee0ccb2b64529f87f4b75cbaa0cbc Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Mon, 6 Jul 2026 00:04:52 -0400 Subject: [PATCH] custom dorm rooms --- apps/match/src/match.app.ts | 56 ++++++++++++--- apps/match/src/rooms-db.ts | 67 ++++++++++++++++++ apps/match/src/test/integration/api.test.ts | 77 +++++++++++++++++---- apps/match/wrangler.jsonc | 5 +- 4 files changed, 181 insertions(+), 24 deletions(-) diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index b35d5fd..0fe38af 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -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 | 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 `@'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, accountId: number): Promise { + 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() .use( '*', @@ -388,7 +422,7 @@ const app = new Hono() 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() 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() // 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() // 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 }) }) diff --git a/apps/match/src/rooms-db.ts b/apps/match/src/rooms-db.ts index 25eea05..5081b3c 100644 --- a/apps/match/src/rooms-db.ts +++ b/apps/match/src/rooms-db.ts @@ -29,3 +29,70 @@ export async function getRoomByName(db: D1Database, name: string): Promise() ) } + +/** 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 { + 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 { + return parseOne( + await db + .prepare('SELECT data FROM rooms WHERE creator_account_id = ?1 AND is_dorm = 1 LIMIT 1') + .bind(accountId) + .first() + ) +} + +/** + * 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 { + 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) + : { SubRoomId: 1, UnitySceneId: '76d98498-60a1-430c-ab76-b54a29b7a163', MaxPlayers: 4 } + + // Named after the owner: `@'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 +} diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index 2d68e3d..15e3d20 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -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 player’s 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: `@'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 () => { diff --git a/apps/match/wrangler.jsonc b/apps/match/wrangler.jsonc index 6d9e8d6..1912d75 100644 --- a/apps/match/wrangler.jsonc +++ b/apps/match/wrangler.jsonc @@ -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",