/** * Room storage on D1. Each room is a single JSON blob in the `data` column; * queryable fields (RoomId, Name, CreatorAccountId, IsDorm) are SQLite * generated (virtual) columns extracted from that JSON and indexed. This keeps * the room shape flexible while still allowing fast lookups by id/name/creator. * * `SCHEMA_DDL` mirrors `migrations/0001_init.sql`; the room data is seeded from * `static/ImportRooms.json` by `migrations/0002_import_rooms.sql`. Tests apply * `SCHEMA_DDL` then seed the imported rooms directly. */ /** Schema DDL (mirror of migrations/0001_init.sql, sans the seed INSERT). */ export const SCHEMA_DDL: string[] = [ `CREATE TABLE IF NOT EXISTS rooms ( data TEXT NOT NULL, room_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.RoomId')) VIRTUAL, name TEXT GENERATED ALWAYS AS (json_extract(data, '$.Name')) VIRTUAL, name_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Name'))) VIRTUAL, creator_account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.CreatorAccountId')) VIRTUAL, is_dorm INTEGER GENERATED ALWAYS AS (json_extract(data, '$.IsDorm')) VIRTUAL )`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_rooms_room_id ON rooms (room_id)`, `CREATE INDEX IF NOT EXISTS idx_rooms_name_lower ON rooms (name_lower)`, `CREATE INDEX IF NOT EXISTS idx_rooms_creator ON rooms (creator_account_id)`, // Per-player interaction state with a room (cheered/favorited + last visit). // One row per (player, room); cheer/favorite are toggled in place. `CREATE TABLE IF NOT EXISTS interaction ( player_id INTEGER NOT NULL, room_id INTEGER NOT NULL, cheered INTEGER NOT NULL DEFAULT 0, favorited INTEGER NOT NULL DEFAULT 0, last_visited_at TEXT, PRIMARY KEY (player_id, room_id) )`, ] /** A stored room — the parsed JSON blob (full client-facing room response). */ export type Room = Record /** * Clone an existing room into a new one owned by `accountId`. Copies the source * room's content (scene/subrooms/settings), assigning a fresh RoomId, the given * name, and the new owner; the `base` template tag is dropped so user clones * aren't themselves listed as base rooms. Returns the new room, or null when the * source isn't in D1 or disallows cloning. */ export async function cloneRoom( db: D1Database, sourceRoomId: number, name: string, accountId: number ): Promise { const source = await getRoomById(db, sourceRoomId) if (!source || source.CloningAllowed === false) return null const row = await db .prepare('SELECT MAX(room_id) AS maxId FROM rooms') .first<{ maxId: number | null }>() const newRoomId = (row?.maxId ?? 0) + 1 const tags = Array.isArray(source.Tags) ? (source.Tags as Array>).filter( (t) => String(t?.Tag).toLowerCase() !== 'base' ) : source.Tags const cloned: Room = { ...source, RoomId: newRoomId, Name: name, CreatorAccountId: accountId, IsDorm: false, Tags: tags, CreatedAt: new Date().toISOString(), } await db.prepare('INSERT INTO rooms (data) VALUES (?1)').bind(JSON.stringify(cloned)).run() return cloned } /** Set a room's Description in place (the caller is responsible for the owner check). */ export async function setRoomDescription( db: D1Database, roomId: number, description: string ): Promise { await db .prepare("UPDATE rooms SET data = json_set(data, '$.Description', ?2) WHERE room_id = ?1") .bind(roomId, description) .run() } /** Set a room's Name in place (the caller checks ownership + name uniqueness first). */ export async function setRoomName(db: D1Database, roomId: number, name: string): Promise { await db .prepare("UPDATE rooms SET data = json_set(data, '$.Name', ?2) WHERE room_id = ?1") .bind(roomId, name) .run() } interface RoomRow { data: string } const parseOne = (row: RoomRow | null): Room | null => (row ? (JSON.parse(row.data) as Room) : null) const parseAll = (rows: RoomRow[]): Room[] => rows.map((r) => JSON.parse(r.data) as Room) /** Look up a single room by its RoomId. */ export async function getRoomById(db: D1Database, roomId: number): Promise { return parseOne( await db.prepare('SELECT data FROM rooms WHERE room_id = ?1').bind(roomId).first() ) } /** Look up a single room by name (case-insensitive exact match). */ export async function getRoomByName(db: D1Database, name: string): Promise { return parseOne( await db .prepare('SELECT data FROM rooms WHERE name_lower = ?1') .bind(name.toLowerCase()) .first() ) } /** Look up multiple rooms by RoomId. */ export async function getRoomsByIds(db: D1Database, ids: number[]): Promise { if (ids.length === 0) return [] const placeholders = ids.map((_, i) => `?${i + 1}`).join(',') const { results } = await db .prepare(`SELECT data FROM rooms WHERE room_id IN (${placeholders})`) .bind(...ids) .all() return parseAll(results) } /** All rooms created by an account (e.g. their dorm). */ export async function getRoomsByCreator(db: D1Database, accountId: number): Promise { const { results } = await db .prepare('SELECT data FROM rooms WHERE creator_account_id = ?1') .bind(accountId) .all() return parseAll(results) } /** * An account's public, non-dorm rooms — the publicly viewable "rooms owned by * " list (excludes private rooms, dorms, and list-excluded rooms). */ export async function getPublicRoomsByCreator(db: D1Database, accountId: number): Promise { return (await getRoomsByCreator(db, accountId)).filter( (r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true ) } /** * Rooms the player has favorited (interaction.favorited = 1), most recently * interacted first. Joins the `interaction` table to `rooms`, so a favorited room * no longer in D1 is simply absent. Paginated via skip/take; returns a bare array * of rooms (the client's room-source loaders expect a plain list). */ export async function getFavoritedRooms( db: D1Database, playerId: number, skip: number, take: number ): Promise { const { results } = await db .prepare( `SELECT r.data AS data FROM interaction i JOIN rooms r ON r.room_id = i.room_id WHERE i.player_id = ?1 AND i.favorited = 1 ORDER BY i.last_visited_at DESC` ) .bind(playerId) .all() return parseAll(results).slice(skip, skip + take) } /** * Rooms the player has visited (an interaction row with a `last_visited_at`), * most recent first. Like favorites, it joins `interaction` to `rooms`, so a * visited room no longer in D1 is simply absent. Paginated via skip/take; returns * a bare array of rooms (the client's room-source loaders expect a plain list). */ export async function getVisitedRooms( db: D1Database, playerId: number, skip: number, take: number ): Promise { const { results } = await db .prepare( `SELECT r.data AS data FROM interaction i JOIN rooms r ON r.room_id = i.room_id WHERE i.player_id = ?1 AND i.last_visited_at IS NOT NULL ORDER BY i.last_visited_at DESC` ) .bind(playerId) .all() return parseAll(results).slice(skip, skip + take) } /** A player's interaction state with a room. */ export interface Interaction { Cheered: boolean Favorited: boolean } interface InteractionRow { cheered: number favorited: number } const toInteraction = (row: InteractionRow | null): Interaction => ({ Cheered: row?.cheered === 1, Favorited: row?.favorited === 1, }) /** Read a player's interaction with a room (defaults to all-false if none). */ export async function getInteraction( db: D1Database, playerId: number, roomId: number ): Promise { return toInteraction( await db .prepare('SELECT cheered, favorited FROM interaction WHERE player_id = ?1 AND room_id = ?2') .bind(playerId, roomId) .first() ) } /** Upsert+toggle a single boolean column, returning the resulting interaction. */ async function toggleInteraction( db: D1Database, playerId: number, roomId: number, column: 'cheered' | 'favorited' ): Promise { const now = new Date().toISOString() // First interaction defaults the toggled column to 1; subsequent calls flip it. return toInteraction( await db .prepare( `INSERT INTO interaction (player_id, room_id, ${column}, last_visited_at) VALUES (?1, ?2, 1, ?3) ON CONFLICT(player_id, room_id) DO UPDATE SET ${column} = NOT ${column}, last_visited_at = ?3 RETURNING cheered, favorited` ) .bind(playerId, roomId, now) .first() ) } /** Toggle the player's cheer on a room, returning the resulting interaction. */ export async function toggleCheer( db: D1Database, playerId: number, roomId: number ): Promise { return toggleInteraction(db, playerId, roomId, 'cheered') } /** Toggle the player's favorite on a room, returning the resulting interaction. */ export async function toggleFavorite( db: D1Database, playerId: number, roomId: number ): Promise { return toggleInteraction(db, playerId, roomId, 'favorited') } /** * Search-tag aliases: a queried `#tag` also matches these stored tag names. * The client's pinned filters don't always match how rooms are tagged (e.g. it * searches `recroomoriginal`, but rooms are tagged `rro`). */ const TAG_ALIASES: Record = { recroomoriginal: ['rro'], } /** A room's tag names, lowercased (empty when it has no Tags array). */ function roomTags(room: Room): string[] { const tags = room.Tags if (!Array.isArray(tags)) return [] return tags .map((t) => (t as Record | null)?.Tag) .filter((v): v is string => typeof v === 'string') .map((v) => v.toLowerCase()) } /** True if the room carries any of the given (lowercased) tags. */ function roomHasAnyTag(room: Room, tags: Set): boolean { return roomTags(room).some((t) => tags.has(t)) } /** * Search public, non-dorm rooms. The query is split into terms (space/`+`): * `#tag` terms match the room's Tags; plain terms match the room name * (substring). All terms must match. Returns a paginated `{ Results, TotalResults }`. * The dataset is small, so this filters in memory rather than in SQL. */ export async function searchRooms( db: D1Database, query: string, skip: number, take: number ): Promise<{ Results: Room[]; TotalResults: number }> { const q = query.trim().toLowerCase() if (q === '') return { Results: [], TotalResults: 0 } const terms = q.split(/[\s+]+/).filter(Boolean) const { results } = await db.prepare('SELECT data FROM rooms').all() let rooms = parseAll(results).filter((r) => r.IsDorm !== true && r.Accessibility === 1) for (const term of terms) { if (term.startsWith('#')) { const tag = term.slice(1) const accepted = new Set([tag, ...(TAG_ALIASES[tag] ?? [])]) rooms = rooms.filter((r) => roomHasAnyTag(r, accepted)) } else { rooms = rooms.filter((r) => typeof r.Name === 'string' && r.Name.toLowerCase().includes(term)) } } return { Results: rooms.slice(skip, skip + take), TotalResults: rooms.length } } /** Engagement score used to order the hot feed (cheers weigh most, then favorites). */ function hotScore(room: Room): number { const stats = room.Stats as Record | null | undefined const n = (v: unknown): number => (typeof v === 'number' ? v : 0) return n(stats?.CheerCount) * 3 + n(stats?.FavoriteCount) * 2 + n(stats?.VisitorCount) } /** * The "hot" rooms feed: public, non-dorm rooms not excluded from lists, ordered * by engagement and optionally filtered to a single `tag` (with the same aliases * as search). Paginated via skip/take; returns `{ Results, TotalResults }` like * search. Ties (and the all-zero seed data) fall back to RoomId order so paging * is stable. The dataset is small, so this filters/sorts in memory rather than * in SQL. */ export async function getHotRooms( db: D1Database, tag: string, skip: number, take: number ): Promise<{ Results: Room[]; TotalResults: number }> { const { results } = await db.prepare('SELECT data FROM rooms').all() let rooms = parseAll(results).filter( (r) => r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true ) const t = tag.trim().toLowerCase() if (t !== '') { const accepted = new Set([t, ...(TAG_ALIASES[t] ?? [])]) rooms = rooms.filter((r) => roomHasAnyTag(r, accepted)) } const roomId = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0) rooms.sort((a, b) => hotScore(b) - hotScore(a) || roomId(a) - roomId(b)) return { Results: rooms.slice(skip, skip + take), TotalResults: rooms.length } } /** * Rooms similar to a target room: public, non-dorm rooms (excluding the target) * that share at least one tag with it, ranked by shared-tag count then * engagement. Returns a paginated `{ Results, TotalResults }` (the client's * RoomSimilarity source expects an object, not a bare array); empty if the target * isn't in D1 or is untagged. Small dataset, so done in memory. */ export async function getSimilarRooms( db: D1Database, roomId: number, skip: number, take: number ): Promise<{ Results: Room[]; TotalResults: number }> { const empty = { Results: [] as Room[], TotalResults: 0 } const target = await getRoomById(db, roomId) if (!target) return empty const targetTags = new Set(roomTags(target)) if (targetTags.size === 0) return empty const { results } = await db.prepare('SELECT data FROM rooms').all() const sharedCount = (r: Room): number => roomTags(r).filter((t) => targetTags.has(t)).length const roomIdOf = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0) const scored = parseAll(results) .filter( (r) => roomIdOf(r) !== roomId && r.IsDorm !== true && r.Accessibility === 1 && r.ExcludeFromLists !== true ) .map((room) => ({ room, shared: sharedCount(room) })) .filter((x) => x.shared > 0) scored.sort( (a, b) => b.shared - a.shared || hotScore(b.room) - hotScore(a.room) || roomIdOf(a.room) - roomIdOf(b.room) ) const rooms = scored.map((x) => x.room) return { Results: rooms.slice(skip, skip + take), TotalResults: rooms.length } } /** * "Base" rooms — the template rooms tagged `base` that the client offers as * starting points when creating a room. Unlike the public feeds these are * returned regardless of accessibility (most base rooms aren't publicly listed). * Ordered by RoomId for stable paging. Paginated via skip/take; returns a bare * array. Small dataset, so done in memory. */ export async function getBaseRooms(db: D1Database, skip: number, take: number): Promise { const { results } = await db.prepare('SELECT data FROM rooms').all() const base = new Set(['base']) const roomIdOf = (r: Room): number => (typeof r.RoomId === 'number' ? r.RoomId : 0) return parseAll(results) .filter((r) => roomHasAnyTag(r, base)) .sort((a, b) => roomIdOf(a) - roomIdOf(b)) .slice(skip, skip + take) }