mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 22:51:30 -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 { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
|
|
||||||
import { validateAndGetAccountId } from './jwt'
|
import { validateAndGetAccountId } from './jwt'
|
||||||
import { createRoomInstance, getJoinableInstance } from './room-instance-db'
|
import {
|
||||||
import { getRoomById, getRoomByName } from './rooms-db'
|
createRoomInstance,
|
||||||
|
getJoinableInstance,
|
||||||
|
getRoomInstancesByRoom,
|
||||||
|
} from './room-instance-db'
|
||||||
|
import { getOrCreateDormRoom, getRoomById, getRoomByName } from './rooms-db'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
@@ -172,16 +176,17 @@ function instanceFieldsFromRoom(room: Room) {
|
|||||||
Record<string, unknown> | undefined
|
Record<string, unknown> | undefined
|
||||||
const str = (v: unknown, fallback = '') => (typeof v === 'string' ? v : fallback)
|
const str = (v: unknown, fallback = '') => (typeof v === 'string' ? v : fallback)
|
||||||
const num = (v: unknown, fallback: number) => (typeof v === 'number' ? 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
|
// Room instance names are prefixed with `^` so the client resolves the instance
|
||||||
// separate thing, e.g. a dorm is `^@user's Dorm`). The client uses this prefix
|
// (without it the new scene won't load). Personal dorms are the exception: they
|
||||||
// to resolve the instance; without it the new scene won't load. Matches Stella.
|
// carry the owner prefix `@<user>'s Dorm` and must NOT also get a `^`.
|
||||||
const rawName = str(room.Name, 'Room')
|
const rawName = str(room.Name, 'Room')
|
||||||
|
const name = rawName.startsWith('^') || rawName.startsWith('@') ? rawName : `^${rawName}`
|
||||||
return {
|
return {
|
||||||
roomId: num(room.RoomId, 1),
|
roomId: num(room.RoomId, 1),
|
||||||
subRoomId: num(sub?.SubRoomId, 1),
|
subRoomId: num(sub?.SubRoomId, 1),
|
||||||
location: str(sub?.UnitySceneId),
|
location: str(sub?.UnitySceneId),
|
||||||
dataBlob: str(sub?.DataBlob),
|
dataBlob: str(sub?.DataBlob),
|
||||||
name: rawName.startsWith('^') ? rawName : `^${rawName}`,
|
name,
|
||||||
maxCapacity: num(sub?.MaxPlayers, 4),
|
maxCapacity: num(sub?.MaxPlayers, 4),
|
||||||
roomInstanceType: room.IsDorm === true ? 2 : 0,
|
roomInstanceType: room.IsDorm === true ? 2 : 0,
|
||||||
isDorm: room.IsDorm === true,
|
isDorm: room.IsDorm === true,
|
||||||
@@ -266,6 +271,35 @@ async function resolveRoomInstance(
|
|||||||
return roomInstanceFromRoom(room, isPrivate, instance.roomInstanceId, instance.photonRoomId)
|
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>()
|
const app = new Hono<App>()
|
||||||
.use(
|
.use(
|
||||||
'*',
|
'*',
|
||||||
@@ -388,7 +422,7 @@ const app = new Hono<App>()
|
|||||||
const joinMode = await readJoinMode(c)
|
const joinMode = await readJoinMode(c)
|
||||||
const instance =
|
const instance =
|
||||||
room.toLowerCase() === 'dormroom'
|
room.toLowerCase() === 'dormroom'
|
||||||
? dormRoomInstance()
|
? await playerDormInstance(c, id)
|
||||||
: await resolveRoomInstance(c, room, joinMode === 2, id)
|
: await resolveRoomInstance(c, room, joinMode === 2, id)
|
||||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||||
await enterRoom(c, id, instance)
|
await enterRoom(c, id, instance)
|
||||||
@@ -410,7 +444,8 @@ const app = new Hono<App>()
|
|||||||
return c.json({ errorCode: 0, roomInstance: presence.roomInstance })
|
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)
|
if (id !== null) await enterRoom(c, id, instance)
|
||||||
return c.json({ errorCode: 0, roomInstance: 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").
|
// The dorm check here is "dorm" (goto/room uses "dormroom").
|
||||||
const instance =
|
const instance =
|
||||||
room.toLowerCase() === 'dorm'
|
room.toLowerCase() === 'dorm'
|
||||||
? dormRoomInstance()
|
? await playerDormInstance(c, id)
|
||||||
: await resolveRoomInstance(c, room, joinMode === 2, id)
|
: await resolveRoomInstance(c, room, joinMode === 2, id)
|
||||||
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
if (!instance) return c.json({ errorCode: NO_SUCH_ROOM, roomInstance: null })
|
||||||
await enterRoom(c, id, instance)
|
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.
|
// Offline dorm — also persisted as presence so the heartbeat stays in sync.
|
||||||
.post('/goto/none', async (c) => {
|
.post('/goto/none', async (c) => {
|
||||||
const id = await authedId(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)
|
if (id !== null) await enterRoom(c, id, instance)
|
||||||
return c.json({ errorCode: 0, roomInstance: instance })
|
return c.json({ errorCode: 0, roomInstance: instance })
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,3 +29,70 @@ export async function getRoomByName(db: D1Database, name: string): Promise<Room
|
|||||||
.first<RoomRow>()
|
.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 (
|
`CREATE TABLE IF NOT EXISTS rooms (
|
||||||
data TEXT NOT NULL,
|
data TEXT NOT NULL,
|
||||||
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL,
|
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()
|
).run()
|
||||||
const insert = env.DB.prepare('INSERT OR IGNORE INTO rooms (data) VALUES (?1)')
|
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))))
|
await env.DB.batch(TEST_ROOMS.map((r) => insert.bind(JSON.stringify(r))))
|
||||||
// Room instances (owned by the rooms worker) — matchmaking finds/creates here.
|
// 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()
|
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
|
// 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)
|
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`, {
|
const res = await exports.default.fetch(`${ORIGIN}/goto/room/dormroom`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: await bearer(),
|
headers: await bearer('42'),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = (await res.json()) as {
|
const body = (await res.json()) as {
|
||||||
errorCode: number
|
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.errorCode).toBe(0)
|
||||||
expect(body.roomInstance).toMatchObject({
|
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',
|
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
||||||
isPrivate: true,
|
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 () => {
|
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)
|
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`, {
|
const res = await exports.default.fetch(`${ORIGIN}/matchmake/dorm`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: await bearer(),
|
headers: await bearer('43'),
|
||||||
})
|
})
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = (await res.json()) as {
|
const body = (await res.json()) as {
|
||||||
errorCode: number
|
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.errorCode).toBe(0)
|
||||||
expect(body.roomInstance).toMatchObject({
|
expect(body.roomInstance).toMatchObject({
|
||||||
name: '^DormRoom',
|
name: "@Roomie's Dorm",
|
||||||
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
location: '76d98498-60a1-430c-ab76-b54a29b7a163',
|
||||||
isPrivate: true,
|
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 })
|
await exports.default.fetch(`${ORIGIN}/player/heartbeat`, { method: 'POST', headers })
|
||||||
).json()) as { roomInstance: { name: string } | null; isOnline: boolean }
|
).json()) as { roomInstance: { name: string } | null; isOnline: boolean }
|
||||||
expect(hb.isOnline).toBe(true)
|
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 () => {
|
test('GET /player?id reports stored presence per id', async () => {
|
||||||
|
|||||||
@@ -14,8 +14,9 @@
|
|||||||
"id": "local"
|
"id": "local"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
// Shared `recflare` DB (bound read-only here). The "local" placeholder is replaced
|
// Shared `recflare` DB. Mostly read (room scenes for matchmaking); also writes a
|
||||||
// with the real id from RECFLARE_D1 (see .env) at deploy time.
|
// 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": [
|
"d1_databases": [
|
||||||
{
|
{
|
||||||
"binding": "DB",
|
"binding": "DB",
|
||||||
|
|||||||
Reference in New Issue
Block a user