import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; import { assignPlayerToTeam, createPlayerTeamDraft, deletePlayerTeam, normalizeAppPlayerTeams, uniquePlayerTeamName, } from '../../shared/players/playerTeams'; import type { AppPlayer, AppPlayerTeam, PlayerId, PlayerImageOffset, PlayersUpsertProgressEvent, PlayerTeamId, } from '../../shared/types/appPlayers'; import { clampPlayerImageOffset, clampPlayerImageScale, DEFAULT_PLAYER_IMAGE_OFFSET, DEFAULT_PLAYER_IMAGE_SCALE, DEFAULT_PLAYER_RING_COLOR, normalizeAppPlayer, } from '../../shared/types/appPlayers'; import { asPlayerId } from '../../shared/types/ids'; import { normalizeHexColor } from '../../shared/npcs/npcGroups'; import { optimizeImageBufferVisuallyLossless } from '../project/optimizeImageImport.lib.mjs'; type PlayersManifest = { players: AppPlayer[]; teams: AppPlayerTeam[]; }; function mimeFromExt(ext: string): string { const e = ext.toLowerCase(); if (e === '.png') return 'image/png'; if (e === '.jpg' || e === '.jpeg') return 'image/jpeg'; if (e === '.webp') return 'image/webp'; if (e === '.gif') return 'image/gif'; return 'application/octet-stream'; } function safeFileBase(name: string): string { const base = name.replace(/[^\w.\-]+/gu, '_').slice(0, 48); return base || 'player'; } function randomPlayerId(): PlayerId { return asPlayerId(`player_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`); } export class PlayersStore { private readonly rootDir: string; private readonly filesDir: string; private readonly manifestPath: string; private players: AppPlayer[] = []; private teams: AppPlayerTeam[] = []; private loaded = false; constructor(userData: string) { this.rootDir = path.join(userData, 'players'); this.filesDir = path.join(this.rootDir, 'files'); this.manifestPath = path.join(this.rootDir, 'players.json'); } async ensureLoaded(): Promise { if (this.loaded) return; await fs.mkdir(this.filesDir, { recursive: true }); try { const raw = await fs.readFile(this.manifestPath, 'utf8'); const parsed = JSON.parse(raw) as PlayersManifest; this.teams = normalizeAppPlayerTeams(parsed.teams); const teamIds = new Set(this.teams.map((t) => t.id)); this.players = (Array.isArray(parsed.players) ? parsed.players : []) .map((p) => normalizeAppPlayer(p, teamIds)) .filter((p): p is AppPlayer => Boolean(p)); } catch { this.players = []; this.teams = []; await this.persist(); } this.loaded = true; } listPlayers(): AppPlayer[] { return [...this.players]; } listTeams(): AppPlayerTeam[] { return [...this.teams]; } getById(id: PlayerId): AppPlayer | null { return this.players.find((p) => p.id === id) ?? null; } getImageReadInfo(id: PlayerId): { absPath: string; mime: string } | null { const player = this.getById(id); if (!player) return null; const absPath = path.join(this.rootDir, player.imageRelPath); return { absPath, mime: mimeFromExt(path.extname(player.imageRelPath)) }; } getImageUrl(id: PlayerId): string | null { if (!this.getImageReadInfo(id)) return null; return `dnd://player?id=${encodeURIComponent(id)}`; } async upsert( input: { id?: PlayerId | null; name: string; filePath?: string | null; teamId?: PlayerTeamId | null; ringColor?: string; imageOffset?: PlayerImageOffset; imageScale?: number; }, onProgress?: (p: PlayersUpsertProgressEvent) => void, ): Promise { await this.ensureLoaded(); const name = input.name.trim(); if (!name) throw new Error('Player name is required'); const existing = input.id ? this.getById(input.id) : null; if (input.id && !existing) throw new Error('Player not found'); if (!existing && !input.filePath) throw new Error('Player image is required'); const emit = (percent: number, stage: string, detail?: string) => { onProgress?.({ percent, stage, ...(detail ? { detail } : {}) }); }; emit(5, 'prepare', 'Подготовка…'); let imageRelPath = existing?.imageRelPath ?? ''; let sha256 = existing?.sha256 ?? ''; const id = existing?.id ?? randomPlayerId(); if (input.filePath) { emit(15, 'read', 'Чтение изображения…'); let buf = await fs.readFile(input.filePath); emit(40, 'optimize', 'Оптимизация…'); try { const opt = await optimizeImageBufferVisuallyLossless(buf); if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer); } catch { /* keep original */ } sha256 = crypto.createHash('sha256').update(buf).digest('hex'); const ext = path.extname(input.filePath) || '.png'; const fileName = `${id}_${safeFileBase(name)}${ext.toLowerCase()}`; imageRelPath = path.join('files', fileName).replace(/\\/gu, '/'); const abs = path.join(this.rootDir, imageRelPath); await fs.mkdir(path.dirname(abs), { recursive: true }); emit(75, 'write', 'Сохранение…'); await fs.writeFile(abs, buf); if (existing && existing.imageRelPath !== imageRelPath) { try { await fs.unlink(path.join(this.rootDir, existing.imageRelPath)); } catch { /* ignore */ } } } const teamIds = new Set(this.teams.map((t) => t.id)); let teamId: PlayerTeamId | null = input.teamId !== undefined ? input.teamId : (existing?.teamId ?? null); if (teamId && !teamIds.has(teamId)) teamId = null; const player: AppPlayer = { id, name, imageRelPath, sha256, teamId, ringColor: normalizeHexColor( input.ringColor ?? existing?.ringColor, DEFAULT_PLAYER_RING_COLOR, ), imageOffset: clampPlayerImageOffset( input.imageOffset ?? existing?.imageOffset ?? DEFAULT_PLAYER_IMAGE_OFFSET, ), imageScale: clampPlayerImageScale( input.imageScale ?? existing?.imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE, ), }; if (existing) { this.players = this.players.map((p) => (p.id === player.id ? player : p)); } else { this.players = [...this.players, player]; } emit(95, 'persist', 'Запись…'); await this.persist(); emit(100, 'done', 'Готово'); return player; } async delete(id: PlayerId): Promise { await this.ensureLoaded(); const existing = this.getById(id); if (!existing) return; this.players = this.players.filter((p) => p.id !== id); await this.persist(); try { await fs.unlink(path.join(this.rootDir, existing.imageRelPath)); } catch { /* ignore */ } } async setPlayersOrder(playerIds: PlayerId[]): Promise { await this.ensureLoaded(); const byId = new Map(this.players.map((p) => [p.id, p])); const next: AppPlayer[] = []; for (const id of playerIds) { const p = byId.get(id); if (p) { next.push(p); byId.delete(id); } } for (const p of byId.values()) next.push(p); this.players = next; await this.persist(); return this.listPlayers(); } async upsertTeam(input: { id?: PlayerTeamId | null; name: string; color?: string; }): Promise { await this.ensureLoaded(); const existing = input.id ? this.teams.find((t) => t.id === input.id) : null; if (input.id && !existing) throw new Error('Team not found'); if (existing) { const team: AppPlayerTeam = { id: existing.id, name: uniquePlayerTeamName(input.name, this.teams, existing.id), color: normalizeHexColor(input.color ?? existing.color, DEFAULT_PLAYER_RING_COLOR), }; this.teams = this.teams.map((t) => (t.id === team.id ? team : t)); await this.persist(); return team; } const team = createPlayerTeamDraft(input.name, input.color, this.teams); this.teams = [...this.teams, team]; await this.persist(); return team; } async deleteTeam(id: PlayerTeamId): Promise { await this.ensureLoaded(); const next = deletePlayerTeam(this.teams, this.players, id); this.teams = next.teams; this.players = next.players; await this.persist(); } async setTeamsOrder(teamIds: PlayerTeamId[]): Promise { await this.ensureLoaded(); const byId = new Map(this.teams.map((t) => [t.id, t])); const next: AppPlayerTeam[] = []; for (const id of teamIds) { const t = byId.get(id); if (t) { next.push(t); byId.delete(id); } } for (const t of byId.values()) next.push(t); this.teams = next; await this.persist(); return this.listTeams(); } async assignPlayerTeam(playerId: PlayerId, teamId: PlayerTeamId | null): Promise { await this.ensureLoaded(); const teamIds = new Set(this.teams.map((t) => t.id as string)); this.players = assignPlayerToTeam(this.players, playerId, teamId, teamIds); await this.persist(); return this.getById(playerId); } private async persist(): Promise { await fs.mkdir(this.rootDir, { recursive: true }); const payload: PlayersManifest = { players: this.players, teams: this.teams }; await fs.writeFile(this.manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); } }