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
+4
View File
@@ -387,6 +387,7 @@ export async function buildProjectFromFoundryDocuments(
darkenScene: false,
traps: [],
tokens: [],
npcTokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: audioRefs },
settings: {
@@ -446,6 +447,9 @@ export async function buildProjectFromFoundryDocuments(
x: 80 + (npcIndex % 4) * 220,
y: 80 + Math.floor(npcIndex / 4) * 200,
groupId,
ringColor: '#c9a227',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
});
npcIndex += 1;
}
+237 -113
View File
@@ -29,6 +29,8 @@ import { NpcsOverlayStore } from './npcs/npcsOverlayStore';
import { ZipProjectStore } from './project/zipStore';
import { SceneViewStore } from './sceneView/sceneViewStore';
import { registerDndAssetProtocol } from './protocol/dndAssetProtocol';
import { PlayersStore } from './players/playersStore';
import { SceneNpcTokensSessionStore } from './players/sceneNpcTokensSessionStore';
import { SceneTokensSessionStore } from './tokens/sceneTokensSessionStore';
import { TokensStore } from './tokens/tokensStore';
import { installAutoUpdater } from './update/installAutoUpdater';
@@ -86,21 +88,13 @@ function emitScenePreviewImportProgress(evt: ScenePreviewImportEvent): void {
}
}
function emitMaterialUpsertProgress(evt: {
percent: number;
stage: string;
detail?: string;
}): void {
function emitMaterialUpsertProgress(evt: { percent: number; stage: string; detail?: string }): void {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.project.materialUpsertProgress, evt);
}
}
function emitNpcUpsertProgress(evt: {
percent: number;
stage: string;
detail?: string;
}): void {
function emitNpcUpsertProgress(evt: { percent: number; stage: string; detail?: string }): void {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.project.npcUpsertProgress, evt);
}
@@ -179,7 +173,9 @@ const videoStore = new VideoPlaybackStore();
const materialsOverlayStore = new MaterialsOverlayStore();
const npcsOverlayStore = new NpcsOverlayStore();
const sceneTokensSessionStore = new SceneTokensSessionStore();
const sceneNpcTokensSessionStore = new SceneNpcTokensSessionStore();
let tokensStore: TokensStore | null = null;
let playersStore: PlayersStore | null = null;
function emitEffectsState(): void {
const state = effectsStore.getState();
@@ -248,6 +244,27 @@ function emitSceneTokensSessionState(): void {
}
}
function emitPlayersState(): void {
const players = playersStore?.listPlayers() ?? [];
const teams = playersStore?.listTeams() ?? [];
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.players.stateChanged, { players, teams });
}
}
function emitPlayerUpsertProgress(p: { percent: number; stage: string; detail?: string }): void {
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.players.upsertProgress, p);
}
}
function emitSceneNpcTokensSessionState(): void {
const state = sceneNpcTokensSessionStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.sceneNpcTokensSession.stateChanged, { state });
}
}
function syncSceneDarknessForProject(project: Project): void {
const cacheKey = project.currentGraphNodeId ?? project.currentSceneId ?? null;
const scene = project.currentSceneId ? project.scenes[project.currentSceneId] : undefined;
@@ -397,11 +414,13 @@ async function main() {
const licenseService = new LicenseService(app.getPath('userData'));
tokensStore = new TokensStore(app.getPath('userData'));
await tokensStore.ensureLoaded();
playersStore = new PlayersStore(app.getPath('userData'));
await playersStore.ensureLoaded();
setLicenseAssert(() => {
licenseService.assertForIpc();
});
installAppMenuForSession();
registerDndAssetProtocol(projectStore, tokensStore);
registerDndAssetProtocol(projectStore, tokensStore, playersStore);
registerHandler(ipcChannels.app.quit, () => {
markAppQuitting();
app.quit();
@@ -420,6 +439,7 @@ async function main() {
sceneDarknessStore.resetSession();
sceneTrapsStore.resetSession();
sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset();
effectsStore.dispatch({ kind: 'tool.set', tool: effectsDefaultTool() });
openMultiWindow();
const project = projectStore.getOpenProject();
@@ -430,6 +450,7 @@ async function main() {
emitSceneDarknessState();
emitSceneTrapsState();
emitSceneTokensSessionState();
emitSceneNpcTokensSessionState();
emitEffectsState();
return { ok: true };
});
@@ -526,8 +547,10 @@ async function main() {
const project = await projectStore.openProjectById(projectId);
sceneViewStore.reset();
sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset();
emitSceneViewState();
emitSceneTokensSessionState();
emitSceneNpcTokensSessionState();
emitSessionState();
warmNpcsEditorWindow();
return { project };
@@ -542,6 +565,7 @@ async function main() {
sceneTrapsStore.resetSession();
sceneViewStore.reset();
sceneTokensSessionStore.reset();
sceneNpcTokensSessionStore.reset();
emitEffectsState();
emitMaterialsOverlayState();
emitNpcsOverlayState();
@@ -549,6 +573,7 @@ async function main() {
emitSceneTrapsState();
emitSceneViewState();
emitSceneTokensSessionState();
emitSceneNpcTokensSessionState();
emitSessionState();
return { ok: true };
});
@@ -684,36 +709,39 @@ async function main() {
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.upsertMaterial, async ({ materialId, name, filePath: pathFromDrop }) => {
let filePath = pathFromDrop;
if (!filePath && !materialId) {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) {
throw new Error('Material image is required');
registerHandler(
ipcChannels.project.upsertMaterial,
async ({ materialId, name, filePath: pathFromDrop }) => {
let filePath = pathFromDrop;
if (!filePath && !materialId) {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) {
throw new Error('Material image is required');
}
filePath = filePaths[0];
}
filePath = filePaths[0];
}
const project = await projectStore.upsertMaterial(
{
...(materialId ? { materialId } : {}),
name,
...(filePath ? { filePath } : {}),
},
(p) => emitMaterialUpsertProgress(p),
);
syncMaterialsOverlayWithProject(project);
emitMaterialsOverlayState();
emitSessionState();
return { project };
});
const project = await projectStore.upsertMaterial(
{
...(materialId ? { materialId } : {}),
name,
...(filePath ? { filePath } : {}),
},
(p) => emitMaterialUpsertProgress(p),
);
syncMaterialsOverlayWithProject(project);
emitMaterialsOverlayState();
emitSessionState();
return { project };
},
);
registerHandler(ipcChannels.project.deleteMaterial, async ({ materialId }) => {
const project = await projectStore.deleteMaterial(materialId);
syncMaterialsOverlayWithProject(project);
@@ -750,14 +778,13 @@ async function main() {
const filePath = filePaths[0]!;
const buf = await fs.readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime =
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl };
});
registerHandler(
ipcChannels.project.upsertNpc,
async ({ npcId, name, description, filePath: pathFromDrop, groupId }) => {
async ({ npcId, name, description, filePath: pathFromDrop, groupId, ringColor, imageOffset }) => {
let filePath = pathFromDrop;
if (!filePath && !npcId) {
const { canceled, filePaths } = await dialog.showOpenDialog({
@@ -781,6 +808,8 @@ async function main() {
...(typeof description === 'string' ? { description } : {}),
...(filePath ? { filePath } : {}),
...(groupId !== undefined ? { groupId } : {}),
...(ringColor !== undefined ? { ringColor } : {}),
...(imageOffset !== undefined ? { imageOffset } : {}),
},
(p) => emitNpcUpsertProgress(p),
);
@@ -792,11 +821,13 @@ async function main() {
);
registerHandler(
ipcChannels.project.updateNpcFields,
async ({ npcId, name, description, groupId }) => {
async ({ npcId, name, description, groupId, ringColor, imageOffset }) => {
const project = await projectStore.updateNpcFields(npcId, {
...(typeof name === 'string' ? { name } : {}),
...(typeof description === 'string' ? { description } : {}),
...(groupId !== undefined ? { groupId } : {}),
...(ringColor !== undefined ? { ringColor } : {}),
...(imageOffset !== undefined ? { imageOffset } : {}),
});
emitSessionState();
return { project };
@@ -833,8 +864,7 @@ async function main() {
const filePath = filePaths[0]!;
const buf = await fs.readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime =
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl };
});
@@ -856,19 +886,16 @@ async function main() {
emitSessionState();
return { project };
});
registerHandler(
ipcChannels.project.upsertNpcGroup,
async ({ groupId, name, color, parentId }) => {
const project = await projectStore.upsertNpcGroup({
...(groupId ? { groupId } : {}),
name,
...(typeof color === 'string' ? { color } : {}),
...(parentId !== undefined ? { parentId } : {}),
});
emitSessionState();
return { project };
},
);
registerHandler(ipcChannels.project.upsertNpcGroup, async ({ groupId, name, color, parentId }) => {
const project = await projectStore.upsertNpcGroup({
...(groupId ? { groupId } : {}),
name,
...(typeof color === 'string' ? { color } : {}),
...(parentId !== undefined ? { parentId } : {}),
});
emitSessionState();
return { project };
});
registerHandler(ipcChannels.project.deleteNpcGroup, async ({ groupId }) => {
const project = await projectStore.deleteNpcGroup(groupId);
syncNpcsOverlayWithProject(project);
@@ -1042,9 +1069,12 @@ async function main() {
registerHandler(ipcChannels.project.peekImportZipPath, async ({ filePath, labels, targetHasMainStart }) => {
return projectStore.peekImportFromZipPath(filePath, labels, targetHasMainStart);
});
registerHandler(ipcChannels.project.peekImportFromProject, async ({ sourceProjectId, labels, targetHasMainStart }) => {
return projectStore.peekImportFromProjectId(sourceProjectId, labels, targetHasMainStart);
});
registerHandler(
ipcChannels.project.peekImportFromProject,
async ({ sourceProjectId, labels, targetHasMainStart }) => {
return projectStore.peekImportFromProjectId(sourceProjectId, labels, targetHasMainStart);
},
);
registerHandler(
ipcChannels.project.mergeImportZip,
async ({ filePath, storylineSelections, sceneResolutions, npcResolutions }) => {
@@ -1143,7 +1173,8 @@ async function main() {
const project = await projectStore.importProjectFromFoundry(sourcePath, (p) => {
emitZipProgress({
kind: 'import',
stage: p.stage === 'unzip' ? 'unzip' : p.stage === 'zip' ? 'zip' : p.stage === 'done' ? 'done' : 'copy',
stage:
p.stage === 'unzip' ? 'unzip' : p.stage === 'zip' ? 'zip' : p.stage === 'done' ? 'done' : 'copy',
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
@@ -1156,53 +1187,56 @@ async function main() {
throw e;
}
});
registerHandler(ipcChannels.project.exportZip, async ({ projectId, storylineSelections, npcIds, labels }) => {
const list = await projectStore.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
throw new Error('Проект не найден');
}
const defaultName = isProjectZipFileName(entry.fileName)
? entry.fileName.toLowerCase().endsWith('.ttrpg.zip')
? entry.fileName
: projectZipFileNameFromBase(stripProjectZipExtension(entry.fileName))
: projectZipFileNameFromBase(stripProjectZipExtension(entry.fileName));
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: defaultName,
filters: [PROJECT_ZIP_SAVE_DIALOG_FILTER],
});
if (canceled || !filePath) {
return { canceled: true as const };
}
const dest = normalizeSaveProjectZipPath(filePath);
try {
emitZipProgress({ kind: 'export', stage: 'zip', percent: 0, detail: 'Экспорт…' });
await projectStore.exportStorylinesZipToPath(
projectId,
storylineSelections,
npcIds ?? [],
dest,
labels,
(p) => {
emitZipProgress({
kind: 'export',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
},
async (tokenIds, exportRoot) => {
await tokensStore!.packForExport(tokenIds, exportRoot);
},
);
emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' });
return { canceled: false as const };
} catch (err) {
const detail = err instanceof Error ? err.message : 'Ошибка экспорта';
emitZipProgress({ kind: 'export', stage: 'error', percent: 0, detail });
throw err;
}
});
registerHandler(
ipcChannels.project.exportZip,
async ({ projectId, storylineSelections, npcIds, labels }) => {
const list = await projectStore.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
throw new Error('Проект не найден');
}
const defaultName = isProjectZipFileName(entry.fileName)
? entry.fileName.toLowerCase().endsWith('.ttrpg.zip')
? entry.fileName
: projectZipFileNameFromBase(stripProjectZipExtension(entry.fileName))
: projectZipFileNameFromBase(stripProjectZipExtension(entry.fileName));
const { canceled, filePath } = await dialog.showSaveDialog({
defaultPath: defaultName,
filters: [PROJECT_ZIP_SAVE_DIALOG_FILTER],
});
if (canceled || !filePath) {
return { canceled: true as const };
}
const dest = normalizeSaveProjectZipPath(filePath);
try {
emitZipProgress({ kind: 'export', stage: 'zip', percent: 0, detail: 'Экспорт…' });
await projectStore.exportStorylinesZipToPath(
projectId,
storylineSelections,
npcIds ?? [],
dest,
labels,
(p) => {
emitZipProgress({
kind: 'export',
stage: p.stage,
percent: p.percent,
...(p.detail ? { detail: p.detail } : null),
});
},
async (tokenIds, exportRoot) => {
await tokensStore!.packForExport(tokenIds, exportRoot);
},
);
emitZipProgress({ kind: 'export', stage: 'done', percent: 100, detail: 'Готово' });
return { canceled: false as const };
} catch (err) {
const detail = err instanceof Error ? err.message : 'Ошибка экспорта';
emitZipProgress({ kind: 'export', stage: 'error', percent: 0, detail });
throw err;
}
},
);
registerHandler(ipcChannels.project.deleteProject, async ({ projectId }) => {
await projectStore.deleteProjectById(projectId);
emitSessionState();
@@ -1253,7 +1287,11 @@ async function main() {
return { tokens: tokensStore!.list() };
});
registerHandler(ipcChannels.tokens.upsert, async ({ id, name, filePath }) => {
const token = await tokensStore!.upsert({ id, name, filePath });
const token = await tokensStore!.upsert({
name,
...(id !== undefined ? { id } : {}),
...(filePath !== undefined ? { filePath } : {}),
});
emitTokensState();
return { token };
});
@@ -1288,8 +1326,7 @@ async function main() {
const filePath = filePaths[0]!;
const buf = await fs.readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime =
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl };
});
@@ -1305,6 +1342,93 @@ async function main() {
return { ok: true };
});
registerHandler(ipcChannels.players.list, async () => {
await playersStore!.ensureLoaded();
return { players: playersStore!.listPlayers(), teams: playersStore!.listTeams() };
});
registerHandler(
ipcChannels.players.upsert,
async ({ id, name, filePath, teamId, ringColor, imageOffset, imageScale }) => {
const player = await playersStore!.upsert(
{
name,
...(id !== undefined ? { id } : {}),
...(filePath !== undefined ? { filePath } : {}),
...(teamId !== undefined ? { teamId } : {}),
...(ringColor !== undefined ? { ringColor } : {}),
...(imageOffset !== undefined ? { imageOffset } : {}),
...(imageScale !== undefined ? { imageScale } : {}),
},
(p) => emitPlayerUpsertProgress(p),
);
emitPlayersState();
return { player };
},
);
registerHandler(ipcChannels.players.delete, async ({ id }) => {
await playersStore!.delete(id);
emitPlayersState();
return { ok: true };
});
registerHandler(ipcChannels.players.setOrder, async ({ playerIds }) => {
const players = await playersStore!.setPlayersOrder(playerIds);
emitPlayersState();
return { players };
});
registerHandler(ipcChannels.players.upsertTeam, async ({ id, name, color }) => {
const team = await playersStore!.upsertTeam({
name,
...(id !== undefined ? { id } : {}),
...(color !== undefined ? { color } : {}),
});
emitPlayersState();
return { team };
});
registerHandler(ipcChannels.players.deleteTeam, async ({ id }) => {
await playersStore!.deleteTeam(id);
emitPlayersState();
return { ok: true };
});
registerHandler(ipcChannels.players.setTeamsOrder, async ({ teamIds }) => {
const teams = await playersStore!.setTeamsOrder(teamIds);
emitPlayersState();
return { teams };
});
registerHandler(ipcChannels.players.assignTeam, async ({ playerId, teamId }) => {
const player = await playersStore!.assignPlayerTeam(playerId, teamId);
emitPlayersState();
return { player };
});
registerHandler(ipcChannels.players.pickImage, async () => {
const { canceled, filePaths } = await dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{
name: openDialogFilterLabel('images', app.getLocale()),
extensions: ['png', 'jpg', 'jpeg', 'webp'],
},
],
});
if (canceled || filePaths.length === 0) return { canceled: true as const };
const filePath = filePaths[0]!;
const buf = await fs.readFile(filePath);
const ext = path.extname(filePath).toLowerCase();
const mime = ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
const previewDataUrl = `data:${mime};base64,${buf.toString('base64')}`;
return { canceled: false as const, filePath, previewDataUrl };
});
registerHandler(ipcChannels.players.imageUrl, ({ id }) => {
return { url: playersStore!.getImageUrl(id) };
});
registerHandler(ipcChannels.sceneNpcTokensSession.getState, () => {
return { state: sceneNpcTokensSessionStore.getState() };
});
registerHandler(ipcChannels.sceneNpcTokensSession.dispatch, ({ event }) => {
sceneNpcTokensSessionStore.dispatch(event);
emitSceneNpcTokensSessionState();
return { ok: true };
});
registerHandler(ipcChannels.video.getState, () => {
return { state: videoStore.getState() };
});
+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;
}
}
}
}
+117 -54
View File
@@ -53,9 +53,23 @@ import type {
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
import { normalizeMaterialLegend } from '../../shared/types/materialLegend';
import { normalizeSceneToken, type SceneToken } from '../../shared/types/appTokens';
import {
clampPlayerImageOffset,
clampPlayerImageScale,
DEFAULT_PLAYER_RING_COLOR,
normalizeSceneNpcToken,
type SceneNpcToken,
} from '../../shared/types/appPlayers';
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
import { normalizeSceneTrap } from '../../shared/types/sceneTraps';
import type { AssetId, GraphNodeId, MaterialId, NpcGroupId, NpcId, NpcRelationId } from '../../shared/types/ids';
import type {
AssetId,
GraphNodeId,
MaterialId,
NpcGroupId,
NpcId,
NpcRelationId,
} from '../../shared/types/ids';
import {
asAssetId,
asGraphNodeId,
@@ -614,6 +628,7 @@ export class ZipProjectStore {
darkenScene: false,
traps: [],
tokens: [],
npcTokens: [],
grid: { ...DEFAULT_SCENE_GRID },
} satisfies Scene);
@@ -621,6 +636,7 @@ export class ZipProjectStore {
...base,
traps: base.traps ?? [],
tokens: base.tokens ?? [],
npcTokens: base.npcTokens ?? [],
grid: base.grid ?? { ...DEFAULT_SCENE_GRID },
...(patch.title !== undefined ? { title: patch.title } : null),
...(patch.description !== undefined ? { description: patch.description } : null),
@@ -636,9 +652,7 @@ export class ZipProjectStore {
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
...(patch.traps !== undefined
? {
traps: patch.traps
.map((t) => normalizeSceneTrap(t))
.filter((t): t is SceneTrap => Boolean(t)),
traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)),
}
: null),
...(patch.tokens !== undefined
@@ -648,6 +662,13 @@ export class ZipProjectStore {
.filter((t): t is SceneToken => Boolean(t)),
}
: null),
...(patch.npcTokens !== undefined
? {
npcTokens: patch.npcTokens
.map((t) => normalizeSceneNpcToken(t))
.filter((t): t is SceneNpcToken => Boolean(t)),
}
: null),
...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null),
...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null),
...(patch.media ? { media: { ...base.media, ...patch.media } } : null),
@@ -798,7 +819,10 @@ export class ZipProjectStore {
const node = open.project.sceneGraphNodes.find((n) => n.id === graphNodeId);
if (!node) throw new Error('Graph node not found');
const enabling = !node.isSideStoryStart;
if (enabling && !canSetSideStoryStart(open.project.sceneGraphNodes, open.project.sceneGraphEdges, graphNodeId)) {
if (
enabling &&
!canSetSideStoryStart(open.project.sceneGraphNodes, open.project.sceneGraphEdges, graphNodeId)
) {
return open.project;
}
await this.updateProject((p) => {
@@ -1158,16 +1182,11 @@ export class ZipProjectStore {
return latest;
}
async setMaterialRotation(
materialId: MaterialId,
rotationDeg: 0 | 90 | 180 | 270,
): Promise<Project> {
async setMaterialRotation(materialId: MaterialId, rotationDeg: 0 | 90 | 180 | 270): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => {
const materials = (p.materials ?? []).map((m) =>
m.id === materialId ? { ...m, rotationDeg } : m,
);
const materials = (p.materials ?? []).map((m) => (m.id === materialId ? { ...m, rotationDeg } : m));
return { ...p, materials };
});
const latest = this.getOpenProject();
@@ -1243,6 +1262,9 @@ export class ZipProjectStore {
description?: string;
filePath?: string;
groupId?: NpcGroupId | null;
ringColor?: string;
imageOffset?: { x: number; y: number };
imageScale?: number;
},
onProgress?: (p: { percent: number; stage: string; detail?: string }) => void,
): Promise<Project> {
@@ -1300,7 +1322,10 @@ export class ZipProjectStore {
if (stagedAsset) assets[stagedAsset.id] = stagedAsset;
const groupIds = new Set((p.npcGroups ?? []).map((g) => g.id));
const resolveGroup = (raw: NpcGroupId | null | undefined, prev: NpcGroupId | null): NpcGroupId | null => {
const resolveGroup = (
raw: NpcGroupId | null | undefined,
prev: NpcGroupId | null,
): NpcGroupId | null => {
if (raw === undefined) return prev;
if (raw === null) return null;
return groupIds.has(raw) ? raw : null;
@@ -1314,9 +1339,17 @@ export class ZipProjectStore {
...prev,
name,
avatarAssetId: nextAssetId ?? prev.avatarAssetId,
description:
typeof input.description === 'string' ? input.description : prev.description,
description: typeof input.description === 'string' ? input.description : prev.description,
groupId: resolveGroup(input.groupId, prev.groupId),
...(input.ringColor !== undefined
? { ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR) }
: {}),
...(input.imageOffset !== undefined
? { imageOffset: clampPlayerImageOffset(input.imageOffset) }
: {}),
...(input.imageScale !== undefined
? { imageScale: clampPlayerImageScale(input.imageScale) }
: {}),
};
} else {
if (!nextAssetId) throw new Error('NPC avatar is required');
@@ -1329,6 +1362,9 @@ export class ZipProjectStore {
x: 80 + (count % 4) * 220,
y: 80 + Math.floor(count / 4) * 200,
groupId: resolveGroup(input.groupId, null),
ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR),
imageOffset: clampPlayerImageOffset(input.imageOffset),
imageScale: clampPlayerImageScale(input.imageScale),
});
}
return { ...p, assets, npcs };
@@ -1346,20 +1382,18 @@ export class ZipProjectStore {
name?: string;
description?: string;
groupId?: NpcGroupId | null;
ringColor?: string;
imageOffset?: { x: number; y: number };
imageScale?: number;
},
): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const name =
typeof patch.name === 'string' ? patch.name.trim() : undefined;
const name = typeof patch.name === 'string' ? patch.name.trim() : undefined;
if (name !== undefined) {
if (name.length < 1) throw new Error('NPC name is required');
const nameKey = name.toLowerCase();
if (
(open.project.npcs ?? []).some(
(n) => n.id !== npcId && n.name.trim().toLowerCase() === nameKey,
)
) {
if ((open.project.npcs ?? []).some((n) => n.id !== npcId && n.name.trim().toLowerCase() === nameKey)) {
throw new Error('NPC name already exists');
}
}
@@ -1369,14 +1403,22 @@ export class ZipProjectStore {
if (n.id !== npcId) return n;
let groupId = n.groupId;
if (patch.groupId !== undefined) {
groupId =
patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null;
groupId = patch.groupId === null ? null : groupIds.has(patch.groupId) ? patch.groupId : null;
}
return {
...n,
...(name !== undefined ? { name } : {}),
...(typeof patch.description === 'string' ? { description: patch.description } : {}),
groupId,
...(patch.ringColor !== undefined
? { ringColor: normalizeHexColor(patch.ringColor, DEFAULT_PLAYER_RING_COLOR) }
: {}),
...(patch.imageOffset !== undefined
? { imageOffset: clampPlayerImageOffset(patch.imageOffset) }
: {}),
...(patch.imageScale !== undefined
? { imageScale: clampPlayerImageScale(patch.imageScale) }
: {}),
};
});
return { ...p, npcs };
@@ -1401,13 +1443,25 @@ export class ZipProjectStore {
async deleteNpc(npcId: NpcId): Promise<Project> {
const open = this.openProject;
if (!open) throw new Error('No open project');
await this.updateProject((p) => ({
...p,
npcs: (p.npcs ?? []).filter((n) => n.id !== npcId),
npcRelations: (p.npcRelations ?? []).filter(
(r) => r.sourceNpcId !== npcId && r.targetNpcId !== npcId,
),
}));
await this.updateProject((p) => {
const scenes: Record<SceneId, Scene> = { ...p.scenes };
for (const sid of Object.keys(scenes) as SceneId[]) {
const sc = scenes[sid];
if (!sc) continue;
const npcTokens = (sc.npcTokens ?? []).filter((t) => t.npcId !== npcId);
if (npcTokens.length !== (sc.npcTokens ?? []).length) {
scenes[sid] = { ...sc, npcTokens };
}
}
return {
...p,
scenes,
npcs: (p.npcs ?? []).filter((n) => n.id !== npcId),
npcRelations: (p.npcRelations ?? []).filter(
(r) => r.sourceNpcId !== npcId && r.targetNpcId !== npcId,
),
};
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
@@ -1462,10 +1516,7 @@ export class ZipProjectStore {
}
const nameKey = name.toLowerCase();
const siblingConflict = groups.some(
(g) =>
g.id !== editingId &&
g.parentId === parentId &&
g.name.trim().toLowerCase() === nameKey,
(g) => g.id !== editingId && g.parentId === parentId && g.name.trim().toLowerCase() === nameKey,
);
if (siblingConflict) throw new Error('Group name already exists');
@@ -1498,9 +1549,7 @@ export class ZipProjectStore {
const nextGroups = groups
.filter((g) => g.id !== groupId)
.map((g) => (g.parentId === groupId ? { ...g, parentId: parentOfDeleted } : g));
const npcs = (p.npcs ?? []).map((n) =>
n.groupId === groupId ? { ...n, groupId: null } : n,
);
const npcs = (p.npcs ?? []).map((n) => (n.groupId === groupId ? { ...n, groupId: null } : n));
return { ...p, npcGroups: nextGroups, npcs };
});
const latest = this.getOpenProject();
@@ -1541,10 +1590,7 @@ export class ZipProjectStore {
if (label.length < 1) throw new Error('Relation label is required');
if (input.sourceNpcId === input.targetNpcId) throw new Error('Cannot relate NPC to itself');
const npcs = open.project.npcs ?? [];
if (
!npcs.some((n) => n.id === input.sourceNpcId) ||
!npcs.some((n) => n.id === input.targetNpcId)
) {
if (!npcs.some((n) => n.id === input.sourceNpcId) || !npcs.some((n) => n.id === input.targetNpcId)) {
throw new Error('NPC not found');
}
await this.updateProject((p) => {
@@ -2089,16 +2135,14 @@ export class ZipProjectStore {
sourceForMerge = remapProjectSceneTokenIds(source, remap);
}
const offsetX = computeGraphImportOffsetX(this.openProject.project);
const { project: merged, report, assetCopies } = mergeStorylinesIntoProject(
this.openProject.project,
sourceForMerge,
selections,
sceneResolutions,
{
graphOffsetX: offsetX,
...(npcResolutions ? { npcResolutions } : {}),
},
);
const {
project: merged,
report,
assetCopies,
} = mergeStorylinesIntoProject(this.openProject.project, sourceForMerge, selections, sceneResolutions, {
graphOffsetX: offsetX,
...(npcResolutions ? { npcResolutions } : {}),
});
const targetCache = this.openProject.cacheDir;
await fs.mkdir(path.join(targetCache, 'assets'), { recursive: true });
@@ -2307,6 +2351,10 @@ function normalizeScene(s: Scene): Scene {
const tokens = (Array.isArray(rawTokens) ? rawTokens : [])
.map((t) => normalizeSceneToken(t))
.filter((t): t is SceneToken => Boolean(t));
const rawNpcTokens = (s as unknown as { npcTokens?: unknown[] }).npcTokens;
const npcTokens = (Array.isArray(rawNpcTokens) ? rawNpcTokens : [])
.map((t) => normalizeSceneNpcToken(t))
.filter((t): t is SceneNpcToken => Boolean(t));
const grid = normalizeSceneGrid((s as unknown as { grid?: unknown }).grid);
const rawAudios = Array.isArray(raw.audios) ? raw.audios : [];
@@ -2338,6 +2386,7 @@ function normalizeScene(s: Scene): Scene {
darkenScene,
traps,
tokens,
npcTokens,
grid,
layout: layoutIn ?? { x: 0, y: 0 },
media: {
@@ -2429,6 +2478,8 @@ function normalizeProject(p: Project): Project {
x?: number;
y?: number;
groupId?: string | null;
ringColor?: string;
imageOffset?: unknown;
};
if (!obj.id || !obj.avatarAssetId || typeof obj.name !== 'string') return null;
const name = obj.name.trim();
@@ -2444,6 +2495,9 @@ function normalizeProject(p: Project): Project {
x,
y,
groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
ringColor: normalizeHexColor(obj.ringColor, DEFAULT_PLAYER_RING_COLOR),
imageOffset: clampPlayerImageOffset(obj.imageOffset),
imageScale: clampPlayerImageScale((obj as { imageScale?: unknown }).imageScale),
};
})
.filter((x): x is ProjectNpc => Boolean(x));
@@ -2481,6 +2535,15 @@ function normalizeProject(p: Project): Project {
if (a && a.length > 0) return a;
return '0.0.0';
})();
const scenesPruned: Record<SceneId, Scene> = {};
for (const sid of Object.keys(scenes) as SceneId[]) {
const sc = scenes[sid];
if (!sc) continue;
scenesPruned[sid] = {
...sc,
npcTokens: (sc.npcTokens ?? []).filter((t) => npcIdSet.has(t.npcId)),
};
}
return {
...p,
meta: {
@@ -2491,7 +2554,7 @@ function normalizeProject(p: Project): Project {
createdWithAppVersion,
schemaVersion: PROJECT_SCHEMA_VERSION,
},
scenes,
scenes: scenesPruned,
campaignAudios,
materials,
npcs,
@@ -2501,7 +2564,7 @@ function normalizeProject(p: Project): Project {
sceneGraphEdges,
currentGraphNodeId,
sceneListOrder: reconcileSceneListOrder(
scenes,
scenesPruned,
(p as { sceneListOrder?: SceneId[] }).sceneListOrder,
),
};
+6 -2
View File
@@ -2,7 +2,8 @@ import fs from 'node:fs/promises';
import { session } from 'electron';
import { asAssetId, asTokenId } from '../../shared/types/ids';
import { asAssetId, asPlayerId, asTokenId } from '../../shared/types/ids';
import type { PlayersStore } from '../players/playersStore';
import type { ZipProjectStore } from '../project/zipStore';
import type { TokensStore } from '../tokens/tokensStore';
@@ -75,11 +76,12 @@ async function serveFile(info: ReadInfo, request: Request): Promise<Response> {
}
/**
* Обслуживает `dnd://asset?...` и `dnd://token?...`.
* Обслуживает `dnd://asset?...`, `dnd://token?...` и `dnd://player?...`.
*/
export function registerDndAssetProtocol(
projectStore: ZipProjectStore,
tokensStore: TokensStore,
playersStore: PlayersStore,
): void {
session.defaultSession.protocol.handle('dnd', async (request) => {
const url = new URL(request.url);
@@ -92,6 +94,8 @@ export function registerDndAssetProtocol(
info = projectStore.getAssetReadInfo(asAssetId(id));
} else if (url.hostname === 'token') {
info = tokensStore.getImageReadInfo(asTokenId(id));
} else if (url.hostname === 'player') {
info = playersStore.getImageReadInfo(asPlayerId(id));
}
if (!info) {
return new Response(null, { status: 404 });
+3 -8
View File
@@ -92,11 +92,7 @@ export class TokensStore {
return path.join(this.rootDir, relPath);
}
async upsert(input: {
id?: TokenId | null;
name: string;
filePath?: string | null;
}): Promise<AppToken> {
async upsert(input: { id?: TokenId | null; name: string; filePath?: string | null }): Promise<AppToken> {
await this.ensureLoaded();
const name = input.name.trim();
if (!name) throw new Error('Token name is required');
@@ -166,8 +162,7 @@ export class TokensStore {
}): Promise<{ token: AppToken; remappedFrom: TokenId }> {
await this.ensureLoaded();
let buf = await fs.readFile(input.absFilePath);
const sha256 =
input.sha256 ?? crypto.createHash('sha256').update(buf).digest('hex');
const sha256 = input.sha256 ?? crypto.createHash('sha256').update(buf).digest('hex');
const existingByHash = this.findBySha256(sha256);
if (existingByHash) {
return { token: existingByHash, remappedFrom: input.preferredId };
@@ -262,7 +257,7 @@ export class TokensStore {
preferredId: asTokenId(t.id),
name: typeof t.name === 'string' ? t.name : 'Token',
absFilePath: abs,
sha256: typeof t.sha256 === 'string' ? t.sha256 : undefined,
...(typeof t.sha256 === 'string' ? { sha256: t.sha256 } : {}),
});
remap.set(remappedFrom, token.id);
}