import { mkdirSync } from 'node:fs'; import path from 'node:path'; import { DatabaseSync } from 'node:sqlite'; /** * Запись зрителя: инвентарь и XP навыков. Хранится вне ECS (мета-слой). */ export interface ViewerRecord { userId: string; name: string; color: string; /** skill id -> суммарный xp */ skills: Record; /** item id -> количество */ items: Record; } const SCHEMA_VERSION = 2; /** * Тонкий слой persist на встроенном node:sqlite — без нативных сборок и * install-скриптов. При необходимости заменяется (better-sqlite3/postgres) * без изменений в остальном коде. */ export class Db { private readonly db: DatabaseSync; constructor(dbPath: string) { if (dbPath !== ':memory:') { mkdirSync(path.dirname(dbPath), { recursive: true }); } this.db = new DatabaseSync(dbPath); this.db.exec('PRAGMA journal_mode = WAL'); const row = this.db.prepare('PRAGMA user_version').get() as { user_version?: number }; const version = row?.user_version ?? 0; if (version < SCHEMA_VERSION) { // дев-стадия: старую схему не мигрируем, а пересоздаём this.db.exec(` DROP TABLE IF EXISTS viewer_skills; DROP TABLE IF EXISTS viewer_items; DROP TABLE IF EXISTS viewers; CREATE TABLE viewers ( channel_id TEXT NOT NULL, user_id TEXT NOT NULL, name TEXT NOT NULL, color TEXT NOT NULL, updated_at INTEGER NOT NULL, PRIMARY KEY (channel_id, user_id) ); CREATE TABLE viewer_skills ( channel_id TEXT NOT NULL, user_id TEXT NOT NULL, skill TEXT NOT NULL, xp INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (channel_id, user_id, skill) ); CREATE TABLE viewer_items ( channel_id TEXT NOT NULL, user_id TEXT NOT NULL, item TEXT NOT NULL, qty INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (channel_id, user_id, item) ); PRAGMA user_version = ${SCHEMA_VERSION}; `); } } loadViewers(channelId: string): ViewerRecord[] { const viewers = this.db .prepare('SELECT user_id, name, color FROM viewers WHERE channel_id = ?') .all(channelId) as Array<{ user_id: string; name: string; color: string }>; const skills = this.db .prepare('SELECT user_id, skill, xp FROM viewer_skills WHERE channel_id = ?') .all(channelId) as Array<{ user_id: string; skill: string; xp: number }>; const items = this.db .prepare('SELECT user_id, item, qty FROM viewer_items WHERE channel_id = ?') .all(channelId) as Array<{ user_id: string; item: string; qty: number }>; const byId = new Map(); for (const v of viewers) { byId.set(v.user_id, { userId: v.user_id, name: v.name, color: v.color, skills: {}, items: {} }); } for (const s of skills) { const rec = byId.get(s.user_id); if (rec) rec.skills[s.skill] = s.xp; } for (const it of items) { const rec = byId.get(it.user_id); if (rec) rec.items[it.item] = it.qty; } return [...byId.values()]; } saveViewer(channelId: string, v: ViewerRecord): void { this.db.exec('BEGIN'); try { this.db .prepare( `INSERT INTO viewers (channel_id, user_id, name, color, updated_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT(channel_id, user_id) DO UPDATE SET name = excluded.name, color = excluded.color, updated_at = excluded.updated_at`, ) .run(channelId, v.userId, v.name, v.color, Date.now()); this.db .prepare('DELETE FROM viewer_skills WHERE channel_id = ? AND user_id = ?') .run(channelId, v.userId); this.db .prepare('DELETE FROM viewer_items WHERE channel_id = ? AND user_id = ?') .run(channelId, v.userId); const skillStmt = this.db.prepare( 'INSERT INTO viewer_skills (channel_id, user_id, skill, xp) VALUES (?, ?, ?, ?)', ); for (const [skill, xp] of Object.entries(v.skills)) { skillStmt.run(channelId, v.userId, skill, xp); } const itemStmt = this.db.prepare( 'INSERT INTO viewer_items (channel_id, user_id, item, qty) VALUES (?, ?, ?, ?)', ); for (const [item, qty] of Object.entries(v.items)) { if (qty > 0) itemStmt.run(channelId, v.userId, item, qty); } this.db.exec('COMMIT'); } catch (e) { this.db.exec('ROLLBACK'); throw e; } } close(): void { this.db.close(); } }