true matchmaking

This commit is contained in:
Devin Zuczek
2026-07-01 15:16:37 -04:00
parent 70aa33a27a
commit f48d78bd5f
15 changed files with 938 additions and 50 deletions
+47 -6
View File
@@ -9,6 +9,7 @@ import storefrontGiftDrop2 from '../static/storefronts-v3-giftdropstore-2.json'
import storefrontGiftDrop3 from '../static/storefronts-v3-giftdropstore-3.json' import storefrontGiftDrop3 from '../static/storefronts-v3-giftdropstore-3.json'
import storefrontGiftDrop300 from '../static/storefronts-v3-giftdropstore-300.json' import storefrontGiftDrop300 from '../static/storefronts-v3-giftdropstore-300.json'
import { defaultSettings } from './default-settings' import { defaultSettings } from './default-settings'
import { createImage, getImageByName } from './images-db'
import { validateAndGetAccountId } from './jwt' import { validateAndGetAccountId } from './jwt'
import { getRoomById, getRoomByName, getRoomsByCreator, getRoomsByIds } from './rooms-db' import { getRoomById, getRoomByName, getRoomsByCreator, getRoomsByIds } from './rooms-db'
@@ -422,18 +423,24 @@ const app = new Hono<App>({ strict: false })
if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400) if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400)
const file = candidate const file = candidate
// `imgMeta` is a JSON blob describing the upload; its `savedImageType` // `imgMeta` is a JSON blob describing the upload (the C# `SavedImageMetaDTO`),
// decides what (if anything) the image is recorded against. Mirrors the C# // posted as a multipart field. It carries the metadata we record on the image
// `SavedImageMetaDTO` / `SavedImageType` enum. // (savedImageType, roomId, accessibility, description, taggedPlayerIds, …).
let savedImageType: number = SavedImageType.None let meta: Record<string, unknown> = {}
if (typeof body.imgMeta === 'string') { if (typeof body.imgMeta === 'string') {
try { try {
const meta = JSON.parse(body.imgMeta) as { savedImageType?: unknown } | null const parsed = JSON.parse(body.imgMeta)
if (meta && typeof meta.savedImageType === 'number') savedImageType = meta.savedImageType if (parsed && typeof parsed === 'object') meta = parsed as Record<string, unknown>
} catch { } catch {
// Malformed imgMeta — treat as an untyped upload (still stored). // Malformed imgMeta — treat as an untyped upload (still stored).
} }
} }
// imgMeta shape: {playerIds, savedImageType, roomId, playerEventId, accessibility}.
const num = (v: unknown): number | undefined => (typeof v === 'number' ? v : undefined)
const savedImageType = num(meta.savedImageType) ?? SavedImageType.None
// roomId / playerEventId use 0 or -1 as "none" — store null in that case.
const roomId = num(meta.roomId)
const playerEventId = num(meta.playerEventId)
const valid = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'] const valid = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']
const dot = file.name.lastIndexOf('.') const dot = file.name.lastIndexOf('.')
@@ -457,9 +464,43 @@ const app = new Hono<App>({ strict: false })
.run() .run()
} }
// Record the image metadata (the `image` table the img worker owns), pulling
// the fields the client provided in imgMeta.
await createImage(c.env.DB, {
imageName: name,
playerId: id,
type: savedImageType,
accessibility: num(meta.accessibility),
roomId: roomId !== undefined && roomId > 0 ? roomId : null,
description: typeof meta.description === 'string' ? meta.description : null,
taggedPlayerIds: Array.isArray(meta.playerIds)
? meta.playerIds.filter((v): v is number => typeof v === 'number')
: undefined,
playerEventId: playerEventId !== undefined && playerEventId > 0 ? playerEventId : null,
})
return c.json({ ImageName: name }) return c.json({ ImageName: name })
}) })
// Image metadata by filename. Returns the stored SavedImage record, or 404 when
// there's no metadata row for that name.
.get('/api/images/v6', async (c) => {
const name = c.req.query('name') ?? ''
if (name === '') return c.json({ error: 'name is required' }, 400)
const image = await getImageByName(c.env.DB, name)
return image ? c.json(image) : c.notFound()
})
// Cheer / un-cheer a saved image ({ SavedImageId, Cheer }). Auth-gated. Stubbed
// for now — accepted but not persisted; cheer storage is still TBD.
.post('/api/images/v1/cheer', async (c) => {
const id = await authedId(c)
if (id === null) return unauthorized(c)
// TODO: record the cheer against the image once cheer storage is designed.
await c.req.json().catch(() => null)
return c.json({ success: true })
})
// ---- Rooms ---------------------------------------------------------------- // ---- Rooms ----------------------------------------------------------------
// Room search filters. The client deserializes this into an object (not an // Room search filters. The client deserializes this into an object (not an
// array) — shape from the 2025 reference. // array) — shape from the 2025 reference.
+91
View File
@@ -0,0 +1,91 @@
/**
* Image-metadata storage on the shared `recflare` D1 database. Each image is a
* single JSON blob in the `data` column; queryable fields (Id, ImageName,
* PlayerId, RoomId) are SQLite generated (virtual) columns extracted from that
* JSON — the same JSON-blob pattern the rooms/accounts tables use.
*
* Mirror of `apps/img/src/images-db.ts` — the `img` worker owns the schema and
* migration; this worker (which handles uploads + reads) keeps a copy in sync.
*/
/** Schema DDL (mirror of migrations/0001_image.sql, sans any seed rows). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS image (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Id')) VIRTUAL,
image_name TEXT GENERATED ALWAYS AS (json_extract(data, '$.ImageName')) VIRTUAL,
player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerId')) VIRTUAL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_image_id ON image (id)`,
`CREATE INDEX IF NOT EXISTS idx_image_image_name ON image (image_name)`,
`CREATE INDEX IF NOT EXISTS idx_image_player_id ON image (player_id)`,
`CREATE INDEX IF NOT EXISTS idx_image_room_id ON image (room_id)`,
]
/** A stored image record (the client-facing SavedImage shape). */
export interface SavedImage {
Id: number
Type: number
Accessibility: number
AccessibilityLocked: boolean
ImageName: string
Description: string | null
PlayerId: number
TaggedPlayerIds: number[]
RoomId: number | null
PlayerEventId: number | null
CreatedAt: string
CheerCount: number
CommentCount: number
}
interface ImageRow {
data: string
}
/** Fields supplied at upload time (from `imgMeta`); everything else defaults. */
export interface NewImage {
imageName: string
playerId: number
type?: number
accessibility?: number
roomId?: number | null
description?: string | null
taggedPlayerIds?: number[]
playerEventId?: number | null
}
/** Insert a new image record for an upload, returning the stored row. */
export async function createImage(db: D1Database, input: NewImage): Promise<SavedImage> {
// Sequential id: one past the current max (the table starts empty).
const row = await db
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM image')
.first<{ next: number }>()
const image: SavedImage = {
Id: row?.next ?? 1,
Type: input.type ?? 1,
Accessibility: input.accessibility ?? 1,
AccessibilityLocked: false,
ImageName: input.imageName,
Description: input.description ?? null,
PlayerId: input.playerId,
TaggedPlayerIds: input.taggedPlayerIds ?? [],
RoomId: input.roomId ?? null,
PlayerEventId: input.playerEventId ?? null,
CreatedAt: new Date().toISOString(),
CheerCount: 0,
CommentCount: 0,
}
await db.prepare('INSERT INTO image (data) VALUES (?1)').bind(JSON.stringify(image)).run()
return image
}
/** Look up an image record by its ImageName (the R2 key / filename), or null. */
export async function getImageByName(db: D1Database, name: string): Promise<SavedImage | null> {
const row = await db
.prepare('SELECT data FROM image WHERE image_name = ?1')
.bind(name)
.first<ImageRow>()
return row ? (JSON.parse(row.data) as SavedImage) : null
}
+81
View File
@@ -4,6 +4,8 @@ import { beforeAll, describe, expect, test } from 'vitest'
import '../../api.app' import '../../api.app'
import { SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db'
import type { Env } from '../../context' import type { Env } from '../../context'
declare module 'cloudflare:test' { declare module 'cloudflare:test' {
@@ -58,6 +60,9 @@ beforeAll(async () => {
JSON.stringify({ accountId: 42, username: 'Tester', profileImage: 'DefaultProfileImage.jpg' }) JSON.stringify({ accountId: 42, username: 'Tester', profileImage: 'DefaultProfileImage.jpg' })
) )
.run() .run()
// Images table (owned by the img worker) — uploadsaved records a row here.
for (const stmt of IMAGES_SCHEMA_DDL) await env.DB.prepare(stmt).run()
}) })
// 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
@@ -369,6 +374,82 @@ describe('images', () => {
const stored = await env.IMAGES.get(ImageName) const stored = await env.IMAGES.get(ImageName)
expect(stored).not.toBeNull() expect(stored).not.toBeNull()
expect(new Uint8Array(await stored!.arrayBuffer())).toEqual(bytes) expect(new Uint8Array(await stored!.arrayBuffer())).toEqual(bytes)
// A metadata row was created, and it's readable by name via /api/images/v6.
const meta = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
).json()) as { ImageName: string; PlayerId: number; Id: number; CheerCount: number }
expect(meta.ImageName).toBe(ImageName)
expect(meta.PlayerId).toBe(42)
expect(typeof meta.Id).toBe('number')
expect(meta.CheerCount).toBe(0)
})
test('POST /api/images/v1/cheer is auth-gated and stubs success', async () => {
const body = JSON.stringify({ SavedImageId: 2, Cheer: true })
// No token → 401.
expect(
(
await exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
})
).status
).toBe(401)
// With a token → accepted.
const res = await exports.default.fetch(`${ORIGIN}/api/images/v1/cheer`, {
method: 'POST',
headers: { ...(await bearer()), 'Content-Type': 'application/json' },
body,
})
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ success: true })
})
test('GET /api/images/v6 400s without a name and 404s for an unknown one', async () => {
expect((await exports.default.fetch(`${ORIGIN}/api/images/v6`)).status).toBe(400)
expect(
(await exports.default.fetch(`${ORIGIN}/api/images/v6?name=doesnotexist.jpg`)).status
).toBe(404)
})
test('POST /api/images/v4/uploadsaved records metadata from imgMeta', async () => {
const fd = new FormData()
// The client's real imgMeta shape (tagged players are `playerIds`).
fd.append(
'imgMeta',
JSON.stringify({
playerIds: [5, 6],
savedImageType: 1,
roomId: 777,
playerEventId: 0,
accessibility: 2,
})
)
fd.append('image', new File([new Uint8Array([1, 2, 3])], 'pic.png', { type: 'image/png' }))
const res = await exports.default.fetch(`${ORIGIN}/api/images/v4/uploadsaved`, {
method: 'POST',
headers: await bearer('42'),
body: fd,
})
const { ImageName } = (await res.json()) as { ImageName: string }
const meta = (await (
await exports.default.fetch(`${ORIGIN}/api/images/v6?name=${ImageName}`)
).json()) as {
Type: number
RoomId: number
Accessibility: number
TaggedPlayerIds: number[]
PlayerEventId: number | null
}
expect(meta.Type).toBe(1)
expect(meta.RoomId).toBe(777)
expect(meta.Accessibility).toBe(2)
expect(meta.TaggedPlayerIds).toEqual([5, 6])
// playerEventId 0 means "none" → stored as null.
expect(meta.PlayerEventId).toBeNull()
}) })
test('POST /api/images/v4/uploadsaved records a profile thumbnail on the account', async () => { test('POST /api/images/v4/uploadsaved records a profile thumbnail on the account', async () => {
+10 -10
View File
@@ -9,7 +9,7 @@
"Visibility": 0, "Visibility": 0,
"AllowCycling": true, "AllowCycling": true,
"RestrictToNewUsers": false, "RestrictToNewUsers": false,
"ImageName": "gay", "ImageName": "tip.jpg",
"PlatformMask": 175, "PlatformMask": 175,
"CreatedAt": "2019-02-28T18:27:25Z" "CreatedAt": "2019-02-28T18:27:25Z"
}, },
@@ -23,7 +23,7 @@
"Visibility": 0, "Visibility": 0,
"AllowCycling": true, "AllowCycling": true,
"RestrictToNewUsers": false, "RestrictToNewUsers": false,
"ImageName": "gay", "ImageName": "tip.jpg",
"PlatformMask": 167, "PlatformMask": 167,
"CreatedAt": "2019-02-28T18:15:33Z" "CreatedAt": "2019-02-28T18:15:33Z"
}, },
@@ -37,7 +37,7 @@
"Visibility": 0, "Visibility": 0,
"AllowCycling": true, "AllowCycling": true,
"RestrictToNewUsers": false, "RestrictToNewUsers": false,
"ImageName": "gay", "ImageName": "tip.jpg",
"PlatformMask": 175, "PlatformMask": 175,
"CreatedAt": "2019-03-02T00:12:59Z" "CreatedAt": "2019-03-02T00:12:59Z"
}, },
@@ -51,7 +51,7 @@
"Visibility": 0, "Visibility": 0,
"AllowCycling": true, "AllowCycling": true,
"RestrictToNewUsers": false, "RestrictToNewUsers": false,
"ImageName": "gay", "ImageName": "tip.jpg",
"PlatformMask": 175, "PlatformMask": 175,
"CreatedAt": "2019-02-28T18:08:56Z" "CreatedAt": "2019-02-28T18:08:56Z"
}, },
@@ -65,7 +65,7 @@
"Visibility": 0, "Visibility": 0,
"AllowCycling": true, "AllowCycling": true,
"RestrictToNewUsers": false, "RestrictToNewUsers": false,
"ImageName": "gay", "ImageName": "tip.jpg",
"PlatformMask": 175, "PlatformMask": 175,
"CreatedAt": "2019-02-28T18:22:44Z" "CreatedAt": "2019-02-28T18:22:44Z"
}, },
@@ -79,7 +79,7 @@
"Visibility": 0, "Visibility": 0,
"AllowCycling": true, "AllowCycling": true,
"RestrictToNewUsers": false, "RestrictToNewUsers": false,
"ImageName": "gay", "ImageName": "tip.jpg",
"PlatformMask": 175, "PlatformMask": 175,
"CreatedAt": "2019-02-28T18:28:32Z" "CreatedAt": "2019-02-28T18:28:32Z"
}, },
@@ -93,7 +93,7 @@
"Visibility": 0, "Visibility": 0,
"AllowCycling": true, "AllowCycling": true,
"RestrictToNewUsers": false, "RestrictToNewUsers": false,
"ImageName": "gay", "ImageName": "tip.jpg",
"PlatformMask": 167, "PlatformMask": 167,
"CreatedAt": "2019-02-28T18:24:33Z" "CreatedAt": "2019-02-28T18:24:33Z"
}, },
@@ -107,7 +107,7 @@
"Visibility": 0, "Visibility": 0,
"AllowCycling": true, "AllowCycling": true,
"RestrictToNewUsers": false, "RestrictToNewUsers": false,
"ImageName": "gay", "ImageName": "tip.jpg",
"PlatformMask": 131, "PlatformMask": 131,
"CreatedAt": "2019-12-21T01:24:23Z" "CreatedAt": "2019-12-21T01:24:23Z"
}, },
@@ -121,7 +121,7 @@
"Visibility": 0, "Visibility": 0,
"AllowCycling": true, "AllowCycling": true,
"RestrictToNewUsers": false, "RestrictToNewUsers": false,
"ImageName": "gay", "ImageName": "tip.jpg",
"PlatformMask": 175, "PlatformMask": 175,
"CreatedAt": "2019-02-28T18:07:50Z" "CreatedAt": "2019-02-28T18:07:50Z"
}, },
@@ -135,7 +135,7 @@
"Visibility": 1, "Visibility": 1,
"AllowCycling": true, "AllowCycling": true,
"RestrictToNewUsers": false, "RestrictToNewUsers": false,
"ImageName": "gay", "ImageName": "tip.jpg",
"PlatformMask": 239, "PlatformMask": 239,
"CreatedAt": "2019-02-28T18:21:25Z" "CreatedAt": "2019-02-28T18:21:25Z"
} }
+16
View File
@@ -0,0 +1,16 @@
-- Image metadata stored as a JSON blob with generated (virtual) columns for
-- querying. Written by the `api` worker on upload (/api/images/v4/uploadsaved)
-- and read back via /api/images/v6. Generated from src/images-db.ts (SCHEMA_DDL)
-- — keep in sync.
CREATE TABLE IF NOT EXISTS image (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Id')) VIRTUAL,
image_name TEXT GENERATED ALWAYS AS (json_extract(data, '$.ImageName')) VIRTUAL,
player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerId')) VIRTUAL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_image_id ON image (id);
CREATE INDEX IF NOT EXISTS idx_image_image_name ON image (image_name);
CREATE INDEX IF NOT EXISTS idx_image_player_id ON image (player_id);
CREATE INDEX IF NOT EXISTS idx_image_room_id ON image (room_id);
+1
View File
@@ -12,6 +12,7 @@
"deploy": "run-wrangler-deploy", "deploy": "run-wrangler-deploy",
"dev": "run-wrangler-dev", "dev": "run-wrangler-dev",
"fix:workers-types": "run-wrangler-types", "fix:workers-types": "run-wrangler-types",
"migrate": "run-wrangler-migrate",
"test": "run-vitest" "test": "run-vitest"
}, },
"dependencies": { "dependencies": {
+2
View File
@@ -2,6 +2,8 @@ import type { HonoApp } from '@repo/hono-helpers'
import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types'
export type Env = SharedHonoEnv & { export type Env = SharedHonoEnv & {
/** Shared `recflare` D1 — the `images` metadata table this worker owns. */
DB: D1Database
/** R2 bucket holding the served image objects, keyed by filename. */ /** R2 bucket holding the served image objects, keyed by filename. */
IMAGES: R2Bucket IMAGES: R2Bucket
/** /**
+93
View File
@@ -0,0 +1,93 @@
/**
* Image-metadata storage on the shared `recflare` D1 database. Each image is a
* single JSON blob in the `data` column; queryable fields (Id, ImageName,
* PlayerId, RoomId) are SQLite generated (virtual) columns extracted from that
* JSON — the same JSON-blob pattern the rooms/accounts tables use.
*
* The `img` worker owns this schema/migration (migrations/0001_images.sql, applied
* with its own `migrations_table` so it doesn't clash with the other workers'
* migrations on the shared database). The `api` worker writes a row on upload and
* reads it back, keeping its own copy of these helpers in sync.
*/
/** Schema DDL (mirror of migrations/0001_image.sql, sans any seed rows). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS image (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.Id')) VIRTUAL,
image_name TEXT GENERATED ALWAYS AS (json_extract(data, '$.ImageName')) VIRTUAL,
player_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.PlayerId')) VIRTUAL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_image_id ON image (id)`,
`CREATE INDEX IF NOT EXISTS idx_image_image_name ON image (image_name)`,
`CREATE INDEX IF NOT EXISTS idx_image_player_id ON image (player_id)`,
`CREATE INDEX IF NOT EXISTS idx_image_room_id ON image (room_id)`,
]
/** A stored image record (the client-facing SavedImage shape). */
export interface SavedImage {
Id: number
Type: number
Accessibility: number
AccessibilityLocked: boolean
ImageName: string
Description: string | null
PlayerId: number
TaggedPlayerIds: number[]
RoomId: number | null
PlayerEventId: number | null
CreatedAt: string
CheerCount: number
CommentCount: number
}
interface ImageRow {
data: string
}
/** Fields supplied at upload time (from `imgMeta`); everything else defaults. */
export interface NewImage {
imageName: string
playerId: number
type?: number
accessibility?: number
roomId?: number | null
description?: string | null
taggedPlayerIds?: number[]
playerEventId?: number | null
}
/** Insert a new image record for an upload, returning the stored row. */
export async function createImage(db: D1Database, input: NewImage): Promise<SavedImage> {
// Sequential id: one past the current max (the table starts empty).
const row = await db
.prepare('SELECT COALESCE(MAX(id), 0) + 1 AS next FROM image')
.first<{ next: number }>()
const image: SavedImage = {
Id: row?.next ?? 1,
Type: input.type ?? 1,
Accessibility: input.accessibility ?? 1,
AccessibilityLocked: false,
ImageName: input.imageName,
Description: input.description ?? null,
PlayerId: input.playerId,
TaggedPlayerIds: input.taggedPlayerIds ?? [],
RoomId: input.roomId ?? null,
PlayerEventId: input.playerEventId ?? null,
CreatedAt: new Date().toISOString(),
CheerCount: 0,
CommentCount: 0,
}
await db.prepare('INSERT INTO image (data) VALUES (?1)').bind(JSON.stringify(image)).run()
return image
}
/** Look up an image record by its ImageName (the R2 key / filename), or null. */
export async function getImageByName(db: D1Database, name: string): Promise<SavedImage | null> {
const row = await db
.prepare('SELECT data FROM image WHERE image_name = ?1')
.bind(name)
.first<ImageRow>()
return row ? (JSON.parse(row.data) as SavedImage) : null
}
+13
View File
@@ -11,6 +11,19 @@
"bucket_name": "recflare-img" "bucket_name": "recflare-img"
} }
], ],
// Shared `recflare` D1 — the `img` worker owns the `images` metadata table
// (schema/migration here); the `api` worker writes/reads it. Its own
// migrations_table keeps its history separate on the shared database. The
// "local" placeholder is replaced with the real id from RECFLARE_D1 at deploy.
"d1_databases": [
{
"binding": "DB",
"database_name": "recflare",
"database_id": "local",
"migrations_dir": "migrations",
"migrations_table": "d1_migrations_img"
}
],
"upload_source_maps": true, "upload_source_maps": true,
"observability": { "observability": {
"logs": { "logs": {
+68 -29
View File
@@ -4,6 +4,7 @@ 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 { getRoomById, getRoomByName } from './rooms-db' import { getRoomById, getRoomByName } from './rooms-db'
import type { Context } from 'hono' import type { Context } from 'hono'
@@ -11,9 +12,9 @@ import type { App } from './context'
import type { Room } from './rooms-db' import type { Room } from './rooms-db'
/** /**
* The matchmaking surface. Database-backed endpoints are stubbed here — there's * The matchmaking surface. Rooms and room instances are D1-backed (matchmaking
* no DB binding yet, so room/player lookups fall back to default values when * finds/creates a `room_instance` row per session); player lookups still fall back
* nothing is found. * to default values when nothing is found. Presence lives in the match KV.
* *
* Auth-gated routes still validate the Bearer JWT issued by the `auth` worker. * Auth-gated routes still validate the Bearer JWT issued by the `auth` worker.
*/ */
@@ -162,44 +163,60 @@ function dormRoomInstance() {
} }
/** /**
* Build a room instance from a stored D1 room — crucially using the room's real * Instance-relevant fields pulled from a stored room (scene, name, capacity, …).
* SubRoom `UnitySceneId` as the instance `location` (an empty/unknown location * The `location` is the SubRoom's real `UnitySceneId` — an empty/unknown location
* makes the client reject the session with "unknown scene location ID"). * makes the client reject the session with "unknown scene location ID".
*/ */
function roomInstanceFromRoom(room: Room, isPrivate: boolean): RoomInstance { function instanceFieldsFromRoom(room: Room) {
const subRooms = room.SubRooms const sub = (Array.isArray(room.SubRooms) ? room.SubRooms[0] : undefined) as
const sub = (Array.isArray(subRooms) ? subRooms[0] : undefined) as
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)
const roomId = num(room.RoomId, 1)
// All room instance names are prefixed with `^` (the username prefix `@` is a // 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 // 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. // to resolve the instance; without it the new scene won't load. Matches Stella.
const rawName = str(room.Name, 'Room') const rawName = str(room.Name, 'Room')
const name = rawName.startsWith('^') ? rawName : `^${rawName}`
// roomInstanceId must differ from the room the player is leaving — the client
// keys the transition off it. The dorm is instance 1, so a room that also
// returned 1 looked like "no change". Use the room id (per Stella), with a
// unique suffix-free deterministic Photon room so public players share it.
const photonRoomId = isPrivate ? `rec.${roomId}.${crypto.randomUUID()}` : `rec.${roomId}`
return { return {
roomInstanceId: roomId, roomId: num(room.RoomId, 1),
roomId,
subRoomId: num(sub?.SubRoomId, 1), subRoomId: num(sub?.SubRoomId, 1),
roomInstanceType: room.IsDorm === true ? 2 : 0,
location: str(sub?.UnitySceneId), location: str(sub?.UnitySceneId),
dataBlob: str(sub?.DataBlob), dataBlob: str(sub?.DataBlob),
name: rawName.startsWith('^') ? rawName : `^${rawName}`,
maxCapacity: num(sub?.MaxPlayers, 4),
roomInstanceType: room.IsDorm === true ? 2 : 0,
isDorm: room.IsDorm === true,
}
}
/**
* Build the client instance wire shape from a stored room plus the live instance's
* id + Photon room id (both come from the `room_instance` table so joiners of the
* same instance share them).
*/
function roomInstanceFromRoom(
room: Room,
isPrivate: boolean,
instanceId: number,
photonRoomId: string
): RoomInstance {
const f = instanceFieldsFromRoom(room)
return {
roomInstanceId: instanceId,
roomId: f.roomId,
subRoomId: f.subRoomId,
roomInstanceType: f.roomInstanceType,
location: f.location,
dataBlob: f.dataBlob,
eventId: 0, eventId: 0,
clubId: 0, clubId: 0,
roomCode: '', roomCode: '',
photonRegion: 'us', photonRegion: 'us',
photonRegionId: 'us', photonRegionId: 'us',
photonRoomId, photonRoomId,
name, name: f.name,
maxCapacity: num(sub?.MaxPlayers, 4), maxCapacity: f.maxCapacity,
isFull: false, isFull: false,
isPrivate: isPrivate || room.IsDorm === true, isPrivate: isPrivate || f.isDorm,
isInProgress: false, isInProgress: false,
EncryptVoiceChat: false, EncryptVoiceChat: false,
} }
@@ -212,19 +229,41 @@ async function readJoinMode(c: Context<App>): Promise<number> {
} }
/** /**
* Resolve a room by `:room` path segment (numeric id or name) from D1 and build * Resolve a room by `:room` path segment (numeric id or name) from D1, then find a
* its instance. Returns null when the room isn't found. * joinable instance of it (public matchmakes reuse one via the `room_instance`
* table) or create a new one. Returns null when the room isn't found.
*/ */
async function resolveRoomInstance( async function resolveRoomInstance(
c: Context<App>, c: Context<App>,
roomKey: string, roomKey: string,
isPrivate: boolean isPrivate: boolean,
ownerId: number
): Promise<RoomInstance | null> { ): Promise<RoomInstance | null> {
const id = Number.parseInt(roomKey, 10) const id = Number.parseInt(roomKey, 10)
const room = Number.isNaN(id) const room = Number.isNaN(id)
? await getRoomByName(c.env.DB, roomKey) ? await getRoomByName(c.env.DB, roomKey)
: await getRoomById(c.env.DB, id) : await getRoomById(c.env.DB, id)
return room ? roomInstanceFromRoom(room, isPrivate) : null if (!room) return null
const f = instanceFieldsFromRoom(room)
// Reuse an existing joinable public instance; private matchmakes always get a
// fresh instance. Create one when there's nothing to join.
let instance = isPrivate ? null : await getJoinableInstance(c.env.DB, f.roomId)
if (!instance) {
instance = await createRoomInstance(c.env.DB, {
ownerAccountId: ownerId,
roomId: f.roomId,
subRoomId: f.subRoomId,
location: f.location,
dataBlob: f.dataBlob,
photonRoomId: crypto.randomUUID(),
name: f.name,
maxCapacity: f.maxCapacity,
isPrivate: isPrivate || f.isDorm,
roomInstanceType: f.roomInstanceType,
})
}
return roomInstanceFromRoom(room, isPrivate, instance.roomInstanceId, instance.photonRoomId)
} }
const app = new Hono<App>() const app = new Hono<App>()
@@ -350,7 +389,7 @@ const app = new Hono<App>()
const instance = const instance =
room.toLowerCase() === 'dormroom' room.toLowerCase() === 'dormroom'
? dormRoomInstance() ? dormRoomInstance()
: await resolveRoomInstance(c, room, joinMode === 2) : 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)
return c.json({ errorCode: 0, roomInstance: instance }) return c.json({ errorCode: 0, roomInstance: instance })
@@ -381,7 +420,7 @@ const app = new Hono<App>()
const id = await authedId(c) const id = await authedId(c)
if (id === null) return unauthorized(c) if (id === null) return unauthorized(c)
const joinMode = await readJoinMode(c) const joinMode = await readJoinMode(c)
const instance = await resolveRoomInstance(c, c.req.param('roomId'), joinMode === 2) const instance = await resolveRoomInstance(c, c.req.param('roomId'), 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)
return c.json({ errorCode: 0, roomInstance: instance }) return c.json({ errorCode: 0, roomInstance: instance })
@@ -396,7 +435,7 @@ const app = new Hono<App>()
const instance = const instance =
room.toLowerCase() === 'dorm' room.toLowerCase() === 'dorm'
? dormRoomInstance() ? dormRoomInstance()
: await resolveRoomInstance(c, room, joinMode === 2) : 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)
return c.json({ errorCode: 0, roomInstance: instance }) return c.json({ errorCode: 0, roomInstance: instance })
+209
View File
@@ -0,0 +1,209 @@
/**
* Room instances — live sessions of a room. Stored with the same JSON-blob pattern
* as the rooms/accounts tables: the full instance is a JSON blob in `data`, and
* every field is a SQLite generated (virtual) column extracted from it (snake_case
* per the C# `[Column]` names). `id` is a sequential key held in the blob.
*
* Mirror of `apps/rooms/src/room-instance-db.ts` — the `rooms` worker owns the
* schema (migrations/0004_room_instance.sql); this worker finds/creates instances
* here at matchmake time, keeping this copy in sync. Columns marked
* `[JsonIgnore]` in the C# (owner_account_id, data_blob, allow_new_users,
* join_disabled) live in the blob but are dropped from the client DTO (`toDto`).
*/
/** Schema DDL (mirror of migrations/0004_room_instance.sql). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS room_instance (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.roomInstanceId')) VIRTUAL,
owner_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ownerAccountId')) VIRTUAL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.roomId')) VIRTUAL,
sub_room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.subRoomId')) VIRTUAL,
location TEXT GENERATED ALWAYS AS (json_extract(data, '$.location')) VIRTUAL,
data_blob TEXT GENERATED ALWAYS AS (json_extract(data, '$.dataBlob')) VIRTUAL,
event_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.eventId')) VIRTUAL,
photon_region_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.photonRegionId')) VIRTUAL,
photon_room_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.photonRoomId')) VIRTUAL,
name TEXT GENERATED ALWAYS AS (json_extract(data, '$.name')) VIRTUAL,
max_capacity INTEGER GENERATED ALWAYS AS (json_extract(data, '$.maxCapacity')) VIRTUAL,
is_full INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isFull')) VIRTUAL,
is_private INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isPrivate')) VIRTUAL,
is_in_progress INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInProgress')) VIRTUAL,
room_code TEXT GENERATED ALWAYS AS (json_extract(data, '$.roomCode')) VIRTUAL,
room_instance_type INTEGER GENERATED ALWAYS AS (json_extract(data, '$.roomInstanceType')) VIRTUAL,
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.clubId')) VIRTUAL,
encrypt_voice_chat INTEGER GENERATED ALWAYS AS (json_extract(data, '$.EncryptVoiceChat')) VIRTUAL,
matchmaking_policy INTEGER GENERATED ALWAYS AS (json_extract(data, '$.matchmakingPolicy')) VIRTUAL,
allow_new_users INTEGER GENERATED ALWAYS AS (json_extract(data, '$.allowNewUsers')) VIRTUAL,
join_disabled INTEGER GENERATED ALWAYS AS (json_extract(data, '$.joinDisabled')) VIRTUAL,
created_at TEXT GENERATED ALWAYS AS (json_extract(data, '$.createdAt')) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_room_instance_id ON room_instance (id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_room_instance_photon_room_id ON room_instance (photon_room_id)`,
`CREATE INDEX IF NOT EXISTS idx_room_instance_room_id ON room_instance (room_id)`,
]
/** Client-facing RoomInstance JSON (JsonPropertyName keys; JsonIgnore omitted). */
export interface RoomInstanceDto {
roomInstanceId: number
roomId: number
subRoomId: number
location: string
eventId: number
photonRegionId: string
photonRoomId: string
name: string
maxCapacity: number
isFull: boolean
isPrivate: boolean
isInProgress: boolean
roomCode: string
roomInstanceType: number
clubId: number
// PascalCase JSON key, per the C# `[JsonPropertyName("EncryptVoiceChat")]`.
EncryptVoiceChat: boolean
matchmakingPolicy: number
createdAt: string
}
/** The full stored instance — the DTO plus the JsonIgnore fields (in the blob). */
interface StoredRoomInstance extends RoomInstanceDto {
ownerAccountId: number
dataBlob: string
allowNewUsers: boolean
joinDisabled: boolean
}
/** Fields for a new instance; `roomInstanceId` and `createdAt` are assigned here. */
export interface NewRoomInstance {
ownerAccountId: number
roomId: number
photonRoomId: string
subRoomId?: number
location?: string
dataBlob?: string
eventId?: number
photonRegionId?: string
name?: string
maxCapacity?: number
isFull?: boolean
isPrivate?: boolean
isInProgress?: boolean
roomCode?: string
roomInstanceType?: number
clubId?: number
encryptVoiceChat?: boolean
matchmakingPolicy?: number
allowNewUsers?: boolean
joinDisabled?: boolean
}
/** Project a stored instance to the client DTO (JsonIgnore fields dropped). */
function toDto(s: StoredRoomInstance): RoomInstanceDto {
return {
roomInstanceId: s.roomInstanceId,
roomId: s.roomId,
subRoomId: s.subRoomId,
location: s.location,
eventId: s.eventId,
photonRegionId: s.photonRegionId,
photonRoomId: s.photonRoomId,
name: s.name,
maxCapacity: s.maxCapacity,
isFull: s.isFull,
isPrivate: s.isPrivate,
isInProgress: s.isInProgress,
roomCode: s.roomCode,
roomInstanceType: s.roomInstanceType,
clubId: s.clubId,
EncryptVoiceChat: s.EncryptVoiceChat,
matchmakingPolicy: s.matchmakingPolicy,
createdAt: s.createdAt,
}
}
const parse = (data: string): StoredRoomInstance => JSON.parse(data) as StoredRoomInstance
/**
* Ids start high (above 1_000_000) so an instance id never collides with the
* dorm's fixed roomInstanceId of 1 — the client keys room transitions off the id,
* so a room instance that returned 1 would look like "still in the dorm".
*/
const ID_BASE = 1_000_000
/** Insert a new room instance, returning it as a client DTO. */
export async function createRoomInstance(
db: D1Database,
input: NewRoomInstance
): Promise<RoomInstanceDto> {
const idRow = await db
.prepare(`SELECT COALESCE(MAX(id), ${ID_BASE}) + 1 AS next FROM room_instance`)
.first<{ next: number }>()
const stored: StoredRoomInstance = {
roomInstanceId: idRow?.next ?? ID_BASE + 1,
ownerAccountId: input.ownerAccountId,
roomId: input.roomId,
subRoomId: input.subRoomId ?? 0,
location: input.location ?? '',
dataBlob: input.dataBlob ?? '',
eventId: input.eventId ?? 0,
photonRegionId: input.photonRegionId ?? 'us',
photonRoomId: input.photonRoomId,
name: input.name ?? '',
maxCapacity: input.maxCapacity ?? 0,
isFull: input.isFull ?? false,
isPrivate: input.isPrivate ?? false,
isInProgress: input.isInProgress ?? false,
roomCode: input.roomCode ?? '',
roomInstanceType: input.roomInstanceType ?? 0,
clubId: input.clubId ?? 0,
EncryptVoiceChat: input.encryptVoiceChat ?? false,
matchmakingPolicy: input.matchmakingPolicy ?? 0,
allowNewUsers: input.allowNewUsers ?? true,
joinDisabled: input.joinDisabled ?? false,
createdAt: new Date().toISOString(),
}
await db.prepare('INSERT INTO room_instance (data) VALUES (?1)').bind(JSON.stringify(stored)).run()
return toDto(stored)
}
/** Look up a room instance by its id (roomInstanceId). */
export async function getRoomInstance(db: D1Database, id: number): Promise<RoomInstanceDto | null> {
const row = await db
.prepare('SELECT data FROM room_instance WHERE id = ?1')
.bind(id)
.first<{ data: string }>()
return row ? toDto(parse(row.data)) : null
}
/**
* The oldest joinable public instance of a room (not private, not full, joins
* enabled), or null when there's none to join. Used by matchmaking to reuse an
* existing instance before creating a new one.
*/
export async function getJoinableInstance(
db: D1Database,
roomId: number
): Promise<RoomInstanceDto | null> {
const row = await db
.prepare(
`SELECT data FROM room_instance
WHERE room_id = ?1 AND is_private = 0 AND is_full = 0 AND join_disabled = 0
ORDER BY id LIMIT 1`
)
.bind(roomId)
.first<{ data: string }>()
return row ? toDto(parse(row.data)) : null
}
/** All instances of a given room. */
export async function getRoomInstancesByRoom(
db: D1Database,
roomId: number
): Promise<RoomInstanceDto[]> {
const { results } = await db
.prepare('SELECT data FROM room_instance WHERE room_id = ?1')
.bind(roomId)
.all<{ data: string }>()
return results.map((r) => toDto(parse(r.data)))
}
+34 -5
View File
@@ -4,6 +4,8 @@ import { beforeAll, describe, expect, test } from 'vitest'
import '../../match.app' import '../../match.app'
import { SCHEMA_DDL as ROOM_INSTANCE_SCHEMA_DDL } from '../../room-instance-db'
import type { Env } from '../../context' import type { Env } from '../../context'
declare module 'cloudflare:test' { declare module 'cloudflare:test' {
@@ -42,6 +44,8 @@ beforeAll(async () => {
).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.
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
}) })
// 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
@@ -255,15 +259,40 @@ describe('auth-gated endpoints', () => {
} }
expect(body.roomInstance).toMatchObject({ expect(body.roomInstance).toMatchObject({
roomId: 2, roomId: 2,
// Must differ from the dorm's instance id (1) so the client treats this
// as a new room and actually loads the scene.
roomInstanceId: 2,
name: '^RecCenter', name: '^RecCenter',
location: RECCENTER_SCENE, location: RECCENTER_SCENE,
isPrivate: true, isPrivate: true,
}) })
// Private instances get a unique Photon room id; public share `rec.<roomId>`. // The instance id is the room_instance table id (high-based, so it never
expect(body.roomInstance.photonRoomId.startsWith('rec.2')).toBe(true) // collides with the dorm's fixed instance id of 1).
expect(body.roomInstance.roomInstanceId).toBeGreaterThan(1)
// Every non-dorm instance gets a fresh random Photon room id (a bare UUID).
expect(body.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
})
test('POST /matchmake/:room reuses a public instance; a private one is fresh', async () => {
const matchmake = async (joinMode?: string) =>
(await (
await exports.default.fetch(`${ORIGIN}/matchmake/2`, {
method: 'POST',
headers: {
...(await bearer('900')),
'Content-Type': 'application/x-www-form-urlencoded',
},
body: joinMode ? new URLSearchParams({ JoinMode: joinMode }).toString() : undefined,
})
).json()) as { roomInstance: { photonRoomId: string; roomInstanceId: number } }
// Two public matchmakes into the same room share the (reused) instance.
const a = await matchmake()
const b = await matchmake()
expect(a.roomInstance.photonRoomId).toMatch(/^[0-9a-f-]{36}$/)
expect(b.roomInstance.photonRoomId).toBe(a.roomInstance.photonRoomId)
expect(b.roomInstance.roomInstanceId).toBe(a.roomInstance.roomInstanceId)
// A private matchmake (JoinMode 2) gets its own distinct instance.
const priv = await matchmake('2')
expect(priv.roomInstance.photonRoomId).not.toBe(a.roomInstance.photonRoomId)
}) })
test('POST /matchmake/:room 401s without a token', async () => { test('POST /matchmake/:room 401s without a token', async () => {
@@ -0,0 +1,34 @@
-- Room instances — live sessions of a room. Stored as a JSON blob in `data` with
-- generated (virtual) columns for every field (snake_case, per the C# `[Column]`
-- names), the same pattern as the rooms/accounts tables. `id` (roomInstanceId) is
-- a sequential key held in the blob. Generated from src/room-instance-db.ts
-- (SCHEMA_DDL) — keep in sync. Written/read by the match worker.
CREATE TABLE IF NOT EXISTS room_instance (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.roomInstanceId')) VIRTUAL,
owner_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ownerAccountId')) VIRTUAL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.roomId')) VIRTUAL,
sub_room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.subRoomId')) VIRTUAL,
location TEXT GENERATED ALWAYS AS (json_extract(data, '$.location')) VIRTUAL,
data_blob TEXT GENERATED ALWAYS AS (json_extract(data, '$.dataBlob')) VIRTUAL,
event_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.eventId')) VIRTUAL,
photon_region_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.photonRegionId')) VIRTUAL,
photon_room_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.photonRoomId')) VIRTUAL,
name TEXT GENERATED ALWAYS AS (json_extract(data, '$.name')) VIRTUAL,
max_capacity INTEGER GENERATED ALWAYS AS (json_extract(data, '$.maxCapacity')) VIRTUAL,
is_full INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isFull')) VIRTUAL,
is_private INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isPrivate')) VIRTUAL,
is_in_progress INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInProgress')) VIRTUAL,
room_code TEXT GENERATED ALWAYS AS (json_extract(data, '$.roomCode')) VIRTUAL,
room_instance_type INTEGER GENERATED ALWAYS AS (json_extract(data, '$.roomInstanceType')) VIRTUAL,
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.clubId')) VIRTUAL,
encrypt_voice_chat INTEGER GENERATED ALWAYS AS (json_extract(data, '$.EncryptVoiceChat')) VIRTUAL,
matchmaking_policy INTEGER GENERATED ALWAYS AS (json_extract(data, '$.matchmakingPolicy')) VIRTUAL,
allow_new_users INTEGER GENERATED ALWAYS AS (json_extract(data, '$.allowNewUsers')) VIRTUAL,
join_disabled INTEGER GENERATED ALWAYS AS (json_extract(data, '$.joinDisabled')) VIRTUAL,
created_at TEXT GENERATED ALWAYS AS (json_extract(data, '$.createdAt')) VIRTUAL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_room_instance_id ON room_instance (id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_room_instance_photon_room_id ON room_instance (photon_room_id);
CREATE INDEX IF NOT EXISTS idx_room_instance_room_id ON room_instance (room_id);
+208
View File
@@ -0,0 +1,208 @@
/**
* Room instances — live sessions of a room. Stored with the same JSON-blob pattern
* as the rooms/accounts tables: the full instance is a JSON blob in `data`, and
* every field is a SQLite generated (virtual) column extracted from it (snake_case
* per the C# `[Column]` names). `id` is a sequential key held in the blob.
*
* The `rooms` worker owns the schema (migrations/0004_room_instance.sql). The match
* worker finds/creates instances here — keep this in sync. Columns marked
* `[JsonIgnore]` in the C# (owner_account_id, data_blob, allow_new_users,
* join_disabled) live in the blob but are dropped from the client DTO (`toDto`).
*/
/** Schema DDL (mirror of migrations/0004_room_instance.sql). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS room_instance (
data TEXT NOT NULL,
id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.roomInstanceId')) VIRTUAL,
owner_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.ownerAccountId')) VIRTUAL,
room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.roomId')) VIRTUAL,
sub_room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.subRoomId')) VIRTUAL,
location TEXT GENERATED ALWAYS AS (json_extract(data, '$.location')) VIRTUAL,
data_blob TEXT GENERATED ALWAYS AS (json_extract(data, '$.dataBlob')) VIRTUAL,
event_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.eventId')) VIRTUAL,
photon_region_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.photonRegionId')) VIRTUAL,
photon_room_id TEXT GENERATED ALWAYS AS (json_extract(data, '$.photonRoomId')) VIRTUAL,
name TEXT GENERATED ALWAYS AS (json_extract(data, '$.name')) VIRTUAL,
max_capacity INTEGER GENERATED ALWAYS AS (json_extract(data, '$.maxCapacity')) VIRTUAL,
is_full INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isFull')) VIRTUAL,
is_private INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isPrivate')) VIRTUAL,
is_in_progress INTEGER GENERATED ALWAYS AS (json_extract(data, '$.isInProgress')) VIRTUAL,
room_code TEXT GENERATED ALWAYS AS (json_extract(data, '$.roomCode')) VIRTUAL,
room_instance_type INTEGER GENERATED ALWAYS AS (json_extract(data, '$.roomInstanceType')) VIRTUAL,
club_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.clubId')) VIRTUAL,
encrypt_voice_chat INTEGER GENERATED ALWAYS AS (json_extract(data, '$.EncryptVoiceChat')) VIRTUAL,
matchmaking_policy INTEGER GENERATED ALWAYS AS (json_extract(data, '$.matchmakingPolicy')) VIRTUAL,
allow_new_users INTEGER GENERATED ALWAYS AS (json_extract(data, '$.allowNewUsers')) VIRTUAL,
join_disabled INTEGER GENERATED ALWAYS AS (json_extract(data, '$.joinDisabled')) VIRTUAL,
created_at TEXT GENERATED ALWAYS AS (json_extract(data, '$.createdAt')) VIRTUAL
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_room_instance_id ON room_instance (id)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_room_instance_photon_room_id ON room_instance (photon_room_id)`,
`CREATE INDEX IF NOT EXISTS idx_room_instance_room_id ON room_instance (room_id)`,
]
/** Client-facing RoomInstance JSON (JsonPropertyName keys; JsonIgnore omitted). */
export interface RoomInstanceDto {
roomInstanceId: number
roomId: number
subRoomId: number
location: string
eventId: number
photonRegionId: string
photonRoomId: string
name: string
maxCapacity: number
isFull: boolean
isPrivate: boolean
isInProgress: boolean
roomCode: string
roomInstanceType: number
clubId: number
// PascalCase JSON key, per the C# `[JsonPropertyName("EncryptVoiceChat")]`.
EncryptVoiceChat: boolean
matchmakingPolicy: number
createdAt: string
}
/** The full stored instance — the DTO plus the JsonIgnore fields (in the blob). */
interface StoredRoomInstance extends RoomInstanceDto {
ownerAccountId: number
dataBlob: string
allowNewUsers: boolean
joinDisabled: boolean
}
/** Fields for a new instance; `roomInstanceId` and `createdAt` are assigned here. */
export interface NewRoomInstance {
ownerAccountId: number
roomId: number
photonRoomId: string
subRoomId?: number
location?: string
dataBlob?: string
eventId?: number
photonRegionId?: string
name?: string
maxCapacity?: number
isFull?: boolean
isPrivate?: boolean
isInProgress?: boolean
roomCode?: string
roomInstanceType?: number
clubId?: number
encryptVoiceChat?: boolean
matchmakingPolicy?: number
allowNewUsers?: boolean
joinDisabled?: boolean
}
/** Project a stored instance to the client DTO (JsonIgnore fields dropped). */
function toDto(s: StoredRoomInstance): RoomInstanceDto {
return {
roomInstanceId: s.roomInstanceId,
roomId: s.roomId,
subRoomId: s.subRoomId,
location: s.location,
eventId: s.eventId,
photonRegionId: s.photonRegionId,
photonRoomId: s.photonRoomId,
name: s.name,
maxCapacity: s.maxCapacity,
isFull: s.isFull,
isPrivate: s.isPrivate,
isInProgress: s.isInProgress,
roomCode: s.roomCode,
roomInstanceType: s.roomInstanceType,
clubId: s.clubId,
EncryptVoiceChat: s.EncryptVoiceChat,
matchmakingPolicy: s.matchmakingPolicy,
createdAt: s.createdAt,
}
}
const parse = (data: string): StoredRoomInstance => JSON.parse(data) as StoredRoomInstance
/**
* Ids start high (above 1_000_000) so an instance id never collides with the
* dorm's fixed roomInstanceId of 1 — the client keys room transitions off the id,
* so a room instance that returned 1 would look like "still in the dorm".
*/
const ID_BASE = 1_000_000
/** Insert a new room instance, returning it as a client DTO. */
export async function createRoomInstance(
db: D1Database,
input: NewRoomInstance
): Promise<RoomInstanceDto> {
const idRow = await db
.prepare(`SELECT COALESCE(MAX(id), ${ID_BASE}) + 1 AS next FROM room_instance`)
.first<{ next: number }>()
const stored: StoredRoomInstance = {
roomInstanceId: idRow?.next ?? ID_BASE + 1,
ownerAccountId: input.ownerAccountId,
roomId: input.roomId,
subRoomId: input.subRoomId ?? 0,
location: input.location ?? '',
dataBlob: input.dataBlob ?? '',
eventId: input.eventId ?? 0,
photonRegionId: input.photonRegionId ?? 'us',
photonRoomId: input.photonRoomId,
name: input.name ?? '',
maxCapacity: input.maxCapacity ?? 0,
isFull: input.isFull ?? false,
isPrivate: input.isPrivate ?? false,
isInProgress: input.isInProgress ?? false,
roomCode: input.roomCode ?? '',
roomInstanceType: input.roomInstanceType ?? 0,
clubId: input.clubId ?? 0,
EncryptVoiceChat: input.encryptVoiceChat ?? false,
matchmakingPolicy: input.matchmakingPolicy ?? 0,
allowNewUsers: input.allowNewUsers ?? true,
joinDisabled: input.joinDisabled ?? false,
createdAt: new Date().toISOString(),
}
await db.prepare('INSERT INTO room_instance (data) VALUES (?1)').bind(JSON.stringify(stored)).run()
return toDto(stored)
}
/** Look up a room instance by its id (roomInstanceId). */
export async function getRoomInstance(db: D1Database, id: number): Promise<RoomInstanceDto | null> {
const row = await db
.prepare('SELECT data FROM room_instance WHERE id = ?1')
.bind(id)
.first<{ data: string }>()
return row ? toDto(parse(row.data)) : null
}
/**
* The oldest joinable public instance of a room (not private, not full, joins
* enabled), or null when there's none to join. Used by matchmaking to reuse an
* existing instance before creating a new one.
*/
export async function getJoinableInstance(
db: D1Database,
roomId: number
): Promise<RoomInstanceDto | null> {
const row = await db
.prepare(
`SELECT data FROM room_instance
WHERE room_id = ?1 AND is_private = 0 AND is_full = 0 AND join_disabled = 0
ORDER BY id LIMIT 1`
)
.bind(roomId)
.first<{ data: string }>()
return row ? toDto(parse(row.data)) : null
}
/** All instances of a given room. */
export async function getRoomInstancesByRoom(
db: D1Database,
roomId: number
): Promise<RoomInstanceDto[]> {
const { results } = await db
.prepare('SELECT data FROM room_instance WHERE room_id = ?1')
.bind(roomId)
.all<{ data: string }>()
return results.map((r) => toDto(parse(r.data)))
}
@@ -4,6 +4,11 @@ import { beforeAll, describe, expect, it } from 'vitest'
import '../../rooms.app' import '../../rooms.app'
import importRooms from '../../../static/ImportRooms.json' import importRooms from '../../../static/ImportRooms.json'
import {
createRoomInstance,
getRoomInstance,
SCHEMA_DDL as ROOM_INSTANCE_SCHEMA_DDL,
} from '../../room-instance-db'
import { SCHEMA_DDL } from '../../rooms-db' import { SCHEMA_DDL } from '../../rooms-db'
import type { Env } from '../../context' import type { Env } from '../../context'
@@ -41,6 +46,7 @@ async function bearer(sub: string): Promise<Record<string, string>> {
// Apply the schema + seed the imported rooms into the test D1 (mirrors the migrations). // Apply the schema + seed the imported rooms into the test D1 (mirrors the migrations).
beforeAll(async () => { beforeAll(async () => {
for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run() for (const stmt of SCHEMA_DDL) await env.DB.prepare(stmt).run()
for (const stmt of ROOM_INSTANCE_SCHEMA_DDL) await env.DB.prepare(stmt).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(importRooms.map((r) => insert.bind(JSON.stringify(r)))) await env.DB.batch(importRooms.map((r) => insert.bind(JSON.stringify(r))))
}) })
@@ -522,6 +528,31 @@ describe('rooms endpoints', () => {
expect(room.RoomId).toBe(2) expect(room.RoomId).toBe(2)
}) })
it('room_instance: create + read round-trips and hides JsonIgnore fields', async () => {
const created = await createRoomInstance(env.DB, {
ownerAccountId: 5,
roomId: 2,
subRoomId: 3,
photonRoomId: crypto.randomUUID(),
name: '^RecCenter',
maxCapacity: 20,
isPrivate: true,
encryptVoiceChat: true,
})
// The DB assigns a sequential id, mapped to `roomInstanceId` in the DTO.
expect(created.roomInstanceId).toBeGreaterThan(0)
expect(created.roomId).toBe(2)
expect(created.isPrivate).toBe(true)
expect(created.EncryptVoiceChat).toBe(true) // PascalCase JSON key, per the C#
// Reads back identically; JsonIgnore columns are not in the DTO.
const fetched = await getRoomInstance(env.DB, created.roomInstanceId)
expect(fetched).toEqual(created)
expect('ownerAccountId' in (fetched as object)).toBe(false)
expect('dataBlob' in (fetched as object)).toBe(false)
expect('allowNewUsers' in (fetched as object)).toBe(false)
})
it('GET /photon_access_token (bare + /roomserver) returns permissions', async () => { it('GET /photon_access_token (bare + /roomserver) returns permissions', async () => {
for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) { for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) {
const res = await SELF.fetch(`${ORIGIN}${path}`) const res = await SELF.fetch(`${ORIGIN}${path}`)