feat(players): app Players library and circular NPC tokens on scenes
Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
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<void> {
|
||||
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<AppPlayer> {
|
||||
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<void> {
|
||||
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<AppPlayer[]> {
|
||||
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<AppPlayerTeam> {
|
||||
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<void> {
|
||||
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<AppPlayerTeam[]> {
|
||||
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<AppPlayer | null> {
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user