114 Commits

Author SHA1 Message Date
Ivan Fontosh 586166cacd feat(audio): volume в проекте, DnD, параллельные плейлисты
Громкость треков сцены/игры сохраняется в проекте; порядок через DnD; несколько плейлистов сразу; автоимя из файла; стоп превью при запуске сессии.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 17:09:26 +08:00
Ivan Fontosh dbe8ab1fda docs: инструкции и обложка ВК по новым фичам после 1.0.29
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 15:44:25 +08:00
Ivan Fontosh 9ac01d217f feat(control): 3D-кубики в отдельном окне
Бросок синхронизирован на пульте и презентации; d100 как десятки; оверлей скрывается только кнопкой «Убрать».

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 13:01:34 +08:00
Ivan Fontosh 86d6d157f0 feat(control): трекер инициативы в отдельном окне
Очередь NPC/игроков с DnD, курсором на сцене, состояниями и запасом; сброс при смене сцены.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-04 10:09:51 +08:00
Ivan Fontosh db1168a46c feat(control): таймер сессии поверх сцены
Отдельное окно countdown с «Показать игрокам»; на превью — move/resize/rotate, на презентации тот же layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 16:51:56 +08:00
Ivan Fontosh a4d7ab77e7 feat(grid): именованные шаблоны сетки кампании
Несколько шаблонов в проекте с ручным сохранением и применением (включает сетку); без автоподстановки.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 12:04:47 +08:00
Ivan Fontosh 278eb78637 feat(playlists): библиотека плейлистов приложения
CRUD в редакторе, read-only окно на пульте, отдельный аудиоканал, экспорт/импорт и маппинг из Foundry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-03 10:37:21 +08:00
Ivan Fontosh cfb6e62331 feat(control): аккордеон «Сцены» на пульте управления
Список сцен активной линии в порядке бокового списка редактора; переход с записью в историю.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 13:29:12 +08:00
Ivan Fontosh 746b862cb9 feat(editor): настройки экрана презентации при запуске сессии
Выбор монитора и полноэкранного режима в настройках; при «Запустить» окно презентации открывается на выбранном дисплее автоматически.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 13:05:50 +08:00
Ivan Fontosh 8b28a9b11a feat(control): инструмент «Маркер» на области видимости
Добавлена кисть маркера на пульте и презентации с настройками в панели эффектов; штрихи привязаны к жёлтой рамке, ластик и очистка работают с отдельным сессионным слоем.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 12:53:06 +08:00
Ivan Fontosh 153be98780 fix(materials): синхронизация легенды и маркеров на превью и презентации
Легенда и цифры на материале масштабируются от экрана игроков и не обрезаются жёлтой рамкой. Переключение сцен без записи project.json на диск.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:57:50 +08:00
Ivan Fontosh ea718a26df feat(control): история-аккордеон и форма кисти затемнения
Переименовать блок сюжетной линии в «История» и сделать его сворачиваемым. Добавить выбор круглой/квадратной формы для кистей открытия и закрытия с сохранением в штрихах и сбросом при смене инструмента или сцены без затемнения.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:00:10 +08:00
Ivan Fontosh 0a9168b320 fix: scene clear race, path editor anchors, avatar cover, team color crash
Clear scene now patches traps/tokens/npcTokens atomically and updateScene merges against a fresh scene snapshot. Align NPC path-editor previews on token center, default new paths to fixed facing, size avatars by natural aspect for cover, and read team color synchronously from the picker.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-29 15:30:34 +08:00
     Фонтош Иван Сергеевич 028c1be059 chore: bump version to 1.0.29
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 08:10:44 +08:00
Ivan Fontosh 9172660afb docs(help): document NPC overlay, session spawn, and token paths
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 10:54:28 +08:00
Ivan Fontosh 0f4bb76b41 feat(npcs): inspect cards, Show overlay, and optional external link
Clicking an NPC tile opens description cards (multi-select accordion); Show puts avatars on screen. Optional URL opens a webview tab. Avatar pan in the editor commits on pointer up.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 10:42:57 +08:00
Ivan Fontosh 1a9f0f2557 feat(control): spawn session NPCs and resize tokens in preview
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 14:42:06 +08:00
Ivan Fontosh 1ab6ffd593 feat(tokens): animated paths for scene and NPC tokens
Add path editor window, session playback on control/presentation, and RMB controls. Fix live pose clock so motion no longer freezes after ~250ms.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 08:48:41 +08:00
Ivan Fontosh 46bec1a86a feat(scene): keep map tokens with preview rotation
When previewRotationDeg changes, remap non-player, NPC and trap
markers (plus live session token overrides) so they stay on the art.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 12:29:20 +08:00
Ivan Fontosh 7362a36fe5 feat(scene): rotate video previews like images
Reuse previewRotationDeg for video scenes across editor, control,
presentation and overlays via ContainedVideo layout parity.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 12:17:57 +08:00
Ivan Fontosh 1d94c95cc8 fix(release): upload Linux AppImage under feed x64 name
Prevent stale TTRPGPlayer-x64.AppImage on the updates host when
electron-builder leaves an x86_64 alias beside an older x64 file.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-08 18:47:20 +08:00
Ivan Fontosh 1fbaaa6e77 feat(scene): video map editor parity and help updates
Enable scene editor, overlays, effects, and darkness on video scenes; brighten GM trap markers; document snap, NPC types, materials, and control controls in RU/EN help.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 08:23:08 +08:00
Ivan Fontosh 8d5a68c71e fix(pack): lazy-load sharp and verify unpacked natives
Avoid crashing Electron at startup when sharp is corrupt, and fail pack if asarUnpack natives look truncated.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 07:47:36 +08:00
Ivan Fontosh 4456eb0277 feat(control): grid snap, NPC context actions, and overlay dim fix
Re-enable users-branch UI, snap session tokens to square/hex grid from the control preview, refine inactive/open-info NPC menus, block marker actions while an effect brush is active, and dim materials/NPC overlays only when they are open.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 13:48:59 +08:00
     Фонтош Иван Сергеевич e53d1ea934 chore: bump version to 1.0.28
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 10:19:41 +08:00
Ivan Fontosh cbb6edc378 chore(release): gate users-branch features for hotfix
Add USERS_BRANCH_FEATURES_ENABLED (off by default) to hide Players library, session tokens, and related UI while keeping the preview import fix. Restore primary Run button when the flag is off.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 10:15:28 +08:00
Ivan Fontosh 4e6f7321b8 fix(project): race-safe batch scene preview import and close
Serialize updateProject and preview finalize, drain before pack/close, buffer zip entries, and abort finalize cleanly when leaving the project to avoid ENOENT on multi-image drops.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 09:44:45 +08:00
Ivan Fontosh f75444a5dd Merge branch 'users' into main
Players library, circular NPC tokens, disposition types; keep main overlay/release fixes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-04 08:03:22 +08:00
Ivan Fontosh 10cbb3b256 fix(release): unlock rolldown before npm ci in prepare-release
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:17:37 +08:00
Ivan Fontosh c46ff34393 chore: revert accidental bump to 1.0.28
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:17:25 +08:00
Ivan Fontosh 7a25b18268 chore: release v1.0.28 2026-08-03 14:17:11 +08:00
     Фонтош Иван Сергеевич d4dc4e7f3c chore: bump version to 1.0.27
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 14:06:29 +08:00
Ivan Fontosh 2979d06f1c feat: multi-material overlays, presentation guides, and release prebuilds
Align control/presentation with presentation screen rect and darkness z-order; sync window titles and session window cleanup. Pack Win/Mac/Linux with npmRebuild disabled and release-native-prep for classic-level and sharp.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 11:28:18 +08:00
Ivan Fontosh 61446dacfc fix(ui): hide Electron menu bar and polish editor chrome
Drop the system View menu across windows, show the open project name above Game properties, and keep scene list kebab visible with truncated titles and overflow tooltips.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 09:21:26 +08:00
Ivan Fontosh 41b112159f feat(effects): add Closing brush as inverse of Opening brush
Allow covering revealed darkness again during darkened scene sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:21:50 +08:00
Ivan Fontosh 0f01950cc1 feat(effects): add Closing brush as inverse of Opening brush
Allow covering revealed darkness again during darkened scene sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:21:40 +08:00
Ivan Fontosh cc50e64e21 feat(players): NPC disposition types and launch-with-players session tokens
Add Hostile/Neutral/Friendly ring types with session-only inactive overrides on control, plus launch-with-players flow and live player tokens on the map.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 17:11:01 +08:00
Ivan Fontosh 101f595bac 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>
2026-07-30 13:38:35 +08:00
Ivan Fontosh a3a03eb9e3 docs(help): remove NPC scene-binding wording from instructions
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 10:06:51 +08:00
Ivan Fontosh e08f5ef550 feat(npcs): drop binding; select NPCs on storyline export
Remove storyline/scene NPC binding and export chosen NPCs with relations/groups. Harden export modal so a missing npcs payload no longer blacks out the editor.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 10:05:09 +08:00
Ivan Fontosh 04c75cd725 fix(npcs): stop TipTap crash black screen in secondary windows
Guard TipTap getHTML under StrictMode, wrap secondary window roots in WindowErrorBoundary, and add stability regression tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 09:10:47 +08:00
Ivan Fontosh e687303c57 fix(npcs): faster editor open, save progress, black screen crash
Warm and show the NPC window sooner, lazy-load ReactFlow/TipTap, overlay save progress like materials, and fix the undefined controlStyles crash after creating an NPC.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-30 08:50:05 +08:00
Ivan Fontosh c7bf7cf449 fix(ui): polish editor layouts and confirm scene delete
Stretch key action buttons, center empty audio/image pickers, and require confirmation before deleting a scene.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 15:35:07 +08:00
Ivan Fontosh a2b418b78a feat(help): traps/tokens/grid sections and clickable cross-links
Add instruction pages for traps, non-player tokens, and grid generator, and turn section mentions into in-modal navigation links.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:13:53 +08:00
Ivan Fontosh f270812219 feat(tokens): app-local non-player tokens with session moves and UI polish
Add token library/placements, keep play-time moves for the session, lock presentation interactions, and fix export/import modal layout plus freeform trap label.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:04:12 +08:00
Ivan Fontosh bdeb64e356 feat(scene): battle grid overlay with color and trap VFX stacking
Add configurable square/hex grid in the scene editor (persisted and shown on control/presentation) and keep trap activation effects above trap icons.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 06:47:59 +08:00
Ivan Fontosh 4aa0f257d5 feat(materials): show save progress overlay while optimizing images
Block the editor with a spinner and percent stages during material upsert so long image optimization is visible and non-interactive.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 14:50:35 +08:00
Ivan Fontosh 9d82e74272 fix: control UX — no default tool, launch loader, NPC layout
Deselect effect tools by default and on re-click, block the editor during session launch, and fix NPC overlay/window empty-state layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 14:41:27 +08:00
Ivan Fontosh fed5674468 feat(traps): activation VFX/SFX, explosion effect, and help section
Wire mimic/pit/arrow/laser media on activate, poison/explosion via effects, and document the scene editor and traps in Instructions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 12:35:22 +08:00
Ivan Fontosh 02d73ddf81 feat: scene traps, material legends, and scene editor window
Add trap overlays with session state, material legend editor/panel, and a dedicated scene editor window wired through IPC and project persistence.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 16:29:43 +08:00
     Фонтош Иван Сергеевич d3b1c4660d fix(test): align locale normalize test with system infer path
Compare blank/invalid locale normalization against inferEditorLocaleFromSystem()
so the test passes consistently when Node exposes navigator.languages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 22:00:28 +08:00
Ivan Fontosh eb127c11c2 feat(scene): shared zoom/pan for control and presentation
Keep effects pinned to map coordinates when the viewport changes; materials/NPC overlays stay screen-fixed.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 14:08:19 +08:00
Ivan Fontosh 2c0e6fbf09 feat(effects): extinguish fire with water/rain and ambient SFX volume
Water and rain erase fire under a tight brush hit radius; add looping fire/rain ambients and an Effects sound slider that also scales one-shot effect SFX.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 13:12:41 +08:00
Ivan Fontosh a797162f16 perf: trim session IPC hot paths and fix clear-effects idle render
Skip full project broadcast on graph/NPC position commits, send session.stateChanged only to consumer windows, and force one Pixi render before stopping the idle ticker so Clear effects actually clears the canvas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 12:32:03 +08:00
Ivan Fontosh d9fbecf5a7 perf: cut ControlApp re-renders and lazy-load VFX packs
Move audio scrub/volume and brush drafts off root React ticks, coalesce overlay layout IPC, cache machine fingerprint and asset URLs, share one scene overlay host, and stop idle Pixi ticker. Also harden console against EPIPE on window load failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 11:58:16 +08:00
Ivan Fontosh 32a5479086 fix(license): bind deviceId to physical machine, not OS user
Use OS machine identifiers (Windows MachineGuid, macOS IOPlatformUUID, Linux machine-id) hashed as deviceId so all accounts on one PC share one license slot. Keep legacy userData/device.id for migration and retire it on re-activation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-23 10:39:46 +08:00
Ivan Fontosh c1c332364c feat(materials): free rotate session overlays and show save progress
Match NPC drag-to-rotate on the material frame during playback, and change the add/edit Save button to Saving… while import runs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 14:56:42 +08:00
Ivan Fontosh 0cab7ce7ca feat(npcs): free rotate overlays and drop session zoom tools
Drag the top-edge handle to rotate the whole frame with the avatar; remove unused zoom controls from the session NPC window.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 14:22:46 +08:00
Ivan Fontosh 1829191410 feat(npcs): show multiple character overlays in session playback
Toggle NPCs independently from the characters window, keep one shared dim/close-all, and scroll descriptions for all selected characters.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 13:23:19 +08:00
Ivan Fontosh f4c0ac1438 feat(npcs): add groups, storyline bindings, and Foundry import
Nested NPC groups with color, graph filter, and scene/storyline binding; Foundry worlds/modules import actors into groups; storyline merge asks on NPC name conflicts and reports NPC counts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 13:19:22 +08:00
Ivan Fontosh 37ba855faf feat(npcs): add campaign NPCs with relation graph and session overlay
Add a dedicated NPC editor window, directed relations, control/presentation avatar overlay, and ru/en help for the new section.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 13:13:58 +08:00
Ivan Fontosh 61875be857 feat(materials): add campaign materials overlay for sessions
Let GMs manage and show images over the scene from the editor and control panel, with zoom tools, help docs, and full ru/en i18n.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 11:05:38 +08:00
Ivan Fontosh 195d4be086 fix(editor): polish project picker and scene description UX
Validate unique campaign names, add search, flip menus at scroll end, drop TipTap links, and scroll long description previews.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 19:04:02 +08:00
Ivan Fontosh 8d73f7744e chore: bump to 1.0.26 and allow graph zoom down to 10%
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 11:42:33 +08:00
Ivan Fontosh e731ddb1c0 fix(i18n): rename effects cleanup label and update description help
Rename the effects subgroup from Tools to Cleanup/Очистка and document the description tools and viewer in the instructions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 10:19:16 +08:00
Ivan Fontosh cfa3959fb3 feat(scenes): rich scene descriptions and control viewer window
Add TipTap editing in the editor and an Electron window on the control panel to read the current scene description.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-15 09:49:40 +08:00
Ivan Fontosh 35a6e979eb feat(editor): DnD сцен в список, порядок списка и правки импорта
Можно перетащить изображения/видео в колонку сцен: создаются новые сцены
с превью и названием из файла, с полноэкранным прогрессом и отчётом о
пропущенных файлах.

Добавлен ручной порядок сцен в списке (sceneListOrder) с сохранением в
проект; перетаскивание карточек отделено от импорта файлов. Во время
сортировки снова работает скролл колесом.

Исправлен залипающий disabled у «+ Новая сцена» после создания сцены.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 10:32:50 +08:00
Ivan Fontosh b7e6ff6915 feat(editor): drag-and-drop для аудио и превью сцены
Добавлена загрузка файлов перетаскиванием в блоки «Аудио игры», «Аудио сцены»
и «Превью сцены»: подсветка зоны, подсказки, фильтрация по типу, импорт без диалога.

IPC и main принимают пути файлов напрямую; для Electron 41 пути получаются через
webUtils.getPathForFile в preload (File.path в renderer больше недоступен).

Улучшен dev-запуск: Vite слушает 127.0.0.1, ожидание готовности по localhost.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 19:22:51 +08:00
Ivan Fontosh c8ab9dd567 feat(editor): побочные сюжетные линии и импорт/экспорт линий
Добавлена полноценная поддержка побочных сюжетных линий в редакторе и на
пульте: визуальное выделение компонент графа, запрет недопустимых связей
между основной и побочными линиями, метки «ПОБОЧНАЯ» и названия линий.

Реализован partial export/import сюжетных линий:
- экспорт выбранных линий в урезанный .ttrpg.zip с manifest в project.json;
- импорт в открытый проект с выбором линий, разрешением конфликтов названий
  сцен (одна модалка на операцию) и отчётом о результате;
- новое окно источника импорта: «Из проекта» (dropdown, без текущего) или
  «Из файла»; на главном экране — только полный импорт из файла.

Исправлены гонки при открытии/закрытии проекта (сериализация open/close в
main, сброс зависших состояний UI), залипание оверлея прогресса экспорта и
старт Electron в dev после готовности Vite.

Добавлены unit-тесты для lineage, export/import и контрактов zipStore.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 14:47:28 +08:00
Ivan Fontosh de9190959c feat(editor): add Run help hint and rename from project list
Show a help icon when Run is disabled, open the relevant instruction section
on click, and position its tooltip below-left. Add Rename project to the home
screen project card menu.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 22:45:19 +08:00
Ivan Fontosh 10bb7013e6 feat(editor): show scene preview immediately and optimize in background
Return control after copying the original asset, then run image optimization
and thumbnail generation in the background with IPC progress updates.
Block duplicate preview uploads and scene creation while requests are in flight.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 22:38:51 +08:00
Ivan Fontosh 83a326b0b9 docs(legal): expand EULA for RF compliance and bump version to 2
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 22:03:02 +08:00
Ivan Fontosh 089da3cae0 feat(effects): migrate battle effects to VFX
- add WebP frame-sequence assets for lightning, electric accent, fire, sunbeam, poison, rain, water, and rounded fog effects
- replace procedural lightning rendering with VFX frame playback
- align lightning impact to the click point
- remove the full-screen lightning blur/flash overlay
- add electric accent playback for lightning impact dispersion
- keep lightning scorch marks as the persistent ground trace after the action effect
- consolidate the lightning tool into a single public `Молния` action
- replace procedural fire with the VFX fire implementation
- remove the separate `Огонь 2` tool and keep the VFX fire under `Огонь`
- keep VFX fire brightness fixed at full intensity
- preserve fire as a field effect with brush placement and eraser support
- replace procedural sunbeam with the Pulse Discharge VFX implementation
- remove the separate `Луч света 2` tool and keep the VFX sunbeam under `Луч света`
- align sunbeam impact to the click point instead of the raw frame center
- remove the full-screen sunbeam flash overlay
- add Dust Burst VFX playback for poison cloud
- tint poison smoke green
- keep the skull glyph rendered above the poison smoke
- align poison smoke bottom to the click point
- remove the old procedural poison cloud implementation
- remove the separate `Яд 2` tool and keep the VFX poison under `Яд`
- replace procedural rain strokes with medium-rain VFX frame playback
- recolor rain frames to gray/light-gray so rain no longer renders black
- preserve rain as a field effect with brush placement and eraser support
- replace static water fill with generated animated water VFX frames
- render water VFX through the existing stroke mask so water keeps the painted brush shape
- preserve water as a field effect with brush placement and eraser support
- replace procedural fog texture generation with Smokey Atmosphere VFX frames
- generate rounded fog VFX frames with soft alpha falloff at the edges
- size each fog VFX stamp to the brush diameter
- remove per-stamp fog drift and rotation so fog elements stay fixed in place
- keep fog animation limited to internal frame playback
- preload VFX frame textures for smoother first use
- cache VFX textures per Pixi instance to avoid duplicate decoding
- filter expired action instances before node synchronization to stop old effects from flashing back during rapid casts
- add brush-stroke erasing for field effects using the same model as scene darkness reveal
- keep whole-instance erasing for action effects
- update effect hit tests for the consolidated VFX action effects
- update effect store pruning for renamed and consolidated action effects
- update control-panel buttons for consolidated fire, sunbeam, poison, and VFX-backed field effects
- update editor localization for removed and renamed effect tools
- add tests for field-effect brush erasing
- update eraser hit-test coverage for VFX-backed effects
- update effects store lifetime pruning tests for consolidated poison
- update control-panel tests for consolidated effect buttons
- include the field-effect eraser test in the default test script

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 21:20:43 +08:00
Ivan Fontosh f5240d1623 docs(help): improve instructions for darkness and project delete
Add scene darkness help text, format numbered lists on separate lines, and describe the correct project deletion flow via the card menu.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:50:05 +08:00
Ivan Fontosh 4631a1bece feat(scenes): add scene darkness reveal and fix project reopen crash
Add darkenScene with Opening brush in presentation, persist reveal strokes per scene during a session, and close projects properly on return to home. Harden dnd asset streaming to avoid Windows main-process crashes when reopening projects.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:41:26 +08:00
Ivan Fontosh d54e9ed02d docs(help): document Start from this scene in instructions
Add RU/EN help text for launching a session from any graph node via the context menu.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:15:51 +08:00
Ivan Fontosh ca0cf9c0ce fix(control): improve storyline history and graph launch
Append history on revisit instead of truncating, highlight the latest duplicate entry, add Start from this scene to the graph context menu, and keep storyline state isolated from project graph edits.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:14:01 +08:00
Ivan Fontosh 657e3c862e feat(effects): add Darkness action effect like freeze
Adds a fullscreen darkness vignette on click and a permanent shadow spot with a black radial gradient (40% edge, 100% center). Includes control panel UI, i18n, eraser support, and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 16:37:06 +08:00
Ivan Fontosh 97c77a025a Fix typo in Russian EULA license grant wording.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 00:44:13 +08:00
Ivan Fontosh 42b067615d Document fixed and period license key expiry on the server.
Client activation flow is unchanged; exp in the token remains the source of truth.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 22:46:11 +08:00
     Фонтош Иван Сергеевич 0c45d58359 chore: bump version to 1.0.25
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 16:52:47 +08:00
Ivan Fontosh 1eed2b38f8 docs(help): rewrite Instructions in friendlier RU/EN with clear steps
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 16:48:57 +08:00
Ivan Fontosh 60ef837e1c chore: migrate production URLs to ttrpgplayer.ru (v1.0.24)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 15:59:37 +08:00
Ivan Fontosh 374e041321 fix(editor): delete graph edges via right-click context menu
Remove accidental edge deletion on click and document the new graph link workflow in help and docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-01 14:26:14 +08:00
Ivan Fontosh 3acb89c80d chore: bump version to 1.0.23
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 14:13:29 +08:00
Ivan Fontosh 8eb88399a3 feat(editor): add About menu with app info and user guide
Add header menu with About and Instructions modals so GMs can read product details and step-by-step help for all app features.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 14:09:23 +08:00
     Фонтош Иван Сергеевич a96e1f8465 chore: bump version to 1.0.22
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 14:56:21 +08:00
     Фонтош Иван Сергеевич 80103a00e7 fix(mac): updater progress UI and skip redundant download
Show update stage in the modal, forward electron-updater progress over IPC,
and install immediately when the build is already cached. Rename window titles to TTRPG - *.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 11:07:19 +08:00
     Фонтош Иван Сергеевич b017155eaf chore: bump version to 1.0.21
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 10:22:02 +08:00
Ivan Fontosh 5706355c5f fix(mac): prevent updater hang on manual install
Bump to 1.0.20. Disable autoDownload during manual check and fix
Squirrel.Mac quitAndInstall race on darwin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 08:24:52 +08:00
     Фонтош Иван Сергеевич 428fa09224 fix 2026-05-18 10:31:40 +08:00
Ivan Fontosh 1cda87fe13 fix error and new docs 2026-05-18 10:16:48 +08:00
Ivan Fontosh 10de99bb06 fix error and new docs 2026-05-18 10:06:47 +08:00
Ivan Fontosh 0ae3c39333 fix 2026-05-18 09:48:09 +08:00
Ivan Fontosh d07dcae626 Merge branch 'main' of https://git.mailib.ru/ifontosh/DndGamePlayer 2026-05-18 09:44:39 +08:00
Ivan Fontosh dd0dd646f6 save 2026-05-18 09:44:22 +08:00
     Фонтош Иван Сергеевич 8ec830cdb5 fiz mac build 2026-05-18 09:22:35 +08:00
Ivan Fontosh 02b3131f19 фикс 2026-05-18 08:52:39 +08:00
Ivan Fontosh cfa067519d фикс 2026-05-18 08:35:36 +08:00
Ivan Fontosh 4e5d320c36 фикс 2026-05-18 08:26:43 +08:00
Ivan Fontosh 411ac634f4 chore: release v1.0.18 2026-05-18 01:13:18 +08:00
Ivan Fontosh ece48fe53d chore: release v1.0.17 2026-05-18 01:10:07 +08:00
Ivan Fontosh 744ead383d фикс 2026-05-18 01:09:04 +08:00
Ivan Fontosh 9f82a541fc chore(release): point updater feed at updates.mailib.ru (v1.0.16)
Set build.publish.url to the production HTTPS feed, bump version to 1.0.16, add publish-to-updates.ps1 for VPS upload, and normalize Linux AppImage names from x86_64 to x64 after pack:linux.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 00:55:53 +08:00
Ivan Fontosh 963a1f0790 fix 2026-05-17 23:20:37 +08:00
Ivan Fontosh 394b42e845 fix 2026-05-17 22:04:00 +08:00
Ivan Fontosh 6204359330 fix 2026-05-17 21:55:52 +08:00
Ivan Fontosh 7c858ba633 feat(phase1): rebrand to TTRPG Player and drop Git updates feed
Rename product to TTRPG Player (TTRPGPlayer / com.ttrpgplayer.app), use .ttrpg.zip for new saves while keeping .dnd.zip import, accept TTRPG- and DND- license keys on client, and remove sync-update-feed plus CI push to DndGamePlayerUpdates.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 20:56:14 +08:00
Ivan Fontosh 2c03921d23 docs: updates repo size, GC, and 1.5 GiB expectations
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 00:18:30 +08:00
Ivan Fontosh 285a1a9667 fix(update): disable differential download for Gitea generic feed (Range 400)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 00:16:33 +08:00
Ivan Fontosh 0e14180044 chore: release 1.0.15
Release / release (push) Successful in 8m28s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-14 00:00:40 +08:00
Ivan Fontosh cd3ba5fe07 fix(ci): prune old DNDGamePlayer artifacts from updates feed before copy
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 23:53:25 +08:00
Ivan Fontosh 26f8a81631 fix(ci): more git push retries for feed; document HTTP 500 and temp branch
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 23:48:39 +08:00
Ivan Fontosh 2dc7015f53 chore: release 1.0.14
Release / release (push) Failing after 10m20s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 23:34:26 +08:00
855 changed files with 57914 additions and 3585 deletions
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
-150
View File
@@ -1,150 +0,0 @@
# Сборка по тегу v* и выкладка в публичный репо (ветка updates).
#
# Метки runs-on = labels твоих act_runner (Админка Gitea → Действия → Раннеры).
# Не используем windows-latest/macos-latest — это только GitHub-hosted.
# По умолчанию: один job на ubuntu-22.04 — Win (Wine+NSIS), Linux AppImage (x64+arm64), sync в updates без затирания других ОС.
#
# Один job без actions/upload-artifact: официальный upload-artifact@v4 с GitHub на Gitea
# падает (GHESNotSupportedError). Сборка и sync-update-feed идут в одном окружении.
#
# Secrets: DND_UPDATE_FEED_URL, DND_UPDATES_SERVER, UPDATES_REPO, DND_UPDATES_PUSH_TOKEN — docs/GITEA_AUTO_UPDATE.md
name: Release
on:
push:
tags:
- 'v*'
env:
CSC_IDENTITY_AUTO_DISCOVERY: 'false'
jobs:
release:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
# Не включаем i386 / wine32: на Debian с репами только amd64 (nginx, sury, …)
# apt ломается на цепочке libgphoto2/libgd:i386. NSIS — нативный пакет `nsis`;
# electron-builder подхватывает makensis; wine64 — для win-утилит (rcedit и т.п.).
# Пакет `wine` (мета) тянет wine32 → снова i386. electron-builder ищет именно `wine` в PATH.
- name: Зависимости Win-сборки на Linux (amd64, без multiarch i386)
shell: bash
run: |
sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ca-certificates \
nsis \
wine64
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
W64="$(command -v wine64 2>/dev/null || true)"
if [[ -z "$W64" || ! -x "$W64" ]]; then
for p in /usr/bin/wine64 /usr/lib/wine/wine64; do
if [[ -x "$p" ]]; then W64="$p"; break; fi
done
fi
if [[ -z "${W64:-}" || ! -x "$W64" ]]; then
echo "wine64 binary not found after apt install wine64" >&2
dpkg -L wine64 2>/dev/null | head -80 >&2 || true
exit 1
fi
printf '%s\n' '#!/bin/sh' "exec $W64 \"\$@\"" | sudo tee /usr/local/bin/wine >/dev/null
sudo chmod +x /usr/local/bin/wine
wine --version
- name: Версия из тега
shell: bash
run: |
TAG="${GITHUB_REF_NAME:-${GITEA_REF_NAME:-}}"
VERSION="${TAG#v}"
npm version "$VERSION" --allow-same-version --no-git-tag-version
- run: npm ci
- run: npm run build
# Linux AppImage (x64 + arm64) до подмешивания win32-sharp в node_modules.
- name: Зависимости Linux AppImage и кросс-сборка arm64 на amd64
shell: bash
run: |
sudo apt-get update
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
qemu-user-static \
binfmt-support \
desktop-file-utils \
squashfs-tools
- name: electron-builder (linux AppImage x64, arm64)
shell: bash
env:
DND_UPDATE_FEED_URL: ${{ secrets.DND_UPDATE_FEED_URL }}
run: |
set -euo pipefail
if [[ -z "${DND_UPDATE_FEED_URL:-}" ]]; then
echo "Secret DND_UPDATE_FEED_URL is not set (URL со слэшем в конце)" >&2
exit 1
fi
npx electron-builder --linux AppImage --x64 --arm64 --publish never \
--config.publish.provider=generic \
--config.publish.url="${DND_UPDATE_FEED_URL}"
# Не используем `npm install`: на Linux npm падает с EBADPLATFORM для win32-пакета.
- name: sharp (@img/sharp-win32-x64) для Windows-артефакта при сборке на Linux
shell: bash
run: |
set -euo pipefail
tmp="$(mktemp -d)"
npm pack @img/sharp-win32-x64@0.34.5 --pack-destination "$tmp"
mkdir -p node_modules/@img/sharp-win32-x64
tar -xzf "$tmp/img-sharp-win32-x64-0.34.5.tgz" -C "$tmp"
cp -a "$tmp/package/." node_modules/@img/sharp-win32-x64/
test -f node_modules/@img/sharp-win32-x64/lib/sharp-win32-x64.node
rm -rf "$tmp"
- name: electron-builder (win)
shell: bash
env:
DND_UPDATE_FEED_URL: ${{ secrets.DND_UPDATE_FEED_URL }}
run: |
set -euo pipefail
if [[ -z "${DND_UPDATE_FEED_URL:-}" ]]; then
echo "Secret DND_UPDATE_FEED_URL is not set (URL со слэшем в конце)" >&2
exit 1
fi
npx electron-builder --win --publish never \
--config.publish.provider=generic \
--config.publish.url="${DND_UPDATE_FEED_URL}"
# Артефакты читает sync напрямую из release/ (без дублирующих _win/_linux — меньше ENOSPC).
- name: Пустой каталог mac (пока нет сборки mac в CI)
run: mkdir -p _mac
- name: Push в публичный репозиторий updates
shell: bash
env:
DND_UPDATES_SERVER: ${{ secrets.DND_UPDATES_SERVER }}
UPDATES_REPO: ${{ secrets.UPDATES_REPO }}
DND_UPDATES_PUSH_TOKEN: ${{ secrets.DND_UPDATES_PUSH_TOKEN }}
ARTIFACT_WIN: ${{ github.workspace }}/release
ARTIFACT_MAC: ${{ github.workspace }}/_mac
ARTIFACT_LINUX: ${{ github.workspace }}/release
DND_FEED_TMP_ROOT: ${{ github.workspace }}/.dnd-feed-tmp
DND_UPDATES_SQUASH_HISTORY: '1'
GIT_COMMIT_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
mkdir -p "${{ github.workspace }}/.dnd-feed-tmp" "${{ github.workspace }}/_mac"
node scripts/sync-update-feed.mjs
# Когда появится macOS-раннер: отдельный job build-macos, копирование eb-mac в _mac перед sync
# или расширить шаг «Каталог артефактов»; сейчас всё в одном job `release`.
#
# build-macos:
# runs-on: macos-14
# steps: ...
+3
View File
@@ -17,3 +17,6 @@ Thumbs.db
.vscode/*
!.vscode/extensions.json
*.tsbuildinfo
test-results/
playwright-report/
e2e/fixtures/sample.png
+1
View File
@@ -0,0 +1 @@
22.22.3
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DiceRollSessionStore } from './diceRollSessionStore';
import {
D100_FACE_VALUES,
formatPredeterminedDiceNotation,
snapD100Value,
} from '../../shared/types/diceRoll';
void test('diceRoll: roll produces values in range and notation', () => {
const store = new DiceRollSessionStore();
store.dispatch({ kind: 'roll', count: 3, sides: 6 });
const st = store.getState();
assert.equal(st.active, true);
assert.equal(st.notation, '3d6');
assert.equal(st.values.length, 3);
assert.ok(st.values.every((v) => v >= 1 && v <= 6));
assert.equal(st.total, st.values.reduce((a, b) => a + b, 0));
});
void test('diceRoll: d100 values are tens faces only', () => {
const store = new DiceRollSessionStore();
store.dispatch({ kind: 'roll', count: 5, sides: 100 });
const st = store.getState();
assert.equal(st.notation, '5d100');
assert.equal(st.values.length, 5);
const allowed = new Set<number>(D100_FACE_VALUES);
assert.ok(st.values.every((v) => allowed.has(v)));
assert.equal(st.total, st.values.reduce((a, b) => a + b, 0));
});
void test('diceRoll: clear resets', () => {
const store = new DiceRollSessionStore();
store.dispatch({ kind: 'roll', count: 1, sides: 20 });
store.dispatch({ kind: 'clear' });
assert.equal(store.getState().active, false);
assert.deepEqual(store.getState().values, []);
});
void test('diceRoll: hideOverlay keeps values for master UI', () => {
const store = new DiceRollSessionStore();
store.dispatch({ kind: 'roll', count: 2, sides: 6 });
const values = [...store.getState().values];
const total = store.getState().total;
store.dispatch({ kind: 'hideOverlay' });
assert.equal(store.getState().active, false);
assert.deepEqual(store.getState().values, values);
assert.equal(store.getState().total, total);
assert.equal(store.getState().notation, '2d6');
});
void test('formatPredeterminedDiceNotation', () => {
assert.equal(formatPredeterminedDiceNotation(2, 20, [15, 7]), '2d20@15,7');
assert.equal(formatPredeterminedDiceNotation(2, 100, [20, 100]), '2d100@20,100');
assert.equal(formatPredeterminedDiceNotation(1, 100, [27]), '1d100@30');
});
void test('snapD100Value', () => {
assert.equal(snapD100Value(20), 20);
assert.equal(snapD100Value(21), 30);
assert.equal(snapD100Value(100), 100);
assert.equal(snapD100Value(1), 10);
});
+64
View File
@@ -0,0 +1,64 @@
import {
clampDiceCount,
createInitialDiceRollState,
formatDiceNotation,
isDiceSides,
rollDiceValues,
type DiceRollEvent,
type DiceRollState,
} from '../../shared/types/diceRoll';
export class DiceRollSessionStore {
private state: DiceRollState = createInitialDiceRollState();
getState(): DiceRollState {
return this.state;
}
clear(): DiceRollState {
this.state = createInitialDiceRollState();
return this.state;
}
dispatch(event: DiceRollEvent): DiceRollState {
switch (event.kind) {
case 'clear': {
if (!this.state.active && this.state.values.length === 0) return this.state;
this.state = {
...createInitialDiceRollState(),
revision: this.state.revision + 1,
};
return this.state;
}
case 'hideOverlay': {
if (!this.state.active) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
active: false,
startedAt: null,
};
return this.state;
}
case 'roll': {
if (!isDiceSides(event.sides)) return this.state;
const count = clampDiceCount(event.count);
const values = rollDiceValues(count, event.sides);
const total = values.reduce((a, b) => a + b, 0);
this.state = {
revision: this.state.revision + 1,
active: true,
notation: formatDiceNotation(count, event.sides),
values,
total,
startedAt: Date.now(),
};
return this.state;
}
default: {
const _exhaustive: never = event;
return _exhaustive;
}
}
}
}
+25 -1
View File
@@ -3,6 +3,11 @@ import test from 'node:test';
import { EffectsStore } from './effectsStore';
void test('defaultTool: при старте инструмент не выбран', () => {
const store = new EffectsStore();
assert.equal(store.getState().tool.tool, 'none');
});
void test('pruneExpired: лёд не удаляется по времени', () => {
const store = new EffectsStore();
const createdAtMs = Date.now() - 365 * 24 * 60 * 60 * 1000;
@@ -64,7 +69,7 @@ void test('pruneExpired: луч света удаляется после lifetim
assert.equal(store.getState().instances.length, 0);
});
void test('pruneExpired: облако яда удаляется после lifetime', () => {
void test('pruneExpired: яд удаляется после lifetime', () => {
const store = new EffectsStore();
store.dispatch({
kind: 'instance.add',
@@ -82,3 +87,22 @@ void test('pruneExpired: облако яда удаляется после lifet
assert.equal(store.pruneExpired(), true);
assert.equal(store.getState().instances.length, 0);
});
void test('pruneExpired: взрыв удаляется после lifetime', () => {
const store = new EffectsStore();
store.dispatch({
kind: 'instance.add',
instance: {
id: 'ex_test',
type: 'explosion',
seed: 1,
createdAtMs: Date.now() - 20_000,
at: { x: 0.5, y: 0.5 },
radiusN: 0.08,
intensity: 1,
lifetimeMs: 4200,
},
});
assert.equal(store.pruneExpired(), true);
assert.equal(store.getState().instances.length, 0);
});
+34 -6
View File
@@ -1,5 +1,6 @@
import crypto from 'node:crypto';
import { applyFieldEffectEraserStroke } from '../../shared/fieldEffectEraser';
import type { EffectsEvent, EffectsState, EffectToolState } from '../../shared/types';
function nowMs(): number {
@@ -7,7 +8,12 @@ function nowMs(): number {
}
function defaultTool(): EffectToolState {
return { tool: 'fog', radiusN: 0.08, intensity: 0.6 };
return { tool: 'none', radiusN: 0.08, intensity: 0.6 };
}
/** Сброс выбранного инструмента (при старте сессии). */
export function effectsDefaultTool(): EffectToolState {
return defaultTool();
}
export class EffectsStore {
@@ -35,7 +41,7 @@ export class EffectsStore {
dispatch(event: EffectsEvent): EffectsState {
const s = this.state;
const next: EffectsState = applyEvent(s, event);
const next: EffectsState = applyEvent(s, event, (prefix) => this.makeId(prefix));
this.state = next;
return next;
}
@@ -46,14 +52,20 @@ export class EffectsStore {
const before = this.state.instances.length;
const kept = this.state.instances.filter((i) => {
// Пятно льда не истекает по таймеру (только «очистить все» или ластик в UI).
if (i.type === 'ice') return true;
if (i.type === 'lightning' || i.type === 'sunbeam' || i.type === 'poisonCloud') {
if (i.type === 'ice' || i.type === 'shadow') return true;
if (
i.type === 'lightning' ||
i.type === 'sunbeam' ||
i.type === 'poisonCloud' ||
i.type === 'explosion' ||
i.type === 'darkness'
) {
return now - i.createdAtMs < i.lifetimeMs;
}
if (i.type === 'scorch') {
return now - i.createdAtMs < i.lifetimeMs;
}
if (i.type === 'fog' || i.type === 'water') {
if (i.type === 'fog' || i.type === 'fire' || i.type === 'rain' || i.type === 'water') {
if (i.lifetimeMs === null) return true;
return now - i.createdAtMs < i.lifetimeMs;
}
@@ -74,7 +86,11 @@ export class EffectsStore {
}
}
function applyEvent(state: EffectsState, event: EffectsEvent): EffectsState {
function applyEvent(
state: EffectsState,
event: EffectsEvent,
makeId: (prefix: string) => string,
): EffectsState {
const bump = (patch: Omit<EffectsState, 'revision' | 'serverNowMs'>): EffectsState => ({
...patch,
revision: state.revision + 1,
@@ -89,6 +105,18 @@ function applyEvent(state: EffectsState, event: EffectsEvent): EffectsState {
return bump({ ...state, instances: [...state.instances, event.instance] });
case 'instance.remove':
return bump({ ...state, instances: state.instances.filter((i) => i.id !== event.id) });
case 'field.erase':
return bump({
...state,
instances: applyFieldEffectEraserStroke(
state.instances,
event.points,
event.radiusN,
makeId,
event.types,
event.hitMode ?? 'inclusive',
),
});
default: {
// Exhaustiveness
// eslint-disable-next-line @typescript-eslint/no-unused-vars
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SceneDarknessStore } from './sceneDarknessStore';
void test('SceneDarknessStore: сохраняет штрихи при переключении сцен', () => {
const store = new SceneDarknessStore();
store.switchScene('scene_a', true);
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 's1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
radiusN: 0.08,
},
});
store.switchScene('scene_b', true);
assert.equal(store.getState().strokes.length, 0);
store.switchScene('scene_a', true);
assert.equal(store.getState().strokes.length, 1);
assert.equal(store.getState().strokes[0]?.id, 's1');
});
void test('SceneDarknessStore: resetSession очищает кэш', () => {
const store = new SceneDarknessStore();
store.switchScene('scene_a', true);
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 's1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.2, y: 0.2, tMs: 100 }],
radiusN: 0.08,
},
});
store.resetSession();
store.switchScene('scene_a', true);
assert.equal(store.getState().strokes.length, 0);
});
void test('SceneDarknessStore: draft синхронизируется и сбрасывается при commit', () => {
const store = new SceneDarknessStore();
store.switchScene('scene_a', true);
store.dispatch({
kind: 'draft.set',
draft: { points: [{ x: 0.1, y: 0.1, tMs: 1 }], radiusN: 0.05, mode: 'cover' },
});
assert.ok(store.getState().draft);
assert.equal(store.getState().draft?.mode, 'cover');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 's1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.1, y: 0.1, tMs: 1 }],
radiusN: 0.05,
mode: 'cover',
},
});
assert.equal(store.getState().draft, null);
assert.equal(store.getState().strokes.length, 1);
});
void test('SceneDarknessStore: cover-штрих сохраняется в кэше сцены', () => {
const store = new SceneDarknessStore();
store.switchScene('scene_a', true);
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'open1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
radiusN: 0.08,
mode: 'reveal',
},
});
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'close1',
seed: 2,
createdAtMs: 200,
points: [{ x: 0.5, y: 0.5, tMs: 200 }],
radiusN: 0.08,
mode: 'cover',
},
});
assert.equal(store.getState().strokes.length, 2);
assert.equal(store.getState().strokes[1]?.mode, 'cover');
store.switchScene('scene_b', true);
store.switchScene('scene_a', true);
assert.equal(store.getState().strokes.length, 2);
assert.equal(store.getState().strokes[1]?.mode, 'cover');
});
+80
View File
@@ -0,0 +1,80 @@
import type { SceneDarknessEvent, SceneDarknessRevealStroke, SceneDarknessState } from '../../shared/types';
function emptyState(): SceneDarknessState {
return {
revision: 1,
enabled: false,
cacheKey: null,
strokes: [],
draft: null,
};
}
export class SceneDarknessStore {
private state: SceneDarknessState = emptyState();
/** Кэш раскрытых областей по ключу (graphNodeId или sceneId) на время сессии показа. */
private cache = new Map<string, SceneDarknessRevealStroke[]>();
private currentKey: string | null = null;
getState(): SceneDarknessState {
return { ...this.state, strokes: [...this.state.strokes] };
}
/** Сброс кэша при новом запуске показа (кнопка «Запустить»). */
resetSession(): void {
this.cache.clear();
this.currentKey = null;
this.state = emptyState();
}
/**
* Переключение сцены: сохраняем текущие штрихи в кэш и загружаем состояние новой сцены.
*/
switchScene(cacheKey: string | null, enabled: boolean): SceneDarknessState {
if (this.currentKey !== null) {
this.cache.set(this.currentKey, [...this.state.strokes]);
}
this.currentKey = cacheKey;
const strokes =
cacheKey && enabled ? [...(this.cache.get(cacheKey) ?? [])] : [];
this.state = {
revision: this.state.revision + 1,
enabled,
cacheKey,
strokes,
draft: null,
};
return this.getState();
}
dispatch(event: SceneDarknessEvent): SceneDarknessState {
if (!this.state.enabled) return this.getState();
switch (event.kind) {
case 'draft.set':
this.state = {
...this.state,
revision: this.state.revision + 1,
draft: event.draft,
};
return this.getState();
case 'stroke.add': {
const strokes = [...this.state.strokes, event.stroke];
if (this.currentKey) {
this.cache.set(this.currentKey, strokes);
}
this.state = {
...this.state,
revision: this.state.revision + 1,
strokes,
draft: null,
};
return this.getState();
}
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _x: never = event;
return this.getState();
}
}
}
}
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SceneMarkerStore } from './sceneMarkerStore';
void test('SceneMarkerStore: сохраняет штрихи при переключении сцен', () => {
const store = new SceneMarkerStore();
store.switchScene('scene_a');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'm1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
radiusN: 0.08,
color: '#ff0000',
},
});
store.switchScene('scene_b');
assert.equal(store.getState().strokes.length, 0);
store.switchScene('scene_a');
assert.equal(store.getState().strokes.length, 1);
assert.equal(store.getState().strokes[0]?.id, 'm1');
});
void test('SceneMarkerStore: resetSession очищает кэш', () => {
const store = new SceneMarkerStore();
store.switchScene('scene_a');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'm1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.2, y: 0.2, tMs: 100 }],
radiusN: 0.08,
color: '#00ff00',
},
});
store.resetSession();
store.switchScene('scene_a');
assert.equal(store.getState().strokes.length, 0);
});
void test('SceneMarkerStore: strokes.erase удаляет точки под ластиком', () => {
const store = new SceneMarkerStore();
store.switchScene('scene_a');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'm1',
seed: 1,
createdAtMs: 100,
points: [
{ x: 0.1, y: 0.5, tMs: 100 },
{ x: 0.5, y: 0.5, tMs: 101 },
{ x: 0.9, y: 0.5, tMs: 102 },
],
radiusN: 0.04,
color: '#ff0000',
},
});
store.dispatch({
kind: 'strokes.erase',
points: [{ x: 0.5, y: 0.5, tMs: 200 }],
radiusN: 0.06,
});
const strokes = store.getState().strokes;
assert.ok(strokes.length >= 1);
assert.ok(strokes.every((s) => s.points.every((p) => Math.abs(p.x - 0.5) > 0.05)));
});
void test('SceneMarkerStore: strokes.clear удаляет все штрихи', () => {
const store = new SceneMarkerStore();
store.switchScene('scene_a');
store.dispatch({
kind: 'stroke.add',
stroke: {
id: 'm1',
seed: 1,
createdAtMs: 100,
points: [{ x: 0.5, y: 0.5, tMs: 100 }],
radiusN: 0.08,
color: '#0000ff',
},
});
store.dispatch({ kind: 'strokes.clear' });
assert.equal(store.getState().strokes.length, 0);
store.switchScene('scene_b');
store.switchScene('scene_a');
assert.equal(store.getState().strokes.length, 0);
});
+108
View File
@@ -0,0 +1,108 @@
import { applySceneMarkerEraserStroke } from '../../shared/sceneMarkerEraser';
import type { SceneMarkerEvent, SceneMarkerState, SceneMarkerStroke } from '../../shared/types';
function emptyState(): SceneMarkerState {
return {
revision: 1,
cacheKey: null,
strokes: [],
draft: null,
};
}
function makeStrokeId(prefix: string): string {
return `${prefix}_${String(Date.now())}_${String(Math.floor(Math.random() * 1_000_000_000))}`;
}
export class SceneMarkerStore {
private state: SceneMarkerState = emptyState();
/** Кэш штрихов маркера по ключу (graphNodeId или sceneId) на время сессии показа. */
private cache = new Map<string, SceneMarkerStroke[]>();
private currentKey: string | null = null;
getState(): SceneMarkerState {
return { ...this.state, strokes: [...this.state.strokes] };
}
/** Сброс кэша при новом запуске показа (кнопка «Запустить»). */
resetSession(): void {
this.cache.clear();
this.currentKey = null;
this.state = emptyState();
}
/** Переключение сцены: сохраняем штрихи в кэш и загружаем новую сцену. */
switchScene(cacheKey: string | null): SceneMarkerState {
if (this.currentKey !== null) {
this.cache.set(this.currentKey, [...this.state.strokes]);
}
this.currentKey = cacheKey;
const strokes = cacheKey ? [...(this.cache.get(cacheKey) ?? [])] : [];
this.state = {
revision: this.state.revision + 1,
cacheKey,
strokes,
draft: null,
};
return this.getState();
}
dispatch(event: SceneMarkerEvent): SceneMarkerState {
switch (event.kind) {
case 'draft.set':
this.state = {
...this.state,
revision: this.state.revision + 1,
draft: event.draft,
};
return this.getState();
case 'stroke.add': {
const strokes = [...this.state.strokes, event.stroke];
if (this.currentKey) {
this.cache.set(this.currentKey, strokes);
}
this.state = {
...this.state,
revision: this.state.revision + 1,
strokes,
draft: null,
};
return this.getState();
}
case 'strokes.erase': {
const strokes = applySceneMarkerEraserStroke(
this.state.strokes,
event.points,
event.radiusN,
(prefix) => makeStrokeId(prefix),
);
if (this.currentKey) {
this.cache.set(this.currentKey, strokes);
}
this.state = {
...this.state,
revision: this.state.revision + 1,
strokes,
draft: null,
};
return this.getState();
}
case 'strokes.clear':
if (this.currentKey) {
this.cache.set(this.currentKey, []);
}
this.state = {
...this.state,
revision: this.state.revision + 1,
strokes: [],
draft: null,
};
return this.getState();
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _x: never = event;
return this.getState();
}
}
}
}
+242
View File
@@ -0,0 +1,242 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { ClassicLevel } from 'classic-level';
import type {
FoundryActorDoc,
FoundryAdventureDoc,
FoundryFolderDoc,
FoundryJournalDoc,
FoundryLoadedDocuments,
FoundryPlaylistDoc,
FoundrySceneDoc,
} from '../../shared/foundry/foundryTypes';
function isRecord(v: unknown): v is Record<string, unknown> {
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
}
function asDocArray<T>(docs: unknown[]): T[] {
return docs.filter((d) => isRecord(d) && typeof d._id === 'string' && typeof d.name === 'string') as T[];
}
/** NeDB: по строке JSON на линию (игнор пустых / битых). */
export async function readNedbDocuments(filePath: string): Promise<Record<string, unknown>[]> {
const raw = await fs.readFile(filePath, 'utf8');
const out: Record<string, unknown>[] = [];
for (const line of raw.split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const parsed: unknown = JSON.parse(trimmed);
if (isRecord(parsed) && typeof parsed._id === 'string') out.push(parsed);
} catch {
// skip broken line
}
}
return out;
}
function isLevelDbDir(entries: { name: string; isFile(): boolean; isDirectory(): boolean }[]): boolean {
const names = new Set(entries.map((e) => e.name));
return (
names.has('CURRENT') ||
names.has('LOG') ||
[...names].some((n) => n.endsWith('.ldb') || n.startsWith('MANIFEST-'))
);
}
/** Читает primary documents из LevelDB-папки Foundry (ключи `!collection!id`). */
export async function readLevelDbDocuments(dirPath: string): Promise<Record<string, unknown>[]> {
const db = new ClassicLevel(dirPath, {
keyEncoding: 'utf8',
valueEncoding: 'json',
createIfMissing: false,
});
try {
const out: Record<string, unknown>[] = [];
for await (const [key, value] of db.iterator()) {
if (typeof key !== 'string') continue;
const parts = key.split('!');
// "", collection, id
if (parts.length < 3) continue;
const collection = parts[1] ?? '';
if (!collection || collection.includes('.')) continue; // embedded
if (!isRecord(value)) continue;
if (typeof value._id !== 'string') continue;
out.push(value);
}
return out;
} finally {
await db.close().catch(() => undefined);
}
}
async function pathExists(p: string): Promise<boolean> {
try {
await fs.access(p);
return true;
} catch {
return false;
}
}
async function readCollectionDocs(baseDir: string, collection: string): Promise<Record<string, unknown>[]> {
const candidates = [
path.join(baseDir, collection),
path.join(baseDir, `${collection}.db`),
path.join(baseDir, 'data', collection),
path.join(baseDir, 'data', `${collection}.db`),
];
for (const candidate of candidates) {
if (!(await pathExists(candidate))) continue;
const st = await fs.stat(candidate);
if (st.isFile() && candidate.endsWith('.db')) {
return readNedbDocuments(candidate);
}
if (st.isDirectory()) {
const entries = await fs.readdir(candidate, { withFileTypes: true });
if (isLevelDbDir(entries)) {
return readLevelDbDocuments(candidate);
}
}
}
return [];
}
export async function readPackDocuments(
packAbsPath: string,
documentType: string,
): Promise<Record<string, unknown>[]> {
if (!(await pathExists(packAbsPath))) return [];
const st = await fs.stat(packAbsPath);
if (st.isFile() && packAbsPath.endsWith('.db')) {
return readNedbDocuments(packAbsPath);
}
if (st.isDirectory()) {
const entries = await fs.readdir(packAbsPath, { withFileTypes: true });
if (isLevelDbDir(entries)) {
return readLevelDbDocuments(packAbsPath);
}
// Иногда path указывает на папку с .db внутри.
const nestedDb = path.join(packAbsPath, `${path.basename(packAbsPath)}.db`);
if (await pathExists(nestedDb)) return readNedbDocuments(nestedDb);
}
void documentType;
return [];
}
function mergeById<T extends { _id: string }>(lists: T[][]): T[] {
const map = new Map<string, T>();
for (const list of lists) {
for (const doc of list) {
if (!map.has(doc._id)) map.set(doc._id, doc);
}
}
return [...map.values()];
}
function flattenAdventureDocs(adventures: FoundryAdventureDoc[]): {
scenes: FoundrySceneDoc[];
actors: FoundryActorDoc[];
playlists: FoundryPlaylistDoc[];
journals: FoundryJournalDoc[];
folders: FoundryFolderDoc[];
} {
const scenes: FoundrySceneDoc[] = [];
const actors: FoundryActorDoc[] = [];
const playlists: FoundryPlaylistDoc[] = [];
const journals: FoundryJournalDoc[] = [];
const folders: FoundryFolderDoc[] = [];
for (const adv of adventures) {
if (Array.isArray(adv.scenes)) scenes.push(...asDocArray<FoundrySceneDoc>(adv.scenes));
if (Array.isArray(adv.actors)) actors.push(...asDocArray<FoundryActorDoc>(adv.actors));
if (Array.isArray(adv.playlists)) playlists.push(...asDocArray<FoundryPlaylistDoc>(adv.playlists));
if (Array.isArray(adv.journal)) journals.push(...asDocArray<FoundryJournalDoc>(adv.journal));
if (Array.isArray(adv.folders)) {
for (const f of adv.folders) {
if (f && typeof f === 'object' && typeof (f as FoundryFolderDoc)._id === 'string') {
folders.push(f as FoundryFolderDoc);
}
}
}
}
return { scenes, actors, playlists, journals, folders };
}
function asFolderArray(docs: unknown[]): FoundryFolderDoc[] {
return docs.filter(
(d): d is FoundryFolderDoc =>
Boolean(d) &&
typeof d === 'object' &&
typeof (d as FoundryFolderDoc)._id === 'string' &&
typeof (d as FoundryFolderDoc).name === 'string',
);
}
export async function loadWorldDocuments(rootDir: string): Promise<FoundryLoadedDocuments> {
const [scenesRaw, actorsRaw, playlistsRaw, journalsRaw, adventuresRaw, foldersRaw] =
await Promise.all([
readCollectionDocs(rootDir, 'scenes'),
readCollectionDocs(rootDir, 'actors'),
readCollectionDocs(rootDir, 'playlists'),
readCollectionDocs(rootDir, 'journal'),
readCollectionDocs(rootDir, 'adventures'),
readCollectionDocs(rootDir, 'folders'),
]);
const adventures = asDocArray<FoundryAdventureDoc>(adventuresRaw);
const embedded = flattenAdventureDocs(adventures);
return {
scenes: mergeById([asDocArray<FoundrySceneDoc>(scenesRaw), embedded.scenes]),
actors: mergeById([asDocArray<FoundryActorDoc>(actorsRaw), embedded.actors]),
playlists: mergeById([asDocArray<FoundryPlaylistDoc>(playlistsRaw), embedded.playlists]),
journals: mergeById([asDocArray<FoundryJournalDoc>(journalsRaw), embedded.journals]),
adventures,
folders: mergeById([asFolderArray(foldersRaw), embedded.folders]),
};
}
export async function loadModuleDocuments(
rootDir: string,
packs: { path: string; type: string }[],
): Promise<FoundryLoadedDocuments> {
const scenes: FoundrySceneDoc[][] = [];
const actors: FoundryActorDoc[][] = [];
const playlists: FoundryPlaylistDoc[][] = [];
const journals: FoundryJournalDoc[][] = [];
const adventures: FoundryAdventureDoc[][] = [];
for (const pack of packs) {
const abs = path.isAbsolute(pack.path) ? pack.path : path.join(rootDir, pack.path);
// path в module.json иногда с `./` и иногда ещё со старым `.db`
const variants = [abs, abs.endsWith('.db') ? abs : `${abs}.db`, abs.replace(/\.db$/u, '')];
let docs: Record<string, unknown>[] = [];
for (const v of variants) {
docs = await readPackDocuments(v, pack.type);
if (docs.length > 0) break;
}
const type = pack.type;
if (type === 'Scene') scenes.push(asDocArray<FoundrySceneDoc>(docs));
else if (type === 'Actor') actors.push(asDocArray<FoundryActorDoc>(docs));
else if (type === 'Playlist') playlists.push(asDocArray<FoundryPlaylistDoc>(docs));
else if (type === 'JournalEntry') journals.push(asDocArray<FoundryJournalDoc>(docs));
else if (type === 'Adventure') adventures.push(asDocArray<FoundryAdventureDoc>(docs));
}
const advMerged = mergeById(adventures);
const embedded = flattenAdventureDocs(advMerged);
return {
scenes: mergeById([...scenes, embedded.scenes]),
actors: mergeById([...actors, embedded.actors]),
playlists: mergeById([...playlists, embedded.playlists]),
journals: mergeById([...journals, embedded.journals]),
adventures: advMerged,
folders: mergeById([embedded.folders]),
};
}
+209
View File
@@ -0,0 +1,209 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { decodeFoundryAssetPath } from '../../shared/foundry/foundryPaths';
import type { FoundryPackageManifest, FoundryPackRef } from '../../shared/foundry/foundryTypes';
import { isSupportedFoundryPackage } from '../../shared/foundry/foundryVersion';
function isRecord(v: unknown): v is Record<string, unknown> {
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
}
async function readJsonFile(filePath: string): Promise<unknown> {
const raw = await fs.readFile(filePath, 'utf8');
return JSON.parse(raw) as unknown;
}
function parsePacks(raw: unknown): FoundryPackRef[] {
if (!Array.isArray(raw)) return [];
const out: FoundryPackRef[] = [];
for (const item of raw) {
if (!isRecord(item)) continue;
const name = typeof item.name === 'string' ? item.name : '';
const label = typeof item.label === 'string' ? item.label : name;
const packPath = typeof item.path === 'string' ? item.path.replace(/^\.\//u, '') : '';
const type =
typeof item.type === 'string' ? item.type : typeof item.entity === 'string' ? item.entity : '';
if (!packPath || !type) continue;
out.push({ name: name || path.basename(packPath), label: label || name, path: packPath, type });
}
return out;
}
function titleFromManifest(data: Record<string, unknown>, fallbackId: string): string {
if (typeof data.title === 'string' && data.title.trim()) return data.title.trim();
if (typeof data.name === 'string' && data.name.trim()) return data.name.trim();
return fallbackId;
}
function idFromManifest(data: Record<string, unknown>, dirName: string): string {
if (typeof data.id === 'string' && data.id.trim()) return data.id.trim();
if (typeof data.name === 'string' && data.name.trim()) return data.name.trim();
return dirName;
}
async function tryParseManifest(dir: string): Promise<FoundryPackageManifest | null> {
const worldPath = path.join(dir, 'world.json');
const modulePath = path.join(dir, 'module.json');
try {
await fs.access(worldPath);
const data = await readJsonFile(worldPath);
if (!isRecord(data)) return null;
const id = idFromManifest(data, path.basename(dir));
const manifest: FoundryPackageManifest = {
kind: 'world',
id,
title: titleFromManifest(data, id),
rootDir: dir,
packs: [],
};
const compatibility = parseCompatibility(data.compatibility);
if (compatibility) manifest.compatibility = compatibility;
if (typeof data.coreVersion === 'string') manifest.coreVersion = data.coreVersion;
return manifest;
} catch {
// not a world
}
try {
await fs.access(modulePath);
const data = await readJsonFile(modulePath);
if (!isRecord(data)) return null;
const id = idFromManifest(data, path.basename(dir));
const manifest: FoundryPackageManifest = {
kind: 'module',
id,
title: titleFromManifest(data, id),
rootDir: dir,
packs: parsePacks(data.packs),
};
const compatibility = parseCompatibility(data.compatibility);
if (compatibility) manifest.compatibility = compatibility;
if (typeof data.coreVersion === 'string') manifest.coreVersion = data.coreVersion;
return manifest;
} catch {
return null;
}
}
function parseCompatibility(raw: unknown): FoundryPackageManifest['compatibility'] | null {
if (!isRecord(raw)) return null;
const out: NonNullable<FoundryPackageManifest['compatibility']> = {};
if (typeof raw.minimum === 'string') out.minimum = raw.minimum;
if (typeof raw.verified === 'string') out.verified = raw.verified;
if (typeof raw.maximum === 'string') out.maximum = raw.maximum;
return out;
}
/** Ищет world.json / module.json в папке и на 1–2 уровня глубже. */
export async function detectFoundryPackage(rootPath: string): Promise<FoundryPackageManifest> {
const abs = path.resolve(rootPath);
const direct = await tryParseManifest(abs);
if (direct) {
const ver = isSupportedFoundryPackage(direct);
if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version');
return direct;
}
// Архив мог содержать один корневой каталог.
const entries = await fs.readdir(abs, { withFileTypes: true });
const dirs = entries.filter((e) => e.isDirectory()).map((e) => path.join(abs, e.name));
for (const dir of dirs) {
const found = await tryParseManifest(dir);
if (found) {
const ver = isSupportedFoundryPackage(found);
if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version');
return found;
}
}
// Data/worlds/<id> или Data/modules/<id>
for (const mid of ['worlds', 'modules']) {
const midDir = path.join(abs, mid);
try {
const st = await fs.stat(midDir);
if (!st.isDirectory()) continue;
const children = await fs.readdir(midDir, { withFileTypes: true });
for (const child of children.filter((c) => c.isDirectory())) {
const found = await tryParseManifest(path.join(midDir, child.name));
if (found) {
const ver = isSupportedFoundryPackage(found);
if (!ver.ok) throw new Error(ver.reason ?? 'Unsupported Foundry version');
return found;
}
}
} catch {
// ignore
}
}
// Data как корень выбранной папки
const dataDir = path.join(abs, 'Data');
try {
const st = await fs.stat(dataDir);
if (st.isDirectory()) {
return await detectFoundryPackage(dataDir);
}
} catch {
// ignore
}
throw new Error(
'Не похоже на пакет Foundry VTT: не найдены world.json или module.json. Выберите папку мира/модуля или архив с ними.',
);
}
/**
* Резолвит путь ассета Foundry относительно корня пакета / Data.
* foundryPath — как в документах: `worlds/id/...`, `modules/id/...` или относительный.
* Поддерживает URL-encoding (`The%20Withered%20Grove%20(day).webp`).
*/
export async function resolveFoundryAssetPath(
manifest: FoundryPackageManifest,
foundryPath: string,
): Promise<string | null> {
const cleaned = decodeFoundryAssetPath(foundryPath);
if (!cleaned) return null;
if (/^https?:\/\//iu.test(cleaned)) return null;
const packagePrefix = manifest.kind === 'world' ? `worlds/${manifest.id}/` : `modules/${manifest.id}/`;
const rawNormalized = foundryPath.trim().replace(/\\/gu, '/');
const pathVariants = cleaned === rawNormalized ? [cleaned] : [cleaned, rawNormalized];
const candidates: string[] = [];
for (const variant of pathVariants) {
const v = variant.replace(/^\/+/u, '');
if (!v) continue;
if (v.toLowerCase().startsWith(packagePrefix.toLowerCase())) {
candidates.push(path.join(manifest.rootDir, v.slice(packagePrefix.length)));
}
candidates.push(path.join(manifest.rootDir, v));
const dataRootGuesses = [
path.resolve(manifest.rootDir, '..', '..'),
path.resolve(manifest.rootDir, '..'),
];
for (const dataRoot of dataRootGuesses) {
candidates.push(path.join(dataRoot, v));
}
const base = path.basename(v);
if (base && base !== v) {
candidates.push(path.join(manifest.rootDir, base));
candidates.push(path.join(manifest.rootDir, 'assets', base));
}
}
for (const c of candidates) {
try {
const st = await fs.stat(c);
if (st.isFile()) return c;
} catch {
// next
}
}
return null;
}
+682
View File
@@ -0,0 +1,682 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
actorPortraitSrc,
extractActorDescriptionHtml,
filterScenesForImport,
journalDescriptionHtml,
planFoundrySceneGraph,
sceneBackgroundSrc,
} from '../../shared/foundry/foundryGraph';
import type {
FoundryFolderDoc,
FoundryLoadedDocuments,
FoundryPackageManifest,
FoundryPlaylistDoc,
FoundrySceneDoc,
} from '../../shared/foundry/foundryTypes';
import { DEFAULT_NPC_GROUP_COLOR, normalizeHexColor } from '../../shared/npcs/npcGroups';
import type {
MediaAsset,
Project,
ProjectNpc,
ProjectNpcGroup,
Scene,
SceneGraphEdge,
SceneGraphNode,
} from '../../shared/types';
import { PROJECT_SCHEMA_VERSION } from '../../shared/types';
import type { AssetId, GraphNodeId, NpcGroupId, SceneId } from '../../shared/types/ids';
import {
asAssetId,
asGraphNodeId,
asNpcGroupId,
asNpcId,
asProjectId,
asSceneId,
} from '../../shared/types/ids';
import { optimizeImageBufferVisuallyLossless } from '../project/optimizeImageImport.lib.mjs';
import { generateScenePreviewThumbnailBytes } from '../project/scenePreviewThumbnail';
import { unzipToDir } from '../project/yauzlProjectZip';
import { getAppSemanticVersion } from '../versionInfo';
import { loadModuleDocuments, loadWorldDocuments } from './foundryDb';
import { detectFoundryPackage, resolveFoundryAssetPath } from './foundryDetect';
export type FoundryImportProgress = {
stage: 'copy' | 'unzip' | 'zip' | 'done';
percent: number;
detail?: string;
};
/** Минимальный 1×1 PNG (серый), если у актёра нет портрета на диске. */
const PLACEHOLDER_PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
);
type MediaKind = { type: 'image' | 'video' | 'audio'; mime: string };
function classifyMediaPath(filePath: string): MediaKind | null {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case '.png':
return { type: 'image', mime: 'image/png' };
case '.jpg':
case '.jpeg':
return { type: 'image', mime: 'image/jpeg' };
case '.webp':
return { type: 'image', mime: 'image/webp' };
case '.gif':
return { type: 'image', mime: 'image/gif' };
case '.bmp':
return { type: 'image', mime: 'image/bmp' };
case '.mp4':
return { type: 'video', mime: 'video/mp4' };
case '.webm':
return { type: 'video', mime: 'video/webm' };
case '.mov':
return { type: 'video', mime: 'video/quicktime' };
case '.mp3':
return { type: 'audio', mime: 'audio/mpeg' };
case '.wav':
return { type: 'audio', mime: 'audio/wav' };
case '.ogg':
return { type: 'audio', mime: 'audio/ogg' };
case '.m4a':
return { type: 'audio', mime: 'audio/mp4' };
case '.aac':
return { type: 'audio', mime: 'audio/aac' };
case '.flac':
return { type: 'audio', mime: 'audio/flac' };
default:
return null;
}
}
function sanitizeFileName(name: string): string {
const cleaned = name
.split('')
.map((ch) => {
const code = ch.charCodeAt(0);
if (code < 32 || '<>:"/\\|?*'.includes(ch)) return '_';
return ch;
})
.join('')
.trim();
return cleaned.length > 0 ? cleaned.slice(0, 180) : 'file';
}
function randomId(): string {
return crypto.randomBytes(16).toString('hex');
}
function buildMediaAsset(
id: AssetId,
kind: MediaKind,
originalName: string,
relPath: string,
sha256: string,
sizeBytes: number,
): MediaAsset {
const createdAt = new Date().toISOString();
const base = { id, mime: kind.mime, originalName, relPath, sha256, sizeBytes, createdAt };
if (kind.type === 'image') return { ...base, type: 'image' };
if (kind.type === 'video') return { ...base, type: 'video' };
return { ...base, type: 'audio' };
}
async function isZipFile(filePath: string): Promise<boolean> {
try {
const st = await fs.stat(filePath);
if (!st.isFile()) return false;
const ext = path.extname(filePath).toLowerCase();
return ext === '.zip' || ext === '.fvtt';
} catch {
return false;
}
}
async function prepareSourceDir(
sourcePath: string,
onProgress?: (p: FoundryImportProgress) => void,
): Promise<{ workDir: string; cleanup: () => Promise<void> }> {
const abs = path.resolve(sourcePath);
if (await isZipFile(abs)) {
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'ttrpg-foundry-'));
onProgress?.({ stage: 'unzip', percent: 5, detail: 'Распаковка архива Foundry…' });
await unzipToDir(abs, workDir, (done, total) => {
const pct = total > 0 ? 5 + Math.round((done / total) * 25) : 15;
onProgress?.({ stage: 'unzip', percent: Math.min(30, pct), detail: 'Распаковка архива Foundry…' });
});
return {
workDir,
cleanup: async () => {
await fs.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
},
};
}
const st = await fs.stat(abs);
if (!st.isDirectory()) {
throw new Error('Выберите папку мира/модуля Foundry или архив (.zip / .fvtt).');
}
return {
workDir: abs,
cleanup: () => Promise.resolve(),
};
}
type AssetWriteCtx = {
cacheDir: string;
assets: Record<AssetId, MediaAsset>;
/** foundry path or abs path → assetId */
bySourceKey: Map<string, AssetId>;
};
async function importFileAsAsset(
ctx: AssetWriteCtx,
absPath: string,
opts?: { optimizeImage?: boolean; forceKind?: MediaKind },
): Promise<AssetId | null> {
const key = path.resolve(absPath).toLowerCase();
const existing = ctx.bySourceKey.get(key);
if (existing) return existing;
let kind = opts?.forceKind ?? classifyMediaPath(absPath);
if (!kind) return null;
let buf = await fs.readFile(absPath);
if (kind.type === 'image' && opts?.optimizeImage !== false) {
try {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (!opt.passthrough && opt.buffer.length > 0) {
buf = Buffer.from(opt.buffer);
kind = { type: 'image', mime: opt.mime };
}
} catch {
// keep original
}
}
const id = asAssetId(randomId());
const orig = path.basename(absPath);
const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`;
const absOut = path.join(ctx.cacheDir, relPath);
await fs.mkdir(path.dirname(absOut), { recursive: true });
await fs.writeFile(absOut, buf);
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
ctx.assets[id] = buildMediaAsset(id, kind, orig, relPath, sha256, buf.length);
ctx.bySourceKey.set(key, id);
return id;
}
async function importPlaceholderAvatar(ctx: AssetWriteCtx, name: string): Promise<AssetId> {
const id = asAssetId(randomId());
const orig = `${sanitizeFileName(name)}_avatar.png`;
const relPath = `assets/${id}_${orig}`;
const absOut = path.join(ctx.cacheDir, relPath);
await fs.mkdir(path.dirname(absOut), { recursive: true });
await fs.writeFile(absOut, PLACEHOLDER_PNG);
const sha256 = crypto.createHash('sha256').update(PLACEHOLDER_PNG).digest('hex');
ctx.assets[id] = buildMediaAsset(
id,
{ type: 'image', mime: 'image/png' },
orig,
relPath,
sha256,
PLACEHOLDER_PNG.length,
);
return id;
}
async function resolveAndImport(
ctx: AssetWriteCtx,
manifest: FoundryPackageManifest,
foundryPath: string | null | undefined,
opts?: { optimizeImage?: boolean },
): Promise<AssetId | null> {
if (!foundryPath?.trim()) return null;
const abs = await resolveFoundryAssetPath(manifest, foundryPath);
if (!abs) return null;
return importFileAsAsset(ctx, abs, opts);
}
function uniqueNpcName(base: string, used: Set<string>): string {
const root = base.trim() || 'NPC';
if (!used.has(root.toLowerCase())) {
used.add(root.toLowerCase());
return root;
}
let i = 2;
while (used.has(`${root} (${String(i)})`.toLowerCase())) i += 1;
const name = `${root} (${String(i)})`;
used.add(name.toLowerCase());
return name;
}
function collectSceneAudioPaths(
scene: FoundrySceneDoc,
playlistsById: Map<string, FoundryPlaylistDoc>,
): string[] {
const paths: string[] = [];
const playlistId = typeof scene.playlist === 'string' ? scene.playlist : null;
if (!playlistId) return paths;
const playlist = playlistsById.get(playlistId);
if (!playlist?.sounds?.length) return paths;
const soundId = typeof scene.playlistSound === 'string' ? scene.playlistSound : null;
const sounds = soundId
? playlist.sounds.filter((s) => s._id === soundId)
: [...playlist.sounds].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
for (const s of sounds) {
if (typeof s.path === 'string' && s.path.trim()) paths.push(s.path.trim());
}
return paths;
}
export type FoundryPendingAppPlaylist = {
name: string;
tracks: { title: string; absPath: string; mime: string }[];
};
export type FoundryBuiltProject = {
project: Project;
/** Абсолютные пути исходников превью для последующей генерации thumb (sceneId → abs path). */
previewSources: { sceneId: SceneId; absPath: string }[];
/** Неиспользуемые на сценах Foundry-плейлисты → локальная библиотека. */
pendingAppPlaylists: FoundryPendingAppPlaylist[];
};
export async function buildProjectFromFoundryDocuments(
manifest: FoundryPackageManifest,
docs: FoundryLoadedDocuments,
cacheDir: string,
onProgress?: (p: FoundryImportProgress) => void,
options?: { projectId?: ReturnType<typeof asProjectId> },
): Promise<FoundryBuiltProject> {
const projectId = options?.projectId ?? asProjectId(randomId());
const now = new Date().toISOString();
const appVer = getAppSemanticVersion();
const name = manifest.title.trim() || manifest.id;
const fileBaseName = `${sanitizeFileName(name)}_${projectId}`;
const ctx: AssetWriteCtx = { cacheDir, assets: {}, bySourceKey: new Map() };
const journalsById = new Map(docs.journals.map((j) => [j._id, j]));
const playlistsById = new Map(docs.playlists.map((p) => [p._id, p]));
const importScenes = filterScenesForImport(docs.scenes);
const graphPlan = planFoundrySceneGraph(importScenes, docs.adventures, docs.journals);
const sceneOrderIds =
graphPlan.orderedSceneIds.length > 0 ? graphPlan.orderedSceneIds : importScenes.map((s) => s._id);
const scenesByFoundryId = new Map(importScenes.map((s) => [s._id, s]));
const foundryToSceneId = new Map<string, SceneId>();
const scenes: Record<SceneId, Scene> = {};
const sceneListOrder: SceneId[] = [];
const previewSources: { sceneId: SceneId; absPath: string }[] = [];
const totalSteps = Math.max(1, sceneOrderIds.length + docs.actors.length);
let step = 0;
for (const foundrySceneId of sceneOrderIds) {
const doc = scenesByFoundryId.get(foundrySceneId);
if (!doc) continue;
step += 1;
onProgress?.({
stage: 'copy',
percent: 30 + Math.round((step / totalSteps) * 50),
detail: `Сцена: ${doc.name}`,
});
const sceneId = asSceneId(`s_${randomId()}`);
foundryToSceneId.set(foundrySceneId, sceneId);
let previewAssetId: AssetId | null = null;
let previewAssetType: 'image' | 'video' | null = null;
let previewThumbAssetId: AssetId | null = null;
const bg = sceneBackgroundSrc(doc);
if (bg) {
const abs = await resolveFoundryAssetPath(manifest, bg);
if (abs) {
const kind = classifyMediaPath(abs);
if (kind?.type === 'image' || kind?.type === 'video') {
previewAssetId = await importFileAsAsset(ctx, abs, { optimizeImage: kind.type === 'image' });
previewAssetType = kind.type;
previewSources.push({ sceneId, absPath: abs });
if (previewAssetId && kind.type === 'image') {
try {
const thumbBytes = await generateScenePreviewThumbnailBytes(abs, 'image');
if (thumbBytes && thumbBytes.length > 0) {
const thumbId = asAssetId(randomId());
const thumbRel = `assets/${thumbId}_preview_thumb.webp`;
await fs.writeFile(path.join(cacheDir, thumbRel), thumbBytes);
ctx.assets[thumbId] = buildMediaAsset(
thumbId,
{ type: 'image', mime: 'image/webp' },
`${sanitizeFileName(doc.name)}_preview_thumb.webp`,
thumbRel,
crypto.createHash('sha256').update(thumbBytes).digest('hex'),
thumbBytes.length,
);
previewThumbAssetId = thumbId;
}
} catch {
// optional
}
}
}
}
}
const audioRefs: Scene['media']['audios'] = [];
for (const audioPath of collectSceneAudioPaths(doc, playlistsById)) {
const assetId = await resolveAndImport(ctx, manifest, audioPath);
if (!assetId) continue;
if (ctx.assets[assetId]?.type !== 'audio') continue;
audioRefs.push({ assetId, autoplay: true, loop: true, volume: 1 });
}
scenes[sceneId] = {
id: sceneId,
title: doc.name.trim() || 'Scene',
description: journalDescriptionHtml(doc, journalsById),
previewAssetId,
previewAssetType,
previewThumbAssetId,
previewVideoAutostart: previewAssetType === 'video',
previewRotationDeg: 0,
darkenScene: false,
traps: [],
tokens: [],
npcTokens: [],
grid: { enabled: false, type: 'square', sizeN: 0.06, color: '#ffffff' },
media: { videos: [], audios: audioRefs },
settings: {
autoplayVideo: previewAssetType === 'video',
autoplayAudio: true,
loopVideo: true,
loopAudio: true,
},
connections: [],
layout: { x: 0, y: 0 },
};
sceneListOrder.push(sceneId);
}
const usedPlaylistIds = new Set(
importScenes.map((s) => s.playlist).filter((id): id is string => typeof id === 'string'),
);
const campaignAudios: Project['campaignAudios'] = [];
const pendingAppPlaylists: FoundryPendingAppPlaylist[] = [];
for (const pl of docs.playlists) {
if (usedPlaylistIds.has(pl._id)) continue;
const sounds = [...(pl.sounds ?? [])].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
const tracks: FoundryPendingAppPlaylist['tracks'] = [];
for (const s of sounds) {
if (typeof s.path !== 'string' || !s.path.trim()) continue;
const abs = await resolveFoundryAssetPath(manifest, s.path);
if (!abs) continue;
const kind = classifyMediaPath(abs);
if (!kind || kind.type !== 'audio') continue;
const title =
(typeof s.name === 'string' && s.name.trim()) ||
path.basename(s.path).replace(/\.[^.]+$/u, '') ||
'Track';
tracks.push({ title, absPath: abs, mime: kind.mime });
}
if (tracks.length === 0) continue;
pendingAppPlaylists.push({
name: (typeof pl.name === 'string' && pl.name.trim()) || 'Playlist',
tracks,
});
}
const { npcGroups, foundryFolderToGroupId } = buildNpcGroupsFromFoundryFolders(docs.folders);
const npcs: ProjectNpc[] = [];
const usedNpcNames = new Set<string>();
let npcIndex = 0;
for (const actor of docs.actors) {
// Группы актёров Foundry (party/group) — не персонажи.
if (actor.type === 'group') continue;
step += 1;
onProgress?.({
stage: 'copy',
percent: 30 + Math.round((step / totalSteps) * 50),
detail: `НПС: ${actor.name}`,
});
const portrait = actorPortraitSrc(actor);
let avatarAssetId = portrait ? await resolveAndImport(ctx, manifest, portrait) : null;
if (!avatarAssetId || ctx.assets[avatarAssetId]?.type !== 'image') {
avatarAssetId = await importPlaceholderAvatar(ctx, actor.name);
}
const npcName = uniqueNpcName(actor.name, usedNpcNames);
const folderId = typeof actor.folder === 'string' ? actor.folder : null;
const groupId = folderId ? (foundryFolderToGroupId.get(folderId) ?? null) : null;
npcs.push({
id: asNpcId(`npc_${randomId()}`),
name: npcName,
avatarAssetId,
description: extractActorDescriptionHtml(actor),
x: 80 + (npcIndex % 4) * 220,
y: 80 + Math.floor(npcIndex / 4) * 200,
groupId,
ringColor: '#c9a227',
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
externalUrl: '',
});
npcIndex += 1;
}
const sceneGraphNodes: SceneGraphNode[] = [];
const sceneGraphEdges: SceneGraphEdge[] = [];
const foundryToGraphNode = new Map<string, GraphNodeId>();
const startFoundryId = graphPlan.startSceneId;
const startSceneId =
(startFoundryId ? foundryToSceneId.get(startFoundryId) : null) ?? sceneListOrder[0] ?? null;
sceneListOrder.forEach((sceneId, index) => {
const foundryId = [...foundryToSceneId.entries()].find(([, sid]) => sid === sceneId)?.[0];
const col = index % 4;
const row = Math.floor(index / 4);
const gnId = asGraphNodeId(`gn_${randomId()}`);
sceneGraphNodes.push({
id: gnId,
sceneId,
x: 80 + col * 280,
y: 80 + row * 200,
isStartScene: startSceneId !== null && sceneId === startSceneId,
isSideStoryStart: false,
sideStoryLineTitle: '',
});
if (foundryId) foundryToGraphNode.set(foundryId, gnId);
});
for (const edge of graphPlan.edges) {
const source = foundryToGraphNode.get(edge.sourceId);
const target = foundryToGraphNode.get(edge.targetId);
if (!source || !target || source === target) continue;
sceneGraphEdges.push({
id: `e_${randomId()}`,
sourceGraphNodeId: source,
targetGraphNodeId: target,
});
}
// connections из рёбер графа
const outgoing = new Map<SceneId, Set<SceneId>>();
const gnMap = new Map(sceneGraphNodes.map((n) => [n.id, n]));
for (const e of sceneGraphEdges) {
const a = gnMap.get(e.sourceGraphNodeId);
const b = gnMap.get(e.targetGraphNodeId);
if (!a || !b || a.sceneId === b.sceneId) continue;
let set = outgoing.get(a.sceneId);
if (!set) {
set = new Set();
outgoing.set(a.sceneId, set);
}
set.add(b.sceneId);
}
for (const [sid, set] of outgoing) {
const sc = scenes[sid];
if (sc) sc.connections = [...set];
}
const startGraphNodeId = sceneGraphNodes.find((n) => n.isStartScene)?.id ?? null;
const project: Project = {
id: projectId,
meta: {
name,
fileBaseName,
createdAt: now,
updatedAt: now,
createdWithAppVersion: appVer,
appVersion: appVer,
schemaVersion: PROJECT_SCHEMA_VERSION,
},
scenes,
sceneListOrder,
assets: ctx.assets,
campaignAudios,
materials: [],
gridTemplates: [],
npcs,
npcGroups,
npcRelations: [],
currentSceneId: startSceneId,
currentGraphNodeId: startGraphNodeId,
sceneGraphNodes,
sceneGraphEdges,
};
return { project, previewSources, pendingAppPlaylists };
}
/** Actor folders Foundry → дерево групп НПС. */
function buildNpcGroupsFromFoundryFolders(folders: FoundryFolderDoc[]): {
npcGroups: ProjectNpcGroup[];
foundryFolderToGroupId: Map<string, NpcGroupId>;
} {
const actorFolders = folders
.filter((f) => !f.type || f.type === 'Actor')
.sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0) || a.name.localeCompare(b.name));
const foundryFolderToGroupId = new Map<string, NpcGroupId>();
for (const f of actorFolders) {
foundryFolderToGroupId.set(f._id, asNpcGroupId(`ng_${randomId()}`));
}
const nameKeysByParent = new Map<string | null, Set<string>>();
const npcGroups: ProjectNpcGroup[] = [];
// Parents first
const remaining = [...actorFolders];
const placed = new Set<string>();
while (remaining.length > 0) {
let progress = false;
for (let i = 0; i < remaining.length; i += 1) {
const f = remaining[i]!;
const parentFoundry = typeof f.folder === 'string' && f.folder.trim() ? f.folder.trim() : null;
if (parentFoundry && !foundryFolderToGroupId.has(parentFoundry)) {
// orphan parent ref → root
} else if (parentFoundry && !placed.has(parentFoundry) && foundryFolderToGroupId.has(parentFoundry)) {
continue;
}
const parentId = parentFoundry ? (foundryFolderToGroupId.get(parentFoundry) ?? null) : null;
const parentKey = parentId;
let keys = nameKeysByParent.get(parentKey);
if (!keys) {
keys = new Set();
nameKeysByParent.set(parentKey, keys);
}
let name = f.name.trim() || 'Group';
const base = name.toLowerCase();
if (keys.has(base)) {
let n = 2;
while (keys.has(`${base} (${String(n)})`)) n += 1;
name = `${name} (${String(n)})`;
}
keys.add(name.toLowerCase());
const id = foundryFolderToGroupId.get(f._id)!;
npcGroups.push({
id,
name,
color: normalizeHexColor(f.color, DEFAULT_NPC_GROUP_COLOR),
parentId,
});
placed.add(f._id);
remaining.splice(i, 1);
progress = true;
break;
}
if (!progress) {
// cycle / leftover → force as roots
for (const f of remaining) {
const id = foundryFolderToGroupId.get(f._id)!;
let name = f.name.trim() || 'Group';
const keys = nameKeysByParent.get(null) ?? new Set();
nameKeysByParent.set(null, keys);
if (keys.has(name.toLowerCase())) {
let n = 2;
while (keys.has(`${name.toLowerCase()} (${String(n)})`)) n += 1;
name = `${name} (${String(n)})`;
}
keys.add(name.toLowerCase());
npcGroups.push({
id,
name,
color: normalizeHexColor(f.color, DEFAULT_NPC_GROUP_COLOR),
parentId: null,
});
}
break;
}
}
return { npcGroups, foundryFolderToGroupId };
}
export async function loadFoundryDocumentsForImport(
sourcePath: string,
onProgress?: (p: FoundryImportProgress) => void,
): Promise<{
manifest: FoundryPackageManifest;
docs: FoundryLoadedDocuments;
cleanup: () => Promise<void>;
}> {
const prepared = await prepareSourceDir(sourcePath, onProgress);
try {
onProgress?.({ stage: 'copy', percent: 32, detail: 'Определение пакета Foundry…' });
const manifest = await detectFoundryPackage(prepared.workDir);
onProgress?.({
stage: 'copy',
percent: 36,
detail: manifest.kind === 'world' ? 'Чтение мира…' : 'Чтение модуля…',
});
const docs =
manifest.kind === 'world'
? await loadWorldDocuments(manifest.rootDir)
: await loadModuleDocuments(manifest.rootDir, manifest.packs);
if (docs.scenes.length === 0 && docs.actors.length === 0) {
throw new Error(
'В пакете Foundry не найдено сцен и актёров. Проверьте, что выбран мир/модуль с данными (не пустой шаблон).',
);
}
return { manifest, docs, cleanup: prepared.cleanup };
} catch (e) {
await prepared.cleanup();
throw e;
}
}
+1683 -103
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { InitiativeSessionStore } from './initiativeSessionStore';
import {
initiativeNpcEntryId,
initiativePlayerEntryId,
} from '../../shared/types/initiative';
void test('initiative: addManual + next cycles order', () => {
const store = new InitiativeSessionStore();
store.dispatch({ kind: 'addManual', name: 'A' });
store.dispatch({ kind: 'addManual', name: 'B' });
const a = store.getState().order[0]!;
const b = store.getState().order[1]!;
assert.equal(store.getState().activeId, a);
store.dispatch({ kind: 'next' });
assert.equal(store.getState().activeId, b);
store.dispatch({ kind: 'next' });
assert.equal(store.getState().activeId, a);
});
void test('initiative: syncPresence adds npcs and players', () => {
const store = new InitiativeSessionStore();
store.dispatch({
kind: 'syncPresence',
npcs: [{ placementId: 'p1', npcId: 'n1', name: 'Goblin', avatarAssetId: null }],
players: [{ playerId: 'pl1', name: 'Hero' }],
playersVisible: true,
});
const st = store.getState();
assert.ok(st.entries[initiativeNpcEntryId('p1')]);
assert.ok(st.entries[initiativePlayerEntryId('pl1')]);
assert.deepEqual(st.order, [initiativeNpcEntryId('p1'), initiativePlayerEntryId('pl1')]);
});
void test('initiative: hide players moves them to reserve', () => {
const store = new InitiativeSessionStore();
store.dispatch({
kind: 'syncPresence',
npcs: [],
players: [{ playerId: 'pl1', name: 'Hero' }],
playersVisible: true,
});
const pid = initiativePlayerEntryId('pl1');
assert.ok(store.getState().order.includes(pid));
store.dispatch({
kind: 'syncPresence',
npcs: [],
players: [{ playerId: 'pl1', name: 'Hero' }],
playersVisible: false,
});
assert.ok(!store.getState().order.includes(pid));
assert.ok(store.getState().reserve.includes(pid));
store.dispatch({
kind: 'syncPresence',
npcs: [],
players: [{ playerId: 'pl1', name: 'Hero' }],
playersVisible: true,
});
assert.ok(store.getState().reserve.includes(pid));
assert.ok(!store.getState().order.includes(pid));
});
void test('initiative: conditions unique + reserve/restore', () => {
const store = new InitiativeSessionStore();
store.dispatch({ kind: 'addManual', name: 'X' });
const id = store.getState().order[0]!;
store.dispatch({ kind: 'addCondition', id, conditionId: 'poisoned' });
store.dispatch({ kind: 'addCondition', id, conditionId: 'poisoned' });
assert.deepEqual(store.getState().conditions[id], ['poisoned']);
store.dispatch({ kind: 'moveToReserve', id });
assert.deepEqual(store.getState().order, []);
assert.deepEqual(store.getState().reserve, [id]);
store.dispatch({ kind: 'restoreFromReserve', id });
assert.deepEqual(store.getState().order, [id]);
assert.deepEqual(store.getState().conditions[id], ['poisoned']);
});
void test('initiative: clear keeps enabled', () => {
const store = new InitiativeSessionStore();
store.dispatch({ kind: 'setEnabled', enabled: true });
store.dispatch({ kind: 'addManual', name: 'Z' });
store.clear();
assert.equal(store.getState().enabled, true);
assert.deepEqual(store.getState().order, []);
});
void test('initiative: despawn removes npc entry', () => {
const store = new InitiativeSessionStore();
store.dispatch({
kind: 'syncPresence',
npcs: [{ placementId: 'p1', npcId: 'n1', name: 'Goblin', avatarAssetId: null }],
players: [],
playersVisible: false,
});
store.dispatch({
kind: 'syncPresence',
npcs: [],
players: [],
playersVisible: false,
});
assert.deepEqual(store.getState().order, []);
assert.deepEqual(Object.keys(store.getState().entries), []);
});
@@ -0,0 +1,318 @@
import {
createInitialInitiativeState,
initiativeManualLetter,
initiativeNpcEntryId,
initiativePlayerEntryId,
isInitiativeConditionId,
newInitiativeManualId,
pickInitiativeManualGradient,
type InitiativeEntry,
type InitiativeEvent,
type InitiativePresenceNpc,
type InitiativePresencePlayer,
type InitiativeState,
} from '../../shared/types/initiative';
function withoutId(ids: readonly string[], id: string): string[] {
return ids.filter((x) => x !== id);
}
function ensureActiveValid(state: InitiativeState): InitiativeState {
if (state.activeId != null && state.order.includes(state.activeId)) return state;
const nextActive = state.order[0] ?? null;
if (state.activeId === nextActive) return state;
return { ...state, activeId: nextActive };
}
export class InitiativeSessionStore {
private state: InitiativeState = createInitialInitiativeState();
getState(): InitiativeState {
return this.state;
}
clear(): InitiativeState {
const enabled = this.state.enabled;
this.state = { ...createInitialInitiativeState(), enabled, revision: this.state.revision + 1 };
return this.state;
}
dispatch(event: InitiativeEvent): InitiativeState {
switch (event.kind) {
case 'clear': {
this.state = {
...createInitialInitiativeState(),
enabled: this.state.enabled,
revision: this.state.revision + 1,
};
return this.state;
}
case 'setEnabled': {
if (this.state.enabled === event.enabled) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
enabled: event.enabled,
};
return this.state;
}
case 'syncPresence': {
return this.syncPresence(event.npcs, event.players, event.playersVisible);
}
case 'reorder': {
const allowed = new Set(this.state.order);
const nextOrder = event.order.filter((id) => allowed.has(id));
for (const id of this.state.order) {
if (!nextOrder.includes(id)) nextOrder.push(id);
}
if (nextOrder.length === this.state.order.length && nextOrder.every((id, i) => id === this.state.order[i])) {
return this.state;
}
this.state = ensureActiveValid({
...this.state,
revision: this.state.revision + 1,
order: nextOrder,
});
return this.state;
}
case 'setActive': {
if (event.id != null && !this.state.order.includes(event.id)) return this.state;
if (this.state.activeId === event.id) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
activeId: event.id,
};
return this.state;
}
case 'next': {
if (this.state.order.length === 0) {
if (this.state.activeId == null) return this.state;
this.state = { ...this.state, revision: this.state.revision + 1, activeId: null };
return this.state;
}
const cur = this.state.activeId ? this.state.order.indexOf(this.state.activeId) : -1;
const nextIdx = cur < 0 ? 0 : (cur + 1) % this.state.order.length;
const nextId = this.state.order[nextIdx] ?? null;
if (nextId === this.state.activeId) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
activeId: nextId,
};
return this.state;
}
case 'addManual': {
const name = event.name.trim();
if (!name) return this.state;
const id = newInitiativeManualId();
const entry: InitiativeEntry = {
id,
kind: 'manual',
name,
letter: initiativeManualLetter(name),
gradient: pickInitiativeManualGradient(),
};
this.state = {
...this.state,
revision: this.state.revision + 1,
entries: { ...this.state.entries, [id]: entry },
order: [...this.state.order, id],
activeId: this.state.activeId ?? id,
};
return this.state;
}
case 'moveToReserve': {
if (!this.state.order.includes(event.id)) return this.state;
if (!this.state.entries[event.id]) return this.state;
const order = withoutId(this.state.order, event.id);
const reserve = this.state.reserve.includes(event.id)
? this.state.reserve
: [...this.state.reserve, event.id];
this.state = ensureActiveValid({
...this.state,
revision: this.state.revision + 1,
order,
reserve,
});
return this.state;
}
case 'restoreFromReserve': {
if (!this.state.reserve.includes(event.id)) return this.state;
if (!this.state.entries[event.id]) return this.state;
const reserve = withoutId(this.state.reserve, event.id);
const order = this.state.order.includes(event.id)
? this.state.order
: [...this.state.order, event.id];
this.state = {
...this.state,
revision: this.state.revision + 1,
order,
reserve,
activeId: this.state.activeId ?? event.id,
};
return this.state;
}
case 'deleteEntry': {
if (!this.state.entries[event.id]) return this.state;
const { [event.id]: _removed, ...entries } = this.state.entries;
const { [event.id]: _c, ...conditions } = this.state.conditions;
this.state = ensureActiveValid({
...this.state,
revision: this.state.revision + 1,
entries,
conditions,
order: withoutId(this.state.order, event.id),
reserve: withoutId(this.state.reserve, event.id),
});
return this.state;
}
case 'addCondition': {
if (!this.state.entries[event.id]) return this.state;
if (!isInitiativeConditionId(event.conditionId)) return this.state;
const cur = this.state.conditions[event.id] ?? [];
if (cur.includes(event.conditionId)) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
conditions: {
...this.state.conditions,
[event.id]: [...cur, event.conditionId],
},
};
return this.state;
}
case 'removeCondition': {
const cur = this.state.conditions[event.id];
if (!cur?.includes(event.conditionId)) return this.state;
const next = cur.filter((c) => c !== event.conditionId);
const conditions = { ...this.state.conditions };
if (next.length === 0) delete conditions[event.id];
else conditions[event.id] = next;
this.state = {
...this.state,
revision: this.state.revision + 1,
conditions,
};
return this.state;
}
default: {
const _exhaustive: never = event;
return _exhaustive;
}
}
}
private syncPresence(
npcs: readonly InitiativePresenceNpc[],
players: readonly InitiativePresencePlayer[],
playersVisible: boolean,
): InitiativeState {
const npcByPlacement = new Map(npcs.map((n) => [n.placementId, n]));
const playerById = new Map(players.map((p) => [p.playerId, p]));
let entries: Record<string, InitiativeEntry> = { ...this.state.entries };
let order = [...this.state.order];
let reserve = [...this.state.reserve];
let conditions = { ...this.state.conditions };
let changed = false;
for (const [id, entry] of Object.entries(entries)) {
if (entry.kind === 'manual') continue;
if (entry.kind === 'npc') {
const live = npcByPlacement.get(entry.placementId);
if (!live) {
delete entries[id];
delete conditions[id];
order = withoutId(order, id);
reserve = withoutId(reserve, id);
changed = true;
continue;
}
if (entry.name !== live.name || entry.avatarAssetId !== live.avatarAssetId || entry.npcId !== live.npcId) {
entries[id] = {
...entry,
npcId: live.npcId,
name: live.name,
avatarAssetId: live.avatarAssetId,
};
changed = true;
}
continue;
}
const live = playerById.get(entry.playerId);
if (!live) {
delete entries[id];
delete conditions[id];
order = withoutId(order, id);
reserve = withoutId(reserve, id);
changed = true;
continue;
}
if (entry.name !== live.name) {
entries[id] = { ...entry, name: live.name };
changed = true;
}
}
for (const npc of npcs) {
const id = initiativeNpcEntryId(npc.placementId);
if (entries[id]) continue;
entries[id] = {
id,
kind: 'npc',
placementId: npc.placementId,
npcId: npc.npcId,
name: npc.name,
avatarAssetId: npc.avatarAssetId,
};
order.push(id);
changed = true;
}
for (const player of players) {
const id = initiativePlayerEntryId(player.playerId);
if (entries[id]) continue;
entries[id] = {
id,
kind: 'player',
playerId: player.playerId,
name: player.name,
};
order.push(id);
changed = true;
}
const wasVisible = this.state.playersVisible;
if (wasVisible && !playersVisible) {
const playerIdsInOrder = order.filter((id) => entries[id]?.kind === 'player');
if (playerIdsInOrder.length > 0) {
for (const id of playerIdsInOrder) {
order = withoutId(order, id);
if (!reserve.includes(id)) reserve.push(id);
}
changed = true;
}
}
if (this.state.playersVisible !== playersVisible) changed = true;
if (!changed) {
// Still update playersVisible if somehow same structure
if (this.state.playersVisible === playersVisible) return this.state;
}
let next: InitiativeState = {
...this.state,
revision: this.state.revision + 1,
entries,
order,
reserve,
conditions,
playersVisible,
};
next = ensureActiveValid(next);
this.state = next;
return this.state;
}
}
+4
View File
@@ -18,6 +18,10 @@ function channelRequiresLicense(channel: string): boolean {
if (channel.startsWith('license.')) return false;
if (channel.startsWith('app.')) return false;
if (channel === ipcChannels.windows.closeMultiWindow) return false;
if (channel === ipcChannels.windows.closeSceneDescription) return false;
if (channel === ipcChannels.windows.closeMaterials) return false;
if (channel === ipcChannels.windows.closeNpcs) return false;
if (channel === ipcChannels.windows.closeNpcsEditor) return false;
if (channel === ipcChannels.windows.togglePresentationFullscreen) return false;
// Список файлов в %userData%/projects — только чтение; без лицензии список не должен «пропадать».
if (channel === ipcChannels.project.list) return false;
+22 -8
View File
@@ -1,18 +1,32 @@
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
import { resolveMachineFingerprint } from './machineFingerprint';
import { deviceIdPath } from './paths';
export function getOrCreateDeviceId(userData: string): string {
const p = deviceIdPath(userData);
/** Старый per-user UUID из userData/device.id (до привязки к железу). */
export function readLegacyDeviceId(userData: string): string | null {
try {
const existing = fs.readFileSync(p, 'utf8').trim();
const existing = fs.readFileSync(deviceIdPath(userData), 'utf8').trim();
if (existing.length >= 8) return existing;
} catch {
/* empty */
}
const id = randomUUID();
fs.mkdirSync(userData, { recursive: true });
fs.writeFileSync(p, `${id}\n`, 'utf8');
return id;
return null;
}
export function clearLegacyDeviceId(userData: string): void {
try {
fs.unlinkSync(deviceIdPath(userData));
} catch {
/* empty */
}
}
/**
* Идентификатор устройства для лицензии: отпечаток физической машины.
* Одинаков для всех пользователей ОС на одном ПК (Windows/macOS/Linux).
* `userData` — путь для дискового кэша fingerprint (без повторного reg/wmic).
*/
export function getOrCreateDeviceId(userData?: string): string {
return resolveMachineFingerprint(userData ? { userData } : {});
}
+36 -12
View File
@@ -7,10 +7,10 @@ import { ipcChannels } from '../../shared/ipc/contracts';
import { EULA_CURRENT_VERSION } from '../../shared/license/eulaVersion';
import type { LicenseSnapshot } from '../../shared/license/licenseSnapshot';
import type { LicensePayloadV1 } from '../../shared/license/payloadV1';
import { isDndProductKey } from '../../shared/license/productKey';
import { isProductKey } from '../../shared/license/productKey';
import { normalizeLicenseTokenInput } from '../../shared/license/tokenFormat';
import { getOrCreateDeviceId } from './deviceId';
import { clearLegacyDeviceId, getOrCreateDeviceId, readLegacyDeviceId } from './deviceId';
import { licenseEncryptedPath, licenseFallbackSealedPath, preferencesPath } from './paths';
import { verifyLicenseToken } from './verifyLicenseToken';
@@ -66,12 +66,28 @@ function emitLicenseStatusChanged(): void {
export class LicenseService {
private readonly userData: string;
private readonly deviceId: string;
/** UUID из старого userData/device.id — только для совместимости до повторной активации. */
private legacyDeviceId: string | null;
private lastRemoteRevokeCheckMs = 0;
private lastRemoteRevoked = false;
constructor(userData: string) {
this.userData = userData;
this.deviceId = getOrCreateDeviceId(userData);
const legacy = readLegacyDeviceId(userData);
this.legacyDeviceId = legacy && legacy !== this.deviceId ? legacy : null;
}
private verifyOpts(nowSec: number): {
nowSec: number;
deviceId: string;
alsoAcceptDeviceIds?: readonly string[];
} {
return {
nowSec,
deviceId: this.deviceId,
...(this.legacyDeviceId ? { alsoAcceptDeviceIds: [this.legacyDeviceId] } : {}),
};
}
private isSkipLicense(): boolean {
@@ -87,7 +103,7 @@ export class LicenseService {
private deriveFallbackKey(): Buffer {
return crypto
.createHash('sha256')
.update('DNDGamePlayer.license.fallback.v1\0', 'utf8')
.update('TTRPGPlayer.license.fallback.v1\0', 'utf8')
.update(this.deviceId, 'utf8')
.digest();
}
@@ -184,16 +200,23 @@ export class LicenseService {
private resolveLicenseActivateBaseUrl(): string {
const raw = process.env.DND_LICENSE_STATUS_URL?.trim();
if (raw) return raw.endsWith('/') ? raw : `${raw}/`;
return 'https://license.mailib.ru/';
return 'https://license.ttrpgplayer.ru/';
}
private async activateWithProductKey(productKey: string): Promise<string> {
const base = this.resolveLicenseActivateBaseUrl();
const url = new URL('v1/activate', base);
const body: { productKey: string; deviceId: string; retireDeviceId?: string } = {
productKey: productKey.trim(),
deviceId: this.deviceId,
};
if (this.legacyDeviceId) {
body.retireDeviceId = this.legacyDeviceId;
}
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ productKey: productKey.trim(), deviceId: this.deviceId }),
body: JSON.stringify(body),
signal: AbortSignal.timeout(20_000),
});
const text = await res.text();
@@ -211,6 +234,10 @@ export class LicenseService {
if (!token || typeof token !== 'string') {
throw new Error('LICENSE_ACTIVATE_FAILED:token_missing');
}
if (this.legacyDeviceId) {
clearLegacyDeviceId(this.userData);
this.legacyDeviceId = null;
}
return normalizeLicenseTokenInput(token);
}
@@ -278,7 +305,7 @@ export class LicenseService {
};
}
const v = verifyLicenseToken(token, { nowSec, deviceId: this.deviceId });
const v = verifyLicenseToken(token, this.verifyOpts(nowSec));
if (!v.ok) {
return {
active: false,
@@ -328,10 +355,7 @@ export class LicenseService {
if (!base.active || !base.summary) return base;
const token = this.readSealedToken();
if (!token?.trim()) return base;
const v = verifyLicenseToken(token, {
nowSec: Math.floor(Date.now() / 1000),
deviceId: this.deviceId,
});
const v = verifyLicenseToken(token, this.verifyOpts(Math.floor(Date.now() / 1000)));
if (!v.ok) return this.getStatusSync();
void this.maybeRefreshRemoteRevocation(v.payload);
return this.getStatusSync();
@@ -342,11 +366,11 @@ export class LicenseService {
return this.getStatusSync();
}
let trimmed = normalizeLicenseTokenInput(token);
if (isDndProductKey(trimmed)) {
if (isProductKey(trimmed)) {
trimmed = await this.activateWithProductKey(trimmed);
}
const nowSec = Math.floor(Date.now() / 1000);
const v = verifyLicenseToken(trimmed, { nowSec, deviceId: this.deviceId });
const v = verifyLicenseToken(trimmed, this.verifyOpts(nowSec));
if (!v.ok) {
throw new Error(`LICENSE_INVALID:${v.reason}`);
}
+151
View File
@@ -0,0 +1,151 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import {
clearMachineFingerprintMemoryCache,
hashMachineRawId,
machineWideIdPath,
parseMacIOPlatformUUID,
parseWindowsMachineGuid,
parseWmicUuid,
resolveMachineFingerprint,
} from './machineFingerprint';
import { machineFingerprintCachePath } from './paths';
void test('hashMachineRawId: стабилен и не зависит от регистра GUID', () => {
const a = hashMachineRawId('win32', 'ABCDEF00-1111-2222-3333-444455556666');
const b = hashMachineRawId('win32', 'abcdef00-1111-2222-3333-444455556666');
assert.equal(a, b);
assert.equal(a.length, 64);
assert.notEqual(a, hashMachineRawId('linux', 'abcdef00-1111-2222-3333-444455556666'));
});
void test('parseWindowsMachineGuid', () => {
const out = `
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
MachineGuid REG_SZ A1B2C3D4-E5F6-7890-ABCD-EF1234567890
`;
assert.equal(parseWindowsMachineGuid(out), 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
assert.equal(parseWindowsMachineGuid('nope'), null);
});
void test('parseMacIOPlatformUUID', () => {
const out = `
+-o IOPlatformExpertDevice <class IOPlatformExpertDevice, id 0x1000001ea, registered, matched>
{
"IOPlatformUUID" = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890"
}
`;
assert.equal(parseMacIOPlatformUUID(out), 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
});
void test('parseWmicUuid', () => {
assert.equal(parseWmicUuid('UUID\nA1B2C3D4-E5F6-7890-ABCD-EF1234567890\n'), 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
assert.equal(parseWmicUuid('UUID\n00000000-0000-0000-0000-000000000000\n'), null);
});
void test('resolveMachineFingerprint: override через DND_LICENSE_DEVICE_ID', () => {
clearMachineFingerprintMemoryCache();
const id = resolveMachineFingerprint({
platform: 'linux',
env: { DND_LICENSE_DEVICE_ID: 'override-device-id-12345' },
});
assert.equal(id, 'override-device-id-12345');
});
void test('resolveMachineFingerprint: Windows MachineGuid → одинаковый hash', () => {
clearMachineFingerprintMemoryCache();
const exec = () =>
`
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
MachineGuid REG_SZ A1B2C3D4-E5F6-7890-ABCD-EF1234567890
`;
const a = resolveMachineFingerprint({
platform: 'win32',
env: {},
execFileSync: exec as never,
});
clearMachineFingerprintMemoryCache();
const b = resolveMachineFingerprint({
platform: 'win32',
env: {},
execFileSync: exec as never,
});
assert.equal(a, b);
assert.equal(a, hashMachineRawId('win32', 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890'));
});
void test('resolveMachineFingerprint: Linux /etc/machine-id', () => {
clearMachineFingerprintMemoryCache();
const id = resolveMachineFingerprint({
platform: 'linux',
env: {},
readFileSync: (p) => {
if (p === '/etc/machine-id') return '0123456789abcdef0123456789abcdef\n';
throw new Error('enoent');
},
});
assert.equal(id, hashMachineRawId('linux', '0123456789abcdef0123456789abcdef'));
});
void test('resolveMachineFingerprint: fallback в machine-wide путь', () => {
clearMachineFingerprintMemoryCache();
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'machine-fp-'));
const env = { PROGRAMDATA: tmp };
const p = machineWideIdPath('win32', env);
const id1 = resolveMachineFingerprint({
platform: 'win32',
env,
execFileSync: () => {
throw new Error('no reg');
},
});
clearMachineFingerprintMemoryCache();
const id2 = resolveMachineFingerprint({
platform: 'win32',
env,
execFileSync: () => {
throw new Error('no reg');
},
});
assert.equal(id1, id2);
assert.ok(fs.existsSync(p));
fs.rmSync(tmp, { recursive: true, force: true });
});
void test('resolveMachineFingerprint: disk cache — без повторного exec', () => {
clearMachineFingerprintMemoryCache();
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'machine-fp-cache-'));
const expected = hashMachineRawId('win32', 'A1B2C3D4-E5F6-7890-ABCD-EF1234567890');
let execCalls = 0;
const exec = () => {
execCalls += 1;
return `
HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Cryptography
MachineGuid REG_SZ A1B2C3D4-E5F6-7890-ABCD-EF1234567890
`;
};
const a = resolveMachineFingerprint({
platform: 'win32',
env: {},
userData: tmp,
execFileSync: exec as never,
});
assert.equal(a, expected);
assert.equal(execCalls, 1);
assert.ok(fs.existsSync(machineFingerprintCachePath(tmp)));
clearMachineFingerprintMemoryCache();
const b = resolveMachineFingerprint({
platform: 'win32',
env: {},
userData: tmp,
execFileSync: exec as never,
});
assert.equal(b, expected);
assert.equal(execCalls, 1, 'второй вызов читает disk cache, без reg/wmic');
fs.rmSync(tmp, { recursive: true, force: true });
});
+260
View File
@@ -0,0 +1,260 @@
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { machineFingerprintCachePath } from './paths';
type ExecFile = (
file: string,
args: readonly string[],
options: { encoding: 'utf8'; windowsHide?: boolean; timeout?: number },
) => string;
export type MachineFingerprintDeps = {
platform?: NodeJS.Platform;
env?: NodeJS.ProcessEnv;
/** Electron userData — для дискового кэша hashed fingerprint. */
userData?: string;
execFileSync?: ExecFile;
readFileSync?: (p: string, encoding: 'utf8') => string;
existsSync?: (p: string) => boolean;
mkdirSync?: (p: string, opts: { recursive: boolean }) => void;
writeFileSync?: (p: string, data: string, opts?: { mode?: number }) => void;
};
/** Process-level кэш: повторные вызовы в том же процессе без I/O. */
let memoryFingerprint: string | null = null;
/** Только для тестов. */
export function clearMachineFingerprintMemoryCache(): void {
memoryFingerprint = null;
}
function isPlausiblyFingerprint(id: string): boolean {
return id.length >= 8 && id.length <= 128 && !/\s/.test(id);
}
const HASH_PREFIX = 'TTRPGPlayer.machine.v1\0';
/** Стабильный opaque id из сырого машинного идентификатора ОС. */
export function hashMachineRawId(platform: string, rawId: string): string {
return createHash('sha256')
.update(HASH_PREFIX, 'utf8')
.update(platform, 'utf8')
.update('\0', 'utf8')
.update(rawId.trim().toLowerCase(), 'utf8')
.digest('hex');
}
export function parseWindowsMachineGuid(regOutput: string): string | null {
const m = /MachineGuid\s+REG_SZ\s+([0-9a-fA-F-]{8,})/.exec(regOutput);
const id = m?.[1]?.trim();
return id && id.length >= 8 ? id : null;
}
export function parseMacIOPlatformUUID(ioregOutput: string): string | null {
const m = /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(ioregOutput);
const id = m?.[1]?.trim();
return id && id.length >= 8 ? id : null;
}
export function parseWmicUuid(wmicOutput: string): string | null {
const lines = wmicOutput
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
for (const line of lines) {
if (/^uuid$/i.test(line)) continue;
if (/^[0-9a-fA-F-]{8,}$/.test(line) && !/^0+-?0+-?0+-?0+-?0+$/.test(line)) {
return line;
}
}
return null;
}
function tryExec(exec: ExecFile, file: string, args: readonly string[]): string | null {
try {
return exec(file, args, { encoding: 'utf8', windowsHide: true, timeout: 8_000 });
} catch {
return null;
}
}
function readWindowsRawId(exec: ExecFile): string | null {
const regOut = tryExec(exec, 'reg', [
'query',
'HKLM\\SOFTWARE\\Microsoft\\Cryptography',
'/v',
'MachineGuid',
]);
if (regOut) {
const guid = parseWindowsMachineGuid(regOut);
if (guid) return guid;
}
const wmicOut = tryExec(exec, 'wmic', ['csproduct', 'get', 'uuid']);
if (wmicOut) {
const uuid = parseWmicUuid(wmicOut);
if (uuid) return uuid;
}
return null;
}
function readDarwinRawId(exec: ExecFile): string | null {
const out = tryExec(exec, 'ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice']);
if (!out) return null;
return parseMacIOPlatformUUID(out);
}
function readLinuxRawId(readFile: (p: string, encoding: 'utf8') => string): string | null {
for (const p of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
try {
const id = readFile(p, 'utf8').trim();
if (id.length >= 8 && !/^0+$/.test(id)) return id;
} catch {
/* try next */
}
}
try {
const id = readFile('/sys/class/dmi/id/product_uuid', 'utf8').trim();
if (id.length >= 8 && !/^0+-?0+-?0+-?0+-?0+$/i.test(id)) return id;
} catch {
/* optional, often root-only */
}
return null;
}
/** Каталог на уровне машины (не профиль пользователя), для редкого fallback UUID. */
export function machineWideIdPath(platform: NodeJS.Platform, env: NodeJS.ProcessEnv): string {
if (platform === 'win32') {
const base = env.PROGRAMDATA?.trim() || path.join(env.SystemDrive || 'C:', 'ProgramData');
return path.join(base, 'TTRPGPlayer', 'machine.id');
}
if (platform === 'darwin') {
return path.join('/Library/Application Support', 'TTRPGPlayer', 'machine.id');
}
return path.join('/var/lib', 'ttrpg-player', 'machine.id');
}
function readOrCreateMachineWideFallback(
platform: NodeJS.Platform,
env: NodeJS.ProcessEnv,
deps: Required<
Pick<MachineFingerprintDeps, 'existsSync' | 'readFileSync' | 'mkdirSync' | 'writeFileSync'>
>,
): string | null {
const p = machineWideIdPath(platform, env);
try {
if (deps.existsSync(p)) {
const existing = deps.readFileSync(p, 'utf8').trim();
if (existing.length >= 8) return existing;
}
} catch {
/* create below */
}
const id = createHash('sha256')
.update(`TTRPGPlayer.machine.fallback\0${platform}\0${os.hostname()}\0${Date.now()}\0${Math.random()}`, 'utf8')
.digest('hex');
try {
deps.mkdirSync(path.dirname(p), { recursive: true });
deps.writeFileSync(p, `${id}\n`, { mode: 0o644 });
return id;
} catch {
return null;
}
}
function readDiskFingerprintCache(
cacheFile: string,
readFile: (p: string, encoding: 'utf8') => string,
existsSync: (p: string) => boolean,
): string | null {
try {
if (!existsSync(cacheFile)) return null;
const cached = readFile(cacheFile, 'utf8').trim();
return isPlausiblyFingerprint(cached) ? cached : null;
} catch {
return null;
}
}
function writeDiskFingerprintCache(
cacheFile: string,
fingerprint: string,
mkdirSync: (p: string, opts: { recursive: boolean }) => void,
writeFileSync: (p: string, data: string, opts?: { mode?: number }) => void,
): void {
try {
mkdirSync(path.dirname(cacheFile), { recursive: true });
writeFileSync(cacheFile, `${fingerprint}\n`, { mode: 0o644 });
} catch {
/* кэш необязателен */
}
}
/**
* Стабильный идентификатор физической машины (одинаковый для всех пользователей ОС на одном ПК).
* Источники: Windows MachineGuid, macOS IOPlatformUUID, Linux /etc/machine-id.
* Кэш: память процесса → userData/machine.fingerprint → sync probe ОС только при miss.
*/
export function resolveMachineFingerprint(deps: MachineFingerprintDeps = {}): string {
const platform = deps.platform ?? process.platform;
const env = deps.env ?? process.env;
const exec = deps.execFileSync ?? (execFileSync as ExecFile);
const readFile = deps.readFileSync ?? ((p, enc) => fs.readFileSync(p, enc));
const existsSync = deps.existsSync ?? ((p) => fs.existsSync(p));
const mkdirSync = deps.mkdirSync ?? ((p, opts) => {
fs.mkdirSync(p, opts);
});
const writeFileSync = deps.writeFileSync ?? ((p, data, opts) => {
fs.writeFileSync(p, data, opts);
});
const override = env.DND_LICENSE_DEVICE_ID?.trim();
if (override && override.length >= 8) {
memoryFingerprint = override;
return override;
}
if (memoryFingerprint && isPlausiblyFingerprint(memoryFingerprint)) {
return memoryFingerprint;
}
const userData = deps.userData?.trim();
const cacheFile = userData ? machineFingerprintCachePath(userData) : null;
if (cacheFile) {
const fromDisk = readDiskFingerprintCache(cacheFile, readFile, existsSync);
if (fromDisk) {
memoryFingerprint = fromDisk;
return fromDisk;
}
}
let raw: string | null = null;
if (platform === 'win32') raw = readWindowsRawId(exec);
else if (platform === 'darwin') raw = readDarwinRawId(exec);
else if (platform === 'linux') raw = readLinuxRawId(readFile);
if (!raw) {
raw = readOrCreateMachineWideFallback(platform, env, {
existsSync,
readFileSync: readFile,
mkdirSync,
writeFileSync,
});
}
if (!raw) {
throw new Error(
'LICENSE_MACHINE_ID_UNAVAILABLE: не удалось получить идентификатор физической машины',
);
}
const fingerprint = hashMachineRawId(platform, raw);
memoryFingerprint = fingerprint;
if (cacheFile) {
writeDiskFingerprintCache(cacheFile, fingerprint, mkdirSync, writeFileSync);
}
return fingerprint;
}
+6
View File
@@ -9,10 +9,16 @@ export function licenseFallbackSealedPath(userData: string): string {
return path.join(userData, 'license.sealed.fallback');
}
/** Устаревший per-user UUID; актуальный deviceId — fingerprint машины (см. machineFingerprint.ts). */
export function deviceIdPath(userData: string): string {
return path.join(userData, 'device.id');
}
/** Кэш hashed machine fingerprint — без повторного reg/wmic/ioreg на каждом старте. */
export function machineFingerprintCachePath(userData: string): string {
return path.join(userData, 'machine.fingerprint');
}
export function preferencesPath(userData: string): string {
return path.join(userData, 'preferences.json');
}
@@ -55,6 +55,30 @@ void test('verifyLicenseToken: неверное устройство', () => {
assert.equal(bad.reason, 'wrong_device');
});
void test('verifyLicenseToken: принимает legacy deviceId при миграции', () => {
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
const payload = {
v: 1 as const,
sub: 'lic_legacy',
pid: 'dnd_player',
iat: 100,
exp: 2_000_000_000,
did: 'legacy-uuid-from-userdata',
};
const body = canonicalJson(payload);
const sig = sign(null, Buffer.from(body, 'utf8'), privateKey);
const token = joinSignedLicenseToken(body, new Uint8Array(sig.buffer, sig.byteOffset, sig.byteLength));
const ok = verifyLicenseToken(token, {
nowSec: 1_700_000_000,
deviceId: 'machine-fingerprint-hash',
alsoAcceptDeviceIds: ['legacy-uuid-from-userdata'],
publicKeyOverrideSpkiDerB64: pubB64,
});
if (!ok.ok) assert.fail(`expected ok, got ${ok.reason}`);
});
void test('verifyLicenseToken: токен с переносами строк после копирования', () => {
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const pubB64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
+12 -3
View File
@@ -23,7 +23,13 @@ function getBundledPublicKey() {
export function verifyLicenseToken(
token: string,
opts: { nowSec: number; deviceId: string; publicKeyOverrideSpkiDerB64?: string },
opts: {
nowSec: number;
deviceId: string;
/** Старые deviceId (например UUID из userData) — принимаются до повторной активации. */
alsoAcceptDeviceIds?: readonly string[];
publicKeyOverrideSpkiDerB64?: string;
},
): LicenseVerifyResult {
const parts = splitSignedLicenseToken(token);
if (!parts) return { ok: false, reason: 'malformed' };
@@ -53,8 +59,11 @@ export function verifyLicenseToken(
return { ok: false, reason: 'not_yet_valid' };
}
if (opts.nowSec >= payload.exp) return { ok: false, reason: 'expired' };
if (payload.did !== null && payload.did !== opts.deviceId) {
return { ok: false, reason: 'wrong_device' };
if (payload.did !== null) {
const accepted = new Set<string>([opts.deviceId, ...(opts.alsoAcceptDeviceIds ?? [])]);
if (!accepted.has(payload.did)) {
return { ok: false, reason: 'wrong_device' };
}
}
return { ok: true, payload };
+228
View File
@@ -0,0 +1,228 @@
import {
clampMaterialsLayout,
DEFAULT_MATERIALS_OVERLAY_LAYOUT,
defaultLegendLayoutForMaterial,
type MaterialId,
type MaterialsOverlayEvent,
type MaterialsOverlayLayout,
type MaterialsOverlayState,
type MaterialsZoomTool,
zoomMaterialsLayoutAt,
} from '../../shared/types';
function emptyState(): MaterialsOverlayState {
return {
revision: 1,
activeMaterialIds: [],
layouts: {},
legendLayouts: {},
focusMaterialId: null,
zoomTool: null,
};
}
function initialLayout(rotationDeg?: number): MaterialsOverlayLayout {
return clampMaterialsLayout({
...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
rotationDeg: rotationDeg ?? 0,
});
}
function layoutFor(state: MaterialsOverlayState, materialId: MaterialId): MaterialsOverlayLayout {
return state.layouts[materialId] ?? { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT };
}
function legendLayoutFor(state: MaterialsOverlayState, materialId: MaterialId): MaterialsOverlayLayout {
return state.legendLayouts[materialId] ?? defaultLegendLayoutForMaterial();
}
export class MaterialsOverlayStore {
private state: MaterialsOverlayState = emptyState();
getState(): MaterialsOverlayState {
return this.state;
}
clear(): MaterialsOverlayState {
if (this.state.activeMaterialIds.length === 0 && this.state.zoomTool === null) {
return this.state;
}
this.state = {
revision: this.state.revision + 1,
activeMaterialIds: [],
layouts: {},
legendLayouts: {},
focusMaterialId: null,
zoomTool: null,
};
return this.state;
}
dispatch(event: MaterialsOverlayEvent): MaterialsOverlayState {
switch (event.kind) {
case 'hide':
return this.clear();
case 'show': {
if (this.state.activeMaterialIds.includes(event.materialId)) {
this.state = {
...this.state,
revision: this.state.revision + 1,
focusMaterialId: event.materialId,
};
return this.state;
}
this.state = {
revision: this.state.revision + 1,
activeMaterialIds: [...this.state.activeMaterialIds, event.materialId],
layouts: {
...this.state.layouts,
[event.materialId]: initialLayout(event.rotationDeg),
},
legendLayouts: {
...this.state.legendLayouts,
[event.materialId]: defaultLegendLayoutForMaterial(),
},
focusMaterialId: event.materialId,
zoomTool: this.state.zoomTool,
};
return this.state;
}
case 'toggle': {
if (this.state.activeMaterialIds.includes(event.materialId)) {
const activeMaterialIds = this.state.activeMaterialIds.filter((id) => id !== event.materialId);
const layouts = { ...this.state.layouts };
const legendLayouts = { ...this.state.legendLayouts };
delete layouts[event.materialId];
delete legendLayouts[event.materialId];
const focusMaterialId =
this.state.focusMaterialId === event.materialId
? (activeMaterialIds[activeMaterialIds.length - 1] ?? null)
: this.state.focusMaterialId;
this.state = {
revision: this.state.revision + 1,
activeMaterialIds,
layouts,
legendLayouts,
focusMaterialId,
zoomTool: activeMaterialIds.length === 0 ? null : this.state.zoomTool,
};
return this.state;
}
this.state = {
revision: this.state.revision + 1,
activeMaterialIds: [...this.state.activeMaterialIds, event.materialId],
layouts: {
...this.state.layouts,
[event.materialId]: initialLayout(event.rotationDeg),
},
legendLayouts: {
...this.state.legendLayouts,
[event.materialId]: defaultLegendLayoutForMaterial(),
},
focusMaterialId: event.materialId,
zoomTool: this.state.zoomTool,
};
return this.state;
}
case 'layout.set': {
if (!this.state.activeMaterialIds.includes(event.materialId)) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
focusMaterialId: event.materialId,
layouts: {
...this.state.layouts,
[event.materialId]: clampMaterialsLayout({
...layoutFor(this.state, event.materialId),
...event.layout,
}),
},
};
return this.state;
}
case 'legendLayout.set': {
if (!this.state.activeMaterialIds.includes(event.materialId)) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
focusMaterialId: event.materialId,
legendLayouts: {
...this.state.legendLayouts,
[event.materialId]: clampMaterialsLayout({
...legendLayoutFor(this.state, event.materialId),
...event.layout,
rotationDeg: 0,
}),
},
};
return this.state;
}
case 'zoomTool.set': {
const tool: MaterialsZoomTool = event.tool;
this.state = {
...this.state,
revision: this.state.revision + 1,
zoomTool: tool,
};
return this.state;
}
case 'zoomAt': {
if (this.state.activeMaterialIds.length === 0 || !this.state.zoomTool) return this.state;
const targetId =
(event.materialId && this.state.activeMaterialIds.includes(event.materialId)
? event.materialId
: null) ??
this.state.focusMaterialId ??
this.state.activeMaterialIds[this.state.activeMaterialIds.length - 1] ??
null;
if (!targetId) return this.state;
const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25;
const layout: MaterialsOverlayLayout = zoomMaterialsLayoutAt(
layoutFor(this.state, targetId),
event.nx,
event.ny,
factor,
);
this.state = {
...this.state,
revision: this.state.revision + 1,
focusMaterialId: targetId,
layouts: {
...this.state.layouts,
[targetId]: layout,
},
};
return this.state;
}
default:
return this.state;
}
}
ensureMaterialStillExists(materialIds: ReadonlySet<MaterialId>): MaterialsOverlayState {
const activeMaterialIds = this.state.activeMaterialIds.filter((id) => materialIds.has(id));
if (activeMaterialIds.length === this.state.activeMaterialIds.length) return this.state;
if (activeMaterialIds.length === 0) return this.clear();
const layouts: Record<string, MaterialsOverlayLayout> = {};
const legendLayouts: Record<string, MaterialsOverlayLayout> = {};
for (const id of activeMaterialIds) {
const layout = this.state.layouts[id];
if (layout) layouts[id] = layout;
const legend = this.state.legendLayouts[id];
if (legend) legendLayouts[id] = legend;
}
const focusMaterialId =
this.state.focusMaterialId && activeMaterialIds.includes(this.state.focusMaterialId)
? this.state.focusMaterialId
: (activeMaterialIds[activeMaterialIds.length - 1] ?? null);
this.state = {
revision: this.state.revision + 1,
activeMaterialIds,
layouts,
legendLayouts,
focusMaterialId,
zoomTool: this.state.zoomTool,
};
return this.state;
}
}
+40
View File
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { asNpcId } from '../../shared/types/ids';
import { NpcsOverlayStore } from './npcsOverlayStore';
const a = asNpcId('npc_a');
const b = asNpcId('npc_b');
void test('NpcsOverlayStore: inspect is independent from overlay show', () => {
const store = new NpcsOverlayStore();
store.dispatch({ kind: 'inspect.toggle', npcId: a });
store.dispatch({ kind: 'inspect.show', npcId: b });
assert.deepEqual(store.getState().inspectNpcIds, [a, b]);
assert.equal(store.getState().inspectFocusNpcId, b);
assert.deepEqual(store.getState().activeNpcIds, []);
store.dispatch({ kind: 'show', npcId: a });
assert.deepEqual(store.getState().activeNpcIds, [a]);
assert.deepEqual(store.getState().inspectNpcIds, [a, b]);
store.dispatch({ kind: 'hide' });
assert.deepEqual(store.getState().activeNpcIds, []);
assert.deepEqual(store.getState().inspectNpcIds, [a, b]);
store.dispatch({ kind: 'inspect.toggle', npcId: a });
assert.deepEqual(store.getState().inspectNpcIds, [b]);
});
void test('NpcsOverlayStore: clear wipes inspect; ensureNpcStillExists prunes', () => {
const store = new NpcsOverlayStore();
store.dispatch({ kind: 'inspect.show', npcId: a });
store.dispatch({ kind: 'show', npcId: b });
store.ensureNpcStillExists(new Set([a]));
assert.deepEqual(store.getState().inspectNpcIds, [a]);
assert.deepEqual(store.getState().activeNpcIds, []);
store.clear();
assert.deepEqual(store.getState().inspectNpcIds, []);
});
+259
View File
@@ -0,0 +1,259 @@
import {
clampNpcsLayout,
DEFAULT_NPCS_OVERLAY_LAYOUT,
type NpcId,
type NpcsOverlayEvent,
type NpcsOverlayLayout,
type NpcsOverlayState,
type NpcsZoomTool,
zoomNpcsLayoutAt,
} from '../../shared/types';
function emptyState(): NpcsOverlayState {
return {
revision: 1,
activeNpcIds: [],
layouts: {},
focusNpcId: null,
zoomTool: null,
inspectNpcIds: [],
inspectFocusNpcId: null,
};
}
function layoutFor(state: NpcsOverlayState, npcId: NpcId): NpcsOverlayLayout {
return state.layouts[npcId] ?? { ...DEFAULT_NPCS_OVERLAY_LAYOUT };
}
function addInspect(ids: NpcId[], npcId: NpcId): NpcId[] {
if (ids.includes(npcId)) return ids;
return [...ids, npcId];
}
function removeInspect(ids: NpcId[], npcId: NpcId): NpcId[] {
return ids.filter((id) => id !== npcId);
}
export class NpcsOverlayStore {
private state: NpcsOverlayState = emptyState();
getState(): NpcsOverlayState {
return this.state;
}
/** Полный сброс (закрытие проекта / конец сессии). */
clear(): NpcsOverlayState {
const s = this.state;
if (
s.activeNpcIds.length === 0 &&
s.zoomTool === null &&
s.inspectNpcIds.length === 0 &&
s.inspectFocusNpcId === null
) {
return s;
}
this.state = emptyState();
this.state.revision = s.revision + 1;
return this.state;
}
/** Снять аватары с экрана, карточки описания оставить. */
hideAvatars(): NpcsOverlayState {
if (this.state.activeNpcIds.length === 0 && this.state.zoomTool === null) {
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
activeNpcIds: [],
layouts: {},
focusNpcId: null,
zoomTool: null,
};
return this.state;
}
dispatch(event: NpcsOverlayEvent): NpcsOverlayState {
switch (event.kind) {
case 'hide':
return this.hideAvatars();
case 'show': {
if (this.state.activeNpcIds.includes(event.npcId)) {
this.state = {
...this.state,
revision: this.state.revision + 1,
focusNpcId: event.npcId,
};
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
layouts: {
...this.state.layouts,
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
},
focusNpcId: event.npcId,
};
return this.state;
}
case 'toggle': {
if (this.state.activeNpcIds.includes(event.npcId)) {
const activeNpcIds = this.state.activeNpcIds.filter((id) => id !== event.npcId);
const layouts = { ...this.state.layouts };
delete layouts[event.npcId];
const focusNpcId =
this.state.focusNpcId === event.npcId
? (activeNpcIds[activeNpcIds.length - 1] ?? null)
: this.state.focusNpcId;
this.state = {
...this.state,
revision: this.state.revision + 1,
activeNpcIds,
layouts,
focusNpcId,
zoomTool: activeNpcIds.length === 0 ? null : this.state.zoomTool,
};
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
layouts: {
...this.state.layouts,
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
},
focusNpcId: event.npcId,
};
return this.state;
}
case 'inspect.toggle': {
if (this.state.inspectNpcIds.includes(event.npcId)) {
const inspectNpcIds = removeInspect(this.state.inspectNpcIds, event.npcId);
const inspectFocusNpcId =
this.state.inspectFocusNpcId === event.npcId
? (inspectNpcIds[inspectNpcIds.length - 1] ?? null)
: this.state.inspectFocusNpcId;
this.state = {
...this.state,
revision: this.state.revision + 1,
inspectNpcIds,
inspectFocusNpcId,
};
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
inspectNpcIds: addInspect(this.state.inspectNpcIds, event.npcId),
inspectFocusNpcId: event.npcId,
};
return this.state;
}
case 'inspect.show': {
this.state = {
...this.state,
revision: this.state.revision + 1,
inspectNpcIds: addInspect(this.state.inspectNpcIds, event.npcId),
inspectFocusNpcId: event.npcId,
};
return this.state;
}
case 'layout.set': {
if (!this.state.activeNpcIds.includes(event.npcId)) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
focusNpcId: event.npcId,
layouts: {
...this.state.layouts,
[event.npcId]: clampNpcsLayout({
...layoutFor(this.state, event.npcId),
...event.layout,
}),
},
};
return this.state;
}
case 'zoomTool.set': {
const tool: NpcsZoomTool = event.tool;
this.state = {
...this.state,
revision: this.state.revision + 1,
zoomTool: tool,
};
return this.state;
}
case 'zoomAt': {
if (this.state.activeNpcIds.length === 0 || !this.state.zoomTool) return this.state;
const targetId =
(event.npcId && this.state.activeNpcIds.includes(event.npcId) ? event.npcId : null) ??
this.state.focusNpcId ??
this.state.activeNpcIds[this.state.activeNpcIds.length - 1] ??
null;
if (!targetId) return this.state;
const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25;
const layout: NpcsOverlayLayout = zoomNpcsLayoutAt(
layoutFor(this.state, targetId),
event.nx,
event.ny,
factor,
);
this.state = {
...this.state,
revision: this.state.revision + 1,
focusNpcId: targetId,
layouts: {
...this.state.layouts,
[targetId]: layout,
},
};
return this.state;
}
default:
return this.state;
}
}
ensureNpcStillExists(npcIds: ReadonlySet<NpcId>): NpcsOverlayState {
const activeNpcIds = this.state.activeNpcIds.filter((id) => npcIds.has(id));
const inspectNpcIds = this.state.inspectNpcIds.filter((id) => npcIds.has(id));
const sameActive = activeNpcIds.length === this.state.activeNpcIds.length;
const sameInspect = inspectNpcIds.length === this.state.inspectNpcIds.length;
if (sameActive && sameInspect) return this.state;
if (activeNpcIds.length === 0 && inspectNpcIds.length === 0) {
const zoomTool = this.state.zoomTool;
this.state = {
...emptyState(),
revision: this.state.revision + 1,
zoomTool,
};
return this.state;
}
const layouts: Record<string, NpcsOverlayLayout> = {};
for (const id of activeNpcIds) {
const layout = this.state.layouts[id];
if (layout) layouts[id] = layout;
}
const focusNpcId =
this.state.focusNpcId && activeNpcIds.includes(this.state.focusNpcId)
? this.state.focusNpcId
: (activeNpcIds[activeNpcIds.length - 1] ?? null);
const inspectFocusNpcId =
this.state.inspectFocusNpcId && inspectNpcIds.includes(this.state.inspectFocusNpcId)
? this.state.inspectFocusNpcId
: (inspectNpcIds[inspectNpcIds.length - 1] ?? null);
this.state = {
revision: this.state.revision + 1,
activeNpcIds,
layouts,
focusNpcId,
zoomTool: activeNpcIds.length === 0 ? null : this.state.zoomTool,
inspectNpcIds,
inspectFocusNpcId,
};
return this.state;
}
}
+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,104 @@
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, {});
});
void test('SceneNpcTokensSessionStore setDisposition without inventing position', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({
kind: 'setDisposition',
placementId: 'a',
disposition: 'hostile',
});
assert.equal(s1.byPlacementId.a?.disposition, 'hostile');
assert.equal(s1.byPlacementId.a?.nx, undefined);
assert.equal(s1.byPlacementId.a?.ny, undefined);
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.4, ny: 0.6 });
assert.equal(s2.byPlacementId.a?.nx, 0.4);
assert.equal(s2.byPlacementId.a?.disposition, 'hostile');
});
void test('SceneNpcTokensSessionStore spawn is session-only and despawn removes it', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({
kind: 'spawn',
npcId: 'npc1',
sceneId: 'sceneA',
nx: 0.4,
ny: 0.6,
disposition: 'hostile',
});
const ids = Object.keys(s1.spawned);
assert.equal(ids.length, 1);
const spawn = s1.spawned[ids[0]!];
assert.equal(spawn?.npcId, 'npc1');
assert.equal(spawn?.sceneId, 'sceneA');
assert.equal(spawn?.nx, 0.4);
const s2 = store.dispatch({ kind: 'move', placementId: ids[0]!, nx: 0.7, ny: 0.2 });
assert.equal(s2.spawned[ids[0]!]?.nx, 0.7);
const s3 = store.dispatch({ kind: 'despawn', placementId: ids[0]! });
assert.deepEqual(s3.spawned, {});
assert.equal(s3.byPlacementId[ids[0]!], undefined);
});
void test('SceneNpcTokensSessionStore setInactive is session flag', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: true });
assert.equal(s1.byPlacementId.a?.inactive, true);
const s2 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: false });
assert.deepEqual(s2.byPlacementId, {});
store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.3 });
store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: true });
const s3 = store.dispatch({ kind: 'setInactive', placementId: 'a', inactive: false });
assert.equal(s3.byPlacementId.a?.nx, 0.2);
assert.equal(s3.byPlacementId.a?.inactive, undefined);
});
void test('SceneNpcTokensSessionStore setSize is session-only and survives move', () => {
const store = new SceneNpcTokensSessionStore();
const s1 = store.dispatch({ kind: 'setSize', placementId: 'a', sizeN: 0.2 });
assert.equal(s1.byPlacementId.a?.sizeN, 0.2);
assert.equal(s1.byPlacementId.a?.nx, undefined);
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.3, ny: 0.4 });
assert.equal(s2.byPlacementId.a?.sizeN, 0.2);
assert.equal(s2.byPlacementId.a?.nx, 0.3);
const spawned = store.dispatch({
kind: 'spawn',
npcId: 'npc1',
sceneId: 'sceneA',
nx: 0.5,
ny: 0.5,
});
const id = Object.keys(spawned.spawned)[0]!;
const s3 = store.dispatch({ kind: 'setSize', placementId: id, sizeN: 0.18 });
assert.equal(s3.spawned[id]?.sizeN, 0.18);
assert.equal(s3.byPlacementId[id]?.sizeN, 0.18);
});
@@ -0,0 +1,284 @@
import {
clampNpcTokenSessionScale,
clampSceneNpcTokenSizeN,
DEFAULT_NPC_TOKEN_SESSION_SCALE,
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
asSceneNpcTokenId,
emptySceneNpcTokensSessionState,
newSceneNpcSessionSpawnId,
type SceneNpcSessionSpawn,
type SceneNpcTokensSessionEvent,
type SceneNpcTokensSessionPlacement,
type SceneNpcTokensSessionState,
} from '../../shared/types/appPlayers';
import { asNpcId } from '../../shared/types/ids';
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
function isEmptyPlacement(p: SceneNpcTokensSessionPlacement): boolean {
return (
p.nx === undefined &&
p.ny === undefined &&
p.sizeN === undefined &&
!p.disposition &&
!p.inactive
);
}
function restOf(prev: SceneNpcTokensSessionPlacement | undefined): SceneNpcTokensSessionPlacement {
return {
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(prev?.inactive ? { inactive: true } : {}),
};
}
function withMove(
prev: SceneNpcTokensSessionPlacement | undefined,
nx: number,
ny: number,
): SceneNpcTokensSessionPlacement {
return {
...restOf(prev),
nx,
ny,
};
}
function withoutPlacement(
state: SceneNpcTokensSessionState,
placementId: string,
): SceneNpcTokensSessionState {
if (!(placementId in state.byPlacementId)) return state;
const { [placementId]: _removed, ...rest } = state.byPlacementId;
return {
revision: state.revision + 1,
byPlacementId: rest,
scale: state.scale,
spawned: state.spawned,
};
}
export class SceneNpcTokensSessionStore {
private state: SceneNpcTokensSessionState = emptySceneNpcTokensSessionState();
getState(): SceneNpcTokensSessionState {
return this.state;
}
reset(): SceneNpcTokensSessionState {
if (
Object.keys(this.state.byPlacementId).length === 0 &&
Object.keys(this.state.spawned).length === 0 &&
this.state.scale === DEFAULT_NPC_TOKEN_SESSION_SCALE
) {
return this.state;
}
this.state = emptySceneNpcTokensSessionState(this.state.revision + 1);
return this.state;
}
/** Rotate session position overrides with the scene preview (CSS rotate steps). */
rotateMapCwSteps(steps: number): SceneNpcTokensSessionState {
const n = ((steps % 4) + 4) % 4;
if (n === 0) return this.state;
const ids = Object.keys(this.state.byPlacementId);
const spawnIds = Object.keys(this.state.spawned);
if (ids.length === 0 && spawnIds.length === 0) return this.state;
let changed = false;
const byPlacementId: SceneNpcTokensSessionState['byPlacementId'] = {};
for (const id of ids) {
const prev = this.state.byPlacementId[id];
if (!prev) continue;
if (typeof prev.nx === 'number' && typeof prev.ny === 'number') {
const p = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
byPlacementId[id] = { ...prev, nx: p.nx, ny: p.ny };
changed = true;
} else {
byPlacementId[id] = prev;
}
}
const spawned: SceneNpcTokensSessionState['spawned'] = {};
for (const id of spawnIds) {
const prev = this.state.spawned[id];
if (!prev) continue;
const p = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
spawned[id] = { ...prev, nx: p.nx, ny: p.ny };
changed = true;
}
if (!changed) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId,
scale: this.state.scale,
spawned,
};
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 'spawn': {
const npcId = String(event.npcId ?? '');
const sceneId = String(event.sceneId ?? '');
if (!npcId || !sceneId) 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 id = event.placementId
? asSceneNpcTokenId(String(event.placementId))
: newSceneNpcSessionSpawnId();
const spawn: SceneNpcSessionSpawn = {
id,
sceneId,
npcId: asNpcId(npcId),
nx,
ny,
sizeN: clampSceneNpcTokenSizeN(
typeof event.sizeN === 'number' ? event.sizeN : DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
),
disposition: normalizeNpcDisposition(event.disposition),
};
this.state = {
revision: this.state.revision + 1,
byPlacementId: this.state.byPlacementId,
scale: this.state.scale,
spawned: { ...this.state.spawned, [String(spawn.id)]: spawn },
};
return this.state;
}
case 'despawn': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
if (!(placementId in this.state.spawned) && !(placementId in this.state.byPlacementId)) {
return this.state;
}
const { [placementId]: _s, ...spawned } = this.state.spawned;
const { [placementId]: _p, ...byPlacementId } = this.state.byPlacementId;
this.state = {
revision: this.state.revision + 1,
byPlacementId,
scale: this.state.scale,
spawned,
};
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];
const spawn = this.state.spawned[placementId];
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
const spawned = spawn
? { ...this.state.spawned, [placementId]: { ...spawn, nx, ny } }
: this.state.spawned;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: withMove(prev, nx, ny),
},
scale: this.state.scale,
spawned,
};
return this.state;
}
case 'setDisposition': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const disposition = normalizeNpcDisposition(event.disposition);
const prev = this.state.byPlacementId[placementId];
const spawn = this.state.spawned[placementId];
if (prev?.disposition === disposition && (!spawn || spawn.disposition === disposition)) {
return this.state;
}
const next: SceneNpcTokensSessionPlacement = {
...restOf(prev),
disposition,
};
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: next,
},
scale: this.state.scale,
spawned: spawn
? { ...this.state.spawned, [placementId]: { ...spawn, disposition } }
: this.state.spawned,
};
return this.state;
}
case 'setInactive': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const inactive = Boolean(event.inactive);
const prev = this.state.byPlacementId[placementId];
if (Boolean(prev?.inactive) === inactive) return this.state;
const next: SceneNpcTokensSessionPlacement = {
...(prev?.nx !== undefined ? { nx: prev.nx } : {}),
...(prev?.ny !== undefined ? { ny: prev.ny } : {}),
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
...(prev?.disposition ? { disposition: prev.disposition } : {}),
...(inactive ? { inactive: true } : {}),
};
if (isEmptyPlacement(next)) {
this.state = withoutPlacement(this.state, placementId);
return this.state;
}
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: next,
},
scale: this.state.scale,
spawned: this.state.spawned,
};
return this.state;
}
case 'setSize': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
const prev = this.state.byPlacementId[placementId];
const spawn = this.state.spawned[placementId];
if (prev?.sizeN === sizeN && (!spawn || spawn.sizeN === sizeN)) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: { ...restOf(prev), sizeN },
},
scale: this.state.scale,
spawned: spawn
? { ...this.state.spawned, [placementId]: { ...spawn, sizeN } }
: this.state.spawned,
};
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
return this.state;
}
}
}
}
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { ScenePlayerTokensSessionStore } from './scenePlayerTokensSessionStore';
void test('ScenePlayerTokensSessionStore selection and show/hide', () => {
const store = new ScenePlayerTokensSessionStore();
store.dispatch({ kind: 'setSelection', playerIds: ['a', 'b', 'a'] });
assert.deepEqual(store.getState().selectedPlayerIds, ['a', 'b']);
assert.equal(store.getState().visible, false);
store.dispatch({ kind: 'show', sizeN: 0.08 });
assert.equal(store.getState().visible, true);
assert.equal(Object.keys(store.getState().byPlayerId).length, 2);
assert.ok(store.getState().byPlayerId.a);
assert.equal(store.getState().byPlayerId.a?.ny, 0.88);
store.dispatch({ kind: 'setVisible', visible: false });
assert.equal(store.getState().visible, false);
assert.ok(store.getState().byPlayerId.a);
store.dispatch({ kind: 'clearPlacements' });
assert.equal(store.getState().visible, false);
assert.deepEqual(store.getState().byPlayerId, {});
assert.deepEqual(store.getState().selectedPlayerIds, ['a', 'b']);
});
void test('ScenePlayerTokensSessionStore move and reset', () => {
const store = new ScenePlayerTokensSessionStore();
store.dispatch({ kind: 'setSelection', playerIds: ['p1'] });
store.dispatch({ kind: 'show', sizeN: 0.1 });
const s1 = store.dispatch({ kind: 'move', playerId: 'p1', nx: 0.3, ny: 0.4 });
assert.equal(s1.byPlayerId.p1?.nx, 0.3);
assert.equal(s1.byPlayerId.p1?.ny, 0.4);
const s2 = store.reset();
assert.deepEqual(s2.selectedPlayerIds, []);
assert.equal(s2.visible, false);
assert.deepEqual(s2.byPlayerId, {});
});
void test('ScenePlayerTokensSessionStore rotateMapCwSteps', () => {
const store = new ScenePlayerTokensSessionStore();
store.dispatch({ kind: 'setSelection', playerIds: ['p1'] });
store.dispatch({ kind: 'show', sizeN: 0.1 });
store.dispatch({ kind: 'move', playerId: 'p1', nx: 0.2, ny: 0.1 });
const s = store.rotateMapCwSteps(1);
assert.equal(s.byPlayerId.p1?.nx, 0.9);
assert.equal(s.byPlayerId.p1?.ny, 0.2);
});
void test('ScenePlayerTokensSessionStore setSize updates placement', () => {
const store = new ScenePlayerTokensSessionStore();
store.dispatch({ kind: 'setSelection', playerIds: ['p1'] });
store.dispatch({ kind: 'show', sizeN: 0.1 });
const nxBefore = store.getState().byPlayerId.p1?.nx;
const s1 = store.dispatch({ kind: 'setSize', playerId: 'p1', sizeN: 0.22 });
assert.equal(s1.byPlayerId.p1?.sizeN, 0.22);
assert.equal(s1.byPlayerId.p1?.nx, nxBefore);
const s2 = store.dispatch({ kind: 'move', playerId: 'p1', nx: 0.4, ny: 0.5 });
assert.equal(s2.byPlayerId.p1?.sizeN, 0.22);
});
@@ -0,0 +1,182 @@
import {
clampSceneNpcTokenSizeN,
DEFAULT_SCENE_NPC_TOKEN_SIZE_N,
layoutPlayerTokensBottom,
type ScenePlayerTokensSessionEvent,
type ScenePlayerTokensSessionState,
} from '../../shared/types/appPlayers';
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
function emptyState(revision = 1): ScenePlayerTokensSessionState {
return {
revision,
selectedPlayerIds: [],
visible: false,
byPlayerId: {},
};
}
function normalizePlayerIds(raw: readonly string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const id of raw) {
const s = String(id ?? '').trim();
if (!s || seen.has(s)) continue;
seen.add(s);
out.push(s);
}
return out;
}
export class ScenePlayerTokensSessionStore {
private state: ScenePlayerTokensSessionState = emptyState();
getState(): ScenePlayerTokensSessionState {
return this.state;
}
reset(): ScenePlayerTokensSessionState {
if (
this.state.selectedPlayerIds.length === 0 &&
!this.state.visible &&
Object.keys(this.state.byPlayerId).length === 0
) {
return this.state;
}
this.state = emptyState(this.state.revision + 1);
return this.state;
}
/** Смена сцены: спрятать токены, сбросить позиции; выбор игроков сохранить. */
clearPlacements(): ScenePlayerTokensSessionState {
if (!this.state.visible && Object.keys(this.state.byPlayerId).length === 0) {
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
visible: false,
byPlayerId: {},
};
return this.state;
}
/** Rotate live player tokens with the scene preview (CSS rotate steps). */
rotateMapCwSteps(steps: number): ScenePlayerTokensSessionState {
const n = ((steps % 4) + 4) % 4;
if (n === 0) return this.state;
const ids = Object.keys(this.state.byPlayerId);
if (ids.length === 0) return this.state;
const byPlayerId: ScenePlayerTokensSessionState['byPlayerId'] = {};
for (const id of ids) {
const prev = this.state.byPlayerId[id];
if (!prev) continue;
const p = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
byPlayerId[id] = { ...prev, nx: p.nx, ny: p.ny };
}
this.state = {
...this.state,
revision: this.state.revision + 1,
byPlayerId,
};
return this.state;
}
dispatch(event: ScenePlayerTokensSessionEvent): ScenePlayerTokensSessionState {
switch (event.kind) {
case 'clear':
return this.reset();
case 'clearPlacements':
return this.clearPlacements();
case 'setSelection': {
const selectedPlayerIds = normalizePlayerIds(event.playerIds);
const same =
selectedPlayerIds.length === this.state.selectedPlayerIds.length &&
selectedPlayerIds.every((id, i) => id === this.state.selectedPlayerIds[i]);
if (same) return this.state;
this.state = {
revision: this.state.revision + 1,
selectedPlayerIds,
visible: false,
byPlayerId: {},
};
return this.state;
}
case 'setVisible': {
const visible = Boolean(event.visible);
if (this.state.visible === visible) return this.state;
if (visible && this.state.selectedPlayerIds.length === 0) return this.state;
this.state = { ...this.state, revision: this.state.revision + 1, visible };
return this.state;
}
case 'show': {
if (this.state.selectedPlayerIds.length === 0) return this.state;
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
const byPlayerId =
Object.keys(this.state.byPlayerId).length > 0
? this.state.byPlayerId
: layoutPlayerTokensBottom(this.state.selectedPlayerIds, sizeN);
if (this.state.visible && byPlayerId === this.state.byPlayerId) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
visible: true,
byPlayerId,
};
return this.state;
}
case 'seedBottom': {
if (this.state.selectedPlayerIds.length === 0) return this.state;
if (Object.keys(this.state.byPlayerId).length > 0) return this.state;
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
this.state = {
...this.state,
revision: this.state.revision + 1,
byPlayerId: layoutPlayerTokensBottom(this.state.selectedPlayerIds, sizeN),
};
return this.state;
}
case 'move': {
const playerId = String(event.playerId ?? '');
if (!playerId || !this.state.selectedPlayerIds.includes(playerId)) 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.byPlayerId[playerId];
if (prev && prev.nx === nx && prev.ny === ny) return this.state;
const sizeN = clampSceneNpcTokenSizeN(prev?.sizeN ?? DEFAULT_SCENE_NPC_TOKEN_SIZE_N);
this.state = {
...this.state,
revision: this.state.revision + 1,
byPlayerId: {
...this.state.byPlayerId,
[playerId]: { nx, ny, sizeN },
},
};
return this.state;
}
case 'setSize': {
const playerId = String(event.playerId ?? '');
if (!playerId || !this.state.selectedPlayerIds.includes(playerId)) return this.state;
const prev = this.state.byPlayerId[playerId];
if (!prev) return this.state;
const sizeN = clampSceneNpcTokenSizeN(event.sizeN);
if (prev.sizeN === sizeN) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
byPlayerId: {
...this.state.byPlayerId,
[playerId]: { ...prev, sizeN },
},
};
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
return this.state;
}
}
}
}
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { filterPlaylistsByQuery } from '../../shared/types/appPlaylists';
import { asPlaylistAudioId, asPlaylistId, asPlaylistTrackId } from '../../shared/types/ids';
import { PlaylistsStore } from './playlistsStore';
void test('filterPlaylistsByQuery: matches playlist name and track title', () => {
const state = {
playlists: [
{
id: asPlaylistId('p1'),
name: 'Battle',
tracks: [{ id: asPlaylistTrackId('t1'), audioId: asPlaylistAudioId('a1') }],
},
{
id: asPlaylistId('p2'),
name: 'Town',
tracks: [{ id: asPlaylistTrackId('t2'), audioId: asPlaylistAudioId('a2') }],
},
],
audios: [
{
id: asPlaylistAudioId('a1'),
title: 'Drums',
originalName: 'drums.mp3',
relPath: 'files/a.mp3',
sha256: '1',
mime: 'audio/mpeg',
sizeBytes: 1,
},
{
id: asPlaylistAudioId('a2'),
title: 'Market',
originalName: 'market.mp3',
relPath: 'files/b.mp3',
sha256: '2',
mime: 'audio/mpeg',
sizeBytes: 1,
},
],
};
const byName = filterPlaylistsByQuery(state, 'bat');
assert.equal(byName.playlists.length, 1);
assert.equal(byName.playlists[0]?.id, asPlaylistId('p1'));
const byTrack = filterPlaylistsByQuery(state, 'market');
assert.equal(byTrack.playlists.length, 1);
assert.ok(byTrack.matchedAudioIds.has(asPlaylistAudioId('a2')));
});
void test('PlaylistsStore: create playlist and add audio by hash reuse', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-pl-'));
const store = new PlaylistsStore(dir);
await store.ensureLoaded();
const pl = await store.createPlaylist('Test');
const audioPath = path.join(dir, 'sample.mp3');
fs.writeFileSync(audioPath, Buffer.from('ID3fake-audio-bytes'));
await store.addAudioFiles(pl.id, [audioPath]);
await store.addAudioFiles(pl.id, [audioPath]);
const state = store.getState();
assert.equal(state.audios.length, 1);
assert.equal(state.playlists[0]?.tracks.length, 2);
assert.equal(state.playlists[0]?.tracks[0]?.audioId, state.playlists[0]?.tracks[1]?.audioId);
});
+424
View File
@@ -0,0 +1,424 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import type {
AppPlaylist,
PlaylistAudio,
PlaylistTrack,
PlaylistsExportBundle,
PlaylistsState,
} from '../../shared/types/appPlaylists';
import {
normalizePlaylistName,
titleFromAudioFileName,
} from '../../shared/types/appPlaylists';
import {
asPlaylistAudioId,
asPlaylistId,
asPlaylistTrackId,
type PlaylistAudioId,
type PlaylistId,
type PlaylistTrackId,
} from '../../shared/types/ids';
type PlaylistsManifest = {
playlists: AppPlaylist[];
audios: PlaylistAudio[];
};
const AUDIO_EXTS = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.aac']);
function mimeFromExt(ext: string): string {
const e = ext.toLowerCase();
if (e === '.mp3') return 'audio/mpeg';
if (e === '.wav') return 'audio/wav';
if (e === '.ogg') return 'audio/ogg';
if (e === '.m4a') return 'audio/mp4';
if (e === '.aac') return 'audio/aac';
return 'application/octet-stream';
}
function safeFileBase(name: string): string {
const base = name.replace(/[^\w.\-]+/gu, '_').slice(0, 48);
return base || 'audio';
}
function randomPlaylistId(): PlaylistId {
return asPlaylistId(`pl_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
}
function randomAudioId(): PlaylistAudioId {
return asPlaylistAudioId(`pla_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
}
function randomTrackId(): PlaylistTrackId {
return asPlaylistTrackId(`plt_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
}
function uniquePlaylistName(desired: string, playlists: AppPlaylist[], exceptId?: PlaylistId): string {
const base = normalizePlaylistName(desired);
const taken = new Set(
playlists.filter((p) => p.id !== exceptId).map((p) => p.name.trim().toLowerCase()),
);
if (!taken.has(base.toLowerCase())) return base;
let i = 2;
while (taken.has(`${base} (${String(i)})`.toLowerCase())) i += 1;
return `${base} (${String(i)})`;
}
export class PlaylistsStore {
private readonly rootDir: string;
private readonly filesDir: string;
private readonly manifestPath: string;
private playlists: AppPlaylist[] = [];
private audios: PlaylistAudio[] = [];
private loaded = false;
constructor(userData: string) {
this.rootDir = path.join(userData, 'playlists');
this.filesDir = path.join(this.rootDir, 'files');
this.manifestPath = path.join(this.rootDir, 'playlists.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 PlaylistsManifest;
this.audios = Array.isArray(parsed.audios) ? parsed.audios : [];
const audioIds = new Set(this.audios.map((a) => a.id));
this.playlists = (Array.isArray(parsed.playlists) ? parsed.playlists : []).map((p) => ({
id: asPlaylistId(String(p.id)),
name: normalizePlaylistName(String(p.name ?? '')),
tracks: (Array.isArray(p.tracks) ? p.tracks : [])
.filter((t) => audioIds.has(t.audioId))
.map((t) => ({
id: asPlaylistTrackId(String(t.id)),
audioId: asPlaylistAudioId(String(t.audioId)),
})),
}));
} catch {
this.playlists = [];
this.audios = [];
await this.persist();
}
this.loaded = true;
}
getState(): PlaylistsState {
return {
playlists: this.playlists.map((p) => ({ ...p, tracks: [...p.tracks] })),
audios: [...this.audios],
};
}
getAudioReadInfo(id: PlaylistAudioId): { absPath: string; mime: string } | null {
const audio = this.audios.find((a) => a.id === id);
if (!audio) return null;
return {
absPath: path.join(this.rootDir, audio.relPath),
mime: audio.mime || mimeFromExt(path.extname(audio.relPath)),
};
}
getAudioUrl(id: PlaylistAudioId): string | null {
if (!this.getAudioReadInfo(id)) return null;
return `dnd://playlist-audio?id=${encodeURIComponent(id)}`;
}
private async persist(): Promise<void> {
await fs.mkdir(this.rootDir, { recursive: true });
const manifest: PlaylistsManifest = { playlists: this.playlists, audios: this.audios };
await fs.writeFile(this.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8');
}
async createPlaylist(name?: string): Promise<AppPlaylist> {
await this.ensureLoaded();
const pl: AppPlaylist = {
id: randomPlaylistId(),
name: uniquePlaylistName(name ?? 'Playlist', this.playlists),
tracks: [],
};
this.playlists = [pl, ...this.playlists];
await this.persist();
return pl;
}
async renamePlaylist(id: PlaylistId, name: string): Promise<AppPlaylist> {
await this.ensureLoaded();
const idx = this.playlists.findIndex((p) => p.id === id);
if (idx < 0) throw new Error('Playlist not found');
const next = {
...this.playlists[idx]!,
name: uniquePlaylistName(name, this.playlists, id),
};
this.playlists = this.playlists.map((p, i) => (i === idx ? next : p));
await this.persist();
return next;
}
async duplicatePlaylist(id: PlaylistId): Promise<AppPlaylist> {
await this.ensureLoaded();
const src = this.playlists.find((p) => p.id === id);
if (!src) throw new Error('Playlist not found');
const copy: AppPlaylist = {
id: randomPlaylistId(),
name: uniquePlaylistName(`${src.name} copy`, this.playlists),
tracks: src.tracks.map((t) => ({ id: randomTrackId(), audioId: t.audioId })),
};
const at = this.playlists.findIndex((p) => p.id === id);
const next = [...this.playlists];
next.splice(at + 1, 0, copy);
this.playlists = next;
await this.persist();
return copy;
}
async deletePlaylist(id: PlaylistId): Promise<void> {
await this.ensureLoaded();
this.playlists = this.playlists.filter((p) => p.id !== id);
await this.pruneOrphanAudios();
await this.persist();
}
async setPlaylistsOrder(playlistIds: PlaylistId[]): Promise<void> {
await this.ensureLoaded();
const map = new Map(this.playlists.map((p) => [p.id, p]));
const next: AppPlaylist[] = [];
for (const id of playlistIds) {
const p = map.get(id);
if (p) {
next.push(p);
map.delete(id);
}
}
for (const p of map.values()) next.push(p);
this.playlists = next;
await this.persist();
}
async setTracksOrder(playlistId: PlaylistId, trackIds: PlaylistTrackId[]): Promise<AppPlaylist> {
await this.ensureLoaded();
const pl = this.playlists.find((p) => p.id === playlistId);
if (!pl) throw new Error('Playlist not found');
const map = new Map(pl.tracks.map((t) => [t.id, t]));
const tracks: PlaylistTrack[] = [];
for (const id of trackIds) {
const t = map.get(id);
if (t) {
tracks.push(t);
map.delete(id);
}
}
for (const t of map.values()) tracks.push(t);
const next = { ...pl, tracks };
this.playlists = this.playlists.map((p) => (p.id === playlistId ? next : p));
await this.persist();
return next;
}
async renameAudio(audioId: PlaylistAudioId, title: string): Promise<PlaylistAudio> {
await this.ensureLoaded();
const idx = this.audios.findIndex((a) => a.id === audioId);
if (idx < 0) throw new Error('Audio not found');
const next = { ...this.audios[idx]!, title: title.trim() || this.audios[idx]!.title };
this.audios = this.audios.map((a, i) => (i === idx ? next : a));
await this.persist();
return next;
}
async removeTrack(playlistId: PlaylistId, trackId: PlaylistTrackId): Promise<AppPlaylist> {
await this.ensureLoaded();
const pl = this.playlists.find((p) => p.id === playlistId);
if (!pl) throw new Error('Playlist not found');
const next = { ...pl, tracks: pl.tracks.filter((t) => t.id !== trackId) };
this.playlists = this.playlists.map((p) => (p.id === playlistId ? next : p));
await this.pruneOrphanAudios();
await this.persist();
return next;
}
async addAudioFiles(playlistId: PlaylistId, filePaths: string[]): Promise<AppPlaylist> {
await this.ensureLoaded();
const pl = this.playlists.find((p) => p.id === playlistId);
if (!pl) throw new Error('Playlist not found');
const tracks = [...pl.tracks];
for (const filePath of filePaths) {
const ext = path.extname(filePath).toLowerCase();
if (!AUDIO_EXTS.has(ext)) continue;
const audio = await this.importOrReuseFile(filePath);
tracks.push({ id: randomTrackId(), audioId: audio.id });
}
const next = { ...pl, tracks };
this.playlists = this.playlists.map((p) => (p.id === playlistId ? next : p));
await this.persist();
return next;
}
/** Импорт файла с известным содержимым (Foundry / zip bundle). */
async importAudioBuffer(input: {
preferredId?: PlaylistAudioId;
title: string;
originalName: string;
mime: string;
buffer: Buffer;
}): Promise<PlaylistAudio> {
await this.ensureLoaded();
const sha256 = crypto.createHash('sha256').update(input.buffer).digest('hex');
const existing = this.audios.find((a) => a.sha256 === sha256);
if (existing) return existing;
const id =
input.preferredId && !this.audios.some((a) => a.id === input.preferredId)
? input.preferredId
: randomAudioId();
const ext = path.extname(input.originalName).toLowerCase() || '.mp3';
const fileName = `${id}_${safeFileBase(input.title)}${AUDIO_EXTS.has(ext) ? ext : '.mp3'}`;
const abs = path.join(this.filesDir, fileName);
await fs.writeFile(abs, input.buffer);
const audio: PlaylistAudio = {
id,
title: input.title.trim() || titleFromAudioFileName(input.originalName),
originalName: input.originalName,
relPath: path.join('files', fileName).replace(/\\/gu, '/'),
sha256,
mime: input.mime || mimeFromExt(ext),
sizeBytes: input.buffer.length,
};
this.audios = [...this.audios, audio];
await this.persist();
return audio;
}
async importBundle(
bundle: PlaylistsExportBundle,
readFile: (relPath: string) => Promise<Buffer>,
): Promise<{ importedPlaylistIds: PlaylistId[] }> {
await this.ensureLoaded();
const audioIdMap = new Map<PlaylistAudioId, PlaylistAudioId>();
for (const a of bundle.audios ?? []) {
try {
const buf = await readFile(a.relPath);
const imported = await this.importAudioBuffer({
title: a.title,
originalName: a.originalName,
mime: a.mime,
buffer: buf,
});
audioIdMap.set(a.id, imported.id);
} catch {
/* skip missing file */
}
}
const importedPlaylistIds: PlaylistId[] = [];
for (const pl of bundle.playlists ?? []) {
const tracks: PlaylistTrack[] = [];
for (const t of pl.tracks ?? []) {
const mapped = audioIdMap.get(t.audioId);
if (!mapped) continue;
tracks.push({ id: randomTrackId(), audioId: mapped });
}
const created: AppPlaylist = {
id: randomPlaylistId(),
name: uniquePlaylistName(pl.name, this.playlists),
tracks,
};
this.playlists = [...this.playlists, created];
importedPlaylistIds.push(created.id);
}
await this.persist();
return { importedPlaylistIds };
}
buildExportBundle(playlistIds: PlaylistId[]): {
bundle: PlaylistsExportBundle;
fileAbsPaths: { relPath: string; absPath: string }[];
} {
const selected = this.playlists.filter((p) => playlistIds.includes(p.id));
const usedAudioIds = new Set<PlaylistAudioId>();
for (const p of selected) {
for (const t of p.tracks) usedAudioIds.add(t.audioId);
}
const audios = this.audios.filter((a) => usedAudioIds.has(a.id));
const fileAbsPaths = audios.map((a) => ({
relPath: a.relPath,
absPath: path.join(this.rootDir, a.relPath),
}));
return {
bundle: { version: 1, playlists: selected, audios },
fileAbsPaths,
};
}
async packForExport(playlistIds: PlaylistId[], exportRoot: string): Promise<void> {
await this.ensureLoaded();
if (playlistIds.length === 0) return;
const { bundle, fileAbsPaths } = this.buildExportBundle(playlistIds);
if (bundle.playlists.length === 0) return;
const outDir = path.join(exportRoot, 'app-playlists');
await fs.mkdir(outDir, { recursive: true });
for (const f of fileAbsPaths) {
const dest = path.join(outDir, f.relPath);
await fs.mkdir(path.dirname(dest), { recursive: true });
await fs.copyFile(f.absPath, dest);
}
await fs.writeFile(
path.join(outDir, 'playlists.json'),
`${JSON.stringify(bundle, null, 2)}\n`,
'utf8',
);
}
async importFromExportDir(sourceCache: string): Promise<void> {
await this.ensureLoaded();
const manifestPath = path.join(sourceCache, 'app-playlists', 'playlists.json');
let raw: string;
try {
raw = await fs.readFile(manifestPath, 'utf8');
} catch {
return;
}
let bundle: PlaylistsExportBundle;
try {
bundle = JSON.parse(raw) as PlaylistsExportBundle;
} catch {
return;
}
await this.importBundle(bundle, async (relPath) => {
return fs.readFile(path.join(sourceCache, 'app-playlists', relPath));
});
}
private async importOrReuseFile(filePath: string): Promise<PlaylistAudio> {
const buf = await fs.readFile(filePath);
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const existing = this.audios.find((a) => a.sha256 === sha256);
if (existing) return existing;
const originalName = path.basename(filePath);
const ext = path.extname(filePath).toLowerCase();
return this.importAudioBuffer({
title: titleFromAudioFileName(originalName),
originalName,
mime: mimeFromExt(ext),
buffer: buf,
});
}
private async pruneOrphanAudios(): Promise<void> {
const used = new Set<PlaylistAudioId>();
for (const p of this.playlists) {
for (const t of p.tracks) used.add(t.audioId);
}
const orphans = this.audios.filter((a) => !used.has(a.id));
this.audios = this.audios.filter((a) => used.has(a.id));
for (const a of orphans) {
try {
await fs.unlink(path.join(this.rootDir, a.relPath));
} catch {
/* ignore */
}
}
}
}
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { readPresentationSettings, writePresentationSettings } from './appPreferences';
void test('appPreferences: presentation settings round-trip without clobbering eula', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-prefs-'));
const prefsPath = path.join(dir, 'preferences.json');
fs.writeFileSync(prefsPath, `${JSON.stringify({ eulaAcceptedVersion: 2 }, null, 2)}\n`, 'utf8');
writePresentationSettings(dir, { displayId: 42, fullScreen: false });
const loaded = readPresentationSettings(dir);
assert.equal(loaded.displayId, 42);
assert.equal(loaded.fullScreen, false);
const raw = JSON.parse(fs.readFileSync(prefsPath, 'utf8')) as {
eulaAcceptedVersion?: number;
presentation?: { displayId: number | null; fullScreen: boolean };
};
assert.equal(raw.eulaAcceptedVersion, 2);
assert.equal(raw.presentation?.displayId, 42);
assert.equal(raw.presentation?.fullScreen, false);
});
void test('appPreferences: defaults when preferences missing', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dnd-prefs-'));
const loaded = readPresentationSettings(dir);
assert.equal(loaded.displayId, null);
assert.equal(loaded.fullScreen, true);
});
+47
View File
@@ -0,0 +1,47 @@
import fs from 'node:fs';
import type { PresentationSettings } from '../../shared/types/presentationSettings';
import { DEFAULT_PRESENTATION_SETTINGS } from '../../shared/types/presentationSettings';
import { preferencesPath } from '../license/paths';
type StoredPreferences = {
eulaAcceptedVersion?: number;
presentation?: Partial<PresentationSettings>;
};
function readAll(userData: string): StoredPreferences {
try {
const raw = fs.readFileSync(preferencesPath(userData), 'utf8');
return JSON.parse(raw) as StoredPreferences;
} catch {
return {};
}
}
function writeAll(userData: string, prefs: StoredPreferences): void {
fs.mkdirSync(userData, { recursive: true });
fs.writeFileSync(preferencesPath(userData), `${JSON.stringify(prefs, null, 2)}\n`, 'utf8');
}
export function readPresentationSettings(userData: string): PresentationSettings {
const prefs = readAll(userData);
return {
...DEFAULT_PRESENTATION_SETTINGS,
...prefs.presentation,
};
}
export function writePresentationSettings(
userData: string,
patch: Partial<PresentationSettings>,
): PresentationSettings {
const prefs = readAll(userData);
const next: PresentationSettings = {
...DEFAULT_PRESENTATION_SETTINGS,
...prefs.presentation,
...patch,
};
prefs.presentation = next;
writeAll(userData, prefs);
return next;
}
+8 -4
View File
@@ -17,14 +17,16 @@ void test('collectReferencedAssetIds: превью, видео и аудио', (
previewThumbAssetId: 'th' as AssetId,
media: {
videos: ['v1' as AssetId],
audios: [{ assetId: 'a1' as AssetId, autoplay: true, loop: true }],
audios: [{ assetId: 'a1' as AssetId, autoplay: true, loop: true, volume: 1 }],
},
},
},
campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true }],
campaignAudios: [{ assetId: 'ca1' as AssetId, autoplay: true, loop: true, volume: 1 }],
materials: [{ id: 'm1', name: 'Map', assetId: 'mat1' as AssetId }],
npcs: [{ id: 'n1', name: 'Guard', avatarAssetId: 'npc1' as AssetId }],
} as unknown as Project;
const s = collectReferencedAssetIds(p);
assert.deepEqual([...s].sort(), ['a1', 'ca1', 'pr', 'th', 'v1'].sort());
assert.deepEqual([...s].sort(), ['a1', 'ca1', 'mat1', 'npc1', 'pr', 'th', 'v1'].sort());
});
void test('reconcileAssetFiles: снимает осиротевшие assets и удаляет файлы', async () => {
@@ -65,12 +67,14 @@ void test('reconcileAssetFiles: снимает осиротевшие assets и
...base,
scenes: {},
campaignAudios: [],
materials: [],
assets: { orphan: asset } as Project['assets'],
};
const next: Project = {
...base,
scenes: {},
campaignAudios: [],
materials: [],
assets: { orphan: asset } as Project['assets'],
};
@@ -115,7 +119,7 @@ void test('reconcileAssetFiles: удаляет файл при исключен
} as unknown as Project;
const prev: Project = { ...base, assets: { gone: asset } as Project['assets'] };
const next: Project = { ...base, campaignAudios: [], assets: {} as Project['assets'] };
const next: Project = { ...base, campaignAudios: [], materials: [], assets: {} as Project['assets'] };
const out = await reconcileAssetFiles(prev, next, tmp);
assert.deepEqual(out.assets, {});
+2
View File
@@ -14,6 +14,8 @@ export function collectReferencedAssetIds(p: Project): Set<AssetId> {
for (const au of sc.media.audios) refs.add(au.assetId);
}
for (const au of p.campaignAudios) refs.add(au.assetId);
for (const m of p.materials ?? []) refs.add(m.assetId);
for (const n of p.npcs ?? []) refs.add(n.avatarAssetId);
return refs;
}
+17 -5
View File
@@ -2,10 +2,16 @@ import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import {
PROJECT_ZIP_EXTENSION,
PROJECT_ZIP_EXTENSION_LEGACY,
isProjectZipFileName,
} from '../../shared/project/projectZipExtension';
import { readProjectJsonFromZip } from './yauzlProjectZip';
/**
* Подменяет файл `finalPath` готовым `completedSrc` (обычно `*.dnd.zip.tmp`).
* Подменяет файл `finalPath` готовым `completedSrc` (обычно `*.ttrpg.zip.tmp` / `*.dnd.zip.tmp`).
* Нельзя сначала удалять `finalPath`: при сбое rename после rm проект теряется (Windows/антивирус).
*/
export async function replaceFileAtomic(completedSrc: string, finalPath: string): Promise<void> {
@@ -50,8 +56,10 @@ export async function replaceFileAtomic(completedSrc: string, finalPath: string)
await fs.unlink(backupPath).catch(() => undefined);
}
/** Если сохранение оборвалось, остаётся только `*.dnd.zip.tmp` — восстанавливаем в `*.dnd.zip`. */
export async function recoverOrphanDndZipTmpInRoot(root: string): Promise<void> {
const ZIP_TMP_SUFFIXES = [`${PROJECT_ZIP_EXTENSION}.tmp`, `${PROJECT_ZIP_EXTENSION_LEGACY}.tmp`] as const;
/** Если сохранение оборвалось, остаётся `*.ttrpg.zip.tmp` / `*.dnd.zip.tmp` — восстанавливаем финальный архив. */
export async function recoverOrphanProjectZipTmpInRoot(root: string): Promise<void> {
let names: string[];
try {
names = await fs.readdir(root);
@@ -59,10 +67,11 @@ export async function recoverOrphanDndZipTmpInRoot(root: string): Promise<void>
return;
}
for (const name of names) {
if (!name.endsWith('.dnd.zip.tmp')) continue;
const matchedSuffix = ZIP_TMP_SUFFIXES.find((s) => name.endsWith(s));
if (!matchedSuffix) continue;
const tmpPath = path.join(root, name);
const finalName = name.slice(0, -'.tmp'.length);
if (!finalName.endsWith('.dnd.zip')) continue;
if (!isProjectZipFileName(finalName)) continue;
const finalPath = path.join(root, finalName);
try {
await fs.access(finalPath);
@@ -80,3 +89,6 @@ export async function recoverOrphanDndZipTmpInRoot(root: string): Promise<void>
}
}
}
/** @deprecated Используйте {@link recoverOrphanProjectZipTmpInRoot}. */
export const recoverOrphanDndZipTmpInRoot = recoverOrphanProjectZipTmpInRoot;
+9 -1
View File
@@ -2,7 +2,7 @@
* Visually lossless re-encode for imported raster images (same pixel dimensions).
* Node-only; shared by the main app and ../project-converter (monorepo sibling).
*/
import sharp from 'sharp';
import { getSharp } from './sharpRuntime.mjs';
/** @typedef {import('node:buffer').Buffer} Buffer */
@@ -102,6 +102,7 @@ function makePassthrough(buf, meta) {
* @param {number} h0
*/
async function sameDimensionsOrThrow(outBuf, w0, h0) {
const sharp = getSharp();
const m = await sharp(outBuf).metadata();
if ((m.width ?? 0) !== w0 || (m.height ?? 0) !== h0) {
const err = new Error('encode changed dimensions');
@@ -120,6 +121,13 @@ export async function optimizeImageBufferVisuallyLossless(src) {
return makePassthrough(input, { width: 0, height: 0, format: 'png' });
}
let sharp;
try {
sharp = getSharp();
} catch {
return makePassthrough(input, null);
}
let meta0;
try {
meta0 = await sharp(input, { failOn: 'error', unlimited: true }).metadata();
+8 -1
View File
@@ -17,7 +17,14 @@ export function getProjectsCacheRootDir(): string {
export function getLegacyProjectsRootDirs(): string[] {
const cur = getProjectsRootDir();
const parent = path.dirname(app.getPath('userData'));
const siblingNames = ['DnD Player', 'dnd-player', 'DNDGamePlayer', 'dnd_player'];
const siblingNames = [
'TTRPG Player',
'TTRPGPlayer',
'DnD Player',
'dnd-player',
'DNDGamePlayer',
'dnd_player',
];
const out: string[] = [];
for (const n of siblingNames) {
const p = path.join(parent, n, 'projects');
@@ -33,3 +33,19 @@ void test('generateScenePreviewThumbnailBytes: image scales to max edge', async
await fs.rm(tmp, { recursive: true, force: true });
});
void test('generateScenePreviewThumbnailBytes: accepts image Buffer', async () => {
const png = await sharp({
create: {
width: 120,
height: 80,
channels: 3,
background: { r: 10, g: 20, b: 30 },
},
})
.png()
.toBuffer();
const buf = await generateScenePreviewThumbnailBytes(png, 'image');
assert.ok(buf !== null);
assert.ok(buf.length > 0);
});
+9 -3
View File
@@ -5,7 +5,8 @@ import path from 'node:path';
import { promisify } from 'node:util';
import ffmpegStatic from 'ffmpeg-static';
import sharp from 'sharp';
import { getSharp } from './sharpRuntime.mjs';
const execFileAsync = promisify(execFile);
@@ -14,14 +15,16 @@ export const SCENE_PREVIEW_THUMB_MAX_PX = 320;
/**
* Builds a small WebP still for graph/list previews. Returns null if generation fails (import still succeeds).
* For images, prefer a Buffer so callers are not racy with reconcileAssetFiles unlinking paths.
*/
export async function generateScenePreviewThumbnailBytes(
sourceAbsPath: string,
source: string | Buffer,
kind: 'image' | 'video',
): Promise<Buffer | null> {
try {
const sharp = getSharp();
if (kind === 'image') {
return await sharp(sourceAbsPath)
return await sharp(source)
.rotate()
.resize(SCENE_PREVIEW_THUMB_MAX_PX, SCENE_PREVIEW_THUMB_MAX_PX, {
fit: 'inside',
@@ -31,6 +34,9 @@ export async function generateScenePreviewThumbnailBytes(
.toBuffer();
}
const sourceAbsPath = typeof source === 'string' ? source : null;
if (!sourceAbsPath) return null;
const ffmpegPath = ffmpegStatic;
if (!ffmpegPath) return null;
+71
View File
@@ -0,0 +1,71 @@
/**
* Lazy `sharp` load so a corrupt/missing native install does not crash Electron at import time.
* Call only from image-processing paths; errors are recoverable for the rest of the app.
*
* Note: main is bundled to CJS (esbuild). `import.meta.url` is empty there — prefer `__filename`.
*/
import { createRequire } from 'node:module';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
/**
* @returns {string}
*/
function requireBaseFilename() {
// CJS bundle / Electron main
if (typeof __filename === 'string' && __filename.length > 0) {
return __filename;
}
// Direct ESM (unit tests)
const metaUrl = import.meta.url;
if (typeof metaUrl === 'string' && metaUrl.startsWith('file:')) {
return fileURLToPath(metaUrl);
}
return path.join(process.cwd(), 'package.json');
}
const require = createRequire(requireBaseFilename());
/** @type {typeof import('sharp') | null} */
let cached = null;
/** @type {Error | null} */
let loadError = null;
/**
* @param {unknown} err
* @returns {Error}
*/
export function sharpLoadFailure(err) {
const detail = err instanceof Error ? err.message : String(err);
return new Error(
[
'Не удалось загрузить модуль обработки изображений (sharp).',
'Переустановите приложение полностью (удалите и поставьте заново)',
'или исключите папку установки из проверки антивируса.',
detail ? `Детали: ${detail}` : '',
]
.filter(Boolean)
.join(' '),
);
}
/**
* @returns {typeof import('sharp')}
*/
export function getSharp() {
if (cached) return cached;
if (loadError) throw loadError;
try {
cached = require('sharp');
return cached;
} catch (err) {
loadError = sharpLoadFailure(err);
throw loadError;
}
}
/** Reset cache (tests only). */
export function __resetSharpRuntimeForTests() {
cached = null;
loadError = null;
}
+20
View File
@@ -0,0 +1,20 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
__resetSharpRuntimeForTests,
getSharp,
sharpLoadFailure,
} from './sharpRuntime.mjs';
void test('getSharp: loads sharp when install is healthy', () => {
__resetSharpRuntimeForTests();
const sharp = getSharp();
assert.equal(typeof sharp, 'function');
});
void test('sharpLoadFailure: includes reinstall hint', () => {
const err = sharpLoadFailure(new Error('SyntaxError: Unexpected end of input'));
assert.match(err.message, /переустановите/i);
assert.match(err.message, /SyntaxError/);
});
+3 -3
View File
@@ -13,7 +13,7 @@ import { readProjectJsonFromZip } from './yauzlProjectZip';
void test('readProjectJsonFromZip: sequential reads close yauzl (no EMFILE)', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-zip-read-'));
const zipPath = path.join(tmp, 'test.dnd.zip');
const zipPath = path.join(tmp, 'test.ttrpg.zip');
const minimal = {
id: 'p1',
meta: {
@@ -54,7 +54,7 @@ void test('readProjectJsonFromZip: sequential reads close yauzl (no EMFILE)', as
void test('readProjectJsonFromZip: campaignAudios round-trips inside project.json', async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-zip-campaign-'));
const zipPath = path.join(tmp, 'test.dnd.zip');
const zipPath = path.join(tmp, 'test.ttrpg.zip');
const assetId = 'audio_asset_1';
const minimal = {
id: 'p1',
@@ -69,7 +69,7 @@ void test('readProjectJsonFromZip: campaignAudios round-trips inside project.jso
},
scenes: {},
assets: {},
campaignAudios: [{ assetId, autoplay: true, loop: false }],
campaignAudios: [{ assetId, autoplay: true, loop: false, volume: 1 }],
currentSceneId: null,
currentGraphNodeId: null,
sceneGraphNodes: [],
@@ -28,9 +28,43 @@ void test('zipStore: openProjectById flushes pending saveNow before cache reset'
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
// When switching projects we rm cacheDir and unzip zip; ensure pending debounced pack is flushed first.
assert.match(src, /async openProjectById/);
assert.match(src, /enqueueOpenProject/);
assert.match(src, /openProjectByIdInner/);
assert.match(src, /if \(this\.openProject\)\s*\{\s*await this\.saveNow\(\);\s*\}/);
assert.match(src, /await this\.drainSavePipeline\(\)/);
assert.match(src, /await fs\.rm\(cacheDir, \{ recursive: true, force: true \}\)/);
assert.match(src, /await unzipToDir\(zipPath, cacheDir\)/);
assert.match(src, /await unzipToDir\(zipPath, cacheDir/);
});
void test('zipStore: openProjectById skips re-unzip when project is already open', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /if \(this\.openProject\?\.id === projectId\)\s*\{\s*return this\.openProject\.project;\s*\}/);
});
void test('zipStore: pack and open operations are serialized', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /private packChain: Promise<void>/);
assert.match(src, /private projectSwitchChain: Promise<void>/);
assert.match(src, /enqueuePack/);
assert.match(src, /enqueueOpenProject/);
assert.match(src, /enqueueProjectSwitch/);
});
void test('zipStore: project updates and preview finalize are serialized', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /private projectUpdateChain: Promise<unknown>/);
assert.match(src, /private previewFinalizeChain: Promise<unknown>/);
assert.match(src, /enqueueProjectUpdate/);
assert.match(src, /enqueuePreviewFinalize/);
assert.match(src, /finalizeScenePreviewImportInner/);
assert.match(src, /drainProjectMutations/);
assert.match(src, /addBuffer/);
});
void test('zipStore: closeOpenProject is serialized with open on projectSwitchChain', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /async closeOpenProject\(\): Promise<void> \{[\s\S]*enqueueProjectSwitch/);
assert.match(src, /Открытие проекта отменено/);
});
void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => {
@@ -46,6 +80,23 @@ void test('zipStore: normalizeScene defaults previewThumbAssetId for older proje
assert.match(src, /function normalizeScene\(/);
});
void test('zipStore: NPC parse defaults externalUrl', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /externalUrl: normalizeNpcExternalUrl/);
});
void test('zipStore: updateScene merges patch inside updateProject mutator (no stale base race)', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
const i = src.indexOf('async updateScene(sceneId: SceneId, patch: ScenePatch)');
assert.ok(i >= 0);
const j = src.indexOf('async updateConnections(', i);
assert.ok(j > i);
const block = src.slice(i, j);
assert.match(block, /await this\.updateProject\(\(p\) => \{/);
assert.match(block, /const existing = p\.scenes\[sceneId\]/);
assert.doesNotMatch(block, /const existing = open\.project\.scenes\[sceneId\]/);
});
void test('zipStore: listProjects skips unreadable archives', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(
@@ -54,11 +105,28 @@ void test('zipStore: listProjects skips unreadable archives', () => {
);
});
void test('zipStore: setSessionPointer updates pointer in memory without disk write', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
const m = src.match(/setSessionPointer\([\s\S]*?\n \}/);
assert.ok(m);
assert.doesNotMatch(m[0], /writeCacheProject|queueSave|enqueueProjectUpdate/);
});
void test('main index: setCurrentGraphNode uses setSessionPointer not updateProject', () => {
const src = fs.readFileSync(path.join(here, '..', 'index.ts'), 'utf8');
const m = src.match(
/registerHandler\(ipcChannels\.project\.setCurrentGraphNode[\s\S]*?emitSessionState\(\)/,
);
assert.ok(m);
assert.match(m[0], /setSessionPointer/);
assert.doesNotMatch(m[0], /updateProject/);
});
void test('atomicReplace: replaceFileAtomic must not rm destination before successful commit', () => {
const src = fs.readFileSync(path.join(here, 'atomicReplace.ts'), 'utf8');
const i = src.indexOf('export async function replaceFileAtomic');
assert.ok(i >= 0);
const j = src.indexOf('export async function recoverOrphanDndZipTmpInRoot', i);
const j = src.indexOf('export async function recoverOrphanProjectZipTmpInRoot', i);
assert.ok(j > i);
const block = src.slice(i, j);
assert.match(block, /rename\(finalPath, backupPath\)/);
+1848 -194
View File
File diff suppressed because it is too large Load Diff
+90 -54
View File
@@ -2,72 +2,108 @@ import fs from 'node:fs/promises';
import { session } from 'electron';
import { asAssetId } from '../../shared/types/ids';
import { asAssetId, asPlayerId, asPlaylistAudioId, asTokenId } from '../../shared/types/ids';
import type { PlayersStore } from '../players/playersStore';
import type { PlaylistsStore } from '../playlists/playlistsStore';
import type { ZipProjectStore } from '../project/zipStore';
import type { TokensStore } from '../tokens/tokensStore';
type ReadInfo = { absPath: string; mime: string };
async function serveFile(info: ReadInfo, request: Request): Promise<Response> {
try {
const stat = await fs.stat(info.absPath);
const total = stat.size;
const range = request.headers.get('range') ?? request.headers.get('Range');
if (range) {
const m = /^bytes=(\d+)-(\d+)?$/iu.exec(range.trim());
if (m) {
const start = Number(m[1]);
const endRaw = m[2] ? Number(m[2]) : total - 1;
const end = Math.min(endRaw, total - 1);
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start >= total || end < start) {
return new Response(null, {
status: 416,
headers: {
'Content-Range': `bytes */${String(total)}`,
'Cache-Control': 'no-store',
},
});
}
const len = end - start + 1;
const fh = await fs.open(info.absPath, 'r');
try {
const buf = Buffer.alloc(len);
const { bytesRead } = await fh.read(buf, 0, len, start);
if (bytesRead <= 0) {
return new Response(null, {
status: 416,
headers: {
'Content-Range': `bytes */${String(total)}`,
'Cache-Control': 'no-store',
},
});
}
const body = bytesRead === len ? buf : Buffer.from(buf.subarray(0, bytesRead));
const actualEnd = start + bytesRead - 1;
return new Response(body, {
status: 206,
headers: {
'Content-Type': info.mime,
'Accept-Ranges': 'bytes',
'Content-Range': `bytes ${String(start)}-${String(actualEnd)}/${String(total)}`,
'Content-Length': String(body.length),
'Cache-Control': 'no-store',
},
});
} finally {
await fh.close();
}
}
}
const buf = await fs.readFile(info.absPath);
return new Response(buf, {
headers: {
'Content-Type': info.mime,
'Accept-Ranges': 'bytes',
'Cache-Control': 'no-store',
},
});
} catch {
return new Response(null, { status: 404 });
}
}
/**
* Обслуживает `dnd://asset?...` — без этого `<img src="file://...">` в рендерере часто ломается.
* Обслуживает `dnd://asset?...`, `dnd://token?...`, `dnd://player?...` и `dnd://playlist-audio?...`.
*/
export function registerDndAssetProtocol(projectStore: ZipProjectStore): void {
export function registerDndAssetProtocol(
projectStore: ZipProjectStore,
tokensStore: TokensStore,
playersStore: PlayersStore | null,
playlistsStore: PlaylistsStore | null = null,
): void {
session.defaultSession.protocol.handle('dnd', async (request) => {
const url = new URL(request.url);
if (url.hostname !== 'asset') {
return new Response(null, { status: 404 });
}
const id = url.searchParams.get('id');
if (!id) {
return new Response(null, { status: 404 });
}
const info = projectStore.getAssetReadInfo(asAssetId(id));
let info: ReadInfo | null = null;
if (url.hostname === 'asset') {
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)) ?? null;
} else if (url.hostname === 'playlist-audio') {
info = playlistsStore?.getAudioReadInfo(asPlaylistAudioId(id)) ?? null;
}
if (!info) {
return new Response(null, { status: 404 });
}
try {
const stat = await fs.stat(info.absPath);
const total = stat.size;
const range = request.headers.get('range') ?? request.headers.get('Range');
if (range) {
const m = /^bytes=(\d+)-(\d+)?$/iu.exec(range.trim());
if (m) {
const start = Number(m[1]);
const endRaw = m[2] ? Number(m[2]) : total - 1;
const end = Math.min(endRaw, total - 1);
if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || end < start) {
return new Response(null, { status: 416 });
}
const len = end - start + 1;
const fh = await fs.open(info.absPath, 'r');
try {
const buf = Buffer.alloc(len);
await fh.read(buf, 0, len, start);
return new Response(buf, {
status: 206,
headers: {
'Content-Type': info.mime,
'Accept-Ranges': 'bytes',
'Content-Range': `bytes ${String(start)}-${String(end)}/${String(total)}`,
'Content-Length': String(len),
'Cache-Control': 'public, max-age=300',
},
});
} finally {
await fh.close();
}
}
}
const buf = await fs.readFile(info.absPath);
return new Response(buf, {
headers: {
'Content-Type': info.mime,
'Accept-Ranges': 'bytes',
'Content-Length': String(buf.length),
'Cache-Control': 'public, max-age=300',
},
});
} catch {
return new Response(null, { status: 404 });
}
return serveFile(info, request);
});
}
+16
View File
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
void test('main: EPIPE guards ставятся до app.whenReady', () => {
const index = fs.readFileSync(path.join(here, 'index.ts'), 'utf8');
const safe = fs.readFileSync(path.join(here, 'safeConsole.ts'), 'utf8');
assert.ok(index.includes('installStdoutEpipeGuards'));
assert.ok(index.indexOf('installStdoutEpipeGuards()') < index.indexOf('app.requestSingleInstanceLock'));
assert.ok(safe.includes('EPIPE'));
assert.ok(safe.includes('safeConsoleError'));
});
+26
View File
@@ -0,0 +1,26 @@
/**
* В Electron (особенно после рестарта в dev) stdout/stderr часто уже закрыты.
* Обычный `console.error` тогда даёт EPIPE и валит main process диалогом Uncaught Exception.
*/
function isBrokenPipe(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
return code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED';
}
export function installStdoutEpipeGuards(): void {
for (const stream of [process.stdout, process.stderr]) {
stream?.on('error', (err: NodeJS.ErrnoException) => {
if (isBrokenPipe(err)) return;
});
}
}
export function safeConsoleError(...args: unknown[]): void {
try {
console.error(...args);
} catch (err) {
if (isBrokenPipe(err)) return;
throw err;
}
}
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SceneTrapsStore } from './sceneTrapsStore';
void test('SceneTrapsStore: сохраняет runtime при переключении сцен внутри сессии', () => {
const store = new SceneTrapsStore();
store.switchScene('scene_a', ['t1']);
store.dispatch({ kind: 'activate', trapId: 't1' });
assert.equal(store.getState().byId.t1?.status, 'active');
assert.equal(store.getState().byId.t1?.revealed, true);
store.switchScene('scene_b', ['t2']);
assert.equal(store.getState().byId.t2?.status, 'inactive');
store.switchScene('scene_a', ['t1']);
assert.equal(store.getState().byId.t1?.status, 'active');
assert.equal(store.getState().byId.t1?.revealed, true);
});
void test('SceneTrapsStore: resetSession сбрасывает кэш к дефолту (кнопка «Запустить»)', () => {
const store = new SceneTrapsStore();
store.switchScene('scene_a', ['t1', 't2']);
store.dispatch({ kind: 'reveal', trapId: 't1' });
store.dispatch({ kind: 'activate', trapId: 't2' });
assert.equal(store.getState().byId.t1?.revealed, true);
assert.equal(store.getState().byId.t2?.status, 'active');
assert.ok(store.getState().lastActivation);
store.resetSession();
store.switchScene('scene_a', ['t1', 't2']);
assert.equal(store.getState().byId.t1?.status, 'inactive');
assert.equal(store.getState().byId.t1?.revealed, false);
assert.equal(store.getState().byId.t2?.status, 'inactive');
assert.equal(store.getState().byId.t2?.revealed, false);
assert.equal(store.getState().lastActivation, null);
});
+134
View File
@@ -0,0 +1,134 @@
import {
defaultTrapRuntime,
type SceneTrapsEvent,
type SceneTrapsState,
type SceneTrapRuntime,
} from '../../shared/types';
function emptyState(): SceneTrapsState {
return {
revision: 1,
cacheKey: null,
byId: {},
lastActivation: null,
};
}
function cloneById(byId: Record<string, SceneTrapRuntime>): Record<string, SceneTrapRuntime> {
const out: Record<string, SceneTrapRuntime> = {};
for (const [k, v] of Object.entries(byId)) {
out[k] = { ...v };
}
return out;
}
export class SceneTrapsStore {
private state: SceneTrapsState = emptyState();
/** Кэш runtime по ключу сцены/ноды на время сессии показа. */
private cache = new Map<string, Record<string, SceneTrapRuntime>>();
private currentKey: string | null = null;
private activationToken = 0;
getState(): SceneTrapsState {
return {
...this.state,
byId: cloneById(this.state.byId),
lastActivation: this.state.lastActivation ? { ...this.state.lastActivation } : null,
};
}
resetSession(): void {
this.cache.clear();
this.currentKey = null;
this.state = emptyState();
}
/**
* Переключение сцены: сохраняем runtime в кэш и подгружаем/инициализируем для новых trapIds.
*/
switchScene(cacheKey: string | null, trapIds: readonly string[]): SceneTrapsState {
if (this.currentKey !== null) {
this.cache.set(this.currentKey, cloneById(this.state.byId));
}
this.currentKey = cacheKey;
const cached = cacheKey ? this.cache.get(cacheKey) : undefined;
const byId: Record<string, SceneTrapRuntime> = {};
for (const id of trapIds) {
byId[id] = cached?.[id] ? { ...cached[id]! } : defaultTrapRuntime();
}
if (cacheKey) {
this.cache.set(cacheKey, cloneById(byId));
}
this.state = {
revision: this.state.revision + 1,
cacheKey,
byId,
lastActivation: null,
};
return this.getState();
}
dispatch(event: SceneTrapsEvent): SceneTrapsState {
switch (event.kind) {
case 'syncTrapIds': {
const byId: Record<string, SceneTrapRuntime> = {};
for (const id of event.trapIds) {
byId[id] = this.state.byId[id] ? { ...this.state.byId[id]! } : defaultTrapRuntime();
}
if (this.currentKey) this.cache.set(this.currentKey, cloneById(byId));
this.state = {
...this.state,
revision: this.state.revision + 1,
byId,
};
return this.getState();
}
case 'reveal': {
const cur = this.state.byId[event.trapId];
if (!cur) return this.getState();
const byId = cloneById(this.state.byId);
byId[event.trapId] = { ...cur, revealed: true };
if (this.currentKey) this.cache.set(this.currentKey, cloneById(byId));
this.state = {
...this.state,
revision: this.state.revision + 1,
byId,
};
return this.getState();
}
case 'activate': {
const cur = this.state.byId[event.trapId];
if (!cur) return this.getState();
const byId = cloneById(this.state.byId);
byId[event.trapId] = { status: 'active', revealed: true };
this.activationToken += 1;
if (this.currentKey) this.cache.set(this.currentKey, cloneById(byId));
this.state = {
...this.state,
revision: this.state.revision + 1,
byId,
lastActivation: { trapId: event.trapId, token: this.activationToken },
};
return this.getState();
}
case 'disarm': {
const cur = this.state.byId[event.trapId];
if (!cur) return this.getState();
const byId = cloneById(this.state.byId);
byId[event.trapId] = { status: 'disarmed', revealed: true };
if (this.currentKey) this.cache.set(this.currentKey, cloneById(byId));
this.state = {
...this.state,
revision: this.state.revision + 1,
byId,
};
return this.getState();
}
default: {
const _x: never = event;
void _x;
return this.getState();
}
}
}
}
+67
View File
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
DEFAULT_SCENE_VIEW_CAMERA,
sceneViewPanBy,
sceneViewZoomAt,
} from '../../shared/types/sceneView';
import { SceneViewStore } from './sceneViewStore';
describe('SceneViewStore', () => {
it('resets to default camera', () => {
const store = new SceneViewStore();
store.dispatch({ kind: 'set', camera: { scale: 2, ox: 0.2, oy: 0.8 } });
const next = store.dispatch({ kind: 'reset' });
assert.equal(next.scale, 1);
assert.equal(next.ox, 0.5);
assert.equal(next.oy, 0.5);
});
it('clamps scale and origin', () => {
const store = new SceneViewStore();
const next = store.dispatch({ kind: 'set', camera: { scale: 99, ox: -1, oy: 2 } });
assert.equal(next.scale, 8);
assert.equal(next.ox, 0);
assert.equal(next.oy, 1);
});
});
describe('sceneViewZoomAt / panBy', () => {
it('zooms toward cursor and keeps that content point under cursor', () => {
const hostW = 1000;
const hostH = 500;
const containW = 800;
const containH = 400;
const hostX = 700;
const hostY = 250;
const next = sceneViewZoomAt(DEFAULT_SCENE_VIEW_CAMERA, {
hostW,
hostH,
containW,
containH,
hostX,
hostY,
factor: 2,
});
assert.ok(next.scale > 1.5);
const displayW = containW * next.scale;
const displayH = containH * next.scale;
const left = hostW / 2 - next.ox * displayW;
const top = hostH / 2 - next.oy * displayH;
const ix = (hostX - left) / displayW;
const iy = (hostY - top) / displayH;
// At scale=1 contain is centered; cursor was at content x=(700-100)/800=0.75
assert.ok(Math.abs(ix - 0.75) < 1e-6);
assert.ok(Math.abs(iy - 0.5) < 1e-6);
});
it('pans in host pixels', () => {
const cam = { scale: 2, ox: 0.5, oy: 0.5 };
const next = sceneViewPanBy(cam, { containW: 400, containH: 200, dx: 80, dy: 0 });
// displayW=800; dx=80 → ox decreases by 0.1
assert.ok(Math.abs(next.ox - 0.4) < 1e-6);
assert.equal(next.oy, 0.5);
});
});
+64
View File
@@ -0,0 +1,64 @@
import {
clampSceneViewCamera,
DEFAULT_SCENE_VIEW_CAMERA,
type SceneViewCamera,
type SceneViewEvent,
type SceneViewState,
} from '../../shared/types';
function emptyState(): SceneViewState {
return {
revision: 1,
...DEFAULT_SCENE_VIEW_CAMERA,
};
}
export class SceneViewStore {
private state: SceneViewState = emptyState();
getState(): SceneViewState {
return this.state;
}
reset(): SceneViewState {
if (
this.state.scale === 1 &&
this.state.ox === 0.5 &&
this.state.oy === 0.5
) {
return this.state;
}
this.state = {
revision: this.state.revision + 1,
...DEFAULT_SCENE_VIEW_CAMERA,
};
return this.state;
}
dispatch(event: SceneViewEvent): SceneViewState {
switch (event.kind) {
case 'reset':
return this.reset();
case 'set': {
const camera: SceneViewCamera = clampSceneViewCamera(event.camera);
if (
camera.scale === this.state.scale &&
camera.ox === this.state.ox &&
camera.oy === this.state.oy
) {
return this.state;
}
this.state = {
revision: this.state.revision + 1,
...camera,
};
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
return this.state;
}
}
}
}
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
/**
* Фаза 6 (срез): layout-only мутации не должны слать полный Project во все окна.
* Редактор НПС применяет позицию из ответа invoke локально.
*/
void test('session IPC: нет emitSessionState на graph/NPC position hot-path', () => {
const index = fs.readFileSync(path.join(here, 'index.ts'), 'utf8');
const createWindows = fs.readFileSync(path.join(here, 'windows/createWindows.ts'), 'utf8');
const npcsEditor = fs.readFileSync(
path.join(here, '../renderer/npcs/NpcsEditorApp.tsx'),
'utf8',
);
assert.ok(index.includes('sendToAppWindows(ipcChannels.session.stateChanged'));
assert.ok(createWindows.includes('SESSION_STATE_WINDOW_KINDS'));
assert.ok(createWindows.includes('sendToAppWindows'));
const kindsBlock = /SESSION_STATE_WINDOW_KINDS: readonly WindowKind\[\] = \[([\s\S]*?)\] as const/.exec(
createWindows,
);
assert.ok(kindsBlock, 'SESSION_STATE_WINDOW_KINDS объявлен');
assert.doesNotMatch(kindsBlock[1] ?? '', /'editor'/, 'editor не получает session.stateChanged');
assert.match(kindsBlock[1] ?? '', /'presentation'/);
assert.match(kindsBlock[1] ?? '', /'control'/);
// Handlers больше не вызывают emitSessionState сразу после layout-update.
const npcPosHandler =
/registerHandler\(ipcChannels\.project\.updateNpcPosition,\s*async\s*\(\{ npcId, x, y \}\) => \{([\s\S]*?)\}\);/.exec(
index,
);
const graphPosHandler =
/registerHandler\(ipcChannels\.project\.updateSceneGraphNodePosition,\s*async\s*\(\{ nodeId, x, y \}\) => \{([\s\S]*?)\}\);/.exec(
index,
);
assert.ok(npcPosHandler, 'updateNpcPosition handler');
assert.ok(graphPosHandler, 'updateSceneGraphNodePosition handler');
assert.doesNotMatch(npcPosHandler[1] ?? '', /emitSessionState/);
assert.doesNotMatch(graphPosHandler[1] ?? '', /emitSessionState/);
assert.match(npcPosHandler[1] ?? '', /return \{ project \}/);
assert.match(graphPosHandler[1] ?? '', /return \{ project \}/);
assert.ok(npcsEditor.includes('updateNpcPosition'));
assert.match(
npcsEditor,
/onNodePositionCommit[\s\S]*?setSession\([\s\S]*?npcs: prev\.project\.npcs\.map/,
);
assert.match(npcsEditor, /await api\.invoke\(ipcChannels\.project\.updateNpcPosition/);
});
void test('video IPC heartbeat does not fan out to every BrowserWindow', () => {
const index = fs.readFileSync(path.join(here, 'index.ts'), 'utf8');
const emit =
/function emitVideoState\(\): void \{([\s\S]*?)\n\}/.exec(index);
assert.ok(emit, 'emitVideoState');
assert.doesNotMatch(emit[1] ?? '', /getAllWindows/);
assert.match(emit[1] ?? '', /sendToAppWindows\(ipcChannels\.video\.stateChanged/);
assert.match(emit[1] ?? '', /'control'/);
assert.match(emit[1] ?? '', /'presentation'/);
});
@@ -0,0 +1,83 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { TimerSessionStore } from './timerSessionStore';
import {
clampSessionTimerLayout,
formatSessionTimerMs,
getSessionTimerRemainingMs,
parseSessionTimerDurationInput,
} from '../../shared/types/sessionTimer';
void test('getSessionTimerRemainingMs: paused uses remainingMs', () => {
const store = new TimerSessionStore();
const s = store.getState();
assert.equal(getSessionTimerRemainingMs(s, 1_000_000), s.remainingMs);
});
void test('start pause start preserves remaining', () => {
const store = new TimerSessionStore();
const t0 = 1_000_000;
store.dispatch({ kind: 'setDuration', durationMs: 60_000 }, t0);
store.dispatch({ kind: 'start' }, t0);
store.dispatch({ kind: 'pause' }, t0 + 10_000);
const afterPause = store.getState();
assert.equal(afterPause.running, false);
assert.equal(afterPause.remainingMs, 50_000);
store.dispatch({ kind: 'start' }, t0 + 20_000);
assert.equal(getSessionTimerRemainingMs(store.getState(), t0 + 25_000), 45_000);
});
void test('reset restores duration', () => {
const store = new TimerSessionStore();
store.dispatch({ kind: 'setDuration', durationMs: 90_000 });
store.dispatch({ kind: 'start' }, 0);
store.dispatch({ kind: 'pause' }, 30_000);
store.dispatch({ kind: 'reset' });
assert.equal(store.getState().remainingMs, 90_000);
assert.equal(store.getState().running, false);
});
void test('setVisibleToPlayers and layout.set', () => {
const store = new TimerSessionStore();
store.dispatch({ kind: 'setVisibleToPlayers', visible: true });
assert.equal(store.getState().visibleToPlayers, true);
store.dispatch({
kind: 'layout.set',
layout: { cx: 0.2, cy: 0.3, scale: 2, rotationDeg: 45 },
});
assert.equal(store.getState().layout.cx, 0.2);
assert.equal(store.getState().layout.scale, 2);
assert.equal(store.getState().layout.rotationDeg, 45);
});
void test('settleIfExpired stops at zero', () => {
const store = new TimerSessionStore();
store.dispatch({ kind: 'setDuration', durationMs: 5_000 }, 0);
store.dispatch({ kind: 'start' }, 0);
store.settleIfExpired(10_000);
assert.equal(store.getState().running, false);
assert.equal(store.getState().remainingMs, 0);
});
void test('clampSessionTimerLayout / format / parse', () => {
const clamped = clampSessionTimerLayout({ cx: 9, cy: -9, scale: 100, rotationDeg: 370 });
assert.ok(clamped.cx <= 1.2);
assert.ok(clamped.cy >= -0.2);
assert.ok(clamped.scale <= 8);
assert.equal(clamped.rotationDeg, 10);
assert.equal(formatSessionTimerMs(65_000), '01:05');
assert.equal(formatSessionTimerMs(3_661_000), '1:01:01');
assert.equal(parseSessionTimerDurationInput('5:00'), 300_000);
assert.equal(parseSessionTimerDurationInput('1:02:03'), 3723_000);
assert.equal(parseSessionTimerDurationInput('bad'), null);
});
void test('clear restores defaults', () => {
const store = new TimerSessionStore();
store.dispatch({ kind: 'setVisibleToPlayers', visible: true });
store.dispatch({ kind: 'start' }, 0);
store.clear();
assert.equal(store.getState().visibleToPlayers, false);
assert.equal(store.getState().running, false);
});
+131
View File
@@ -0,0 +1,131 @@
import {
clampSessionTimerDurationMs,
clampSessionTimerLayout,
createInitialSessionTimerState,
getSessionTimerRemainingMs,
type SessionTimerEvent,
type SessionTimerState,
} from '../../shared/types/sessionTimer';
export class TimerSessionStore {
private state: SessionTimerState = createInitialSessionTimerState();
getState(): SessionTimerState {
return this.state;
}
clear(): SessionTimerState {
this.state = createInitialSessionTimerState();
return this.state;
}
dispatch(event: SessionTimerEvent, now = Date.now()): SessionTimerState {
switch (event.kind) {
case 'setDuration': {
const durationMs = clampSessionTimerDurationMs(event.durationMs);
if (this.state.running) {
const remaining = getSessionTimerRemainingMs(this.state, now);
this.state = {
...this.state,
revision: this.state.revision + 1,
durationMs,
remainingMs: Math.min(remaining, durationMs),
endsAt: now + Math.min(remaining, durationMs),
};
} else {
this.state = {
...this.state,
revision: this.state.revision + 1,
durationMs,
remainingMs: durationMs,
endsAt: null,
running: false,
};
}
return this.state;
}
case 'start': {
const remaining = getSessionTimerRemainingMs(this.state, now);
if (remaining <= 0) {
// Restart from full duration if already at zero.
const durationMs = this.state.durationMs;
this.state = {
...this.state,
revision: this.state.revision + 1,
remainingMs: durationMs,
running: true,
endsAt: now + durationMs,
};
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
running: true,
endsAt: now + remaining,
remainingMs: remaining,
};
return this.state;
}
case 'pause': {
if (!this.state.running) return this.state;
const remaining = getSessionTimerRemainingMs(this.state, now);
this.state = {
...this.state,
revision: this.state.revision + 1,
running: false,
endsAt: null,
remainingMs: remaining,
};
return this.state;
}
case 'reset': {
this.state = {
...this.state,
revision: this.state.revision + 1,
running: false,
endsAt: null,
remainingMs: this.state.durationMs,
};
return this.state;
}
case 'setVisibleToPlayers': {
if (this.state.visibleToPlayers === event.visible) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
visibleToPlayers: event.visible,
};
return this.state;
}
case 'layout.set': {
const layout = clampSessionTimerLayout(event.layout);
this.state = {
...this.state,
revision: this.state.revision + 1,
layout,
};
return this.state;
}
default: {
const _exhaustive: never = event;
return _exhaustive;
}
}
}
/** Когда running и время вышло — зафиксировать 00:00 (вызывается из UI/main по желанию). */
settleIfExpired(now = Date.now()): SessionTimerState {
if (!this.state.running) return this.state;
const remaining = getSessionTimerRemainingMs(this.state, now);
if (remaining > 0) return this.state;
this.state = {
...this.state,
revision: this.state.revision + 1,
running: false,
endsAt: null,
remainingMs: 0,
};
return this.state;
}
}
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { SceneTokensSessionStore } from './sceneTokensSessionStore';
void test('SceneTokensSessionStore setSize is session-only and survives move', () => {
const store = new SceneTokensSessionStore();
const s1 = store.dispatch({ kind: 'setSize', placementId: 't1', sizeN: 0.2 });
assert.equal(s1.byPlacementId.t1?.sizeN, 0.2);
assert.equal(s1.byPlacementId.t1?.nx, undefined);
const s2 = store.dispatch({ kind: 'move', placementId: 't1', nx: 0.3, ny: 0.4 });
assert.equal(s2.byPlacementId.t1?.sizeN, 0.2);
assert.equal(s2.byPlacementId.t1?.nx, 0.3);
const s3 = store.dispatch({ kind: 'setSize', placementId: 't1', sizeN: 0.01 });
assert.equal(s3.byPlacementId.t1?.sizeN, 0.02); // clamped min
});
void test('SceneTokensSessionStore rotateMapCwSteps keeps sizeN', () => {
const store = new SceneTokensSessionStore();
store.dispatch({ kind: 'move', placementId: 't1', nx: 0.2, ny: 0.1 });
store.dispatch({ kind: 'setSize', placementId: 't1', sizeN: 0.15 });
const s = store.rotateMapCwSteps(1);
assert.equal(s.byPlacementId.t1?.nx, 0.9);
assert.equal(s.byPlacementId.t1?.ny, 0.2);
assert.equal(s.byPlacementId.t1?.sizeN, 0.15);
});
+115
View File
@@ -0,0 +1,115 @@
import {
clampSceneTokenSizeN,
type SceneTokensSessionEvent,
type SceneTokensSessionPlacement,
type SceneTokensSessionState,
} from '../../shared/types';
import { rotateMapNormPointByCwSteps } from '../../shared/types/scenePreviewRotation';
function emptyState(): SceneTokensSessionState {
return {
revision: 1,
byPlacementId: {},
};
}
function withMove(
prev: SceneTokensSessionPlacement | undefined,
nx: number,
ny: number,
): SceneTokensSessionPlacement {
return {
nx,
ny,
...(prev?.sizeN !== undefined ? { sizeN: prev.sizeN } : {}),
};
}
export class SceneTokensSessionStore {
private state: SceneTokensSessionState = emptyState();
getState(): SceneTokensSessionState {
return this.state;
}
reset(): SceneTokensSessionState {
if (Object.keys(this.state.byPlacementId).length === 0) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {},
};
return this.state;
}
/** Rotate session position overrides with the scene preview (CSS rotate steps). */
rotateMapCwSteps(steps: number): SceneTokensSessionState {
const n = ((steps % 4) + 4) % 4;
if (n === 0) return this.state;
const ids = Object.keys(this.state.byPlacementId);
if (ids.length === 0) return this.state;
const byPlacementId: SceneTokensSessionState['byPlacementId'] = {};
for (const id of ids) {
const prev = this.state.byPlacementId[id];
if (!prev) continue;
if (typeof prev.nx === 'number' && typeof prev.ny === 'number') {
const next = rotateMapNormPointByCwSteps(prev.nx, prev.ny, n);
byPlacementId[id] = { ...prev, nx: next.nx, ny: next.ny };
} else {
byPlacementId[id] = prev;
}
}
this.state = {
revision: this.state.revision + 1,
byPlacementId,
};
return this.state;
}
dispatch(event: SceneTokensSessionEvent): SceneTokensSessionState {
switch (event.kind) {
case 'clear':
return this.reset();
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]: withMove(prev, nx, ny),
},
};
return this.state;
}
case 'setSize': {
const placementId = String(event.placementId ?? '');
if (!placementId) return this.state;
const sizeN = clampSceneTokenSizeN(event.sizeN);
const prev = this.state.byPlacementId[placementId];
if (prev?.sizeN === sizeN) return this.state;
this.state = {
revision: this.state.revision + 1,
byPlacementId: {
...this.state.byPlacementId,
[placementId]: {
...(typeof prev?.nx === 'number' ? { nx: prev.nx } : {}),
...(typeof prev?.ny === 'number' ? { ny: prev.ny } : {}),
sizeN,
},
},
};
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
return this.state;
}
}
}
}
@@ -0,0 +1,12 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { TokenGridSnapSessionStore } from './tokenGridSnapSessionStore';
void test('TokenGridSnapSessionStore: set and reset', () => {
const store = new TokenGridSnapSessionStore();
assert.equal(store.getState().enabled, false);
assert.equal(store.setEnabled(true).enabled, true);
assert.equal(store.getState().enabled, true);
assert.equal(store.reset().enabled, false);
});
@@ -0,0 +1,21 @@
export type TokenGridSnapSessionState = {
enabled: boolean;
};
export class TokenGridSnapSessionStore {
private enabled = false;
getState(): TokenGridSnapSessionState {
return { enabled: this.enabled };
}
setEnabled(enabled: boolean): TokenGridSnapSessionState {
this.enabled = Boolean(enabled);
return this.getState();
}
reset(): TokenGridSnapSessionState {
this.enabled = false;
return this.getState();
}
}
+171
View File
@@ -0,0 +1,171 @@
import {
emptyTokenPathSessionState,
tokenPathKey,
type TokenPathPlaybackEntry,
type TokenPathSessionEvent,
type TokenPathSessionState,
type TokenPathTargetRef,
} from '../../shared/types/tokenPathSession';
function bump(
state: TokenPathSessionState,
patch: Partial<Omit<TokenPathSessionState, 'revision' | 'serverNowMs'>>,
): TokenPathSessionState {
return {
...state,
...patch,
revision: state.revision + 1,
serverNowMs: Date.now(),
};
}
export class TokenPathSessionStore {
private state: TokenPathSessionState = emptyTokenPathSessionState();
getState(): TokenPathSessionState {
return this.state;
}
/** Refresh clock without logical change (optional heartbeat). */
touchClock(): TokenPathSessionState {
this.state = { ...this.state, serverNowMs: Date.now() };
return this.state;
}
reset(): TokenPathSessionState {
if (
Object.keys(this.state.playback).length === 0 &&
Object.keys(this.state.presentationVisible).length === 0
) {
return this.state;
}
this.state = emptyTokenPathSessionState(this.state.revision + 1);
return this.state;
}
dispatch(event: TokenPathSessionEvent): TokenPathSessionState {
switch (event.kind) {
case 'clear':
return this.reset();
case 'showPresentation': {
const key = tokenPathKey(event.target.kind, event.target.placementId);
if (this.state.presentationVisible[key]) return this.state;
this.state = bump(this.state, {
presentationVisible: { ...this.state.presentationVisible, [key]: true },
});
return this.state;
}
case 'hidePresentation': {
const key = tokenPathKey(event.target.kind, event.target.placementId);
if (!this.state.presentationVisible[key]) return this.state;
const { [key]: _removed, ...rest } = this.state.presentationVisible;
this.state = bump(this.state, { presentationVisible: rest });
return this.state;
}
case 'stop': {
const key = tokenPathKey(event.target.kind, event.target.placementId);
const prev = this.state.playback[key];
if (!prev || prev.phase === 'stopped') return this.state;
const atDist =
typeof event.atDist === 'number' && Number.isFinite(event.atDist)
? Math.max(0, event.atDist)
: prev.baseDist;
this.state = bump(this.state, {
playback: {
...this.state.playback,
[key]: {
...prev,
phase: 'stopped',
baseDist: atDist,
rejoinDist: null,
segmentStartedAtMs: Date.now(),
},
},
});
return this.state;
}
case 'resume': {
const key = tokenPathKey(event.target.kind, event.target.placementId);
const prev = this.state.playback[key];
if (!prev) return this.state;
if (prev.phase !== 'stopped' && prev.phase !== 'done') return this.state;
const entry: TokenPathPlaybackEntry = {
...prev,
phase: 'moving',
segmentStartedAtMs: Date.now(),
// Jump to nearest-ahead on path, then continue (v1: no off-path lerp).
baseDist: Math.max(0, event.fromDist),
rejoinDist: null,
direction: 1,
};
this.state = bump(this.state, {
playback: { ...this.state.playback, [key]: entry },
});
return this.state;
}
case 'resetToStart': {
const key = tokenPathKey(event.target.kind, event.target.placementId);
const prev = this.state.playback[key];
const entry: TokenPathPlaybackEntry = {
kind: event.target.kind,
placementId: event.target.placementId,
phase: 'moving',
segmentStartedAtMs: Date.now(),
baseDist: 0,
direction: 1,
rejoinDist: null,
durationSec: prev?.durationSec ?? 8,
pathLength: prev?.pathLength ?? 1,
};
this.state = bump(this.state, {
playback: { ...this.state.playback, [key]: entry },
});
return this.state;
}
case 'seedPlayback': {
const e = event.entry;
const key = tokenPathKey(e.kind, e.placementId);
const entry: TokenPathPlaybackEntry = {
kind: e.kind,
placementId: e.placementId,
phase: e.phase,
segmentStartedAtMs: e.segmentStartedAtMs ?? Date.now(),
baseDist: e.baseDist,
direction: e.direction,
rejoinDist: e.rejoinDist,
durationSec: e.durationSec,
pathLength: e.pathLength,
};
this.state = bump(this.state, {
playback: { ...this.state.playback, [key]: entry },
});
return this.state;
}
case 'markDone': {
const key = tokenPathKey(event.target.kind, event.target.placementId);
const prev = this.state.playback[key];
if (!prev || prev.phase === 'done') return this.state;
this.state = bump(this.state, {
playback: {
...this.state.playback,
[key]: {
...prev,
phase: 'done',
baseDist: prev.pathLength,
rejoinDist: null,
segmentStartedAtMs: Date.now(),
},
},
});
return this.state;
}
default: {
const _exhaustive: never = event;
void _exhaustive;
return this.state;
}
}
}
}
export type { TokenPathTargetRef };
+272
View File
@@ -0,0 +1,272 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import type { AppToken, TokenId } from '../../shared/types';
import { asTokenId } from '../../shared/types/ids';
import { optimizeImageBufferVisuallyLossless } from '../project/optimizeImageImport.lib.mjs';
type TokensManifest = {
tokens: AppToken[];
};
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 || 'token';
}
function randomTokenId(): TokenId {
return asTokenId(`token_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`);
}
export class TokensStore {
private readonly rootDir: string;
private readonly filesDir: string;
private readonly manifestPath: string;
private tokens: AppToken[] = [];
private loaded = false;
constructor(userData: string) {
this.rootDir = path.join(userData, 'tokens');
this.filesDir = path.join(this.rootDir, 'files');
this.manifestPath = path.join(this.rootDir, 'tokens.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 TokensManifest;
this.tokens = Array.isArray(parsed.tokens)
? parsed.tokens.filter(
(t): t is AppToken =>
Boolean(t) &&
typeof t.id === 'string' &&
typeof t.name === 'string' &&
typeof t.imageRelPath === 'string' &&
typeof t.sha256 === 'string',
)
: [];
} catch {
this.tokens = [];
await this.persist();
}
this.loaded = true;
}
list(): AppToken[] {
return [...this.tokens];
}
getById(id: TokenId): AppToken | null {
return this.tokens.find((t) => t.id === id) ?? null;
}
findBySha256(sha256: string): AppToken | null {
return this.tokens.find((t) => t.sha256 === sha256) ?? null;
}
getImageReadInfo(id: TokenId): { absPath: string; mime: string } | null {
const token = this.getById(id);
if (!token) return null;
const absPath = path.join(this.rootDir, token.imageRelPath);
return { absPath, mime: mimeFromExt(path.extname(token.imageRelPath)) };
}
getImageUrl(id: TokenId): string | null {
if (!this.getImageReadInfo(id)) return null;
return `dnd://token?id=${encodeURIComponent(id)}`;
}
absPathForRel(relPath: string): string {
return path.join(this.rootDir, relPath);
}
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');
const existing = input.id ? this.getById(input.id) : null;
if (input.id && !existing) throw new Error('Token not found');
if (!existing && !input.filePath) throw new Error('Token image is required');
let imageRelPath = existing?.imageRelPath ?? '';
let sha256 = existing?.sha256 ?? '';
if (input.filePath) {
let buf = await fs.readFile(input.filePath);
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 id = existing?.id ?? randomTokenId();
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 });
await fs.writeFile(abs, buf);
if (existing && existing.imageRelPath !== imageRelPath) {
try {
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
} catch {
/* ignore */
}
}
const token: AppToken = {
id,
name,
imageRelPath,
sha256,
};
if (existing) {
this.tokens = this.tokens.map((t) => (t.id === id ? token : t));
} else {
this.tokens = [...this.tokens, token];
}
await this.persist();
return token;
}
const token: AppToken = {
id: existing!.id,
name,
imageRelPath,
sha256,
};
this.tokens = this.tokens.map((t) => (t.id === token.id ? token : t));
await this.persist();
return token;
}
/** Импорт токена из внешнего файла (storyline zip) с заданным id или дедупом по sha256. */
async importFromFile(input: {
preferredId: TokenId;
name: string;
absFilePath: string;
sha256?: string;
}): 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 existingByHash = this.findBySha256(sha256);
if (existingByHash) {
return { token: existingByHash, remappedFrom: input.preferredId };
}
const existingById = this.getById(input.preferredId);
const id = existingById ? randomTokenId() : input.preferredId;
try {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
} catch {
/* keep */
}
const ext = path.extname(input.absFilePath) || '.png';
const fileName = `${id}_${safeFileBase(input.name)}${ext.toLowerCase()}`;
const imageRelPath = path.join('files', fileName).replace(/\\/gu, '/');
const abs = path.join(this.rootDir, imageRelPath);
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, buf);
const token: AppToken = {
id,
name: input.name.trim() || 'Token',
imageRelPath,
sha256: crypto.createHash('sha256').update(buf).digest('hex'),
};
this.tokens = [...this.tokens, token];
await this.persist();
return { token, remappedFrom: input.preferredId };
}
async delete(id: TokenId): Promise<void> {
await this.ensureLoaded();
const existing = this.getById(id);
if (!existing) return;
this.tokens = this.tokens.filter((t) => t.id !== id);
await this.persist();
try {
await fs.unlink(path.join(this.rootDir, existing.imageRelPath));
} catch {
/* ignore */
}
}
/** Упаковать выбранные токены в каталог экспорта (`app-tokens/`). */
async packForExport(tokenIds: string[], exportRoot: string): Promise<void> {
await this.ensureLoaded();
const idSet = new Set(tokenIds);
const selected = this.tokens.filter((t) => idSet.has(t.id));
if (selected.length === 0) return;
const outDir = path.join(exportRoot, 'app-tokens');
const filesOut = path.join(outDir, 'files');
await fs.mkdir(filesOut, { recursive: true });
const packed: AppToken[] = [];
for (const t of selected) {
const src = path.join(this.rootDir, t.imageRelPath);
const base = path.basename(t.imageRelPath);
const destRel = path.join('files', base).replace(/\\/gu, '/');
await fs.copyFile(src, path.join(outDir, destRel));
packed.push({ ...t, imageRelPath: destRel });
}
await fs.writeFile(
path.join(outDir, 'tokens.json'),
`${JSON.stringify({ tokens: packed }, null, 2)}\n`,
'utf8',
);
}
/**
* Импорт из `sourceCache/app-tokens/`.
* @returns map oldTokenId → newTokenId
*/
async importFromExportDir(sourceCache: string): Promise<Map<string, string>> {
await this.ensureLoaded();
const remap = new Map<string, string>();
const manifestPath = path.join(sourceCache, 'app-tokens', 'tokens.json');
let raw: string;
try {
raw = await fs.readFile(manifestPath, 'utf8');
} catch {
return remap;
}
let parsed: TokensManifest;
try {
parsed = JSON.parse(raw) as TokensManifest;
} catch {
return remap;
}
const list = Array.isArray(parsed.tokens) ? parsed.tokens : [];
for (const t of list) {
if (!t?.id || !t.imageRelPath) continue;
const abs = path.join(sourceCache, 'app-tokens', t.imageRelPath);
const { token, remappedFrom } = await this.importFromFile({
preferredId: asTokenId(t.id),
name: typeof t.name === 'string' ? t.name : 'Token',
absFilePath: abs,
...(typeof t.sha256 === 'string' ? { sha256: t.sha256 } : {}),
});
remap.set(remappedFrom, token.id);
}
return remap;
}
private async persist(): Promise<void> {
await fs.mkdir(this.rootDir, { recursive: true });
const payload: TokensManifest = { tokens: this.tokens };
await fs.writeFile(this.manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
}
}
+157 -34
View File
@@ -1,10 +1,12 @@
import { app, dialog } from 'electron';
import { app, BrowserWindow, dialog } from 'electron';
import { autoUpdater } from 'electron-updater';
import { appDisplayNameForLocale } from '../../shared/appBranding';
import {
ipcChannels,
type UpdaterCheckResponse,
type UpdaterDownloadResponse,
type UpdaterProgressEvent,
} from '../../shared/ipc/contracts';
import type { IpcRegisterHandler } from '../ipc/router';
import { addLicenseChangeListener } from '../license/licenseService';
@@ -13,13 +15,63 @@ import type { LicenseService } from '../license/licenseService';
const STARTUP_CHECK_DELAY_MS = 12_000;
/** Не дёргать сервер чаще (смена лицензии / повторные emit). */
const RE_CHECK_COOLDOWN_MS = 30_000;
/** Ручная загрузка из модалки — не зависать бесконечно на «Загрузка…». */
const MANUAL_DOWNLOAD_TIMEOUT_MS = 30 * 60 * 1000;
function withTimeout<T>(promise: Promise<T>, ms: number, code: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(code)), ms);
promise.then(
(v) => {
clearTimeout(timer);
resolve(v);
},
(e: unknown) => {
clearTimeout(timer);
reject(e instanceof Error ? e : new Error(String(e)));
},
);
});
}
/**
* На macOS Squirrel.Mac может уже получить update-downloaded к моменту quitAndInstall.
* При autoInstallOnAppQuit=true MacUpdater не вызывает checkForUpdates повторно и зависает.
*/
function quitAndInstallForPlatform(): void {
if (process.platform === 'darwin') {
autoUpdater.autoInstallOnAppQuit = false;
}
autoUpdater.quitAndInstall(false, true);
}
function formatUpdaterError(e: unknown): string {
const raw = e instanceof Error ? e.message : String(e);
if (raw === 'UPDATE_DOWNLOAD_TIMEOUT') {
return 'Превышено время ожидания загрузки обновления';
}
return raw;
}
let lastCheckAt = 0;
/** Ручная установка: не показывать второй диалог из `update-downloaded`. */
let suppressAutoInstallDialog = false;
/** Версия, уже скачанная Squirrel/electron-updater (в т.ч. фоном). */
let downloadedUpdateVersion: string | null = null;
type RegisterFn = IpcRegisterHandler;
function emitUpdaterProgress(ev: UpdaterProgressEvent): void {
for (const win of BrowserWindow.getAllWindows()) {
if (win.isDestroyed() || win.webContents.isDestroyed()) continue;
try {
win.webContents.send(ipcChannels.updater.progress, ev);
} catch {
/* окно закрылось */
}
}
}
function isLicensedForUpdates(licenseService: LicenseService): boolean {
const snap = licenseService.getStatusSync();
return snap.active;
@@ -31,6 +83,7 @@ function maybeCheckForUpdates(licenseService: LicenseService, ignoreCooldown: bo
const now = Date.now();
if (!ignoreCooldown && now - lastCheckAt < RE_CHECK_COOLDOWN_MS) return;
lastCheckAt = now;
emitUpdaterProgress({ phase: 'checking' });
void autoUpdater.checkForUpdates().catch(() => undefined);
}
@@ -41,42 +94,132 @@ async function runManualUpdaterCheck(licenseService: LicenseService): Promise<Up
if (!isLicensedForUpdates(licenseService)) {
return { outcome: 'no_license' };
}
const prevAutoDownload = autoUpdater.autoDownload;
autoUpdater.autoDownload = false;
emitUpdaterProgress({ phase: 'checking' });
try {
const result = await autoUpdater.checkForUpdates();
if (result && result.isUpdateAvailable && result.updateInfo.version) {
emitUpdaterProgress({ phase: 'available', version: result.updateInfo.version });
return { outcome: 'available', version: result.updateInfo.version };
}
emitUpdaterProgress({ phase: 'not-available', version: app.getVersion() });
return { outcome: 'current', currentVersion: app.getVersion() };
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
emitUpdaterProgress({ phase: 'error', message });
return { outcome: 'error', message };
} finally {
autoUpdater.autoDownload = prevAutoDownload;
}
}
async function runManualDownloadAndRestart(): Promise<UpdaterDownloadResponse> {
function isUpdateAlreadyDownloaded(targetVersion: string): boolean {
return downloadedUpdateVersion !== null && downloadedUpdateVersion === targetVersion;
}
async function runManualDownloadAndRestart(targetVersion: string): Promise<UpdaterDownloadResponse> {
if (!app.isPackaged) {
return { ok: false, message: 'NOT_PACKAGED' };
}
const prevAutoInstallOnAppQuit = autoUpdater.autoInstallOnAppQuit;
try {
suppressAutoInstallDialog = true;
await autoUpdater.downloadUpdate();
autoUpdater.quitAndInstall(false, true);
if (process.platform === 'darwin') {
autoUpdater.autoInstallOnAppQuit = false;
}
if (!isUpdateAlreadyDownloaded(targetVersion)) {
emitUpdaterProgress({ phase: 'downloading', version: targetVersion, percent: 0 });
await withTimeout(
autoUpdater.downloadUpdate(),
MANUAL_DOWNLOAD_TIMEOUT_MS,
'UPDATE_DOWNLOAD_TIMEOUT',
);
}
emitUpdaterProgress({ phase: 'installing', version: targetVersion });
quitAndInstallForPlatform();
return { ok: true };
} catch (e) {
suppressAutoInstallDialog = false;
const message = e instanceof Error ? e.message : String(e);
const message = formatUpdaterError(e);
emitUpdaterProgress({ phase: 'error', message });
if (process.platform === 'darwin') {
autoUpdater.autoInstallOnAppQuit = prevAutoInstallOnAppQuit;
}
return { ok: false, message };
}
}
function registerUpdaterHandlers(register: RegisterFn, licenseService: LicenseService): void {
register(ipcChannels.updater.check, () => runManualUpdaterCheck(licenseService));
register(ipcChannels.updater.downloadAndRestart, () => runManualDownloadAndRestart());
register(ipcChannels.updater.downloadAndRestart, (req: { version: string }) =>
runManualDownloadAndRestart(req.version),
);
}
function wireAutoUpdaterEvents(licenseService: LicenseService): void {
autoUpdater.on('checking-for-update', () => {
emitUpdaterProgress({ phase: 'checking' });
});
autoUpdater.on('update-available', (info) => {
emitUpdaterProgress({ phase: 'available', version: info.version });
});
autoUpdater.on('update-not-available', (info) => {
emitUpdaterProgress({ phase: 'not-available', version: info.version });
});
autoUpdater.on('download-progress', (progress) => {
const percent =
typeof progress.percent === 'number' && Number.isFinite(progress.percent)
? Math.round(progress.percent)
: undefined;
const ev: UpdaterProgressEvent = { phase: 'downloading' };
if (percent !== undefined) ev.percent = percent;
emitUpdaterProgress(ev);
});
autoUpdater.on('update-downloaded', (info) => {
downloadedUpdateVersion = info.version;
emitUpdaterProgress({ phase: 'downloading', version: info.version, percent: 100 });
if (suppressAutoInstallDialog) {
suppressAutoInstallDialog = false;
return;
}
void dialog
.showMessageBox({
type: 'info',
title: appDisplayNameForLocale(app.getLocale()),
message: `Доступна новая версия ${info.version}. Установить и перезапустить?`,
buttons: ['Перезапустить сейчас', 'Позже'],
defaultId: 0,
cancelId: 1,
})
.then((r) => {
if (r.response === 0) {
emitUpdaterProgress({ phase: 'installing', version: info.version });
quitAndInstallForPlatform();
}
});
});
autoUpdater.on('error', (e) => {
const message = e instanceof Error ? e.message : String(e);
emitUpdaterProgress({ phase: 'error', message });
});
addLicenseChangeListener(() => {
maybeCheckForUpdates(licenseService, false);
});
}
/**
* Проверка обновлений: только упакованное приложение, только при активной лицензии.
* Канал и URL задаются при сборке (`publish` → `app-update.yml` внутри установки).
* Дифференциальное скачивание (HTTP Range / blockmap) по умолчанию **выключено**: за nginx у Gitea raw часто **400** на multi-Range, updater всё равно уходит в полный файл. Включить снова: **`DND_UPDATE_ENABLE_DIFFERENTIAL=1`** (имеет смысл только если на сервере починили Range).
*/
export function installAutoUpdater(licenseService: LicenseService, register: RegisterFn): void {
registerUpdaterHandlers(register, licenseService);
@@ -89,37 +232,17 @@ export function installAutoUpdater(licenseService: LicenseService, register: Reg
autoUpdater.setFeedURL({ provider: 'generic', url });
}
const enableDiff = process.env.DND_UPDATE_ENABLE_DIFFERENTIAL?.trim().toLowerCase();
autoUpdater.disableDifferentialDownload = !(
enableDiff === '1' ||
enableDiff === 'true' ||
enableDiff === 'yes'
);
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.on('update-downloaded', (info) => {
if (suppressAutoInstallDialog) {
suppressAutoInstallDialog = false;
return;
}
void dialog
.showMessageBox({
type: 'info',
title: 'DNDGamePlayer',
message: `Доступна новая версия ${info.version}. Установить и перезапустить?`,
buttons: ['Перезапустить сейчас', 'Позже'],
defaultId: 0,
cancelId: 1,
})
.then((r) => {
if (r.response === 0) {
autoUpdater.quitAndInstall(false, true);
}
});
});
autoUpdater.on('error', () => {
/* без console: в production main минифицируется с drop console */
});
addLicenseChangeListener(() => {
maybeCheckForUpdates(licenseService, false);
});
wireAutoUpdaterEvents(licenseService);
setTimeout(() => {
maybeCheckForUpdates(licenseService, true);
+3 -1
View File
@@ -2,6 +2,7 @@ import path from 'node:path';
import { app, BrowserWindow } from 'electron';
import { appDisplayNameForLocale, windowChromeTitle } from '../../shared/appBranding';
import { getAppSemanticVersion } from '../versionInfo';
import { loadBrandingWindowIcon } from './brandingIcon';
@@ -63,6 +64,7 @@ export function createBootWindow(): BrowserWindow {
/* ignore */
}
}
win.setTitle(windowChromeTitle('boot', app.getLocale()));
bootSplashRef = win;
win.once('closed', () => {
@@ -93,7 +95,7 @@ export function setBootWindowStatus(win: BrowserWindow, text: string): void {
export function applyBootWindowBranding(win: BrowserWindow): void {
if (win.isDestroyed()) return;
const name = app.getName();
const name = appDisplayNameForLocale(app.getLocale());
const version = getAppSemanticVersion();
const versionLabel = version.trim().length > 0 ? `v${version.trim()}` : '';
void win.webContents.executeJavaScript(
@@ -35,6 +35,66 @@ void test('createWindows: пульт поверх экрана просмотр
assert.ok(src.includes("createWindow('control'"));
});
void test('createWindows: окно описания сцены закрывается с multi-window и отдельно', () => {
const src = readCreateWindows();
assert.ok(src.includes('openSceneDescriptionWindow'));
assert.ok(src.includes('closeSceneDescriptionWindow'));
assert.ok(src.includes("createWindow('sceneDescription'"));
assert.match(src, /export function closeMultiWindow[\s\S]*closeSceneDescriptionWindow/);
assert.match(src, /kind !== 'presentation'[\s\S]*closeSceneDescriptionWindow/);
});
void test('createWindows: окно материалов закрывается с multi-window', () => {
const src = readCreateWindows();
assert.ok(src.includes('openMaterialsWindow'));
assert.ok(src.includes('closeMaterialsWindow'));
assert.ok(src.includes("createWindow('materials'"));
assert.match(src, /export function closeMultiWindow[\s\S]*closeMaterialsWindow/);
});
void test('createWindows: окно таймера закрывается с multi-window', () => {
const src = readCreateWindows();
assert.ok(src.includes('openTimerWindow'));
assert.ok(src.includes('closeTimerWindow'));
assert.ok(src.includes("createWindow('timer'"));
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeTimerWindow/);
});
void test('createWindows: окно инициативы закрывается с multi-window', () => {
const src = readCreateWindows();
assert.ok(src.includes('openInitiativeWindow'));
assert.ok(src.includes('closeInitiativeWindow'));
assert.ok(src.includes("createWindow('initiative'"));
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeInitiativeWindow/);
});
void test('createWindows: окно кубиков закрывается с multi-window', () => {
const src = readCreateWindows();
assert.ok(src.includes('openDiceWindow'));
assert.ok(src.includes('closeDiceWindow'));
assert.ok(src.includes("createWindow('dice'"));
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closeDiceWindow/);
});
void test('createWindows: окно НПС закрывается с multi-window', () => {
const src = readCreateWindows();
assert.ok(src.includes('openNpcsWindow'));
assert.ok(src.includes('closeNpcsWindow'));
assert.ok(src.includes('openNpcsEditorWindow'));
assert.ok(src.includes('warmNpcsEditorWindow'));
assert.ok(src.includes("createWindow('npcs'"));
assert.ok(src.includes("createWindow('npcsEditor'"));
assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/);
assert.ok(src.includes('webviewTag: true'));
assert.ok(src.includes('will-attach-webview'));
});
void test('createWindows: закрытие пульта закрывает сессионные окна и презентацию', () => {
const src = readCreateWindows();
assert.match(src, /kind === 'control'/);
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closePresentationWindow/);
});
void test('createWindows: production — loadFile для HTML (не только file://)', () => {
const src = readCreateWindows();
assert.ok(src.includes('loadFile'));
@@ -46,3 +106,10 @@ void test('createWindows: показ окна — не только ready-to-sho
assert.ok(src.includes('ensureWindowBecomesVisible'));
assert.ok(src.includes('did-finish-load'));
});
void test('createWindows: логи окон не валят main через EPIPE', () => {
const src = readCreateWindows();
assert.ok(src.includes('safeConsoleError'));
assert.ok(src.includes('errorCode === -3'));
assert.ok(src.includes('isMainFrame'));
});
+823 -21
View File
@@ -2,16 +2,85 @@ import path from 'node:path';
import { app, BrowserWindow, screen } from 'electron';
import type { PresentationSettings } from '../../shared/types/presentationSettings';
import { readPresentationSettings } from '../preferences/appPreferences';
import { windowChromeTitle, type AppWindowKind } from '../../shared/appBranding';
import { ipcChannels } from '../../shared/ipc/contracts';
import { resolvePresentationDisplay } from './presentationDisplay';
import { safeConsoleError } from '../safeConsole';
import { getBootSplashWindow } from './bootWindow';
import { loadBrandingWindowIcon } from './brandingIcon';
type WindowKind = 'editor' | 'presentation' | 'control';
export type WindowKind =
| 'editor'
| 'presentation'
| 'control'
| 'sceneDescription'
| 'materials'
| 'playlists'
| 'timer'
| 'initiative'
| 'dice'
| 'npcsEditor'
| 'sceneEditor'
| 'tokenPathEditor'
| 'npcs';
/** Окна, которые реально слушают session.stateChanged (редактор синхронизируется через invoke). */
export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
'presentation',
'control',
'materials',
'npcs',
'npcsEditor',
'sceneEditor',
'tokenPathEditor',
] as const;
const windows = new Map<WindowKind, BrowserWindow>();
/** Язык заголовков окон (из редактора); иначе `app.getLocale()`. */
let chromeLocaleTagOverride: string | null = null;
function resolveChromeLocaleTag(): string {
return chromeLocaleTagOverride ?? app.getLocale();
}
let appQuitting = false;
/** Защита от каскада close(control) ↔ close(presentation). */
let closingPlaySession = false;
let pendingSceneDescriptionHtml = '';
/** Окно материалов — только колонка списка. */
const MATERIALS_WINDOW_WIDTH = 300;
const MATERIALS_WINDOW_HEIGHT = 720;
/** Окно плейлистов на пульте. */
const PLAYLISTS_WINDOW_WIDTH = 720;
const PLAYLISTS_WINDOW_HEIGHT = 800;
/** Компактное окно таймера сессии. */
const TIMER_WINDOW_WIDTH = 360;
const TIMER_WINDOW_HEIGHT = 420;
/** Окно трекера инициативы. */
const INITIATIVE_WINDOW_WIDTH = 920;
const INITIATIVE_WINDOW_HEIGHT = 420;
/** Компактное окно броска кубиков. */
const DICE_WINDOW_WIDTH = 380;
const DICE_WINDOW_HEIGHT = 420;
/** Редактор НПС — как основной редактор, шире инспектор. */
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
/** Пульт НПС: деталь слева (webview) + список справа. */
const NPCS_WINDOW_WIDTH = 960;
const NPCS_WINDOW_HEIGHT = 800;
/** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */
function broadcastMultiWindowStateChanged(open: boolean): void {
@@ -26,11 +95,115 @@ function broadcastMultiWindowStateChanged(open: boolean): void {
}
}
export function getPresentationContentSize(): { width: number; height: number } | null {
const pres = windows.get('presentation');
if (!pres || pres.isDestroyed()) return null;
const [width, height] = pres.getContentSize();
if (width <= 0 || height <= 0) return null;
return { width, height };
}
function broadcastPresentationContentSize(): void {
const size = getPresentationContentSize();
if (!size) return;
for (const w of BrowserWindow.getAllWindows()) {
if (w.isDestroyed() || w.webContents.isDestroyed()) continue;
try {
w.webContents.send(ipcChannels.windows.presentationContentSizeChanged, size);
} catch {
/* ignore */
}
}
}
function bindPresentationContentSizeTracking(win: BrowserWindow): void {
const emit = () => broadcastPresentationContentSize();
win.on('resize', emit);
win.on('enter-full-screen', emit);
win.on('leave-full-screen', emit);
win.webContents.once('did-finish-load', emit);
}
function sendSceneDescriptionContent(win: BrowserWindow, html: string): void {
if (win.isDestroyed() || win.webContents.isDestroyed()) return;
try {
win.webContents.send(ipcChannels.windows.sceneDescriptionContent, { html });
} catch {
/* ignore */
}
}
/** Разрешает реальное закрытие окна редактора (выход из приложения). */
export function markAppQuitting(): void {
appQuitting = true;
}
/** Точечная рассылка в известные окна приложения (без splash / чужих BrowserWindow). */
export function sendToAppWindows(
channel: string,
payload: unknown,
kinds: readonly WindowKind[] = SESSION_STATE_WINDOW_KINDS,
): void {
for (const kind of kinds) {
const win = windows.get(kind);
if (!win || win.isDestroyed() || win.webContents.isDestroyed()) continue;
try {
win.webContents.send(channel, payload);
} catch {
/* окно могло закрыться между проверкой и send */
}
}
}
function applyWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
if (win.isDestroyed()) return;
win.setTitle(windowChromeTitle(kind as AppWindowKind, resolveChromeLocaleTag()));
}
export function syncAllWindowChromeTitles(localeTag: string): void {
chromeLocaleTagOverride = localeTag.trim() || null;
for (const [kind, win] of windows.entries()) {
applyWindowChromeTitle(win, kind);
}
}
function bindWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
const apply = () => applyWindowChromeTitle(win, kind);
apply();
win.webContents.on('page-title-updated', (event) => {
event.preventDefault();
apply();
});
win.webContents.on('did-finish-load', () => {
apply();
});
}
/** Закрыть окна сессии, кроме редактора и его дочерних окон. */
function closePlaySessionAuxiliaryWindows(): void {
closeSceneDescriptionWindow();
closeMaterialsWindow();
closePlaylistsWindow();
closeTimerWindow();
closeInitiativeWindow();
closeDiceWindow();
closeNpcsWindow();
}
function closePresentationWindow(): void {
const pres = windows.get('presentation');
if (pres && !pres.isDestroyed()) {
pres.close();
}
}
function closeControlWindow(): void {
const ctrl = windows.get('control');
if (ctrl && !ctrl.isDestroyed()) {
ctrl.close();
}
}
function quitAppFromEditorClose(): void {
markAppQuitting();
app.quit();
@@ -50,6 +223,37 @@ function getRendererHtmlPath(kind: WindowKind): string {
return path.join(app.getAppPath(), 'dist', 'renderer', `${kind}.html`);
}
function pageNameForKind(kind: WindowKind): string {
switch (kind) {
case 'editor':
return 'editor.html';
case 'presentation':
return 'presentation.html';
case 'control':
return 'control.html';
case 'sceneDescription':
return 'sceneDescription.html';
case 'materials':
return 'materials.html';
case 'playlists':
return 'playlists.html';
case 'timer':
return 'timer.html';
case 'initiative':
return 'initiative.html';
case 'dice':
return 'dice.html';
case 'npcsEditor':
return 'npcsEditor.html';
case 'sceneEditor':
return 'sceneEditor.html';
case 'tokenPathEditor':
return 'tokenPathEditor.html';
case 'npcs':
return 'npcs.html';
}
}
/**
* В production `loadURL(file://…)` на Windows с asar иногда даёт чёрный экран;
* `loadFile` корректно открывает HTML из asar и на Windows, и на macOS.
@@ -57,9 +261,7 @@ function getRendererHtmlPath(kind: WindowKind): string {
function loadWindowPage(win: BrowserWindow, kind: WindowKind): void {
const dev = process.env.VITE_DEV_SERVER_URL;
if (dev) {
const page =
kind === 'editor' ? 'editor.html' : kind === 'presentation' ? 'presentation.html' : 'control.html';
void win.loadURL(new URL(page, dev).toString());
void win.loadURL(new URL(pageNameForKind(kind), dev).toString());
return;
}
void win.loadFile(getRendererHtmlPath(kind));
@@ -84,7 +286,7 @@ export function applyDockIconIfNeeded(): void {
type CreateWindowOpts = {
/** Дочернее окно (например пульт) держится над родителем (экран просмотра). */
parent?: BrowserWindow;
/** Только редактор: не показывать окно до `show()` (экран загрузки). */
/** Не показывать окно до явного `show()` (экран загрузки / прогрев НПС). */
deferVisibility?: boolean;
};
@@ -111,12 +313,78 @@ function ensureWindowBecomesVisible(win: BrowserWindow): void {
});
}
function windowSizeForKind(kind: WindowKind): { width: number; height: number } {
if (kind === 'editor') return { width: 1280, height: 800 };
if (kind === 'control') return { width: 1200, height: 800 };
if (kind === 'sceneDescription') return { width: 720, height: 640 };
if (kind === 'materials') return { width: MATERIALS_WINDOW_WIDTH, height: MATERIALS_WINDOW_HEIGHT };
if (kind === 'playlists') return { width: PLAYLISTS_WINDOW_WIDTH, height: PLAYLISTS_WINDOW_HEIGHT };
if (kind === 'timer') return { width: TIMER_WINDOW_WIDTH, height: TIMER_WINDOW_HEIGHT };
if (kind === 'initiative') return { width: INITIATIVE_WINDOW_WIDTH, height: INITIATIVE_WINDOW_HEIGHT };
if (kind === 'dice') return { width: DICE_WINDOW_WIDTH, height: DICE_WINDOW_HEIGHT };
if (kind === 'npcsEditor') return { width: NPCS_EDITOR_WINDOW_WIDTH, height: NPCS_EDITOR_WINDOW_HEIGHT };
if (kind === 'sceneEditor') return { width: 1280, height: 800 };
if (kind === 'tokenPathEditor') return { width: 1100, height: 760 };
if (kind === 'npcs') return { width: NPCS_WINDOW_WIDTH, height: NPCS_WINDOW_HEIGHT };
return { width: 1280, height: 800 };
}
function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow {
const deferEditor = kind === 'editor' && opts?.deferVisibility === true;
const deferShow = opts?.deferVisibility === true;
const icon = loadBrandingWindowIcon();
const size = windowSizeForKind(kind);
const win = new BrowserWindow({
width: kind === 'editor' ? 1280 : kind === 'control' ? 1200 : 1280,
height: 800,
width: size.width,
height: size.height,
autoHideMenuBar: true,
...(kind === 'sceneDescription'
? {
minWidth: 520,
minHeight: 420,
}
: {}),
...(kind === 'materials'
? {
width: MATERIALS_WINDOW_WIDTH,
height: MATERIALS_WINDOW_HEIGHT,
minWidth: 260,
maxWidth: 360,
minHeight: 480,
}
: {}),
...(kind === 'npcsEditor'
? {
width: NPCS_EDITOR_WINDOW_WIDTH,
height: NPCS_EDITOR_WINDOW_HEIGHT,
minWidth: 1100,
minHeight: 640,
}
: {}),
...(kind === 'sceneEditor'
? {
width: 1280,
height: 800,
minWidth: 960,
minHeight: 600,
}
: {}),
...(kind === 'tokenPathEditor'
? {
width: 1100,
height: 760,
minWidth: 900,
minHeight: 560,
}
: {}),
...(kind === 'npcs'
? {
width: NPCS_WINDOW_WIDTH,
height: NPCS_WINDOW_HEIGHT,
minWidth: 720,
maxWidth: 1280,
minHeight: 560,
}
: {}),
show: false,
backgroundColor: '#09090B',
...(icon ? { icon } : {}),
@@ -130,6 +398,13 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
autoplayPolicy: 'no-user-gesture-required',
// file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим.
webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL),
...(kind === 'npcs'
? {
webviewTag: true,
// webviewTag в Electron не сочетается с sandbox хоста.
sandbox: false,
}
: {}),
},
});
if (icon) {
@@ -140,17 +415,47 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
}
}
bindWindowChromeTitle(win, kind);
if (kind === 'npcs') {
win.webContents.on('will-attach-webview', (event, guestPrefs, params) => {
delete guestPrefs.preload;
guestPrefs.nodeIntegration = false;
guestPrefs.contextIsolation = true;
guestPrefs.sandbox = true;
guestPrefs.webSecurity = true;
const src = String(params.src ?? '');
if (!src.startsWith('https:') && !src.startsWith('http:')) {
event.preventDefault();
}
});
}
if (
kind === 'sceneDescription' ||
kind === 'materials' ||
kind === 'playlists' ||
kind === 'timer' ||
kind === 'npcsEditor' ||
kind === 'sceneEditor' ||
kind === 'tokenPathEditor' ||
kind === 'npcs'
) {
win.setMenuBarVisibility(false);
}
win.webContents.on('preload-error', (_event, preloadPath, error) => {
console.error(`[preload-error] ${preloadPath}:`, error);
safeConsoleError(`[preload-error] ${preloadPath}:`, error);
});
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => {
console.error(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`);
win.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
// -3 ERR_ABORTED: частый артефакт при navigate/maximize/закрытии — не шумим.
if (errorCode === -3) return;
if (!isMainFrame) return;
safeConsoleError(`[did-fail-load] ${String(errorCode)} ${errorDescription} ${validatedURL}`);
});
win.webContents.on('render-process-gone', (_event, details) => {
console.error('[render-process-gone]', details.reason, details.exitCode);
safeConsoleError('[render-process-gone]', details.reason, details.exitCode);
});
if (!deferEditor) {
if (!deferShow) {
ensureWindowBecomesVisible(win);
}
loadWindowPage(win, kind);
@@ -161,10 +466,36 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
quitAppFromEditorClose();
});
}
if (kind === 'control') {
win.on('close', () => {
if (appQuitting || closingPlaySession) return;
closingPlaySession = true;
closePlaySessionAuxiliaryWindows();
closePresentationWindow();
});
}
if (kind === 'presentation') {
bindPresentationContentSizeTracking(win);
win.on('close', () => {
if (appQuitting || closingPlaySession) return;
closingPlaySession = true;
closePlaySessionAuxiliaryWindows();
closeControlWindow();
});
}
win.on('closed', () => windows.delete(kind));
if (kind === 'sceneEditor') {
win.on('closed', () => {
closeTokenPathEditorWindow();
});
}
win.on('closed', () => {
if (kind !== 'presentation' && kind !== 'control') return;
const open = windows.has('presentation') || windows.has('control');
if (!open) {
closingPlaySession = false;
closePlaySessionAuxiliaryWindows();
}
broadcastMultiWindowStateChanged(open);
});
windows.set(kind, win);
@@ -228,35 +559,506 @@ export function focusEditorWindow(): void {
}
}
export function openMultiWindow() {
export function openMultiWindow(presentationSettings?: PresentationSettings) {
const settings = presentationSettings ?? readPresentationSettings(app.getPath('userData'));
let presentation = windows.get('presentation');
if (!presentation) {
const display = screen.getPrimaryDisplay();
const display = resolvePresentationDisplay(settings.displayId);
const { x, y, width, height } = display.bounds;
presentation = createWindow('presentation');
presentation.setBounds({ x, y, width, height });
presentation.setMenuBarVisibility(false);
presentation.maximize();
if (settings.fullScreen) {
presentation.setFullScreen(true);
} else {
presentation.maximize();
}
}
if (!windows.has('control')) {
// macOS: parent-child window binding moves child with the parent (unlike Windows behavior we want).
// Keep control window independent on darwin.
createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation });
const editor = windows.get('editor');
const editorDisplay = editor
? screen.getDisplayMatching(editor.getBounds())
: screen.getPrimaryDisplay();
const control = createWindow('control');
const area = editorDisplay.workArea;
const cw = Math.min(1200, Math.max(640, area.width - 80));
const ch = Math.min(800, Math.max(480, area.height - 80));
control.setBounds({
x: area.x + Math.max(0, Math.round((area.width - cw) / 2)),
y: area.y + Math.max(0, Math.round((area.height - ch) / 2)),
width: cw,
height: ch,
});
}
broadcastMultiWindowStateChanged(true);
broadcastPresentationContentSize();
}
export function closeMultiWindow(): void {
closingPlaySession = true;
closePlaySessionAuxiliaryWindows();
const pres = windows.get('presentation');
const ctrl = windows.get('control');
if (pres) pres.close();
if (ctrl) ctrl.close();
if (pres && !pres.isDestroyed()) pres.close();
if (ctrl && !ctrl.isDestroyed()) ctrl.close();
if (!windows.has('presentation') && !windows.has('control')) {
closingPlaySession = false;
}
}
export function isMultiWindowOpen(): boolean {
return windows.has('presentation') || windows.has('control');
}
export function closeSceneDescriptionWindow(): void {
const win = windows.get('sceneDescription');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function closeMaterialsWindow(): void {
const win = windows.get('materials');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function closePlaylistsWindow(): void {
const win = windows.get('playlists');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function closeTimerWindow(): void {
const win = windows.get('timer');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function closeInitiativeWindow(): void {
const win = windows.get('initiative');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function closeDiceWindow(): void {
const win = windows.get('dice');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function closeNpcsEditorWindow(): void {
const win = windows.get('npcsEditor');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function closeSceneEditorWindow(): void {
closeTokenPathEditorWindow();
const win = windows.get('sceneEditor');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function closeTokenPathEditorWindow(): void {
const win = windows.get('tokenPathEditor');
if (win && !win.isDestroyed()) {
win.close();
}
}
let pendingTokenPathEditorTarget: { kind: 'token' | 'npcToken'; placementId: string } | null = null;
export function getTokenPathEditorTarget(): { kind: 'token' | 'npcToken'; placementId: string } | null {
return pendingTokenPathEditorTarget;
}
function broadcastTokenPathEditorTarget(): void {
const win = windows.get('tokenPathEditor');
if (!win || win.isDestroyed() || win.webContents.isDestroyed()) return;
try {
win.webContents.send(ipcChannels.windows.tokenPathEditorTargetChanged, pendingTokenPathEditorTarget);
} catch {
/* ignore */
}
}
/** Одно окно пути: смена токена = фокус + targetChanged, без второго окна. */
export function openTokenPathEditorWindow(kind: 'token' | 'npcToken', placementId: string): void {
pendingTokenPathEditorTarget = { kind, placementId };
const existing = windows.get('tokenPathEditor');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
existing.show();
existing.focus();
existing.moveTop();
broadcastTokenPathEditorTarget();
return;
}
const parent = windows.get('sceneEditor') ?? windows.get('editor');
const win = createWindow('tokenPathEditor', parent ? { parent } : undefined);
const { width, height } = win.getBounds();
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
const { x, y, width: dw, height: dh } = display.workArea;
win.setBounds({
x: Math.round(x + Math.max(0, (dw - width) / 2)),
y: Math.round(y + Math.max(0, (dh - height) / 2)),
width,
height,
});
win.webContents.once('did-finish-load', () => {
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
broadcastTokenPathEditorTarget();
}
});
win.on('closed', () => {
pendingTokenPathEditorTarget = null;
});
}
export function closeNpcsWindow(): void {
const win = windows.get('npcs');
if (win && !win.isDestroyed()) {
win.close();
}
}
export function getSceneDescriptionContent(): string {
return pendingSceneDescriptionHtml;
}
/** Одно окно описания: переиспользовать существующее или создать новое. */
export function openSceneDescriptionWindow(html: string): void {
pendingSceneDescriptionHtml = html;
const existing = windows.get('sceneDescription');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
existing.show();
existing.focus();
existing.moveTop();
sendSceneDescriptionContent(existing, html);
return;
}
// Держим поверх пульта/презентации, иначе окно уходит под полноэкранный экран просмотра.
const parent = windows.get('control') ?? windows.get('presentation');
const win = createWindow('sceneDescription', parent ? { parent } : undefined);
const { width, height } = win.getBounds();
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
const { x, y, width: dw, height: dh } = display.workArea;
win.setBounds({
x: Math.round(x + (dw - width) / 2),
y: Math.round(y + (dh - height) / 2),
width,
height,
});
win.webContents.once('did-finish-load', () => {
sendSceneDescriptionContent(win, pendingSceneDescriptionHtml);
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
/** Полоса материалов: фиксированная ширина плитки, переиспользование окна. */
export function openMaterialsWindow(): void {
const existing = windows.get('materials');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
const b = existing.getBounds();
existing.setBounds({
x: b.x,
y: b.y,
width: MATERIALS_WINDOW_WIDTH,
height: MATERIALS_WINDOW_HEIGHT,
});
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('control') ?? windows.get('presentation');
const win = createWindow('materials', parent ? { parent } : undefined);
const { width, height } = win.getBounds();
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
const { x, y, width: dw, height: dh } = display.workArea;
win.setBounds({
x: Math.round(x + Math.max(0, dw - width - 24)),
y: Math.round(y + (dh - height) / 2),
width,
height,
});
win.webContents.once('did-finish-load', () => {
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
export function openPlaylistsWindow(): void {
const existing = windows.get('playlists');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('control') ?? windows.get('presentation');
const win = createWindow('playlists', parent ? { parent } : undefined);
const { width, height } = win.getBounds();
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
const { x, y, width: dw, height: dh } = display.workArea;
win.setBounds({
x: Math.round(x + Math.max(0, (dw - width) / 2)),
y: Math.round(y + Math.max(0, (dh - height) / 2)),
width,
height,
});
win.webContents.once('did-finish-load', () => {
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
export function openTimerWindow(): void {
const existing = windows.get('timer');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('control') ?? windows.get('presentation');
const win = createWindow('timer', parent ? { parent } : undefined);
const { width, height } = win.getBounds();
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
const { x, y, width: dw, height: dh } = display.workArea;
win.setBounds({
x: Math.round(x + Math.max(0, (dw - width) / 2)),
y: Math.round(y + Math.max(0, (dh - height) / 2)),
width,
height,
});
win.webContents.once('did-finish-load', () => {
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
export function openInitiativeWindow(opts?: { onClosed?: () => void }): void {
const existing = windows.get('initiative');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('control') ?? windows.get('presentation');
const win = createWindow('initiative', parent ? { parent } : undefined);
if (opts?.onClosed) {
win.once('closed', () => {
opts.onClosed?.();
});
}
const { width, height } = win.getBounds();
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
const { x, y, width: dw, height: dh } = display.workArea;
win.setBounds({
x: Math.round(x + Math.max(0, (dw - width) / 2)),
y: Math.round(y + Math.max(0, (dh - height) / 2)),
width,
height,
});
win.webContents.once('did-finish-load', () => {
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
export function openDiceWindow(): void {
const existing = windows.get('dice');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('control') ?? windows.get('presentation');
const win = createWindow('dice', parent ? { parent } : undefined);
const { width, height } = win.getBounds();
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
const { x, y, width: dw, height: dh } = display.workArea;
win.setBounds({
x: Math.round(x + Math.max(0, (dw - width) / 2)),
y: Math.round(y + Math.max(0, (dh - height) / 2)),
width,
height,
});
win.webContents.once('did-finish-load', () => {
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
function positionNpcsEditorWindow(win: BrowserWindow): void {
const parent = windows.get('editor');
const { width, height } = win.getBounds();
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
const { x, y, width: dw, height: dh } = display.workArea;
win.setBounds({
x: Math.round(x + Math.max(0, (dw - width) / 2)),
y: Math.round(y + Math.max(0, (dh - height) / 2)),
width,
height,
});
}
/** Прогрев окна НПС в фоне после открытия проекта — клик «НПС» не ждёт холодной загрузки. */
export function warmNpcsEditorWindow(): void {
const existing = windows.get('npcsEditor');
if (existing && !existing.isDestroyed()) return;
const parent = windows.get('editor');
createWindow('npcsEditor', {
...(parent ? { parent } : {}),
deferVisibility: true,
});
}
/** Редактор НПС: отдельное окно с графом и инспектором. */
export function openNpcsEditorWindow(): void {
const existing = windows.get('npcsEditor');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
positionNpcsEditorWindow(existing);
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('editor');
const win = createWindow('npcsEditor', {
...(parent ? { parent } : {}),
deferVisibility: true,
});
positionNpcsEditorWindow(win);
// Показываем сразу (тёмный фон), не дожидаясь полной загрузки React/ReactFlow.
win.show();
win.focus();
win.moveTop();
win.webContents.once('did-finish-load', () => {
if (!win.isDestroyed()) {
win.focus();
win.moveTop();
}
});
}
/** Редактор сцены: расстановка ловушек и легенды материалов. */
export function openSceneEditorWindow(): void {
const existing = windows.get('sceneEditor');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('editor');
const win = createWindow('sceneEditor', parent ? { parent } : undefined);
const { width, height } = win.getBounds();
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
const { x, y, width: dw, height: dh } = display.workArea;
win.setBounds({
x: Math.round(x + Math.max(0, (dw - width) / 2)),
y: Math.round(y + Math.max(0, (dh - height) / 2)),
width,
height,
});
win.webContents.once('did-finish-load', () => {
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
/** Пульт НПС: список + описание выбранного; оверлей аватара на сцене. */
export function openNpcsWindow(): void {
const existing = windows.get('npcs');
if (existing && !existing.isDestroyed()) {
if (existing.isMinimized()) existing.restore();
const b = existing.getBounds();
existing.setBounds({
x: b.x,
y: b.y,
width: Math.min(1280, Math.max(720, b.width)),
height: Math.max(NPCS_WINDOW_HEIGHT, b.height),
});
existing.show();
existing.focus();
existing.moveTop();
return;
}
const parent = windows.get('control') ?? windows.get('presentation');
const win = createWindow('npcs', parent ? { parent } : undefined);
const { width, height } = win.getBounds();
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
const { x, y, width: dw, height: dh } = display.workArea;
win.setBounds({
x: Math.round(x + Math.max(0, dw - width - 24)),
y: Math.round(y + (dh - height) / 2),
width,
height,
});
win.webContents.once('did-finish-load', () => {
if (!win.isDestroyed()) {
win.show();
win.focus();
win.moveTop();
}
});
}
export function togglePresentationFullscreen(): boolean {
const pres = windows.get('presentation');
if (!pres) return false;
+27
View File
@@ -0,0 +1,27 @@
import { screen, type Display } from 'electron';
import type { DisplayInfo } from '../../shared/types/presentationSettings';
export function listDisplayInfos(): DisplayInfo[] {
const primaryId = screen.getPrimaryDisplay().id;
return screen.getAllDisplays().map((d) => displayToInfo(d, d.id === primaryId));
}
function displayToInfo(display: Display, isPrimary: boolean): DisplayInfo {
const label = display.label?.trim() || `Display ${String(display.id)}`;
return {
id: display.id,
label,
width: display.bounds.width,
height: display.bounds.height,
isPrimary,
};
}
export function resolvePresentationDisplay(displayId: number | null): Display {
if (displayId != null) {
const found = screen.getAllDisplays().find((d) => d.id === displayId);
if (found) return found;
}
return screen.getPrimaryDisplay();
}
+7 -2
View File
@@ -1,4 +1,4 @@
import { contextBridge } from 'electron';
import { contextBridge, webUtils } from 'electron';
import type { IpcEventMap, IpcInvokeMap } from '../shared/ipc/contracts';
@@ -10,9 +10,14 @@ export type DndApi = {
payload: IpcInvokeMap[K]['req'],
) => Promise<IpcInvokeMap[K]['res']>;
on: <K extends keyof IpcEventMap>(channel: K, listener: (payload: IpcEventMap[K]) => void) => () => void;
getPathForFile: (file: File) => string;
};
const api: DndApi = { invoke, on };
const api: DndApi = {
invoke,
on,
getPathForFile: (file) => webUtils.getPathForFile(file),
};
contextBridge.exposeInMainWorld('dnd', api);
+1 -1
View File
@@ -113,7 +113,7 @@
<div class="wrap">
<div class="card">
<img class="logo" src="./app-window-icon.png" width="72" height="72" alt="" />
<h1 class="title" data-boot-title>DNDGamePlayer</h1>
<h1 class="title" data-boot-title>TTRPG Player</h1>
<p class="subtitle">редактор и проигрыватель</p>
<p class="version" data-boot-version></p>
<p class="status" id="boot-status">Запуск…</p>
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-window-icon.png" type="image/png" />
<title>DnD Player — Control</title>
<title>TTRPG - Control</title>
</head>
<body>
<div id="root"></div>
+408 -6
View File
@@ -4,14 +4,20 @@
display: grid;
grid-template-columns: 280px 1fr;
gap: 16px;
overflow: auto;
min-height: 0;
box-sizing: border-box;
}
.remote {
padding: 12px;
height: 100%;
min-height: 0;
min-height: calc(100vh - 32px);
min-width: 0;
overflow: hidden;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.remoteTitle {
@@ -79,13 +85,114 @@
justify-content: center;
}
.bookIcon {
display: block;
color: var(--text-muted-on-dark);
}
.modalBackdrop {
position: fixed;
inset: 0;
z-index: var(--z-modal-backdrop);
border: none;
padding: 0;
margin: 0;
background: var(--color-scrim);
cursor: default;
}
.modalDialog {
position: fixed;
z-index: var(--z-modal);
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 520px;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 32px);
overflow: auto;
box-sizing: border-box;
min-width: 0;
border-radius: var(--radius-lg);
border: 1px solid var(--stroke);
background: var(--color-surface-elevated);
box-shadow: var(--shadow-xl);
padding: 16px;
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr);
}
.descriptionViewDialog {
width: min(720px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
grid-template-rows: auto 1fr auto;
}
.modalHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.modalTitle {
font-weight: 900;
font-size: var(--text-lg);
}
.modalClose {
border: none;
background: var(--panel2);
color: var(--text2);
border-radius: var(--radius-sm);
width: 34px;
height: 34px;
cursor: pointer;
font-size: 18px;
line-height: 1;
}
.modalFooter {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 4px;
}
.descriptionViewBody {
min-height: 360px;
max-height: min(560px, calc(100vh - 200px));
overflow: auto;
padding: 14px 16px;
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3);
}
.descriptionViewProse {
color: var(--text0);
font-size: var(--text-md);
line-height: 1.55;
word-break: break-word;
}
.descriptionViewEmpty {
color: var(--text2);
font-size: var(--text-sm);
}
.radiusRow {
display: grid;
grid-template-columns: 100px 1fr 44px;
grid-template-columns: 120px 1fr 44px;
align-items: center;
gap: 8px;
}
.effectsSoundRow {
margin-top: 4px;
}
.radiusLabel {
color: var(--text2);
font-size: var(--text-xs);
@@ -110,11 +217,58 @@
flex-direction: column;
}
.storyScroll {
flex: 0 0 70%;
.storyWrapCollapsed {
flex: 0 0 auto;
}
.storyAccordions {
flex: 1;
min-height: 0;
border-radius: var(--radius-lg);
display: flex;
flex-direction: column;
gap: 8px;
}
.storyAccordionExpanded {
flex: 1;
min-height: 0;
}
.storyAccordionCollapsed {
flex: 0 0 auto;
}
.storyWrapCollapsed .historyAccordion {
flex: 0 0 auto;
}
.historyAccordion {
min-height: 0;
display: flex;
flex-direction: column;
border: 1px solid var(--stroke);
border-radius: var(--radius-lg);
overflow: hidden;
background: rgba(255, 255, 255, 0.02);
}
.historyAccordionHead {
width: 100%;
text-align: left;
padding: 10px 12px;
font-size: var(--text-xs);
font-weight: 800;
color: var(--text2);
background: transparent;
border: 0;
cursor: pointer;
flex-shrink: 0;
}
.storyScroll {
flex: 1;
min-height: 0;
border-top: 1px solid var(--stroke);
background: var(--color-overlay-dark-2);
overflow: auto;
padding: 10px;
@@ -164,6 +318,7 @@
.historyTitle {
font-weight: 800;
min-width: 0;
}
.emptyStory {
@@ -195,9 +350,45 @@
.previewActions {
display: flex;
align-items: center;
gap: 10px;
}
.npcTokenScale {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.snapToGrid {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
cursor: pointer;
user-select: none;
}
.snapToGridLabel {
color: var(--text2);
font-size: var(--text-xs);
font-weight: 700;
white-space: nowrap;
}
.npcTokenScaleLabel {
color: var(--text2);
font-size: var(--text-xs);
font-weight: 700;
white-space: nowrap;
}
.npcTokenScaleRange {
width: min(140px, 22vw);
accent-color: var(--color-accent, #c9a227);
}
.videoHint {
color: var(--text2);
font-size: var(--text-xs);
@@ -219,6 +410,22 @@
inset: 0;
}
.previewHostSpawnDrag {
cursor: copy;
}
.npcSpawnGhost {
position: absolute;
z-index: 12;
width: 28px;
height: 28px;
border-radius: 999px;
transform: translate(-50%, -50%);
pointer-events: none;
border: 2px dashed rgba(96, 165, 250, 0.95);
background: rgba(96, 165, 250, 0.2);
}
.brushCursor {
position: absolute;
z-index: 2;
@@ -234,6 +441,61 @@
pointer-events: none;
}
.brushCursorSquare {
border-radius: 2px;
}
.brushShapeRow {
display: grid;
grid-template-columns: 120px 1fr;
align-items: center;
gap: 8px;
margin-top: 4px;
}
.brushShapeButtons {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.brushShapeIcon {
display: flex;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
color: var(--text1);
}
.markerColorInput {
flex: 0 0 40px;
width: 40px;
height: 34px;
padding: 0;
border: 1px solid var(--stroke);
border-radius: var(--radius-sm);
background: transparent;
cursor: pointer;
overflow: hidden;
}
.markerColorInput::-webkit-color-swatch-wrapper {
padding: 0;
border-radius: inherit;
}
.markerColorInput::-webkit-color-swatch {
border: none;
border-radius: inherit;
}
.markerColorInput::-moz-color-swatch {
border: none;
border-radius: inherit;
}
.brushLayer {
position: absolute;
inset: 0;
@@ -249,7 +511,7 @@
.branchGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
@@ -260,6 +522,8 @@
padding: 12px;
display: grid;
gap: 10px;
min-width: 0;
max-width: 100%;
}
.branchCardHeader {
@@ -276,6 +540,63 @@
.branchName {
font-weight: 900;
min-width: 0;
}
.branchCardReturn {
border-color: rgba(0, 120, 212, 0.45);
}
.sideStoryGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
}
.sideStoryTile {
display: grid;
gap: 8px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
text-align: left;
color: inherit;
font: inherit;
}
.sideStoryTile:hover .sideStoryPreview {
border-color: rgba(0, 120, 212, 0.55);
}
.sideStoryPreview {
width: 100%;
aspect-ratio: 4 / 3;
border-radius: var(--scene-tile-radius);
overflow: hidden;
border: 2px solid var(--stroke);
background: #0c0c0e;
position: relative;
}
.sideStoryVideo {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.sideStoryPlaceholder {
width: 100%;
height: 100%;
background: #0c0c0e;
}
.sideStoryTitle {
font-size: var(--text-xs);
font-weight: 800;
line-height: 1.25;
color: var(--text1);
}
.musicHeader {
@@ -308,10 +629,51 @@
white-space: nowrap;
}
.audioControls {
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
align-items: stretch;
min-width: 132px;
}
.audioTransport {
display: flex;
gap: 10px;
flex-shrink: 0;
justify-content: flex-end;
}
.audioVolumeRow {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.audioVolumeIcon {
flex-shrink: 0;
width: 16px;
height: 16px;
color: var(--text2);
display: flex;
align-items: center;
justify-content: center;
}
.audioVolumeIcon svg {
display: block;
width: 16px;
height: 16px;
}
.audioVolume {
flex: 1;
min-width: 0;
width: 100%;
margin: 0;
accent-color: var(--accent-fill-solid);
}
.scrubFill {
@@ -371,3 +733,43 @@
.audioScrubDefault {
cursor: default;
}
.ctxMenuBackdrop {
position: fixed;
inset: 0;
z-index: 40;
border: none;
padding: 0;
margin: 0;
background: transparent;
cursor: default;
}
.ctxMenu {
position: fixed;
z-index: 41;
min-width: 200px;
padding: 6px;
border-radius: 8px;
border: 1px solid var(--stroke, #2a2f3a);
background: var(--color-surface-menu, #1a1e28);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
display: grid;
gap: 2px;
}
.ctxItem {
text-align: left;
padding: 8px 10px;
border-radius: 6px;
border: none;
background: transparent;
color: var(--text1, #e8eaef);
font-size: 13px;
cursor: pointer;
width: 100%;
}
.ctxItem:hover {
background: rgba(255, 255, 255, 0.06);
}
File diff suppressed because it is too large Load Diff
+304
View File
@@ -0,0 +1,304 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button } from '../shared/ui/controls';
import styles from './ControlApp.module.css';
function formatTime(sec: number): string {
if (!Number.isFinite(sec) || sec < 0) return '0:00';
const s = Math.floor(sec);
const m = Math.floor(s / 60);
const r = s % 60;
return `${String(m)}:${String(r).padStart(2, '0')}`;
}
function clampAudioGain(v: number): number {
if (!Number.isFinite(v)) return 1;
return Math.max(0, Math.min(1, v));
}
function VolumeSpeakerIcon({ gain }: { gain: number }) {
if (gain <= 0.001) {
return (
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
<path
fill="currentColor"
d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3 3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4 9.91 6.09 12 8.18V4z"
/>
</svg>
);
}
if (gain < 0.5) {
return (
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
<path
fill="currentColor"
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"
/>
</svg>
);
}
return (
<svg viewBox="0 0 24 24" aria-hidden focusable="false">
<path
fill="currentColor"
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
/>
</svg>
);
}
export type ControlAudioCardProps = {
assetId: string;
name: string;
autoplay: boolean;
loop: boolean;
statusLabel: string;
statusDetail?: string;
extraBadge?: React.ReactNode;
audioEl: HTMLAudioElement | null;
initialGain: number;
gainMap: Map<string, number>;
playTitle: string;
playLabel: string;
pauseLabel: string;
stopLabel: string;
volumeLabel: string;
modeAutoLabel: string;
modeManualLabel: string;
loopLabel: string;
onceLabel: string;
scrubSeekLabel: string;
durationUnknownLabel: string;
/** Редкий bump родителя (play/pause/error) — не для scrub. */
onStatusChange: () => void;
onPlay: () => void;
onPause: () => void;
onStop: () => void;
};
/**
* Карточка трека: scrub/time обновляются локально (RAF → DOM), без ре-рендера всего ControlApp.
*/
export function ControlAudioCard({
assetId,
name,
autoplay,
loop,
statusLabel,
statusDetail,
extraBadge,
audioEl,
initialGain,
gainMap,
playTitle,
playLabel,
pauseLabel,
stopLabel,
volumeLabel,
modeAutoLabel,
modeManualLabel,
loopLabel,
onceLabel,
scrubSeekLabel,
durationUnknownLabel,
onStatusChange,
onPlay,
onPause,
onStop,
}: ControlAudioCardProps) {
const scrubRef = useRef<HTMLDivElement | null>(null);
const scrubFillRef = useRef<HTMLDivElement | null>(null);
const curTimeRef = useRef<HTMLDivElement | null>(null);
const durTimeRef = useRef<HTMLDivElement | null>(null);
const [gainUi, setGainUi] = useState(() => clampAudioGain(initialGain));
const onStatusChangeRef = useRef(onStatusChange);
onStatusChangeRef.current = onStatusChange;
useEffect(() => {
setGainUi(clampAudioGain(gainMap.get(assetId) ?? initialGain));
}, [assetId, gainMap, initialGain]);
useEffect(() => {
if (!audioEl) return;
let raf = 0;
const paint = (): void => {
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
const cur = audioEl.currentTime && Number.isFinite(audioEl.currentTime) ? audioEl.currentTime : 0;
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
if (scrubFillRef.current) {
scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
}
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(cur);
if (durTimeRef.current) durTimeRef.current.textContent = dur ? formatTime(dur) : '—:—';
if (scrubRef.current) {
scrubRef.current.setAttribute('aria-valuemin', '0');
scrubRef.current.setAttribute('aria-valuemax', String(dur > 0 ? Math.round(dur) : 0));
scrubRef.current.setAttribute('aria-valuenow', String(Math.round(cur)));
scrubRef.current.title = dur > 0 ? scrubSeekLabel : durationUnknownLabel;
scrubRef.current.classList.toggle(styles.audioScrubPointer ?? 'audioScrubPointer', dur > 0);
scrubRef.current.classList.toggle(styles.audioScrubDefault ?? 'audioScrubDefault', dur <= 0);
}
};
const stopLoop = (): void => {
if (raf !== 0) {
window.cancelAnimationFrame(raf);
raf = 0;
}
};
const loopPaint = (): void => {
paint();
if (!audioEl.paused) {
raf = window.requestAnimationFrame(loopPaint);
} else {
raf = 0;
}
};
const startLoop = (): void => {
stopLoop();
raf = window.requestAnimationFrame(loopPaint);
};
const onPlayEv = (): void => {
startLoop();
onStatusChangeRef.current();
};
const onPauseEv = (): void => {
stopLoop();
paint();
onStatusChangeRef.current();
};
const onEndedEv = (): void => {
stopLoop();
paint();
onStatusChangeRef.current();
};
const onMetaEv = (): void => {
paint();
onStatusChangeRef.current();
};
audioEl.addEventListener('play', onPlayEv);
audioEl.addEventListener('pause', onPauseEv);
audioEl.addEventListener('ended', onEndedEv);
audioEl.addEventListener('canplay', onMetaEv);
audioEl.addEventListener('error', onMetaEv);
paint();
if (!audioEl.paused) startLoop();
return () => {
stopLoop();
audioEl.removeEventListener('play', onPlayEv);
audioEl.removeEventListener('pause', onPauseEv);
audioEl.removeEventListener('ended', onEndedEv);
audioEl.removeEventListener('canplay', onMetaEv);
audioEl.removeEventListener('error', onMetaEv);
};
}, [audioEl, durationUnknownLabel, scrubSeekLabel]);
const seekByClientX = (clientX: number): void => {
if (!audioEl || !scrubRef.current) return;
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
if (!dur) return;
const rect = scrubRef.current.getBoundingClientRect();
const next = (clientX - rect.left) / Math.max(1, rect.width);
audioEl.currentTime = Math.max(0, Math.min(dur, next * dur));
const cur = audioEl.currentTime;
const pct = Math.max(0, Math.min(1, cur / dur));
if (scrubFillRef.current) scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(cur);
};
const applyGain = (v: number): void => {
const g = clampAudioGain(v);
gainMap.set(assetId, g);
if (audioEl) {
try {
audioEl.volume = g;
} catch {
// ignore
}
}
setGainUi(g);
};
return (
<div className={styles.audioCard}>
<div className={styles.audioMeta}>
<div className={styles.audioName}>{name}</div>
<div className={styles.audioBadges}>
<div>{autoplay ? modeAutoLabel : modeManualLabel}</div>
<div>{loop ? loopLabel : onceLabel}</div>
<div title={statusDetail}>{statusLabel}</div>
{extraBadge}
</div>
<div className={styles.spacer10} />
<div
ref={scrubRef}
role="slider"
tabIndex={0}
className={[styles.audioScrub, styles.audioScrubDefault].join(' ')}
onKeyDown={(e) => {
if (!audioEl) return;
const dur = audioEl.duration && Number.isFinite(audioEl.duration) ? audioEl.duration : 0;
if (!dur) return;
if (e.key === 'ArrowLeft') audioEl.currentTime = Math.max(0, audioEl.currentTime - 5);
if (e.key === 'ArrowRight') audioEl.currentTime = Math.min(dur, audioEl.currentTime + 5);
if (curTimeRef.current) curTimeRef.current.textContent = formatTime(audioEl.currentTime);
const pct = Math.max(0, Math.min(1, audioEl.currentTime / dur));
if (scrubFillRef.current) scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
}}
onClick={(e) => seekByClientX(e.clientX)}
>
<div ref={scrubFillRef} className={styles.scrubFill} style={{ width: '0%' }} />
</div>
<div className={styles.timeRow}>
<div ref={curTimeRef}>0:00</div>
<div ref={durTimeRef}>—:—</div>
</div>
</div>
<div className={styles.audioControls}>
<div className={styles.audioTransport}>
<Button variant="primary" title={playTitle} ariaLabel={playLabel} onClick={onPlay}>
▶
</Button>
<Button title={pauseLabel} ariaLabel={pauseLabel} onClick={onPause}>
❚❚
</Button>
<Button
title={stopLabel}
ariaLabel={stopLabel}
onClick={() => {
onStop();
if (scrubFillRef.current) scrubFillRef.current.style.width = '0%';
if (curTimeRef.current) curTimeRef.current.textContent = '0:00';
}}
>
■
</Button>
</div>
<div className={styles.audioVolumeRow}>
<span className={styles.audioVolumeIcon} aria-hidden>
<VolumeSpeakerIcon gain={gainUi} />
</span>
<input
type="range"
min={0}
max={1}
step={0.01}
value={gainUi}
disabled={!audioEl}
className={styles.audioVolume}
aria-label={volumeLabel}
title={`${volumeLabel}: ${String(Math.round(gainUi * 100))}%`}
onChange={(e) => applyGain(Number(e.currentTarget.value))}
/>
</div>
</div>
</div>
);
}
@@ -25,6 +25,8 @@
display: grid;
gap: 8px;
pointer-events: auto;
/* Above brush / traps layers so transport stays usable on video scenes. */
z-index: 50;
}
.scrub {
+81 -50
View File
@@ -1,8 +1,10 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useRef } from 'react';
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
import type { SessionState } from '../../shared/ipc/contracts';
import type { SceneViewCamera } from '../../shared/types';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { ContainedVideo } from '../shared/ContainedVideo';
import { RotatedImage } from '../shared/RotatedImage';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { useVideoPlaybackState } from '../shared/video/useVideoPlaybackState';
@@ -12,6 +14,7 @@ import styles from './ControlScenePreview.module.css';
type Props = {
session: SessionState | null;
videoRef: React.RefObject<HTMLVideoElement | null>;
viewCamera?: SceneViewCamera | null;
onContentRectChange?: (rect: { x: number; y: number; w: number; h: number }) => void;
};
@@ -23,7 +26,14 @@ function fmt(sec: number): string {
return `${String(m)}:${String(r).padStart(2, '0')}`;
}
export function ControlScenePreview({ session, videoRef, onContentRectChange }: Props) {
function readVideoTimes(v: HTMLVideoElement | null): { cur: number; dur: number } {
if (!v) return { cur: 0, dur: 0 };
const dur = Number.isFinite(v.duration) ? v.duration : 0;
const cur = Number.isFinite(v.currentTime) ? v.currentTime : 0;
return { cur, dur };
}
export function ControlScenePreview({ session, videoRef, viewCamera = null, onContentRectChange }: Props) {
const { t } = useEditorI18n();
const [vp, video] = useVideoPlaybackState();
const scene =
@@ -33,40 +43,44 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
const isVideo = scene?.previewAssetType === 'video';
const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null;
const autostart = scene?.previewVideoAutostart ?? false;
const lastTargetRef = useRef<{ sceneKey: string; assetId: string; autostart: boolean } | null>(null);
const scrubRef = useRef<HTMLDivElement | null>(null);
const scrubFillRef = useRef<HTMLDivElement | null>(null);
const curTimeRef = useRef<HTMLDivElement | null>(null);
const durTimeRef = useRef<HTMLDivElement | null>(null);
const [tick, setTick] = useState(0);
const dur = useMemo(
() => {
const v = videoRef.current;
if (!v) return 0;
return Number.isFinite(v.duration) ? v.duration : 0;
},
// tick: timeupdate / loadedmetadata перечитывают duration и currentTime
// eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно
[tick, videoRef],
);
const cur = useMemo(
() => {
const v = videoRef.current;
if (!v) return 0;
return Number.isFinite(v.currentTime) ? v.currentTime : 0;
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно
[tick, videoRef],
);
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
const paintScrub = (): void => {
const { cur, dur } = readVideoTimes(videoRef.current);
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
if (scrubFillRef.current) {
scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
}
if (curTimeRef.current) curTimeRef.current.textContent = fmt(cur);
if (durTimeRef.current) durTimeRef.current.textContent = dur ? fmt(dur) : '—:—';
if (scrubRef.current) {
scrubRef.current.setAttribute('aria-valuemin', '0');
scrubRef.current.setAttribute('aria-valuemax', String(dur > 0 ? Math.round(dur) : 0));
scrubRef.current.setAttribute('aria-valuenow', String(Math.round(cur)));
scrubRef.current.classList.toggle(styles.scrubPointer, dur > 0);
scrubRef.current.classList.toggle(styles.scrubDefault, dur <= 0);
}
};
useEffect(() => {
if (!isVideo) return;
if (!assetId) return;
// `target.set` bumps revision and resets anchors; avoid firing on every render.
if (vp?.targetAssetId === assetId) return;
const sceneKey = session?.project?.currentGraphNodeId ?? session?.currentSceneId ?? '';
const prev = lastTargetRef.current;
if (prev && prev.sceneKey === sceneKey && prev.assetId === assetId && prev.autostart === autostart) {
return;
}
lastTargetRef.current = { sceneKey, assetId, autostart };
void video.dispatch({
kind: 'target.set',
assetId,
autostart,
});
}, [assetId, isVideo, autostart, vp?.targetAssetId, video]);
}, [assetId, isVideo, autostart, session?.currentSceneId, session?.project?.currentGraphNodeId, video]);
useEffect(() => {
const v = videoRef.current;
@@ -83,29 +97,47 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
} else {
v.pause();
}
paintScrub();
// eslint-disable-next-line react-hooks/exhaustive-deps -- avoid reruns on 500ms heartbeats (serverNowMs-only updates)
}, [assetId, url, vp?.revision, vp?.targetAssetId, vp?.playing, vp?.playbackRate, videoRef]);
const scrubClass = [styles.scrub, dur ? styles.scrubPointer : styles.scrubDefault].join(' ');
const seekByClientX = (clientX: number): void => {
const v = videoRef.current;
const { dur } = readVideoTimes(v);
if (!v || !dur || !scrubRef.current) return;
const rect = scrubRef.current.getBoundingClientRect();
const next = (clientX - rect.left) / Math.max(1, rect.width);
const timeSec = Math.max(0, Math.min(dur, next * dur));
void video.dispatch({ kind: 'seek', timeSec });
v.currentTime = timeSec;
paintScrub();
};
return (
<div className={styles.root}>
{url && scene?.previewAssetType === 'image' ? (
<RotatedImage url={url} rotationDeg={rot} mode="contain" onContentRectChange={onContentRectChange} />
<RotatedImage
url={url}
rotationDeg={rot}
mode="contain"
viewCamera={viewCamera}
onContentRectChange={onContentRectChange}
/>
) : url && isVideo ? (
<video
ref={(el) => {
(videoRef as unknown as { current: HTMLVideoElement | null }).current = el;
}}
className={styles.video}
src={url}
<ContainedVideo
url={url}
rotationDeg={rot}
videoRef={videoRef}
playsInline
loop={Boolean(scene?.settings?.loopVideo)}
preload="auto"
onTimeUpdate={() => setTick((x) => x + 1)}
onLoadedMetadata={() => setTick((x) => x + 1)}
viewCamera={viewCamera}
onContentRectChange={onContentRectChange}
onTimeUpdate={paintScrub}
onLoadedMetadata={paintScrub}
>
<track kind="captions" srcLang="ru" label={t('control.previewTrackLabel')} />
</video>
</ContainedVideo>
) : (
<div className={styles.placeholder} />
)}
@@ -113,30 +145,27 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
{isVideo ? (
<div className={styles.controls}>
<div
ref={scrubRef}
role="slider"
tabIndex={0}
aria-valuemin={0}
aria-valuemax={dur > 0 ? Math.round(dur) : 0}
aria-valuenow={Math.round(cur)}
className={scrubClass}
aria-valuemax={0}
aria-valuenow={0}
className={[styles.scrub, styles.scrubDefault].join(' ')}
onClick={(e) => {
const v = videoRef.current;
if (!v || !dur) return;
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
const next = (e.clientX - rect.left) / rect.width;
void video.dispatch({ kind: 'seek', timeSec: Math.max(0, Math.min(dur, next * dur)) });
setTick((x) => x + 1);
seekByClientX(e.clientX);
}}
onKeyDown={(e) => {
const { cur, dur } = readVideoTimes(videoRef.current);
if (!dur) return;
if (e.key === 'ArrowLeft') void video.dispatch({ kind: 'seek', timeSec: Math.max(0, cur - 5) });
if (e.key === 'ArrowRight')
void video.dispatch({ kind: 'seek', timeSec: Math.min(dur, cur + 5) });
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') setTick((x) => x + 1);
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') paintScrub();
}}
title={t('control.scrubSeek')}
>
<div className={styles.scrubFill} style={{ width: `${String(Math.round(pct * 100))}%` }} />
<div ref={scrubFillRef} className={styles.scrubFill} style={{ width: '0%' }} />
</div>
<div className={styles.row}>
<div className={styles.transport}>
@@ -161,7 +190,7 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
className={styles.transportBtn}
onClick={() => {
void video.dispatch({ kind: 'stop' });
setTick((x) => x + 1);
paintScrub();
}}
title={t('control.transportStop')}
>
@@ -169,7 +198,9 @@ export function ControlScenePreview({ session, videoRef, onContentRectChange }:
</button>
</div>
<div>
{fmt(cur)} / {dur ? fmt(dur) : '—:—'}
<span ref={curTimeRef}>0:00</span>
{' / '}
<span ref={durTimeRef}>—:—</span>
</div>
</div>
</div>
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
/** Регресс: RAF в ControlApp бампил оба audio-tick на каждом кадре → полный ре-рендер пульта. */
void test('ControlApp: нет per-frame RAF setState для аудио scrub', () => {
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
const card = fs.readFileSync(path.join(here, 'ControlAudioCard.tsx'), 'utf8');
assert.doesNotMatch(app, /\banyPlaying\b/);
// Старый паттерн: RAF tick → оба set*AudioStateTick.
assert.doesNotMatch(
app,
/const tick = \(\) => \{\s*setSceneAudioStateTick/,
'корневой RAF-тик аудио удалён',
);
assert.doesNotMatch(app, /requestAnimationFrame\s*\(\s*tick\s*\)/);
assert.ok(app.includes('ControlAudioCard'));
assert.ok(card.includes('requestAnimationFrame'), 'scrub крутится локально в карточке');
assert.ok(card.includes('scrubFillRef'), 'прогресс пишется в DOM, не через setState корня');
assert.ok(card.includes('setGainUi'), 'громкость обновляет только карточку');
});
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
/** Регресс: scheduleDraftRepaint бампил draftFxTick → полный ре-рендер ControlApp на кадр штриха. */
void test('ControlApp: draft кисти без корневого draftFxTick', () => {
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
const pixi = fs.readFileSync(
path.join(here, '../shared/effects/PxiEffectsOverlay.tsx'),
'utf8',
);
assert.doesNotMatch(app, /\bdraftFxTick\b/);
assert.doesNotMatch(app, /\bsetDraftFxTick\b/);
assert.doesNotMatch(app, /\bfxMergedState\b/);
assert.ok(app.includes('scheduleDraftRepaint'));
assert.ok(app.includes('pushDraftToPixi'));
assert.ok(app.includes('effectsOverlayRef'));
assert.match(app, /scheduleDraftRepaint[\s\S]*?pushDraftToPixi\(\)/);
assert.doesNotMatch(
app,
/scheduleDraftRepaint[\s\S]*?setDraftFxTick/,
'RAF draft не трогает React state корня',
);
assert.ok(pixi.includes('PixiEffectsOverlayHandle'));
assert.ok(pixi.includes('setDraft'));
assert.ok(pixi.includes('mergeDraftState'));
assert.ok(pixi.includes('forwardRef'));
});
@@ -18,6 +18,7 @@ void test('ControlApp: звук молнии (public/molniya.mp3)', () => {
const src = readControlApp();
assert.ok(src.includes('molniya.mp3'));
assert.ok(src.includes('playLightningEffectSound'));
assert.ok(src.includes('LIGHTNING_EFFECT_MS'));
});
void test('ControlApp: звук заморозки (public/zamorozka.mp3)', () => {
@@ -45,37 +46,149 @@ void test('ControlApp: звук облака яда (public/oblako-yada.mp3)', (
assert.ok(sfxSrc.includes('playbackRate'));
});
void test('ControlApp: активация ловушки «яд» спавнит poisonCloud как на пульте эффектов', () => {
const src = readControlApp();
assert.ok(src.includes("trap?.type === 'poison'"));
assert.ok(src.includes("type: 'poisonCloud'"));
assert.ok(src.includes('trap_pc_'));
assert.ok(src.includes('playPoisonCloudEffectSound(poisonLifeMs)'));
});
void test('ControlApp: эффект «взрыв» + ловушка используют explosion webm/mp3', () => {
const appSrc = readControlApp();
const sfxSrc = fs.readFileSync(path.join(here, 'explosionSfx.ts'), 'utf8');
assert.ok(appSrc.includes("title={t('control.explosion')}"));
assert.ok(appSrc.includes("selectEffectTool('explosion')"));
assert.ok(appSrc.includes("type: 'explosion'"));
assert.ok(appSrc.includes('getExplosionEffectLifeMs'));
assert.ok(appSrc.includes('playExplosionEffectSound'));
assert.ok(appSrc.includes("trap?.type === 'explosion'"));
assert.ok(appSrc.includes('trap_ex_'));
assert.ok(sfxSrc.includes('explosion.mp3'));
assert.ok(sfxSrc.includes('aerial-debris-smoke.webm'));
assert.ok(appSrc.includes('ExplosionVideoOverlay'));
const videoOverlaySrc = fs.readFileSync(
path.join(here, '..', 'shared', 'effects', 'ExplosionVideoOverlay.tsx'),
'utf8',
);
assert.ok(videoOverlaySrc.includes('explosionEffectVideoUrl'));
});
void test('ControlApp: эффекты в пульте, иконки с тултипами и подписью для a11y', () => {
const src = readControlApp();
assert.ok(src.includes("t('control.instruments')"));
assert.ok(src.includes("t('control.descriptionTool')"));
assert.ok(src.includes("t('control.markerTool')"));
assert.ok(src.includes("selectEffectTool('marker')"));
assert.ok(src.includes('SceneMarkerOverlay'));
assert.ok(src.includes('useSceneMarkerState'));
assert.ok(src.includes("t('control.markerColor')"));
assert.ok(src.includes('dispatchMarkerEraserPoints'));
assert.ok(src.includes('markerViewport'));
assert.ok(src.includes('toMarkerNPoint'));
assert.ok(src.includes("kind: 'strokes.clear'"));
assert.ok(src.includes("t('control.descriptionMissing')"));
assert.ok(src.includes('openSceneDescription'));
assert.ok(!src.includes('SceneDescriptionViewModal'));
assert.ok(src.includes("t('control.effects')"));
assert.ok(src.includes("t('control.tools')"));
assert.ok(src.includes("t('control.fieldEffects')"));
assert.ok(src.includes("t('control.actionEffects')"));
assert.ok(src.includes("t('control.darknessControl')"));
assert.ok(src.includes("t('control.explorerBrush')"));
assert.ok(src.includes("t('control.closerBrush')"));
assert.ok(src.includes("tool: 'closeBrush'") || src.includes("selectEffectTool('closeBrush')"));
assert.ok(src.includes("mode: b.tool === 'closeBrush' ? 'cover' : 'reveal'") || src.includes("'cover'"));
assert.ok(src.includes('SceneDarknessOverlay'));
assert.ok(src.includes('useSceneDarknessState'));
assert.ok(src.includes("t('control.sunbeam')"));
assert.ok(src.includes("t('control.lightning')"));
assert.ok(src.includes("title={t('control.water')}"));
assert.ok(src.includes("title={t('control.fire')}"));
assert.ok(src.includes("title={t('control.darkness')}"));
assert.ok(src.includes("title={t('control.poisonCloud')}"));
assert.ok(src.includes("title={t('control.fog')}"));
assert.ok(src.includes("ariaLabel={t('control.fog')}"));
assert.ok(src.includes('selectEffectTool'), 'повторный клик снимает инструмент');
assert.ok(src.includes("tool.tool === next ? 'none' : next"));
assert.ok(src.includes('iconOnly'));
assert.ok(src.includes("title={t('control.clearEffects')}"));
assert.ok(src.includes("ariaLabel={t('control.clearEffects')}"));
assert.ok(src.includes('#e5484d'));
const instruments = src.indexOf("t('control.instruments')");
const fx = src.indexOf("t('control.effects')");
const story = src.indexOf("t('control.storyLine')");
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии');
assert.ok(
instruments !== -1 && fx !== -1 && instruments < fx,
'Блок инструментов должен быть выше эффектов',
);
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше истории');
});
void test('ControlApp: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => {
void test('ControlApp: чекбокс привязки токенов к сетке', () => {
const src = readControlApp();
const css = readControlAppCss();
assert.ok(src.includes('useTokenGridSnapSession'));
assert.ok(src.includes('snapNormToGridCell'));
assert.ok(src.includes('data-testid="token-grid-snap"'));
assert.ok(src.includes("t('control.snapTokensToGrid')"));
assert.ok(src.includes('snapAllTokensToGrid'));
assert.ok(src.includes('snapNorm={snapNormActive}'));
assert.ok(css.includes('.snapToGrid'));
});
void test('ControlApp: история — колонка сверху вниз и фон как у карточек ветвления', () => {
const src = readControlApp();
const css = readControlAppCss();
const story = src.indexOf("t('control.storyLine')");
assert.ok(story !== -1);
assert.ok(src.includes('data-testid="control-history-accordion"'));
assert.ok(src.includes('aria-expanded={historyOpen}'));
assert.ok(src.includes('const [historyOpen, setHistoryOpen] = useState(true)'));
assert.ok(src.includes('className={styles.storyScroll}'));
assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/);
assert.match(css, /\.storyScroll[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
assert.match(css, /\.branchCard[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
});
void test('ControlApp: блок «Сцены» — аккордеон под историей, свёрнут по умолчанию', () => {
const src = readControlApp();
assert.ok(src.includes('data-testid="control-scenes-accordion"'));
assert.ok(src.includes("t('control.scenesAccordion')"));
assert.ok(src.includes('const [scenesOpen, setScenesOpen] = useState(false)'));
assert.ok(src.includes('listActiveStorylineScenes'));
assert.ok(src.includes('storylineScenes.map'));
const historyIdx = src.indexOf('data-testid="control-history-accordion"');
const scenesIdx = src.indexOf('data-testid="control-scenes-accordion"');
assert.ok(historyIdx !== -1 && scenesIdx !== -1 && historyIdx < scenesIdx);
});
void test('ControlApp: клик по истории добавляет новый шаг, не подавляет запись', () => {
const src = readControlApp();
assert.ok(!src.includes('suppressNextHistoryPushRef'));
assert.ok(!src.includes('arr.includes(cur)'));
assert.ok(src.includes('historyRef.current = [...arr, cur]'));
});
void test('ControlApp: текущая сцена в истории — последнее вхождение', () => {
const src = readControlApp();
assert.ok(src.includes('history.lastIndexOf(currentGraphNodeId)'));
assert.ok(src.includes('idx === currentHistoryIdx'));
});
void test('ControlApp: сюжетная линия не меняет граф и сцены проекта', () => {
const src = readControlApp();
const story = src.indexOf("t('control.storyLine')");
assert.ok(story !== -1);
const tail = src.slice(story);
assert.ok(!tail.includes('addSceneGraphNode'));
assert.ok(!tail.includes('removeSceneGraphNode'));
assert.ok(!tail.includes('addSceneGraphEdge'));
assert.ok(!tail.includes('removeSceneGraphEdge'));
assert.ok(!tail.includes('updateProject'));
assert.ok(tail.includes('ipcChannels.project.setCurrentGraphNode'));
});
void test('ControlApp: слой кисти не использует курсор not-allowed (ластик тоже crosshair)', () => {
const src = readControlApp();
const css = readControlAppCss();
@@ -84,6 +197,27 @@ void test('ControlApp: слой кисти не использует курсо
assert.match(css, /\.brushLayer[\s\S]*?cursor:\s*crosshair/);
});
void test('ControlApp: форма кисти затемнения — UI, shape в штрихах и сброс', () => {
const src = readControlApp();
const css = readControlAppCss();
assert.ok(src.includes("t('control.brushShape')"));
assert.ok(src.includes("t('control.brushShapeCircle')"));
assert.ok(src.includes("t('control.brushShapeSquare')"));
assert.ok(src.includes('darknessBrushShape'));
assert.ok(src.includes('darknessBrushDraft'));
assert.ok(src.includes("shape: darknessBrushShapeRef.current"));
assert.ok(src.includes('setDarknessBrushShape'));
assert.ok(src.includes('styles.brushShapeRow'));
assert.ok(src.includes("tool.tool === 'exploreBrush' || tool.tool === 'closeBrush'"));
assert.ok(css.includes('.brushCursorSquare'));
assert.ok(css.includes('.brushShapeRow'));
const radius = src.indexOf("t('control.brushRadius')");
const shape = src.indexOf("t('control.brushShape')");
const sound = src.indexOf("t('control.effectsSound')");
assert.ok(radius !== -1 && shape !== -1 && sound !== -1);
assert.ok(radius < shape && shape < sound, 'Форма кисти между радиусом и звуком эффектов');
});
void test('ControlApp: радиус кисти не в блоке предпросмотра', () => {
const src = readControlApp();
const previewLabel = src.indexOf("t('control.screenPreview')");
@@ -108,6 +242,55 @@ void test('ControlApp: музыка разделена на сцену и кам
assert.match(src, /pause campaign\./i);
});
void test('ControlApp: у каждой аудиозаписи есть регулятор громкости под транспортом', () => {
const src = readControlApp();
const card = fs.readFileSync(path.join(here, 'ControlAudioCard.tsx'), 'utf8');
const css = readControlAppCss();
assert.ok(src.includes('ControlAudioCard'));
assert.ok(src.includes("t('control.volume')"));
assert.ok(src.includes('sceneAudioGainRef'));
assert.ok(src.includes('campaignAudioGainRef'));
assert.ok(src.includes('applyAudioGain'));
assert.ok(card.includes('VolumeSpeakerIcon'));
assert.ok(card.includes('styles.audioVolume'));
assert.ok(card.includes('styles.audioVolumeRow'));
assert.match(css, /\.audioControls[\s\S]*?flex-direction:\s*column/);
assert.match(css, /\.audioVolumeRow\b/);
assert.match(css, /\.audioVolumeIcon\b/);
assert.match(css, /\.audioVolume\b/);
});
void test('ControlApp: весь контент скроллится в окне, отступы сверху и снизу равны', () => {
const css = readControlAppCss();
assert.match(css, /\.page\s*\{[^}]*padding:\s*16px/s);
assert.match(css, /\.page\s*\{[^}]*overflow:\s*auto/s);
assert.doesNotMatch(css, /\.rightStack\s*\{[^}]*overflow-y:\s*auto/s);
});
void test('ControlApp: вода/дождь гасят огонь; ambient огня/дождя + слайдер «Звук эффектов»', () => {
const src = readControlApp();
const fireSfx = fs.readFileSync(path.join(here, 'fireAmbientSfx.ts'), 'utf8');
const rainSfx = fs.readFileSync(path.join(here, 'rainAmbientSfx.ts'), 'utf8');
const i18n = fs.readFileSync(path.join(here, '../editor/i18n/editorMessages.ts'), 'utf8');
assert.ok(src.includes('extinguishFireAlong'));
assert.ok(src.includes("types: ['fire']"));
assert.ok(src.includes("hitMode: 'brush'"));
assert.ok(src.includes('effectsSoundRow'));
assert.ok(src.includes('setFireAmbientActive'));
assert.ok(src.includes('setRainAmbientActive'));
assert.ok(src.includes('hasFireOnScene'));
assert.ok(src.includes('hasRainOnScene'));
assert.ok(src.includes("t('control.effectsSound')"));
assert.ok(fireSfx.includes('fire-ambient.mp3'));
assert.ok(rainSfx.includes('rain-ambient.mp3'));
assert.ok(i18n.includes("'control.effectsSound': 'Звук эффектов'"));
const radius = src.indexOf("t('control.brushRadius')");
const effectsSound = src.indexOf("t('control.effectsSound')");
assert.ok(radius !== -1 && effectsSound !== -1 && radius < effectsSound);
});
void test('ControlApp: загрузка камп. аудио — useEffect зависит только от api и campaignAudioSpecKey', () => {
const src = readControlApp();
const re = /\/\/ Campaign elements:[\s\S]*?useEffect\(\(\) => \{[\s\S]*?\}\s*,\s*\[([^\]]*)\]\s*\)\s*;/;
+17
View File
@@ -0,0 +1,17 @@
/** Общая громкость звуков эффектов (огонь ambient + one-shot). 0…1. */
let effectsSfxGain = 0.75;
export function clampEffectsSfxGain(v: number): number {
if (!Number.isFinite(v)) return 0.75;
return Math.max(0, Math.min(1, v));
}
export function getEffectsSfxGain(): number {
return effectsSfxGain;
}
export function setEffectsSfxGain(v: number): number {
effectsSfxGain = clampEffectsSfxGain(v);
return effectsSfxGain;
}
+63
View File
@@ -0,0 +1,63 @@
/** Звук и длительность эффекта «Взрыв» (`public/explosion.mp3` + WebM с альфой). */
import { getEffectsSfxGain } from './effectsSfxGain';
const EXPLOSION_SFX_VOLUME = 0.95;
/** Запас, если метаданные не прочитались (длина webm ~3.97 с). */
const DEFAULT_EXPLOSION_LIFE_MS = 4200;
export function explosionEffectSoundUrl(): string {
return new URL('explosion.mp3', window.location.href).href;
}
/** VP8/VP9 WebM с `alpha_mode=1` — рендер через HTML `<video>`, не Pixi. */
export function explosionEffectVideoUrl(): string {
return new URL('vfx/explosion/aerial-debris-smoke.webm', window.location.href).href;
}
let cachedExplosionSfxDurationMs: number | null = null;
/** Длительность трека в мс (кэш после первого чтения метаданных). */
export function getExplosionSfxDurationMs(): Promise<number> {
if (cachedExplosionSfxDurationMs !== null) {
return Promise.resolve(cachedExplosionSfxDurationMs);
}
const url = explosionEffectSoundUrl();
return new Promise((resolve) => {
const a = new Audio();
const done = (ms: number): void => {
cachedExplosionSfxDurationMs = ms;
a.removeAttribute('src');
resolve(ms);
};
a.addEventListener('loadedmetadata', () => {
const d = a.duration;
done(Number.isFinite(d) && d > 0 ? Math.round(d * 1000) : DEFAULT_EXPLOSION_LIFE_MS);
});
a.addEventListener('error', () => done(DEFAULT_EXPLOSION_LIFE_MS));
a.src = url;
a.load();
});
}
/** Длительность визуала ≈ max(звук, webm), с разумными пределами. */
export async function getExplosionEffectLifeMs(): Promise<number> {
const sfxMs = await getExplosionSfxDurationMs();
const raw = Math.max(sfxMs, DEFAULT_EXPLOSION_LIFE_MS);
return Math.min(60_000, Math.max(600, raw));
}
export async function playExplosionEffectSound(lifeMs: number): Promise<void> {
try {
const rawMs = await getExplosionSfxDurationMs();
const target = Math.max(200, lifeMs);
const rate = Math.max(0.25, Math.min(4, rawMs / target));
const el = new Audio(explosionEffectSoundUrl());
el.volume = Math.max(0, Math.min(1, EXPLOSION_SFX_VOLUME * getEffectsSfxGain()));
el.playbackRate = rate;
void el.play().catch(() => undefined);
} catch {
/* ignore */
}
}
+50
View File
@@ -0,0 +1,50 @@
/** Фоновый звук горения при наличии огня на сцене (`public/fire-ambient.mp3`). */
import { getEffectsSfxGain } from './effectsSfxGain';
const FIRE_AMBIENT_BASE_VOLUME = 0.72;
export function fireAmbientSoundUrl(): string {
return new URL('fire-ambient.mp3', window.location.href).href;
}
let ambientEl: HTMLAudioElement | null = null;
function ensureAmbientEl(): HTMLAudioElement {
if (ambientEl) return ambientEl;
const el = new Audio(fireAmbientSoundUrl());
el.loop = true;
el.preload = 'auto';
ambientEl = el;
return el;
}
function applyVolume(el: HTMLAudioElement): void {
el.volume = Math.max(0, Math.min(1, FIRE_AMBIENT_BASE_VOLUME * getEffectsSfxGain()));
}
/** Включить/выключить loop горения. */
export function setFireAmbientActive(active: boolean): void {
try {
const el = ensureAmbientEl();
applyVolume(el);
if (active) {
if (el.paused) void el.play().catch(() => undefined);
} else if (!el.paused) {
el.pause();
el.currentTime = 0;
}
} catch {
/* ignore */
}
}
/** Обновить громкость, если ambient уже играет. */
export function syncFireAmbientVolume(): void {
if (!ambientEl) return;
try {
applyVolume(ambientEl);
} catch {
/* ignore */
}
}
+4 -2
View File
@@ -1,9 +1,11 @@
/** Звук и длительность эффекта «Заморозка» (`public/zamorozka.mp3`). */
import { getEffectsSfxGain } from './effectsSfxGain';
const DEFAULT_FREEZE_LIFE_MS = 820;
const FREEZE_SFX_BASE_VOLUME = 0.88;
/** На 25% тише базовой громкости эффекта. */
/** На 25% тише базовой громкости эффекта (до множителя «Звук эффектов»). */
export const FREEZE_SFX_VOLUME = FREEZE_SFX_BASE_VOLUME * 0.75;
export function freezeEffectSoundUrl(): string {
@@ -44,7 +46,7 @@ export async function getFreezeEffectLifeMs(): Promise<number> {
export function playFreezeEffectSound(): void {
try {
const el = new Audio(freezeEffectSoundUrl());
el.volume = FREEZE_SFX_VOLUME;
el.volume = Math.max(0, Math.min(1, FREEZE_SFX_VOLUME * getEffectsSfxGain()));
void el.play().catch(() => undefined);
} catch {
/* ignore */
+6 -3
View File
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { ControlApp } from './ControlApp';
@@ -13,8 +14,10 @@ if (!rootEl) {
createRoot(rootEl).render(
<React.StrictMode>
<EditorI18nProvider>
<ControlApp />
</EditorI18nProvider>
<WindowErrorBoundary title="Пульт">
<EditorI18nProvider>
<ControlApp />
</EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>,
);
+3 -1
View File
@@ -1,5 +1,7 @@
/** Звук и длительность эффекта «Облако яда» (`public/oblako-yada.mp3`). */
import { getEffectsSfxGain } from './effectsSfxGain';
const POISON_CLOUD_SFX_VOLUME = 0.92;
/** Запас, если метаданные не прочитались. */
@@ -50,7 +52,7 @@ export async function playPoisonCloudEffectSound(lifeMs: number): Promise<void>
const target = Math.max(200, lifeMs);
const rate = Math.max(0.25, Math.min(4, rawMs / target));
const el = new Audio(poisonCloudEffectSoundUrl());
el.volume = POISON_CLOUD_SFX_VOLUME;
el.volume = Math.max(0, Math.min(1, POISON_CLOUD_SFX_VOLUME * getEffectsSfxGain()));
el.playbackRate = rate;
void el.play().catch(() => undefined);
} catch {
+50
View File
@@ -0,0 +1,50 @@
/** Фоновый звук дождя при наличии дождя на сцене (`public/rain-ambient.mp3`). */
import { getEffectsSfxGain } from './effectsSfxGain';
const RAIN_AMBIENT_BASE_VOLUME = 0.7;
export function rainAmbientSoundUrl(): string {
return new URL('rain-ambient.mp3', window.location.href).href;
}
let ambientEl: HTMLAudioElement | null = null;
function ensureAmbientEl(): HTMLAudioElement {
if (ambientEl) return ambientEl;
const el = new Audio(rainAmbientSoundUrl());
el.loop = true;
el.preload = 'auto';
ambientEl = el;
return el;
}
function applyVolume(el: HTMLAudioElement): void {
el.volume = Math.max(0, Math.min(1, RAIN_AMBIENT_BASE_VOLUME * getEffectsSfxGain()));
}
/** Включить/выключить loop дождя. */
export function setRainAmbientActive(active: boolean): void {
try {
const el = ensureAmbientEl();
applyVolume(el);
if (active) {
if (el.paused) void el.play().catch(() => undefined);
} else if (!el.paused) {
el.pause();
el.currentTime = 0;
}
} catch {
/* ignore */
}
}
/** Обновить громкость, если ambient уже играет. */
export function syncRainAmbientVolume(): void {
if (!ambientEl) return;
try {
applyVolume(ambientEl);
} catch {
/* ignore */
}
}
+3 -1
View File
@@ -1,5 +1,7 @@
/** Звук эффекта «Луч света» (`public/luch_sveta.mp3`). */
import { getEffectsSfxGain } from './effectsSfxGain';
const SUNBEAM_SFX_VOLUME = 0.88;
/** Воспроизведение на 50% быстрее → реальная длительность = файл / 1.5. */
export const SUNBEAM_PLAYBACK_RATE = 1.5;
@@ -47,7 +49,7 @@ export async function getSunbeamEffectLifeMs(): Promise<number> {
export function playSunbeamEffectSound(): void {
try {
const el = new Audio(sunbeamEffectSoundUrl());
el.volume = SUNBEAM_SFX_VOLUME;
el.volume = Math.max(0, Math.min(1, SUNBEAM_SFX_VOLUME * getEffectsSfxGain()));
el.playbackRate = SUNBEAM_PLAYBACK_RATE;
void el.play().catch(() => undefined);
} catch {
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-window-icon.png" type="image/png" />
<title>TTRPG</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/dice/main.tsx"></script>
</body>
</html>
+128
View File
@@ -0,0 +1,128 @@
.page {
height: 100vh;
box-sizing: border-box;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
background: var(--bg);
color: var(--text);
}
.sectionLabel {
font-size: var(--text-xs);
font-weight: 800;
letter-spacing: 0.06em;
color: var(--text2);
text-transform: uppercase;
}
.sidesGrid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.sidesGrid > * {
width: 100%;
max-width: none;
}
.sidesGrid button {
width: 100%;
justify-content: center;
}
.countRow {
display: grid;
grid-template-columns: 48px 1fr 48px;
gap: 8px;
align-items: center;
}
.countValue {
text-align: center;
font-size: 28px;
font-weight: 800;
font-variant-numeric: tabular-nums;
border: 1px solid var(--stroke);
border-radius: var(--radius-sm);
padding: 8px;
background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.28));
}
.actions {
display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 8px;
margin-top: 4px;
}
.actions > * {
width: 100%;
max-width: none;
}
.actions button {
width: 100%;
justify-content: center;
}
.lastResult {
margin-top: auto;
padding: 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3, rgba(0, 0, 0, 0.28));
display: flex;
flex-direction: column;
gap: 8px;
}
.lastLabel {
font-size: 11px;
font-weight: 700;
color: var(--text2);
}
.lastNotation {
font-size: 13px;
font-weight: 700;
color: #93c5fd;
}
.valueChips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.valueChip {
min-width: 36px;
padding: 6px 10px;
border-radius: 8px;
border: 1px solid rgba(245, 197, 66, 0.55);
background: rgba(245, 197, 66, 0.12);
color: #fef3c7;
font-size: 18px;
font-weight: 800;
font-variant-numeric: tabular-nums;
text-align: center;
}
.lastTotal {
font-size: 16px;
font-variant-numeric: tabular-nums;
}
.rollingHint {
font-size: 12px;
color: var(--text2);
}
.emptyResult {
margin-top: auto;
padding: 12px;
color: var(--text2);
font-size: var(--text-sm);
}
+104
View File
@@ -0,0 +1,104 @@
import React, { useEffect, useState } from 'react';
import { ipcChannels } from '../../shared/ipc/contracts';
import { DICE_SIDES, type DiceSides } from '../../shared/types/diceRoll';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { useDiceRollState } from '../shared/dice/useDiceRollState';
import { Button } from '../shared/ui/controls';
import styles from './DiceApp.module.css';
export function DiceApp() {
const { t } = useEditorI18n();
const api = getDndApi();
const [state, diceApi] = useDiceRollState();
const [sides, setSides] = useState<DiceSides>(20);
const [count, setCount] = useState(1);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
void api.invoke(ipcChannels.windows.closeDice, {});
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [api]);
return (
<div className={styles.page} data-testid="dice-app">
<div className={styles.sectionLabel}>{t('dice.sides')}</div>
<div className={styles.sidesGrid}>
{DICE_SIDES.map((s) => (
<Button
key={s}
variant={sides === s ? 'primary' : 'ghost'}
onClick={() => setSides(s)}
data-testid={`dice-side-${s}`}
>
d{s}
</Button>
))}
</div>
<div className={styles.sectionLabel}>{t('dice.count')}</div>
<div className={styles.countRow}>
<Button
variant="ghost"
onClick={() => setCount((c) => Math.max(1, c - 1))}
ariaLabel={t('dice.dec')}
>
−
</Button>
<div className={styles.countValue} data-testid="dice-count">
{count}
</div>
<Button
variant="ghost"
onClick={() => setCount((c) => Math.min(20, c + 1))}
ariaLabel={t('dice.inc')}
>
+
</Button>
</div>
<div className={styles.actions}>
<Button
variant="primary"
onClick={() => void diceApi.dispatch({ kind: 'roll', count, sides })}
data-testid="dice-roll"
>
{t('dice.roll')}
</Button>
<Button
variant="ghost"
disabled={!state?.values.length && !state?.active}
onClick={() => void diceApi.dispatch({ kind: 'clear' })}
>
{t('dice.clear')}
</Button>
</div>
{state && state.values.length > 0 ? (
<div className={styles.lastResult} data-testid="dice-last-result">
<div className={styles.lastLabel}>{t('dice.result')}</div>
<div className={styles.lastNotation}>{state.notation}</div>
<div className={styles.valueChips} aria-label={t('dice.result')}>
{state.values.map((v, i) => (
<span key={`${state.revision}-${i}`} className={styles.valueChip}>
{v}
</span>
))}
</div>
<div className={styles.lastTotal}>
{t('dice.total')}: <strong>{state.total}</strong>
</div>
{state.active ? <div className={styles.rollingHint}>{t('dice.rolling')}</div> : null}
</div>
) : (
<div className={styles.emptyResult}>{t('dice.noResult')}</div>
)}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import '../shared/ui/globals.css';
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
import { DiceApp } from './DiceApp';
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Missing #root element');
}
createRoot(rootEl).render(
<React.StrictMode>
<WindowErrorBoundary title="Dice">
<EditorI18nProvider>
<DiceApp />
</EditorI18nProvider>
</WindowErrorBoundary>
</React.StrictMode>,
);
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/app-window-icon.png" type="image/png" />
<title>DnD Player — Editor</title>
<title>TTRPG - Editor</title>
</head>
<body>
<div id="root"></div>
+676 -23
View File
@@ -59,15 +59,89 @@
gap: 10px;
}
.splitRun {
display: inline-flex;
align-items: stretch;
height: 34px;
border-radius: var(--radius-sm);
border: 1px solid var(--accent-border);
background: var(--accent-fill-solid);
overflow: hidden;
}
.splitRun:disabled,
.splitRun[aria-disabled='true'] {
opacity: 0.45;
pointer-events: none;
}
.splitRunMain,
.splitRunChevron {
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.95);
cursor: pointer;
font: inherit;
font-weight: 600;
}
.splitRunMain {
padding: 0 14px;
}
.splitRunMain:hover:not(:disabled),
.splitRunChevron:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent-fill-solid) 72%, white);
}
.splitRunMain:active:not(:disabled),
.splitRunChevron:active:not(:disabled),
.splitRunChevronOpen {
background: color-mix(in srgb, var(--accent-fill-solid) 78%, black);
}
.splitRunDivider {
width: 1px;
align-self: stretch;
background: rgba(0, 0, 0, 0.28);
flex: 0 0 auto;
}
.splitRunChevron {
width: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
}
.splitRunChevronSvg {
width: 12px;
height: 12px;
display: block;
}
.editorSidebar {
height: 100%;
min-height: 0;
display: grid;
grid-template-rows: auto 1fr;
display: flex;
flex-direction: column;
gap: 0;
padding: 12px;
border-right: 1px solid var(--stroke);
background: var(--editor-column-bg);
overflow: hidden;
position: relative;
transition:
border-color 0.15s ease,
background 0.15s ease,
box-shadow 0.15s ease;
}
.editorSidebarDragOver {
border-right-color: var(--accent);
background: var(--accent-fill-soft);
box-shadow: inset 0 0 0 2px var(--accent);
}
.editorGraphHost {
@@ -94,25 +168,62 @@
.gridTools {
display: grid;
gap: 10px;
flex: 0 0 auto;
}
.gridTools > * {
width: 100%;
max-width: 100%;
display: flex;
box-sizing: border-box;
}
.gridTools button {
width: 100%;
box-sizing: border-box;
flex: 1;
}
.spacer14 {
height: 14px;
flex: 0 0 auto;
}
.spacer18 {
height: 18px;
}
.gamePropsButtons {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.gamePropsButtons > * {
width: 100%;
min-width: 0;
max-width: 100%;
display: flex;
box-sizing: border-box;
}
.gamePropsButtons button {
width: 100%;
box-sizing: border-box;
flex: 1;
}
.sidebarScroll {
overflow: auto;
padding-right: 2px;
min-height: 0;
flex: 1 1 auto;
align-self: stretch;
}
.sceneListGrid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
align-content: start;
justify-items: stretch;
@@ -131,7 +242,8 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
/* Выше модалок (20001), чтобы прогресс импорта не уходил под диалог выбора. */
z-index: 30000;
}
.editorLockOverlay {
@@ -169,15 +281,19 @@
.progressModal {
width: min(520px, calc(100vw - 32px));
border-radius: 12px;
padding: 14px;
padding: 16px 16px 20px;
background: rgba(25, 28, 38, 0.92);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.55);
display: grid;
gap: 12px;
justify-items: center;
}
.progressTitle {
font-weight: 700;
margin-bottom: 10px;
width: 100%;
text-align: center;
}
.progressBar {
@@ -186,6 +302,7 @@
overflow: hidden;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
width: 100%;
}
.progressFill {
@@ -195,11 +312,13 @@
}
.progressMeta {
margin-top: 10px;
margin-top: 0;
display: flex;
justify-content: space-between;
gap: 12px;
opacity: 0.9;
font-size: 12px;
width: 100%;
}
.inspectorTitle {
@@ -208,6 +327,18 @@
flex-shrink: 0;
}
.projectNameLabel {
font-weight: 700;
font-size: 13px;
letter-spacing: 0.02em;
margin-bottom: 10px;
color: var(--text1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
}
.inspectorScroll {
flex: 1;
min-height: 0;
@@ -219,6 +350,11 @@
color: var(--text2);
}
.noticeMessage {
white-space: pre-wrap;
line-height: 1.45;
}
.fileMenu {
position: fixed;
min-width: 220px;
@@ -243,6 +379,17 @@
font: inherit;
}
.fileMenuItemDanger {
text-align: left;
padding: 10px;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--color-danger);
cursor: pointer;
font: inherit;
}
.fileMenuSubHost {
position: relative;
}
@@ -289,6 +436,10 @@
transform: translate(-50%, -50%);
width: 520px;
max-width: calc(100vw - 32px);
max-height: calc(100vh - 32px);
overflow: auto;
box-sizing: border-box;
min-width: 0;
border-radius: var(--radius-lg);
border: 1px solid var(--stroke);
background: var(--color-surface-elevated);
@@ -296,6 +447,7 @@
padding: 16px;
display: grid;
gap: 12px;
grid-template-columns: minmax(0, 1fr);
}
.modalHeader {
@@ -325,6 +477,17 @@
.fieldGrid {
display: grid;
gap: 6px;
min-width: 0;
}
.fieldGroup {
display: grid;
gap: 6px;
min-width: 0;
}
.fieldGroupSpaced {
margin-top: 10px;
}
.fieldLabel {
@@ -338,17 +501,6 @@
font-size: var(--text-xs);
}
.selectInput {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--bg0);
color: var(--text0);
font: inherit;
}
.rowFlex {
display: flex;
gap: 8px;
@@ -358,8 +510,13 @@
.modalFooter {
display: flex;
justify-content: flex-end;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-top: 4px;
width: 100%;
min-width: 0;
box-sizing: border-box;
}
.licenseBlockTitle {
@@ -393,6 +550,153 @@
line-height: 1.45;
}
.instructionsDialog {
position: fixed;
z-index: var(--z-modal);
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: min(920px, calc(100vw - 32px));
max-height: min(86vh, 720px);
border-radius: var(--radius-lg);
border: 1px solid var(--stroke);
background: var(--color-surface-elevated);
box-shadow: var(--shadow-xl);
padding: 16px;
display: grid;
grid-template-rows: auto 1fr auto;
gap: 12px;
}
.instructionsLayout {
display: grid;
grid-template-columns: 1fr 220px;
gap: 14px;
min-height: 0;
max-height: min(62vh, 560px);
}
.instructionsContent {
min-height: 0;
overflow: auto;
padding: 12px 14px;
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--bg0);
}
.instructionsContentTitle {
font-weight: 800;
font-size: var(--text-md);
margin-bottom: 12px;
}
.instructionsParagraph {
margin: 0 0 12px;
color: var(--text1);
font-size: var(--text-sm);
line-height: 1.55;
white-space: pre-wrap;
}
.instructionsParagraph:last-child {
margin-bottom: 0;
}
.instructionsInlineLink {
display: inline;
margin: 0;
padding: 0;
border: none;
background: none;
color: var(--accent, #a78bfa);
font: inherit;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.instructionsInlineLink:hover {
color: var(--text0);
}
.instructionsNav {
min-height: 0;
overflow: auto;
display: grid;
gap: 4px;
align-content: start;
padding: 6px;
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--panel2);
}
.instructionsNavItem {
text-align: left;
padding: 9px 10px;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--text2);
cursor: pointer;
font: inherit;
font-size: var(--text-xs);
line-height: 1.35;
}
.instructionsNavItem:hover {
background: var(--scene-list-hover-bg);
color: var(--text0);
}
.instructionsNavItemActive {
background: rgba(167, 139, 250, 0.18);
color: var(--text0);
font-weight: 700;
}
.aboutBody {
display: grid;
gap: 10px;
}
.aboutAppName {
font-weight: 900;
font-size: var(--text-lg);
}
.aboutTagline {
color: var(--text2);
font-size: var(--text-sm);
line-height: 1.45;
}
.aboutParagraph {
margin: 0;
color: var(--text1);
font-size: var(--text-sm);
line-height: 1.55;
}
.aboutMetaGrid {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px 14px;
align-items: baseline;
margin-top: 4px;
}
.aboutLink {
color: var(--color-accent, #a78bfa);
text-decoration: none;
}
.aboutLink:hover {
text-decoration: underline;
}
.fileSuffix {
color: var(--text2);
font-size: var(--text-xs);
@@ -414,6 +718,23 @@
gap: 10px;
}
.projectPickerForm > * {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.projectPickerForm > *:has(button),
.projectPickerForm > span {
display: flex;
}
.projectPickerForm button {
width: 100%;
box-sizing: border-box;
flex: 1;
}
.spacer6 {
height: 6px;
}
@@ -452,6 +773,16 @@
border-radius: 10px;
}
.projectCardBodyOpening {
cursor: wait;
opacity: 0.72;
}
.projectCardBodyDisabled {
cursor: default;
opacity: 0.55;
}
.projectCardMenuBtn {
flex-shrink: 0;
margin: -4px -4px 0 0;
@@ -490,6 +821,19 @@
font-weight: 700;
}
.labelRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 28px;
}
.labelRow .labelSm {
flex: 1;
min-width: 0;
}
.spacer8 {
height: 8px;
}
@@ -505,6 +849,75 @@
outline: none;
}
.descriptionEmpty {
min-height: 36px;
padding: 8px 10px;
border-radius: var(--radius-md);
border: 1px dashed var(--stroke-2);
background: var(--color-overlay-dark-2);
color: var(--text2);
font-size: var(--text-xs);
line-height: 1.4;
display: flex;
align-items: center;
}
.descriptionPreview {
max-height: 4.6em;
overflow-x: hidden;
overflow-y: auto;
padding: 8px 10px;
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3);
color: var(--text1);
font-size: var(--text-xs);
line-height: 1.45;
word-break: break-word;
}
.descriptionPreview :global(p),
.descriptionPreview :global(h2),
.descriptionPreview :global(h3),
.descriptionPreview :global(ul),
.descriptionPreview :global(ol),
.descriptionPreview :global(blockquote),
.descriptionPreview :global(pre) {
margin: 0 0 0.35em;
}
.descriptionPreview :global(p:last-child),
.descriptionPreview :global(h2:last-child),
.descriptionPreview :global(h3:last-child),
.descriptionPreview :global(ul:last-child),
.descriptionPreview :global(ol:last-child),
.descriptionPreview :global(blockquote:last-child),
.descriptionPreview :global(pre:last-child) {
margin-bottom: 0;
}
.descriptionPreview :global(h2),
.descriptionPreview :global(h3) {
font-size: 1em;
font-weight: 800;
color: var(--text0);
}
.descriptionPreview :global(ul),
.descriptionPreview :global(ol) {
padding-left: 1.2em;
}
.descriptionPreview :global(strong),
.descriptionPreview :global(b) {
font-weight: 800;
color: var(--text0);
}
.descriptionPreview :global(a) {
color: var(--accent2);
}
.hint {
color: var(--text2);
font-size: var(--text-xs);
@@ -522,6 +935,15 @@
align-items: center;
justify-content: center;
position: relative;
transition:
border-color 0.15s ease,
background 0.15s ease;
}
.previewBoxDragOver {
border-color: var(--accent);
border-style: dashed;
background: var(--accent-fill-soft);
}
.previewFill {
@@ -586,6 +1008,33 @@
flex-wrap: wrap;
}
.actionsRowHalf {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
width: 100%;
}
.actionsRowHalf > span {
display: flex;
min-width: 0;
width: 100%;
flex: 1 1 0;
}
.actionsRowHalf > span > button {
flex: 1 1 auto;
width: 100%;
}
.actionsRowVideoChecks {
display: flex;
flex-wrap: wrap;
gap: 12px;
width: 100%;
align-items: center;
}
.checkboxLabel {
display: flex;
gap: 8px;
@@ -615,12 +1064,53 @@
padding: 10px;
display: grid;
gap: 8px;
position: relative;
transition:
border-color 0.15s ease,
background 0.15s ease;
}
.audioDropEmpty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 96px;
text-align: center;
padding: 8px 4px;
}
.audioDropEmpty > * {
max-width: 100%;
}
.audioDropDragOver {
border-color: var(--accent);
background: var(--accent-fill-soft);
}
.dropHintOverlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
border-radius: inherit;
background: var(--accent-fill-soft-2);
color: var(--accent);
font-size: var(--text-xs);
font-weight: 700;
text-align: center;
pointer-events: none;
z-index: 2;
}
.audioList {
display: grid;
gap: 6px;
max-height: 160px;
max-height: 280px;
overflow: auto;
}
@@ -630,10 +1120,32 @@
border-radius: var(--radius-xs);
background: var(--color-overlay-dark-3);
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
grid-template-columns: minmax(0, 1fr);
gap: 6px;
align-items: start;
min-width: 0;
cursor: default;
}
.audioDragHandle {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
cursor: grab;
user-select: none;
}
.audioDragHandle:active {
cursor: grabbing;
}
.audioDragGrip {
flex-shrink: 0;
color: var(--text2);
font-size: 10px;
letter-spacing: -2px;
line-height: 1;
}
.audioName {
@@ -645,13 +1157,35 @@
.audioControls {
color: var(--text2);
flex-shrink: 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
min-width: 0;
}
.audioVolumeLabel {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.audioVolumeRange {
width: 72px;
accent-color: var(--accent, #3b82f6);
}
.audioPreviewBtn {
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
padding: 0 4px;
line-height: 1;
font-size: 12px;
}
.audioRemove {
flex-shrink: 0;
min-width: 28px;
@@ -687,6 +1221,7 @@
border: 1px solid transparent;
box-sizing: border-box;
background: transparent;
min-width: 0;
}
.sceneCard:not(.sceneCardActive):hover {
@@ -698,6 +1233,36 @@
background: var(--scene-list-selected-bg);
}
.sceneCardDragging {
opacity: 0.45;
}
.sceneCardDropBefore,
.sceneCardDropAfter {
position: relative;
}
.sceneCardDropBefore::before,
.sceneCardDropAfter::after {
content: '';
position: absolute;
left: 8px;
right: 8px;
height: 3px;
border-radius: 999px;
background: var(--accent);
z-index: 2;
pointer-events: none;
}
.sceneCardDropBefore::before {
top: 0;
}
.sceneCardDropAfter::after {
bottom: 0;
}
.sceneThumb {
height: 92px;
position: relative;
@@ -712,6 +1277,13 @@
overflow: hidden;
}
.sceneThumbInner img,
.sceneThumbInner video,
.sceneThumbVideo {
-webkit-user-drag: none;
user-select: none;
}
.sceneThumbVideo {
display: block;
width: 100%;
@@ -737,21 +1309,23 @@
padding: 10px;
display: grid;
gap: 6px;
min-width: 0;
}
.sceneCardHeader {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.badgeCurrent {
font-size: var(--text-xs);
color: var(--accent2);
flex-shrink: 0;
}
.sceneMenuBtn {
margin-left: auto;
border: none;
background: var(--panel2);
border-radius: var(--radius-xs);
@@ -765,6 +1339,11 @@
.sceneCardTitle {
font-weight: 750;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.menuBackdrop {
@@ -802,3 +1381,77 @@
cursor: pointer;
width: 100%;
}
.modalDialogWide {
width: 640px;
}
.storylineChecklist {
display: grid;
gap: 8px;
max-height: 280px;
overflow-y: auto;
padding: 4px 0;
}
.storylineCheck {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-surface);
cursor: pointer;
font-size: 13px;
}
.storylineCheck input {
margin-top: 2px;
}
.storylineCheckDisabled {
opacity: 0.55;
cursor: not-allowed;
}
.conflictList {
display: grid;
gap: 12px;
max-height: 360px;
overflow-y: auto;
}
.conflictRow {
display: grid;
gap: 8px;
padding: 10px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--stroke);
background: var(--color-surface);
}
.conflictRowTitle {
font-weight: 700;
font-size: 13px;
}
.conflictRowOptions {
display: grid;
gap: 6px;
}
.importFileRow {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.reportList {
display: grid;
gap: 6px;
font-size: 13px;
margin: 0;
padding-left: 18px;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,287 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { ipcChannels } from '../../shared/ipc/contracts';
import {
clampSceneAudioVolume,
type AssetId,
type MediaAsset,
type SceneAudioRef,
} from '../../shared/types';
import { getDndApi } from '../shared/dndApi';
import { useEditorI18n } from './i18n/EditorI18nContext';
import { registerEditorAudioStopper } from '../shared/audio/editorAudioStop';
import styles from './EditorApp.module.css';
const AUDIO_REF_DND = 'application/x-dnd-scene-audio-ref';
function reorderRefs(refs: SceneAudioRef[], draggedId: AssetId, targetId: AssetId): SceneAudioRef[] {
if (draggedId === targetId) return refs;
const from = refs.findIndex((r) => r.assetId === draggedId);
const to = refs.findIndex((r) => r.assetId === targetId);
if (from < 0 || to < 0) return refs;
const next = [...refs];
const [item] = next.splice(from, 1);
if (!item) return refs;
next.splice(to, 0, item);
return next;
}
/** Локальный draft — не пишет в проект на каждый tick ползунка (иначе мигает граф). */
function AudioVolumeSlider({
assetId,
volume,
isPlaying,
previewElRef,
label,
onCommit,
}: {
assetId: AssetId;
volume: number;
isPlaying: boolean;
previewElRef: React.MutableRefObject<HTMLAudioElement | null>;
label: string;
onCommit: (volume: number) => void;
}) {
const [draft, setDraft] = useState(() => clampSceneAudioVolume(volume));
const draftRef = useRef(draft);
draftRef.current = draft;
useEffect(() => {
setDraft(clampSceneAudioVolume(volume));
}, [assetId, volume]);
const applyLocal = (v: number) => {
const next = clampSceneAudioVolume(v);
setDraft(next);
if (isPlaying && previewElRef.current) previewElRef.current.volume = next;
};
const commit = () => {
const next = clampSceneAudioVolume(draftRef.current);
if (next === clampSceneAudioVolume(volume)) return;
onCommit(next);
};
return (
<label className={styles.audioVolumeLabel} title={label}>
<span className={styles.spanXs}>{label}</span>
<input
type="range"
className={styles.audioVolumeRange}
min={0}
max={1}
step={0.01}
value={draft}
aria-label={label}
onChange={(e) => applyLocal(Number(e.target.value))}
onPointerUp={commit}
onBlur={commit}
onKeyUp={(e) => {
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Home' || e.key === 'End') {
commit();
}
}}
/>
</label>
);
}
export type EditorAudioTrackListProps = {
audioRefs: SceneAudioRef[];
mediaAssets: MediaAsset[];
removeTitle: string;
onAudioRefsChange: (next: SceneAudioRef[]) => void;
};
export function EditorAudioTrackList({
audioRefs,
mediaAssets,
removeTitle,
onAudioRefsChange,
}: EditorAudioTrackListProps) {
const { t } = useEditorI18n();
const assetById = useMemo(() => new Map(mediaAssets.map((a) => [a.id, a])), [mediaAssets]);
const [playingId, setPlayingId] = useState<AssetId | null>(null);
const previewElRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
return () => {
const el = previewElRef.current;
if (el) {
el.pause();
el.src = '';
previewElRef.current = null;
}
};
}, []);
useEffect(() => {
return registerEditorAudioStopper(() => {
const el = previewElRef.current;
if (el) {
el.pause();
el.onended = null;
el.src = '';
}
previewElRef.current = null;
setPlayingId(null);
});
}, []);
const stopPreview = () => {
const el = previewElRef.current;
if (el) {
el.pause();
el.onended = null;
el.src = '';
}
previewElRef.current = null;
setPlayingId(null);
};
const togglePreview = async (assetId: AssetId, volume: number) => {
if (playingId === assetId) {
stopPreview();
return;
}
stopPreview();
try {
const r = await getDndApi().invoke(ipcChannels.project.assetFileUrl, { assetId });
const url = r.url;
if (!url) return;
const el = new Audio(url);
el.volume = clampSceneAudioVolume(volume);
el.onended = () => {
previewElRef.current = null;
setPlayingId(null);
};
previewElRef.current = el;
setPlayingId(assetId);
await el.play();
} catch {
stopPreview();
}
};
if (audioRefs.length === 0) {
return (
<div className={[styles.muted, styles.spanSm].join(' ')}>{t('campaign.noFiles')}</div>
);
}
return (
<div className={styles.audioList}>
{audioRefs.map((ref) => {
const asset = assetById.get(ref.assetId);
const name = asset?.originalName ?? ref.assetId;
const volume = clampSceneAudioVolume(ref.volume);
const isPlaying = playingId === ref.assetId;
return (
<div
key={ref.assetId}
className={styles.audioRow}
onDragOver={(e) => {
if (e.dataTransfer.types.includes(AUDIO_REF_DND)) e.preventDefault();
}}
onDrop={(e) => {
e.preventDefault();
e.stopPropagation();
const dragged = e.dataTransfer.getData(AUDIO_REF_DND) as AssetId;
if (!dragged) return;
const next = reorderRefs(audioRefs, dragged, ref.assetId);
if (next !== audioRefs) onAudioRefsChange(next);
}}
>
<div
className={styles.audioDragHandle}
draggable
title={name}
onDragStart={(e) => {
e.dataTransfer.setData(AUDIO_REF_DND, ref.assetId);
e.dataTransfer.effectAllowed = 'move';
}}
>
<span className={styles.audioDragGrip} aria-hidden>
⋮⋮
</span>
<span className={styles.audioName}>{name}</span>
</div>
<span className={styles.audioControls}>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={ref.autoplay}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === ref.assetId ? { ...x, autoplay: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.auto')}</span>
</label>
<label className={styles.checkboxLabelSm}>
<input
type="checkbox"
checked={ref.loop}
onChange={(e) => {
const next = audioRefs.map((x) =>
x.assetId === ref.assetId ? { ...x, loop: e.target.checked } : x,
);
onAudioRefsChange(next);
}}
/>
<span className={styles.spanXs}>{t('campaign.loop')}</span>
</label>
<AudioVolumeSlider
assetId={ref.assetId}
volume={volume}
isPlaying={isPlaying}
previewElRef={previewElRef}
label={t('campaign.volume')}
onCommit={(v) => {
const next = audioRefs.map((x) =>
x.assetId === ref.assetId ? { ...x, volume: v } : x,
);
onAudioRefsChange(next);
}}
/>
<button
type="button"
className={styles.audioPreviewBtn}
title={isPlaying ? t('campaign.previewStop') : t('campaign.preview')}
aria-label={isPlaying ? t('campaign.previewStop') : t('campaign.preview')}
onClick={() => void togglePreview(ref.assetId, volume)}
>
{isPlaying ? '■' : '▶'}
</button>
<button
type="button"
title={removeTitle}
className={styles.audioRemove}
onClick={() => {
if (playingId === ref.assetId) stopPreview();
onAudioRefsChange(audioRefs.filter((x) => x.assetId !== ref.assetId));
}}
>
<svg
className={styles.audioRemoveIcon}
viewBox="0 0 24 24"
width={16}
height={16}
aria-hidden
>
<path
fill="currentColor"
d="M9 3h6a1 1 0 0 1 1 1v1h4v2H4V5h4V4a1 1 0 0 1 1-1zm1 5h2v9h-2V8zm4 0h2v9h-2V8zM7 8h2v9H7V8zm9-3H8v1h8V5zM6 21a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V8H6v13z"
/>
</svg>
</button>
</span>
</div>
);
})}
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
import React, { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { Button, Select } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
export type FoundryImportSourceSelection =
| { kind: 'folder'; sourcePath: string }
| { kind: 'archive'; sourcePath: string };
type FoundryImportModalProps = {
open: boolean;
pickSource: (
mode: 'folder' | 'archive',
) => Promise<{ canceled: true } | { canceled: false; sourcePath: string }>;
onClose: () => void;
onImport: (selection: FoundryImportSourceSelection) => Promise<void>;
};
export function FoundryImportModal({ open, pickSource, onClose, onImport }: FoundryImportModalProps) {
const { t } = useEditorI18n();
const [mode, setMode] = useState<'folder' | 'archive'>('folder');
const [picked, setPicked] = useState<{ path: string; name: string } | null>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setMode('folder');
setPicked(null);
setSubmitting(false);
setError(null);
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !submitting) onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose, open, submitting]);
if (!open) return null;
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={() => {
if (!submitting) onClose();
}}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('foundryImport.title')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={() => {
if (!submitting) onClose();
}}
className={styles.modalClose}
>
×
</button>
</div>
<div className={styles.fieldGrid}>
<div className={styles.muted}>{t('foundryImport.hint')}</div>
<div className={styles.fieldLabel}>{t('foundryImport.sourceType')}</div>
<Select
value={mode}
disabled={submitting}
ariaLabel={t('foundryImport.sourceType')}
options={[
{ value: 'folder', label: t('foundryImport.folder') },
{ value: 'archive', label: t('foundryImport.archive') },
]}
onChange={(next) => {
setMode(next as 'folder' | 'archive');
setPicked(null);
setError(null);
}}
/>
<div className={styles.fieldLabel}>{t('foundryImport.source')}</div>
<div className={styles.importFileRow}>
<Button
disabled={submitting}
onClick={() => {
void (async () => {
setError(null);
const res = await pickSource(mode);
if (res.canceled) return;
const name = res.sourcePath.split(/[/\\]/).pop() ?? res.sourcePath;
setPicked({ path: res.sourcePath, name });
})();
}}
>
{mode === 'folder' ? t('foundryImport.chooseFolder') : t('foundryImport.chooseArchive')}
</Button>
<span className={styles.muted}>{picked ? picked.name : t('foundryImport.noSourceSelected')}</span>
</div>
</div>
{error ? <div className={styles.fieldError}>{error}</div> : null}
<div className={styles.modalFooter}>
<Button onClick={onClose} disabled={submitting}>
{t('common.cancel')}
</Button>
<Button
variant="primary"
disabled={!picked || submitting}
onClick={() => {
if (!picked) return;
void (async () => {
setSubmitting(true);
setError(null);
try {
await onImport({
kind: mode,
sourcePath: picked.path,
});
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSubmitting(false);
}
})();
}}
>
{t('foundryImport.import')}
</Button>
</div>
</div>
</>,
document.body,
);
}

Some files were not shown because too many files have changed in this diff Show More