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:
Ivan Fontosh
2026-07-30 13:38:35 +08:00
parent a3a03eb9e3
commit 101f595bac
57 changed files with 4176 additions and 317 deletions
+295
View File
@@ -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');
}
}