[roomcomments] implement basic room comments

This commit is contained in:
Devin Zuczek
2026-08-20 19:00:22 -04:00
parent 062c07cc33
commit 655c7ee4d6
17 changed files with 15670 additions and 2 deletions
+1
View File
@@ -5,6 +5,7 @@ export * from './images-db'
export * from './password'
export * from './rooms-db'
export * from './room-instance-db'
export * from './room-comments-db'
export * from './presence-db'
export * from './gifts-db'
export * from './inventory-invention-db'
+194
View File
@@ -0,0 +1,194 @@
/**
* Room comments on the shared `recflare` D1 database — the notes a player leaves pinned in
* a room's scene, which everyone standing in that subroom sees floating at the position
* they were dropped.
*
* Columns rather than a JSON blob (the `club_announcement` pattern rather than the
* `room`/`club` one): a comment is six scalars and a point, none of it a client-shaped
* document that has to survive round-tripping, and the room/subroom pair has to be
* queryable.
*
* The `roomcomments` worker owns this schema/migration
* (`apps/roomcomments/migrations/0001_room_comment.sql`, applied under its own
* `migrations_table` so it doesn't clash with the other workers' migrations that share the
* database). `ROOM_COMMENT_SCHEMA_DDL` mirrors that migration so tests can build the table
* directly.
*/
/** Schema DDL (mirror of apps/roomcomments/migrations/0001_room_comment.sql). */
export const ROOM_COMMENT_SCHEMA_DDL: string[] = [
// `comment_id` is an ordinary autoincrement integer, and it is the CURSOR the read
// endpoint pages on (`?minId=`), so it has to be monotonic per insert. AUTOINCREMENT
// rather than a bare rowid alias for exactly that reason: a deleted comment's id must
// never be handed out again, or a client polling with `minId` would skip the comment
// that reused it.
//
// The position is three REALs, not the strings the form body carries. The client posts a
// C# float's shortest round-trip text (`positionX=-0.4979804`) but reads the response
// back as a NUMBER — `"PositionX": "1.5"` fails its parser — and a float64 holds those
// 7-9 significant digits exactly, so the text that arrives is the text that goes back out.
`CREATE TABLE IF NOT EXISTS room_comment (
comment_id INTEGER PRIMARY KEY AUTOINCREMENT,
room_id INTEGER NOT NULL,
subroom_id INTEGER NOT NULL,
player_id INTEGER NOT NULL,
style INTEGER NOT NULL DEFAULT 0,
message TEXT NOT NULL DEFAULT '',
position_x REAL NOT NULL DEFAULT 0,
position_y REAL NOT NULL DEFAULT 0,
position_z REAL NOT NULL DEFAULT 0,
created_at TEXT NOT NULL
)`,
// The read is always "this room, newer than this id, newest first" — one index covers
// the filter and the ordering together.
`CREATE INDEX IF NOT EXISTS idx_room_comment_room ON room_comment (room_id, comment_id)`,
]
/**
* One comment as the client parses it. PascalCase, and `PositionX/Y/Z` are NUMBERS even
* though the create body posts them as text.
*
* `Unread` is always TRUE. It is not a stored column and it is NOT derived from who is
* reading: the reference answers `Unread: true` on the create response too, for the
* author's own brand-new comment, so it can't be "unread by this viewer". It is read state
* that only something marking comments read could ever clear, and nothing here does — so
* every comment is unread, to everyone, always. Deriving it from authorship instead would
* make the author's own comments read, which is the one case the reference is known to
* answer `true` for.
*/
export interface RoomComment {
CommentId: number
RoomId: number
SubRoomId: number
AccountId: number
CreatedAt: string
Message: string
Style: number
Unread: boolean
PositionX: number
PositionY: number
PositionZ: number
}
interface RoomCommentRow {
comment_id: number
room_id: number
subroom_id: number
player_id: number
style: number
message: string
position_x: number
position_y: number
position_z: number
created_at: string
}
/** A stored row as the client-facing comment. */
function toRoomComment(row: RoomCommentRow): RoomComment {
return {
CommentId: row.comment_id,
RoomId: row.room_id,
SubRoomId: row.subroom_id,
AccountId: row.player_id,
CreatedAt: row.created_at,
Message: row.message,
Style: row.style,
// Always true — see RoomComment. Nothing marks a comment read.
Unread: true,
PositionX: row.position_x,
PositionY: row.position_y,
PositionZ: row.position_z,
}
}
const SELECT_COLUMNS = `comment_id, room_id, subroom_id, player_id, style, message,
position_x, position_y, position_z, created_at`
/** How many comments a read serves when the request names no `count`, and the ceiling. */
export const DEFAULT_COMMENT_COUNT = 100
const MAX_COMMENT_COUNT = 500
/** Clamp a requested `count` into `[1, MAX_COMMENT_COUNT]`; anything unparseable is the default. */
export function clampCommentCount(count: number | null): number {
if (count === null || !Number.isFinite(count)) return DEFAULT_COMMENT_COUNT
return Math.min(Math.max(Math.trunc(count), 1), MAX_COMMENT_COUNT)
}
/**
* A room's comments, newest first — the `GET /comments/get/:roomId` read.
*
* `minId` is an EXCLUSIVE cursor, which is why the client's "give me everything" sentinel
* is `-1` rather than `0`: a client that holds comments up to id N polls with `minId=N` and
* gets only what was written since. Newest-first, so a fresh client asking for 100 out of a
* room with thousands gets the 100 that are actually on the wall rather than the oldest
* hundred.
*
* `subRoomId` narrows to one subroom when given; the client asks for the whole room and
* sorts them out itself, so it is normally null. An unknown room simply has no comments.
*/
export async function getRoomComments(
db: D1Database,
roomId: number,
opts: { count?: number; minId?: number; subRoomId?: number | null } = {}
): Promise<RoomComment[]> {
const count = clampCommentCount(opts.count ?? null)
const minId = Number.isFinite(opts.minId) ? (opts.minId as number) : -1
const subRoomId = opts.subRoomId ?? null
const { results } = await db
.prepare(
`SELECT ${SELECT_COLUMNS}
FROM room_comment
WHERE room_id = ?1 AND comment_id > ?2 AND (?3 IS NULL OR subroom_id = ?3)
ORDER BY comment_id DESC
LIMIT ?4`
)
.bind(roomId, minId, subRoomId, count)
.all<RoomCommentRow>()
return results.map(toRoomComment)
}
/**
* Leave a comment in a room, returning it as the client reads it back — the create
* response is the new comment itself, so the client can render the bubble it just placed
* without re-fetching the list.
*
* That response carries `Unread: true` like every other, the author's own included; it is
* read state, not a per-viewer flag (see `RoomComment`).
*/
export async function createRoomComment(
db: D1Database,
roomId: number,
playerId: number,
fields: {
subRoomId: number
message: string
style?: number
positionX?: number
positionY?: number
positionZ?: number
}
): Promise<RoomComment | null> {
const row = await db
.prepare(
`INSERT INTO room_comment (room_id, subroom_id, player_id, style, message,
position_x, position_y, position_z, created_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
RETURNING ${SELECT_COLUMNS}`
)
.bind(
roomId,
fields.subRoomId,
playerId,
fields.style ?? 0,
fields.message,
fields.positionX ?? 0,
fields.positionY ?? 0,
fields.positionZ ?? 0,
new Date().toISOString()
)
.first<RoomCommentRow>()
return row ? toRoomComment(row) : null
}