// The reports desk: what a player says went wrong, pinned to the room and // the turn so the moment can be replayed. One JSONL file beside the room // ledgers; replies from the keeper and answers from the player fold onto // the report they name, and a screenshot sits in a folder next to it. import { randomBytes } from 'node:crypto'; import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { SeatId } from '../../src/lib/game/spec'; import { RoomError, type Room, type Seat } from './rooms'; export const TEXT_MAX = 2000; export const IMAGE_MAX_BYTES = 2_500_000; /** How long after filing a picture may still be attached. */ const IMAGE_WINDOW_MS = 60 * 60 * 1000; export interface ReportLine { at: string; text: string; from: 'desk' | 'player'; status?: string; } export interface Report { id: string; at: string; roomId: string; /** The reporter's name, or "(gallery)" for a watcher. */ player: string; seat: SeatId | null; /** The round being written when the report was filed, and the ledger's length. */ turn: number | null; seq: number; happened: string; expected: string; /** The whole exchange in order: the keeper's replies and the player's answers. */ thread: ReportLine[]; /** A screenshot's file name under images/, when one was sent. */ image?: string; } export function cleanText(raw: unknown): string { return String(raw ?? '') .replace(/[\u0000-\u0009\u000b-\u001f\u007f]/g, ' ') .trim() .slice(0, TEXT_MAX); } export class Reports { private path: string; private imageDir: string; constructor(dir: string) { mkdirSync(dir, { recursive: true }); this.path = join(dir, 'feedback.jsonl'); this.imageDir = join(dir, 'feedback-images'); } private append(entry: Record): void { appendFileSync(this.path, JSON.stringify(entry) + '\n'); } read(): Report[] { if (!existsSync(this.path)) return []; const reports = new Map(); for (const raw of readFileSync(this.path, 'utf8').split('\n')) { if (!raw.trim()) continue; let line: Record; try { line = JSON.parse(raw) as Record; } catch { continue; } if (typeof line.reportId === 'string') { const report = reports.get(line.reportId); if (!report) continue; if (typeof line.image === 'string') report.image = line.image; else if (line.from === 'player') report.thread.push({ at: String(line.at ?? ''), text: String(line.text ?? ''), from: 'player' }); else report.thread.push({ at: String(line.at ?? ''), text: String(line.text ?? ''), from: 'desk', status: String(line.status ?? 'open') }); continue; } if (typeof line.id !== 'string') continue; reports.set(line.id, { id: line.id, at: String(line.at ?? ''), roomId: String(line.roomId ?? ''), player: String(line.player ?? '?'), seat: typeof line.seat === 'string' ? line.seat : null, turn: typeof line.turn === 'number' ? line.turn : null, seq: Number(line.seq ?? 0), happened: String(line.happened ?? ''), expected: String(line.expected ?? ''), thread: [] }); } return [...reports.values()]; } find(id: string): Report | undefined { return this.read().find((r) => r.id === id); } /** File a report from a seat, or from the gallery when there is none, pinned to the round being written. */ file(room: Room, seat: Seat | null, turn: number | null, rawHappened: unknown, rawExpected: unknown): Report { const happened = cleanText(rawHappened); if (!happened) throw new RoomError('Say what happened.'); const report: Report = { id: randomBytes(4).toString('hex'), at: new Date().toISOString(), roomId: room.id, player: seat?.name ?? '(gallery)', seat: seat?.id ?? null, turn, seq: room.seq, happened, expected: cleanText(rawExpected), thread: [] }; const { thread: _thread, ...line } = report; this.append(line); return report; } /** The player's word under the keeper's reply; only the seat that filed it may answer. */ answer(report: Report, seat: Seat, rawText: unknown): void { if (report.seat !== seat.id) throw new RoomError('That report is not yours to answer.', 403); const text = cleanText(rawText); if (!text) throw new RoomError('Say something.'); this.append({ reportId: report.id, from: 'player', player: seat.name, text, at: new Date().toISOString() }); } /** One picture per report, soon after filing, a PNG, JPEG or WebP by its own first bytes. */ attachImage(report: Report, body: Buffer): string { if (report.image) throw new RoomError('That report has its picture.', 409); if (Date.now() - Date.parse(report.at) > IMAGE_WINDOW_MS) throw new RoomError('Too late for a picture.', 410); if (body.length > IMAGE_MAX_BYTES) throw new RoomError('A picture of 2.5 MB at most.', 413); const ext = body.subarray(0, 4).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47])) ? 'png' : body.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff])) ? 'jpg' : body.subarray(0, 4).toString('ascii') === 'RIFF' && body.subarray(8, 12).toString('ascii') === 'WEBP' ? 'webp' : null; if (!ext) throw new RoomError('A PNG, JPEG or WebP.', 415); mkdirSync(this.imageDir, { recursive: true }); const name = `${report.id}.${ext}`; writeFileSync(join(this.imageDir, name), body); this.append({ reportId: report.id, image: name, at: new Date().toISOString() }); return name; } /** Reports filed from the seats a browser can prove it holds. */ mine(seats: { roomId: string; seat: SeatId }[]): Report[] { return this.read().filter((r) => seats.some((s) => s.roomId === r.roomId && s.seat === r.seat)); } }