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
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { PlayersStore } from './playersStore';
import { asPlayerId } from '../../shared/types/ids';
async function withTempStore(run: (store: PlayersStore, root: string) => Promise<void>) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-players-'));
const store = new PlayersStore(root);
try {
await run(store, root);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
}
void test('PlayersStore upsert list delete and teams', async () => {
await withTempStore(async (store, root) => {
const png = path.join(root, 'sample.png');
// minimal 1x1 png
const buf = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
await fs.writeFile(png, buf);
const progress: number[] = [];
const player = await store.upsert(
{ name: 'Ada', filePath: png, ringColor: '#112233', imageOffset: { x: 0.1, y: -0.1 } },
(p) => progress.push(p.percent),
);
assert.equal(player.name, 'Ada');
assert.equal(player.ringColor, '#112233');
assert.ok(progress.length > 0);
assert.equal(store.listPlayers().length, 1);
const team = await store.upsertTeam({ name: 'Party', color: '#abcdef' });
assert.equal(team.name, 'Party');
const assigned = await store.assignPlayerTeam(player.id, team.id);
assert.equal(assigned?.teamId, team.id);
await store.deleteTeam(team.id);
assert.equal(store.listTeams().length, 0);
assert.equal(store.getById(player.id)?.teamId, null);
await store.delete(player.id);
assert.equal(store.listPlayers().length, 0);
});
});
void test('PlayersStore persists across reload', async () => {
await withTempStore(async (store, root) => {
const png = path.join(root, 'sample.png');
await fs.writeFile(
png,
Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
),
);
const created = await store.upsert({ name: 'Bob', filePath: png });
const store2 = new PlayersStore(root);
await store2.ensureLoaded();
assert.equal(store2.listPlayers().length, 1);
assert.equal(store2.getById(asPlayerId(created.id))?.name, 'Bob');
});
});
+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');
}
}
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DEFAULT_NPC_TOKEN_SESSION_SCALE } from '../../shared/types/appPlayers';
import { SceneNpcTokensSessionStore } from './sceneNpcTokensSessionStore';
void test('SceneNpcTokensSessionStore move and reset', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
assert.equal(s1.byPlacementId.a?.nx, 0.2);
assert.equal(s1.revision, 2);
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
assert.equal(s2.revision, s1.revision); // no-op
const s3 = store.reset();
assert.deepEqual(s3.byPlacementId, {});
assert.equal(s3.scale, DEFAULT_NPC_TOKEN_SESSION_SCALE);
assert.ok(s3.revision > s1.revision);
});
void test('SceneNpcTokensSessionStore setScale', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'setScale', scale: 1.5 });
assert.equal(s1.scale, 1.5);
store.dispatch({ kind: 'move', placementId: 'a', nx: 0.1, ny: 0.2 });
const s2 = store.dispatch({ kind: 'setScale', scale: 0.1 });
assert.equal(s2.scale, 0.4); // clamped min
assert.ok(s2.byPlacementId.a);
const s3 = store.reset();
assert.equal(s3.scale, DEFAULT_NPC_TOKEN_SESSION_SCALE);
assert.deepEqual(s3.byPlacementId, {});
});
@@ -0,0 +1,73 @@
import {
clampNpcTokenSessionScale,
DEFAULT_NPC_TOKEN_SESSION_SCALE,
type SceneNpcTokensSessionEvent,
type SceneNpcTokensSessionState,
} from '../../shared/types/appPlayers';
function emptyState(revision = 1): SceneNpcTokensSessionState {
return {
revision,
byPlacementId: {},
scale: DEFAULT_NPC_TOKEN_SESSION_SCALE,
};
}
export class SceneNpcTokensSessionStore {
private state: SceneNpcTokensSessionState = emptyState();
getState(): SceneNpcTokensSessionState {
return this.state;
}
reset(): SceneNpcTokensSessionState {
if (
Object.keys(this.state.byPlacementId).length === 0 &&
this.state.scale === DEFAULT_NPC_TOKEN_SESSION_SCALE
) {
return this.state;
}
this.state = emptyState(this.state.revision + 1);
return this.state;
}
dispatch(event: SceneNpcTokensSessionEvent): SceneNpcTokensSessionState {
switch (event.kind) {
case 'clear':
return this.reset();
case 'setScale': {
const scale = clampNpcTokenSessionScale(event.scale);
if (this.state.scale === scale) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
scale,
};
return this.state;
}
case 'move': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const nx = Math.max(0, Math.min(1, event.nx));
const ny = Math.max(0, Math.min(1, event.ny));
if (!Number.isFinite(nx) || !Number.isFinite(ny)) return this.state;
const prev = this.state.byPlacementId[placementId];
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: { nx, ny },
},
scale: this.state.scale,
};
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
return this.state;
}
}
}
}