mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
custom dorm rooms
This commit is contained in:
+46
-10
@@ -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 })
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: `@<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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user