From f48d78bd5f696cae80d34d4e61b5615b74f743bc Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Wed, 1 Jul 2026 15:16:37 -0400 Subject: [PATCH] true matchmaking --- apps/api/src/api.app.ts | 53 ++++- apps/api/src/images-db.ts | 91 ++++++++ apps/api/src/test/integration/api.test.ts | 81 +++++++ apps/cdn/static/loading-screen-tip-data.json | 20 +- apps/img/migrations/0001_image.sql | 16 ++ apps/img/package.json | 1 + apps/img/src/context.ts | 2 + apps/img/src/images-db.ts | 93 +++++++++ apps/img/wrangler.jsonc | 13 ++ apps/match/src/match.app.ts | 97 ++++++--- apps/match/src/room-instance-db.ts | 209 +++++++++++++++++++ apps/match/src/test/integration/api.test.ts | 39 +++- apps/rooms/migrations/0004_room_instance.sql | 34 +++ apps/rooms/src/room-instance-db.ts | 208 ++++++++++++++++++ apps/rooms/src/test/integration/api.test.ts | 31 +++ 15 files changed, 938 insertions(+), 50 deletions(-) create mode 100644 apps/api/src/images-db.ts create mode 100644 apps/img/migrations/0001_image.sql create mode 100644 apps/img/src/images-db.ts create mode 100644 apps/match/src/room-instance-db.ts create mode 100644 apps/rooms/migrations/0004_room_instance.sql create mode 100644 apps/rooms/src/room-instance-db.ts diff --git a/apps/api/src/api.app.ts b/apps/api/src/api.app.ts index 256e77d..0e5fc0d 100644 --- a/apps/api/src/api.app.ts +++ b/apps/api/src/api.app.ts @@ -9,6 +9,7 @@ import storefrontGiftDrop2 from '../static/storefronts-v3-giftdropstore-2.json' import storefrontGiftDrop3 from '../static/storefronts-v3-giftdropstore-3.json' import storefrontGiftDrop300 from '../static/storefronts-v3-giftdropstore-300.json' import { defaultSettings } from './default-settings' +import { createImage, getImageByName } from './images-db' import { validateAndGetAccountId } from './jwt' import { getRoomById, getRoomByName, getRoomsByCreator, getRoomsByIds } from './rooms-db' @@ -422,18 +423,24 @@ const app = new Hono({ strict: false }) if (!(candidate instanceof File)) return c.json({ error: 'No file found in request' }, 400) const file = candidate - // `imgMeta` is a JSON blob describing the upload; its `savedImageType` - // decides what (if anything) the image is recorded against. Mirrors the C# - // `SavedImageMetaDTO` / `SavedImageType` enum. - let savedImageType: number = SavedImageType.None + // `imgMeta` is a JSON blob describing the upload (the C# `SavedImageMetaDTO`), + // posted as a multipart field. It carries the metadata we record on the image + // (savedImageType, roomId, accessibility, description, taggedPlayerIds, …). + let meta: Record = {} if (typeof body.imgMeta === 'string') { try { - const meta = JSON.parse(body.imgMeta) as { savedImageType?: unknown } | null - if (meta && typeof meta.savedImageType === 'number') savedImageType = meta.savedImageType + const parsed = JSON.parse(body.imgMeta) + if (parsed && typeof parsed === 'object') meta = parsed as Record } catch { // 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 dot = file.name.lastIndexOf('.') @@ -457,9 +464,43 @@ const app = new Hono({ strict: false }) .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 }) }) + // 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 ---------------------------------------------------------------- // Room search filters. The client deserializes this into an object (not an // array) — shape from the 2025 reference. diff --git a/apps/api/src/images-db.ts b/apps/api/src/images-db.ts new file mode 100644 index 0000000..e5dd497 --- /dev/null +++ b/apps/api/src/images-db.ts @@ -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 { + // 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 { + const row = await db + .prepare('SELECT data FROM image WHERE image_name = ?1') + .bind(name) + .first() + return row ? (JSON.parse(row.data) as SavedImage) : null +} diff --git a/apps/api/src/test/integration/api.test.ts b/apps/api/src/test/integration/api.test.ts index 25dceaf..8464701 100644 --- a/apps/api/src/test/integration/api.test.ts +++ b/apps/api/src/test/integration/api.test.ts @@ -4,6 +4,8 @@ import { beforeAll, describe, expect, test } from 'vitest' import '../../api.app' +import { SCHEMA_DDL as IMAGES_SCHEMA_DDL } from '../../images-db' + import type { Env } from '../../context' declare module 'cloudflare:test' { @@ -58,6 +60,9 @@ beforeAll(async () => { JSON.stringify({ accountId: 42, username: 'Tester', profileImage: 'DefaultProfileImage.jpg' }) ) .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 @@ -369,6 +374,82 @@ describe('images', () => { const stored = await env.IMAGES.get(ImageName) expect(stored).not.toBeNull() 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 () => { diff --git a/apps/cdn/static/loading-screen-tip-data.json b/apps/cdn/static/loading-screen-tip-data.json index f8bc073..ab9a59e 100644 --- a/apps/cdn/static/loading-screen-tip-data.json +++ b/apps/cdn/static/loading-screen-tip-data.json @@ -9,7 +9,7 @@ "Visibility": 0, "AllowCycling": true, "RestrictToNewUsers": false, - "ImageName": "gay", + "ImageName": "tip.jpg", "PlatformMask": 175, "CreatedAt": "2019-02-28T18:27:25Z" }, @@ -23,7 +23,7 @@ "Visibility": 0, "AllowCycling": true, "RestrictToNewUsers": false, - "ImageName": "gay", + "ImageName": "tip.jpg", "PlatformMask": 167, "CreatedAt": "2019-02-28T18:15:33Z" }, @@ -37,7 +37,7 @@ "Visibility": 0, "AllowCycling": true, "RestrictToNewUsers": false, - "ImageName": "gay", + "ImageName": "tip.jpg", "PlatformMask": 175, "CreatedAt": "2019-03-02T00:12:59Z" }, @@ -51,7 +51,7 @@ "Visibility": 0, "AllowCycling": true, "RestrictToNewUsers": false, - "ImageName": "gay", + "ImageName": "tip.jpg", "PlatformMask": 175, "CreatedAt": "2019-02-28T18:08:56Z" }, @@ -65,7 +65,7 @@ "Visibility": 0, "AllowCycling": true, "RestrictToNewUsers": false, - "ImageName": "gay", + "ImageName": "tip.jpg", "PlatformMask": 175, "CreatedAt": "2019-02-28T18:22:44Z" }, @@ -79,7 +79,7 @@ "Visibility": 0, "AllowCycling": true, "RestrictToNewUsers": false, - "ImageName": "gay", + "ImageName": "tip.jpg", "PlatformMask": 175, "CreatedAt": "2019-02-28T18:28:32Z" }, @@ -93,7 +93,7 @@ "Visibility": 0, "AllowCycling": true, "RestrictToNewUsers": false, - "ImageName": "gay", + "ImageName": "tip.jpg", "PlatformMask": 167, "CreatedAt": "2019-02-28T18:24:33Z" }, @@ -107,7 +107,7 @@ "Visibility": 0, "AllowCycling": true, "RestrictToNewUsers": false, - "ImageName": "gay", + "ImageName": "tip.jpg", "PlatformMask": 131, "CreatedAt": "2019-12-21T01:24:23Z" }, @@ -121,7 +121,7 @@ "Visibility": 0, "AllowCycling": true, "RestrictToNewUsers": false, - "ImageName": "gay", + "ImageName": "tip.jpg", "PlatformMask": 175, "CreatedAt": "2019-02-28T18:07:50Z" }, @@ -135,7 +135,7 @@ "Visibility": 1, "AllowCycling": true, "RestrictToNewUsers": false, - "ImageName": "gay", + "ImageName": "tip.jpg", "PlatformMask": 239, "CreatedAt": "2019-02-28T18:21:25Z" } diff --git a/apps/img/migrations/0001_image.sql b/apps/img/migrations/0001_image.sql new file mode 100644 index 0000000..3774524 --- /dev/null +++ b/apps/img/migrations/0001_image.sql @@ -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); diff --git a/apps/img/package.json b/apps/img/package.json index 21cf617..30010db 100644 --- a/apps/img/package.json +++ b/apps/img/package.json @@ -12,6 +12,7 @@ "deploy": "run-wrangler-deploy", "dev": "run-wrangler-dev", "fix:workers-types": "run-wrangler-types", + "migrate": "run-wrangler-migrate", "test": "run-vitest" }, "dependencies": { diff --git a/apps/img/src/context.ts b/apps/img/src/context.ts index c3f48f2..6a33d03 100644 --- a/apps/img/src/context.ts +++ b/apps/img/src/context.ts @@ -2,6 +2,8 @@ import type { HonoApp } from '@repo/hono-helpers' import type { SharedHonoEnv, SharedHonoVariables } from '@repo/hono-helpers/src/types' 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. */ IMAGES: R2Bucket /** diff --git a/apps/img/src/images-db.ts b/apps/img/src/images-db.ts new file mode 100644 index 0000000..629d682 --- /dev/null +++ b/apps/img/src/images-db.ts @@ -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 { + // 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 { + const row = await db + .prepare('SELECT data FROM image WHERE image_name = ?1') + .bind(name) + .first() + return row ? (JSON.parse(row.data) as SavedImage) : null +} diff --git a/apps/img/wrangler.jsonc b/apps/img/wrangler.jsonc index 33c6d18..498221a 100644 --- a/apps/img/wrangler.jsonc +++ b/apps/img/wrangler.jsonc @@ -11,6 +11,19 @@ "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, "observability": { "logs": { diff --git a/apps/match/src/match.app.ts b/apps/match/src/match.app.ts index 1928935..fa6212a 100644 --- a/apps/match/src/match.app.ts +++ b/apps/match/src/match.app.ts @@ -4,6 +4,7 @@ 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 type { Context } from 'hono' @@ -11,9 +12,9 @@ import type { App } from './context' import type { Room } from './rooms-db' /** - * The matchmaking surface. Database-backed endpoints are stubbed here — there's - * no DB binding yet, so room/player lookups fall back to default values when - * nothing is found. + * The matchmaking surface. Rooms and room instances are D1-backed (matchmaking + * finds/creates a `room_instance` row per session); player lookups still fall back + * 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. */ @@ -162,44 +163,60 @@ function dormRoomInstance() { } /** - * Build a room instance from a stored D1 room — crucially using the room's real - * SubRoom `UnitySceneId` as the instance `location` (an empty/unknown location - * makes the client reject the session with "unknown scene location ID"). + * Instance-relevant fields pulled from a stored room (scene, name, capacity, …). + * The `location` is the SubRoom's real `UnitySceneId` — an empty/unknown location + * makes the client reject the session with "unknown scene location ID". */ -function roomInstanceFromRoom(room: Room, isPrivate: boolean): RoomInstance { - const subRooms = room.SubRooms - const sub = (Array.isArray(subRooms) ? subRooms[0] : undefined) as +function instanceFieldsFromRoom(room: Room) { + const sub = (Array.isArray(room.SubRooms) ? room.SubRooms[0] : undefined) as Record | undefined const str = (v: unknown, fallback = '') => (typeof v === 'string' ? 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 // 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. 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 { - roomInstanceId: roomId, - roomId, + roomId: num(room.RoomId, 1), subRoomId: num(sub?.SubRoomId, 1), - roomInstanceType: room.IsDorm === true ? 2 : 0, location: str(sub?.UnitySceneId), 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, clubId: 0, roomCode: '', photonRegion: 'us', photonRegionId: 'us', photonRoomId, - name, - maxCapacity: num(sub?.MaxPlayers, 4), + name: f.name, + maxCapacity: f.maxCapacity, isFull: false, - isPrivate: isPrivate || room.IsDorm === true, + isPrivate: isPrivate || f.isDorm, isInProgress: false, EncryptVoiceChat: false, } @@ -212,19 +229,41 @@ async function readJoinMode(c: Context): Promise { } /** - * Resolve a room by `:room` path segment (numeric id or name) from D1 and build - * its instance. Returns null when the room isn't found. + * Resolve a room by `:room` path segment (numeric id or name) from D1, then find a + * 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( c: Context, roomKey: string, - isPrivate: boolean + isPrivate: boolean, + ownerId: number ): Promise { const id = Number.parseInt(roomKey, 10) const room = Number.isNaN(id) ? await getRoomByName(c.env.DB, roomKey) : 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() @@ -350,7 +389,7 @@ const app = new Hono() const instance = room.toLowerCase() === 'dormroom' ? 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 }) await enterRoom(c, id, instance) return c.json({ errorCode: 0, roomInstance: instance }) @@ -381,7 +420,7 @@ const app = new Hono() const id = await authedId(c) if (id === null) return unauthorized(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 }) await enterRoom(c, id, instance) return c.json({ errorCode: 0, roomInstance: instance }) @@ -396,7 +435,7 @@ const app = new Hono() const instance = room.toLowerCase() === 'dorm' ? 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 }) await enterRoom(c, id, instance) return c.json({ errorCode: 0, roomInstance: instance }) diff --git a/apps/match/src/room-instance-db.ts b/apps/match/src/room-instance-db.ts new file mode 100644 index 0000000..116778b --- /dev/null +++ b/apps/match/src/room-instance-db.ts @@ -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 { + 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 { + 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 { + 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 { + 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))) +} diff --git a/apps/match/src/test/integration/api.test.ts b/apps/match/src/test/integration/api.test.ts index d1bccdc..2d68e3d 100644 --- a/apps/match/src/test/integration/api.test.ts +++ b/apps/match/src/test/integration/api.test.ts @@ -4,6 +4,8 @@ import { beforeAll, describe, expect, test } from 'vitest' import '../../match.app' +import { SCHEMA_DDL as ROOM_INSTANCE_SCHEMA_DDL } from '../../room-instance-db' + import type { Env } from '../../context' declare module 'cloudflare:test' { @@ -42,6 +44,8 @@ beforeAll(async () => { ).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() }) // 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({ 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', location: RECCENTER_SCENE, isPrivate: true, }) - // Private instances get a unique Photon room id; public share `rec.`. - expect(body.roomInstance.photonRoomId.startsWith('rec.2')).toBe(true) + // The instance id is the room_instance table id (high-based, so it never + // 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 () => { diff --git a/apps/rooms/migrations/0004_room_instance.sql b/apps/rooms/migrations/0004_room_instance.sql new file mode 100644 index 0000000..756f08e --- /dev/null +++ b/apps/rooms/migrations/0004_room_instance.sql @@ -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); diff --git a/apps/rooms/src/room-instance-db.ts b/apps/rooms/src/room-instance-db.ts new file mode 100644 index 0000000..90119cd --- /dev/null +++ b/apps/rooms/src/room-instance-db.ts @@ -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 { + 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 { + 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 { + 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 { + 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))) +} diff --git a/apps/rooms/src/test/integration/api.test.ts b/apps/rooms/src/test/integration/api.test.ts index 448a67f..e449301 100644 --- a/apps/rooms/src/test/integration/api.test.ts +++ b/apps/rooms/src/test/integration/api.test.ts @@ -4,6 +4,11 @@ import { beforeAll, describe, expect, it } from 'vitest' import '../../rooms.app' 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 type { Env } from '../../context' @@ -41,6 +46,7 @@ async function bearer(sub: string): Promise> { // Apply the schema + seed the imported rooms into the test D1 (mirrors the migrations). beforeAll(async () => { 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)') await env.DB.batch(importRooms.map((r) => insert.bind(JSON.stringify(r)))) }) @@ -522,6 +528,31 @@ describe('rooms endpoints', () => { 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 () => { for (const path of ['/photon_access_token', '/roomserver/photon_access_token']) { const res = await SELF.fetch(`${ORIGIN}${path}`)