137 lines
4.7 KiB
TypeScript
137 lines
4.7 KiB
TypeScript
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<string, number>;
|
|
/** item id -> количество */
|
|
items: Record<string, number>;
|
|
}
|
|
|
|
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<string, ViewerRecord>();
|
|
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();
|
|
}
|
|
}
|