157 lines
5.6 KiB
TypeScript
157 lines
5.6 KiB
TypeScript
// 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<string, unknown>): void {
|
|
appendFileSync(this.path, JSON.stringify(entry) + '\n');
|
|
}
|
|
|
|
read(): Report[] {
|
|
if (!existsSync(this.path)) return [];
|
|
const reports = new Map<string, Report>();
|
|
for (const raw of readFileSync(this.path, 'utf8').split('\n')) {
|
|
if (!raw.trim()) continue;
|
|
let line: Record<string, unknown>;
|
|
try {
|
|
line = JSON.parse(raw) as Record<string, unknown>;
|
|
} 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<unknown>, 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));
|
|
}
|
|
}
|