The engine and the bot no longer import each other: geometry, movement
and captures live in src/lib/game/board.ts and both use it. The escape
test, the four directions, the king's neighbours and the enumeration of
a side's moves each exist once; the tally counts by the named end
sentences rather than by regex over them; exports nobody imports are
exports no more. The tests pin the bot's opening move and the three-
beside-the-throne capture they named but never exercised.
In the client: .small, the word-as-button, the × that dismisses, the
visually-hidden rule and the frame of the reading pages are in app.css
once; the preferences panel and the report slip share one modal shape;
the room store drops the simultaneous-round fields this game never read
and gains seatEmpty and seatUnheld, which the lobby and the join page
read instead of three spellings of their own. The wire shapes for
reports and the tally are declared in view.ts for both sides. The
artwork component is re-indented for the top level it lives at.
On the server and in deploy: the plaintext-token fallback and its
migration script guarded ledgers this game never wrote; the seat line
now requires the hash and the start line the rules revision. The route
table lists every route. The visitors digest and the nightly rollup
count Caddy's log through deploy/traffic.py, and the rollup writes the
finished-games count it had been computing behind "and False". The
reports digest is one program, deploy/report-digest.ts, that
pull-reports.sh and the desk skill both use. The deploy README, the
visitors skill and the reports skill no longer describe a browser-only
game, a /play route or a rules text in docs/; conventions.md carries
the kit's Preferences and tally sections.
Kit-shared files touched, to port back: server/src/{index,rooms,store,
tally,reports}.ts, deploy/{deploy.sh,replay-ledgers.ts,pull-reports.sh,
traffic.py,report-digest.ts,*-rollup.sh,*-visitors.sh,*-pulse.sh},
src/lib/net/{client.ts,room.svelte.ts,view.ts}, Preferences.svelte,
ReportSlip.svelte, TableTalk.svelte.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GwFKMuQnPAEHJ5yA1q4orh
133 lines
5.0 KiB
TypeScript
133 lines
5.0 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 type { Report } from '../../src/lib/net/view';
|
|
import { RoomError, type Room, type Seat } from './rooms';
|
|
|
|
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;
|
|
|
|
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));
|
|
}
|
|
}
|