Compare commits
133 Commits
v1.0.7
...
meta_builder
| Author | SHA1 | Date | |
|---|---|---|---|
| f6c301a12b | |||
| 060bbd471a | |||
| d228b9aab7 | |||
| 143d141aa6 | |||
| a29c72b307 | |||
| 712086ddca | |||
| 3d0474fe97 | |||
| 48a9c933af | |||
| 34dd39791b | |||
| fdccc0deb5 | |||
| 586166cacd | |||
| dbe8ab1fda | |||
| 9ac01d217f | |||
| 86d6d157f0 | |||
| db1168a46c | |||
| a4d7ab77e7 | |||
| 278eb78637 | |||
| cfb6e62331 | |||
| 746b862cb9 | |||
| 8b28a9b11a | |||
| 153be98780 | |||
| ea718a26df | |||
| 0a9168b320 | |||
| 028c1be059 | |||
| 9172660afb | |||
| 0f4bb76b41 | |||
| 1a9f0f2557 | |||
| 1ab6ffd593 | |||
| 46bec1a86a | |||
| 7362a36fe5 | |||
| 1d94c95cc8 | |||
| 1fbaaa6e77 | |||
| 8d5a68c71e | |||
| 4456eb0277 | |||
| e53d1ea934 | |||
| cbb6edc378 | |||
| 4e6f7321b8 | |||
| f75444a5dd | |||
| 10cbb3b256 | |||
| c46ff34393 | |||
| 7a25b18268 | |||
| d4dc4e7f3c | |||
| 2979d06f1c | |||
| 61446dacfc | |||
| 41b112159f | |||
| 0f01950cc1 | |||
| cc50e64e21 | |||
| 101f595bac | |||
| a3a03eb9e3 | |||
| e08f5ef550 | |||
| 04c75cd725 | |||
| e687303c57 | |||
| c7bf7cf449 | |||
| a2b418b78a | |||
| f270812219 | |||
| bdeb64e356 | |||
| 4aa0f257d5 | |||
| 9d82e74272 | |||
| fed5674468 | |||
| 02d73ddf81 | |||
| d3b1c4660d | |||
| eb127c11c2 | |||
| 2c0e6fbf09 | |||
| a797162f16 | |||
| d9fbecf5a7 | |||
| 32a5479086 | |||
| c1c332364c | |||
| 0cab7ce7ca | |||
| 1829191410 | |||
| f4c0ac1438 | |||
| 37ba855faf | |||
| 61875be857 | |||
| 195d4be086 | |||
| 8d73f7744e | |||
| e731ddb1c0 | |||
| cfa3959fb3 | |||
| 35a6e979eb | |||
| b7e6ff6915 | |||
| c8ab9dd567 | |||
| de9190959c | |||
| 10bb7013e6 | |||
| 83a326b0b9 | |||
| 089da3cae0 | |||
| f5240d1623 | |||
| 4631a1bece | |||
| d54e9ed02d | |||
| ca0cf9c0ce | |||
| 657e3c862e | |||
| 97c77a025a | |||
| 42b067615d | |||
| 0c45d58359 | |||
| 1eed2b38f8 | |||
| 60ef837e1c | |||
| 374e041321 | |||
| 3acb89c80d | |||
| 8eb88399a3 | |||
| a96e1f8465 | |||
| 80103a00e7 | |||
| b017155eaf | |||
| 5706355c5f | |||
| 428fa09224 | |||
| 1cda87fe13 | |||
| 10de99bb06 | |||
| 0ae3c39333 | |||
| d07dcae626 | |||
| dd0dd646f6 | |||
| 8ec830cdb5 | |||
| 02b3131f19 | |||
| cfa067519d | |||
| 4e5d320c36 | |||
| 411ac634f4 | |||
| ece48fe53d | |||
| 744ead383d | |||
| 9f82a541fc | |||
| 963a1f0790 | |||
| 394b42e845 | |||
| 6204359330 | |||
| 7c858ba633 | |||
| 2c03921d23 | |||
| 285a1a9667 | |||
| 0e14180044 | |||
| cd3ba5fe07 | |||
| 26f8a81631 | |||
| 2dc7015f53 | |||
| 0eadfdce30 | |||
| 7e7827224d | |||
| 8fa8467db7 | |||
| af4c2616f2 | |||
| 3877a6f2a6 | |||
| 1c6f06b278 | |||
| 064592d4d4 | |||
| e923de350d | |||
| 07641be2d2 |
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -1,171 +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}"
|
||||
|
||||
- name: Каталог артефактов для feed (_linux)
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p _linux
|
||||
shopt -s nullglob || true
|
||||
for f in release/*; do
|
||||
[[ -f "$f" ]] || continue
|
||||
base=$(basename "$f")
|
||||
case "$base" in
|
||||
latest-linux.yml|*.yaml|*.AppImage|*.appimage|*.blockmap) cp -v "$f" _linux/ ;;
|
||||
esac
|
||||
done
|
||||
ls -la _linux
|
||||
|
||||
# Не используем `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}"
|
||||
|
||||
- name: Каталог артефактов для feed (_win)
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p _win
|
||||
shopt -s nullglob || true
|
||||
for f in release/*; do
|
||||
[[ -f "$f" ]] || continue
|
||||
base=$(basename "$f")
|
||||
case "$base" in
|
||||
*.yml|*.yaml|*.exe|*.blockmap|*.zip) cp -v "$f" _win/ ;;
|
||||
esac
|
||||
done
|
||||
ls -la _win
|
||||
|
||||
- name: Пустой каталог mac (пока нет сборки mac в CI)
|
||||
run: mkdir -p _mac
|
||||
|
||||
- name: Push в публичный репозиторий updates
|
||||
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 }}/_win
|
||||
ARTIFACT_MAC: ${{ github.workspace }}/_mac
|
||||
ARTIFACT_LINUX: ${{ github.workspace }}/_linux
|
||||
GIT_COMMIT_TAG: ${{ github.ref_name }}
|
||||
run: node scripts/sync-update-feed.mjs
|
||||
|
||||
# Когда появится macOS-раннер: отдельный job build-macos, копирование eb-mac в _mac перед sync
|
||||
# или расширить шаг «Каталог артефактов»; сейчас всё в одном job `release`.
|
||||
#
|
||||
# build-macos:
|
||||
# runs-on: macos-14
|
||||
# steps: ...
|
||||
@@ -17,3 +17,6 @@ Thumbs.db
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
*.tsbuildinfo
|
||||
test-results/
|
||||
playwright-report/
|
||||
e2e/fixtures/sample.png
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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.visibleToPlayers, 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: roll without visibleToPlayers keeps master result only', () => {
|
||||
const store = new DiceRollSessionStore();
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||
store.dispatch({ kind: 'roll', count: 2, sides: 8 });
|
||||
const st = store.getState();
|
||||
assert.equal(st.visibleToPlayers, false);
|
||||
assert.equal(st.active, false);
|
||||
assert.equal(st.notation, '2d8');
|
||||
assert.equal(st.values.length, 2);
|
||||
assert.ok(st.values.every((v) => v >= 1 && v <= 8));
|
||||
assert.equal(st.startedAt, null);
|
||||
});
|
||||
|
||||
void test('diceRoll: unchecking visibleToPlayers hides overlay', () => {
|
||||
const store = new DiceRollSessionStore();
|
||||
store.dispatch({ kind: 'roll', count: 1, sides: 20 });
|
||||
assert.equal(store.getState().active, true);
|
||||
const values = [...store.getState().values];
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||
assert.equal(store.getState().active, false);
|
||||
assert.equal(store.getState().visibleToPlayers, false);
|
||||
assert.deepEqual(store.getState().values, values);
|
||||
});
|
||||
|
||||
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 but keeps visibleToPlayers', () => {
|
||||
const store = new DiceRollSessionStore();
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||
store.dispatch({ kind: 'roll', count: 1, sides: 20 });
|
||||
store.dispatch({ kind: 'clear' });
|
||||
assert.equal(store.getState().active, false);
|
||||
assert.deepEqual(store.getState().values, []);
|
||||
assert.equal(store.getState().visibleToPlayers, false);
|
||||
});
|
||||
|
||||
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');
|
||||
assert.equal(store.getState().visibleToPlayers, true);
|
||||
});
|
||||
|
||||
void test('diceRoll: roll with zero count is no-op', () => {
|
||||
const store = new DiceRollSessionStore();
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||
const before = store.getState().revision;
|
||||
store.dispatch({ kind: 'roll', count: 0, sides: 6 });
|
||||
assert.equal(store.getState().revision, before);
|
||||
assert.deepEqual(store.getState().values, []);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
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,
|
||||
visibleToPlayers: this.state.visibleToPlayers,
|
||||
};
|
||||
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,
|
||||
...(event.visible === false && this.state.active
|
||||
? { active: false, startedAt: null }
|
||||
: {}),
|
||||
};
|
||||
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);
|
||||
if (count < 1) return this.state;
|
||||
const values = rollDiceValues(count, event.sides);
|
||||
const total = values.reduce((a, b) => a + b, 0);
|
||||
const showToPlayers = this.state.visibleToPlayers;
|
||||
this.state = {
|
||||
revision: this.state.revision + 1,
|
||||
active: showToPlayers,
|
||||
visibleToPlayers: showToPlayers,
|
||||
notation: formatDiceNotation(count, event.sides),
|
||||
values,
|
||||
total,
|
||||
startedAt: showToPlayers ? Date.now() : null,
|
||||
};
|
||||
return this.state;
|
||||
}
|
||||
default: {
|
||||
const _exhaustive: never = event;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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 { GameSystemsStore } from './gameSystemsStore';
|
||||
import type { SystemDefinition } from '../../shared/characterSheet/types';
|
||||
import { FORMAT_VERSION, SYSTEM_FORMAT } from '../../shared/characterSheet/types';
|
||||
|
||||
function sampleSystem(): SystemDefinition {
|
||||
return {
|
||||
format: SYSTEM_FORMAT,
|
||||
formatVersion: FORMAT_VERSION,
|
||||
id: 'shadowdark',
|
||||
name: 'Shadowdark',
|
||||
schemaVersion: 1,
|
||||
revision: 1,
|
||||
bricks: { attributes: [{ key: 'str', label: 'STR', scale: 'numeric', default: 10 }] },
|
||||
creationRules: [],
|
||||
sheetLayout: { pageSize: 'fluid', grid: { size: 8 }, pages: [{ id: 'main', label: 'Main', widgets: [] }] },
|
||||
};
|
||||
}
|
||||
|
||||
test('GameSystemsStore import list delete', async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'gs-'));
|
||||
const store = new GameSystemsStore(dir);
|
||||
await store.importSystem(sampleSystem());
|
||||
const list = await store.list();
|
||||
assert.equal(list.length, 1);
|
||||
assert.equal(list[0]!.id, 'shadowdark');
|
||||
const bind = await store.bindSnapshot('shadowdark');
|
||||
assert.ok(bind.snapshotHash.length === 64);
|
||||
const snap = await store.getSnapshot(bind.snapshotHash);
|
||||
assert.equal(snap?.id, 'shadowdark');
|
||||
await store.delete('shadowdark');
|
||||
assert.equal((await store.list()).length, 0);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import type { GameSystemListItem } from '../../shared/ipc/contracts';
|
||||
import type { SystemDefinition } from '../../shared/characterSheet/types';
|
||||
import { validateSystemDefinition } from '../../shared/characterSheet/systemValidate';
|
||||
import { snapshotHash } from '../../shared/characterSheet/snapshot';
|
||||
|
||||
export type { GameSystemListItem };
|
||||
|
||||
type Manifest = {
|
||||
systems: GameSystemListItem[];
|
||||
};
|
||||
|
||||
export class GameSystemsStore {
|
||||
private readonly rootDir: string;
|
||||
private readonly manifestPath: string;
|
||||
private readonly snapshotsDir: string;
|
||||
private manifest: Manifest = { systems: [] };
|
||||
private loaded = false;
|
||||
|
||||
constructor(userData: string) {
|
||||
this.rootDir = path.join(userData, 'game-systems');
|
||||
this.manifestPath = path.join(this.rootDir, 'manifest.json');
|
||||
this.snapshotsDir = path.join(this.rootDir, 'snapshots');
|
||||
}
|
||||
|
||||
async ensureLoaded(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
await fs.mkdir(this.snapshotsDir, { recursive: true });
|
||||
try {
|
||||
const raw = await fs.readFile(this.manifestPath, 'utf8');
|
||||
this.manifest = JSON.parse(raw) as Manifest;
|
||||
if (!Array.isArray(this.manifest.systems)) this.manifest.systems = [];
|
||||
} catch {
|
||||
this.manifest = { systems: [] };
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
async list(): Promise<GameSystemListItem[]> {
|
||||
await this.ensureLoaded();
|
||||
return [...this.manifest.systems];
|
||||
}
|
||||
|
||||
private systemPath(id: string): string {
|
||||
return path.join(this.rootDir, id, 'system.json');
|
||||
}
|
||||
|
||||
async get(id: string): Promise<SystemDefinition | null> {
|
||||
await this.ensureLoaded();
|
||||
try {
|
||||
const raw = await fs.readFile(this.systemPath(id), 'utf8');
|
||||
return JSON.parse(raw) as SystemDefinition;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async importSystem(system: SystemDefinition): Promise<GameSystemListItem> {
|
||||
await this.ensureLoaded();
|
||||
const check = validateSystemDefinition(system);
|
||||
if (!check.ok) throw new Error(check.errors.map((e) => e.message).join('; '));
|
||||
|
||||
const dir = path.join(this.rootDir, system.id);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await fs.writeFile(this.systemPath(system.id), JSON.stringify(system, null, 2), 'utf8');
|
||||
|
||||
const hash = snapshotHash(system);
|
||||
await fs.writeFile(path.join(this.snapshotsDir, `${hash}.json`), JSON.stringify(system), 'utf8');
|
||||
|
||||
const item: GameSystemListItem = {
|
||||
id: system.id,
|
||||
name: system.name,
|
||||
edition: system.edition,
|
||||
schemaVersion: system.schemaVersion,
|
||||
revision: system.revision,
|
||||
};
|
||||
this.manifest.systems = this.manifest.systems.filter((s) => s.id !== system.id).concat(item);
|
||||
await this.saveManifest();
|
||||
return item;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<string[]> {
|
||||
await this.ensureLoaded();
|
||||
this.manifest.systems = this.manifest.systems.filter((s) => s.id !== id);
|
||||
await this.saveManifest();
|
||||
try {
|
||||
await fs.rm(path.join(this.rootDir, id), { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// Caller clears player bindings; return id for convenience
|
||||
return [id];
|
||||
}
|
||||
|
||||
async getSnapshot(hash: string): Promise<SystemDefinition | null> {
|
||||
try {
|
||||
const raw = await fs.readFile(path.join(this.snapshotsDir, `${hash}.json`), 'utf8');
|
||||
return JSON.parse(raw) as SystemDefinition;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async bindSnapshot(systemId: string): Promise<{ systemId: string; snapshotHash: string; system: SystemDefinition }> {
|
||||
const system = await this.get(systemId);
|
||||
if (!system) throw new Error(`System not found: ${systemId}`);
|
||||
const hash = snapshotHash(system);
|
||||
const snapPath = path.join(this.snapshotsDir, `${hash}.json`);
|
||||
try {
|
||||
await fs.access(snapPath);
|
||||
} catch {
|
||||
await fs.writeFile(snapPath, JSON.stringify(system), 'utf8');
|
||||
}
|
||||
return { systemId, snapshotHash: hash, system };
|
||||
}
|
||||
|
||||
private async saveManifest(): Promise<void> {
|
||||
await fs.mkdir(this.rootDir, { recursive: true });
|
||||
await fs.writeFile(this.manifestPath, JSON.stringify(this.manifest, null, 2), 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
/** Test helper — content hash of arbitrary buffer. */
|
||||
export function sha256Hex(data: string): string {
|
||||
return createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
+1807
-106
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;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,11 @@ 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.requestCloseMultiWindowConfirm) 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;
|
||||
|
||||
@@ -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 } : {});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
|
||||
import { BrowserWindow, safeStorage } from 'electron';
|
||||
@@ -6,11 +7,11 @@ 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 { licenseEncryptedPath, preferencesPath } from './paths';
|
||||
import { clearLegacyDeviceId, getOrCreateDeviceId, readLegacyDeviceId } from './deviceId';
|
||||
import { licenseEncryptedPath, licenseFallbackSealedPath, preferencesPath } from './paths';
|
||||
import { verifyLicenseToken } from './verifyLicenseToken';
|
||||
|
||||
type Preferences = {
|
||||
@@ -19,6 +20,8 @@ type Preferences = {
|
||||
|
||||
type LicenseChangeListener = () => void;
|
||||
|
||||
const FALLBACK_MAGIC = Buffer.from('DNDLF1', 'ascii');
|
||||
|
||||
const licenseChangeListeners = new Set<LicenseChangeListener>();
|
||||
|
||||
/** Слушатели вызываются после смены состояния лицензии (сохранённый токен, EULA, отзыв). */
|
||||
@@ -63,35 +66,121 @@ 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 {
|
||||
return process.env.DND_SKIP_LICENSE === '1' || process.env.DND_SKIP_LICENSE === 'true';
|
||||
}
|
||||
|
||||
private readSealedToken(): string | null {
|
||||
const p = licenseEncryptedPath(this.userData);
|
||||
if (!fs.existsSync(p)) return null;
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new Error('safeStorage недоступен: нельзя расшифровать лицензию на этой системе');
|
||||
}
|
||||
/** Только для окружений без OS keychain (WSL и т.п.); слабее safeStorage — см. licensing-spec. */
|
||||
private isInsecureFileStorageAllowed(): boolean {
|
||||
const v = process.env.DND_LICENSE_INSECURE_FILE_STORAGE?.trim().toLowerCase();
|
||||
return v === '1' || v === 'true' || v === 'yes';
|
||||
}
|
||||
|
||||
private deriveFallbackKey(): Buffer {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update('TTRPGPlayer.license.fallback.v1\0', 'utf8')
|
||||
.update(this.deviceId, 'utf8')
|
||||
.digest();
|
||||
}
|
||||
|
||||
private readFallbackSealedToken(): string {
|
||||
const p = licenseFallbackSealedPath(this.userData);
|
||||
const buf = fs.readFileSync(p);
|
||||
return safeStorage.decryptString(buf);
|
||||
if (buf.length < FALLBACK_MAGIC.length + 12 + 16 + 1) {
|
||||
throw new Error('license.fallback: файл повреждён или слишком короткий');
|
||||
}
|
||||
if (!buf.subarray(0, FALLBACK_MAGIC.length).equals(FALLBACK_MAGIC)) {
|
||||
throw new Error('license.fallback: неверный формат');
|
||||
}
|
||||
const iv = buf.subarray(FALLBACK_MAGIC.length, FALLBACK_MAGIC.length + 12);
|
||||
const tag = buf.subarray(FALLBACK_MAGIC.length + 12, FALLBACK_MAGIC.length + 12 + 16);
|
||||
const data = buf.subarray(FALLBACK_MAGIC.length + 12 + 16);
|
||||
const key = this.deriveFallbackKey();
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
|
||||
}
|
||||
|
||||
private writeFallbackSealedToken(token: string): void {
|
||||
const key = this.deriveFallbackKey();
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const enc = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
const payload = Buffer.concat([FALLBACK_MAGIC, iv, tag, enc]);
|
||||
fs.writeFileSync(licenseFallbackSealedPath(this.userData), payload, { mode: 0o600 });
|
||||
}
|
||||
|
||||
private readSealedToken(): string | null {
|
||||
const sealedPath = licenseEncryptedPath(this.userData);
|
||||
const fallbackPath = licenseFallbackSealedPath(this.userData);
|
||||
|
||||
if (fs.existsSync(sealedPath)) {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new Error(
|
||||
'safeStorage недоступен: есть license.sealed, но расшифровать нельзя (часто перенос профиля или WSL без keyring). Удалите файл лицензии в настройках приложения или используйте DND_LICENSE_INSECURE_FILE_STORAGE=1 и активируйте заново.',
|
||||
);
|
||||
}
|
||||
const buf = fs.readFileSync(sealedPath);
|
||||
return safeStorage.decryptString(buf);
|
||||
}
|
||||
|
||||
if (fs.existsSync(fallbackPath)) {
|
||||
return this.readFallbackSealedToken();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private writeSealedToken(token: string): void {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new Error('safeStorage недоступен: нельзя сохранить лицензию на этой системе');
|
||||
}
|
||||
fs.mkdirSync(this.userData, { recursive: true });
|
||||
const enc = safeStorage.encryptString(token);
|
||||
fs.writeFileSync(licenseEncryptedPath(this.userData), enc);
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
const enc = safeStorage.encryptString(token);
|
||||
fs.writeFileSync(licenseEncryptedPath(this.userData), enc);
|
||||
try {
|
||||
fs.unlinkSync(licenseFallbackSealedPath(this.userData));
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.isInsecureFileStorageAllowed()) {
|
||||
this.writeFallbackSealedToken(token);
|
||||
try {
|
||||
fs.unlinkSync(licenseEncryptedPath(this.userData));
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error(
|
||||
'safeStorage недоступен: нельзя сохранить лицензию на этой системе (типично WSL без gnome-keyring). Запустите с переменной DND_LICENSE_INSECURE_FILE_STORAGE=1 — токен будет сохранён в зашифрованном файле (слабее OS-хранилища); либо настройте Secret Service / gnome-keyring.',
|
||||
);
|
||||
}
|
||||
|
||||
private clearSealedTokenFile(): void {
|
||||
@@ -100,22 +189,34 @@ export class LicenseService {
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
try {
|
||||
fs.unlinkSync(licenseFallbackSealedPath(this.userData));
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
}
|
||||
|
||||
/** База для `POST /v1/activate` (и при желании совпадает с сервером отзыва). */
|
||||
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();
|
||||
@@ -133,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);
|
||||
}
|
||||
|
||||
@@ -200,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,
|
||||
@@ -250,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();
|
||||
@@ -264,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}`);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,10 +4,21 @@ export function licenseEncryptedPath(userData: string): string {
|
||||
return path.join(userData, 'license.sealed');
|
||||
}
|
||||
|
||||
/** Fallback, если нет OS keychain (WSL без gnome-keyring и т.п.); только при DND_LICENSE_INSECURE_FILE_STORAGE=1. */
|
||||
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');
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
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?.systemId !== undefined) player.systemId = existing.systemId;
|
||||
if (existing?.characterSheet !== undefined) player.characterSheet = existing.characterSheet;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/** Clears systemId / characterSheet on players bound to a removed game system. */
|
||||
async clearSystemBindings(systemId: string): Promise<number> {
|
||||
await this.ensureLoaded();
|
||||
let changed = 0;
|
||||
this.players = this.players.map((p) => {
|
||||
const hit = p.systemId === systemId || p.characterSheet?.systemId === systemId;
|
||||
if (!hit) return p;
|
||||
changed += 1;
|
||||
return { ...p, systemId: null, characterSheet: null };
|
||||
});
|
||||
if (changed > 0) await this.persist();
|
||||
return changed;
|
||||
}
|
||||
|
||||
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,120 @@
|
||||
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 hide from players keeps the token for the GM', () => {
|
||||
const store = new SceneNpcTokensSessionStore();
|
||||
const s1 = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'a', hidden: true });
|
||||
assert.equal(s1.byPlacementId.a?.hiddenFromPlayers, true);
|
||||
const s2 = store.dispatch({ kind: 'move', placementId: 'a', nx: 0.2, ny: 0.4 });
|
||||
assert.equal(s2.byPlacementId.a?.hiddenFromPlayers, true);
|
||||
assert.equal(s2.byPlacementId.a?.nx, 0.2);
|
||||
const s3 = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'a', hidden: false });
|
||||
assert.equal(s3.byPlacementId.a?.hiddenFromPlayers, undefined);
|
||||
assert.equal(s3.byPlacementId.a?.nx, 0.2);
|
||||
const only = store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'b', hidden: true });
|
||||
store.dispatch({ kind: 'setHiddenFromPlayers', placementId: 'b', hidden: false });
|
||||
assert.equal(store.getState().byPlacementId.b, undefined);
|
||||
assert.ok(only.byPlacementId.b?.hiddenFromPlayers);
|
||||
});
|
||||
|
||||
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,313 @@
|
||||
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 &&
|
||||
!p.hiddenFromPlayers
|
||||
);
|
||||
}
|
||||
|
||||
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 } : {}),
|
||||
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: 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 } : {}),
|
||||
...(prev?.hiddenFromPlayers ? { hiddenFromPlayers: 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 'setHiddenFromPlayers': {
|
||||
const placementId = String(event.placementId ?? '');
|
||||
if (!placementId) return this.state;
|
||||
const hidden = Boolean(event.hidden);
|
||||
const prev = this.state.byPlacementId[placementId];
|
||||
if (Boolean(prev?.hiddenFromPlayers) === hidden) return this.state;
|
||||
const next: SceneNpcTokensSessionPlacement = {
|
||||
...restOf(prev),
|
||||
...(hidden ? { hiddenFromPlayers: true } : {}),
|
||||
};
|
||||
if (!hidden) delete next.hiddenFromPlayers;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, {});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
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 { ensureParentDir } from './ensureParentDir';
|
||||
|
||||
void test('ensureParentDir: creates nested parent', async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'dnd-mkdir-'));
|
||||
try {
|
||||
const file = path.join(root, 'a', 'b', 'out.ttrpg.zip');
|
||||
await ensureParentDir(file);
|
||||
const st = await fs.stat(path.dirname(file));
|
||||
assert.equal(st.isDirectory(), true);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
void test('ensureParentDir: drive root is a no-op (Windows)', async () => {
|
||||
if (process.platform !== 'win32') return;
|
||||
// Must not throw EPERM the way fs.mkdir('E:\\') often does.
|
||||
await ensureParentDir('E:\\campaign.ttrpg.zip');
|
||||
await ensureParentDir('C:\\file.ttrpg.zip');
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
/**
|
||||
* Создаёт родительский каталог для файла.
|
||||
* На Windows `mkdir('E:\\')` даёт EPERM — корень диска пропускаем.
|
||||
*/
|
||||
export async function ensureParentDir(filePath: string): Promise<void> {
|
||||
const dir = path.dirname(filePath);
|
||||
if (!dir || dir === '.') return;
|
||||
const root = path.parse(path.resolve(filePath)).root;
|
||||
if (root && path.resolve(dir) === path.resolve(root)) return;
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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,55 @@ 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('main index: full zip import loads app-tokens like playlists', () => {
|
||||
const src = fs.readFileSync(path.join(here, '..', 'index.ts'), 'utf8');
|
||||
assert.match(src, /async function importAppTokensFromOpenProjectCache/);
|
||||
assert.match(src, /tokensStore\.importFromExportDir\(cacheDir\)/);
|
||||
assert.match(src, /remapProjectSceneTokenIds/);
|
||||
const importZip = src.match(
|
||||
/registerHandler\(ipcChannels\.project\.importZip,[\s\S]*?return \{ canceled: false as const, project \}/,
|
||||
);
|
||||
assert.ok(importZip);
|
||||
assert.match(importZip[0], /importAppTokensFromOpenProjectCache/);
|
||||
const importFromPath = src.match(
|
||||
/registerHandler\(ipcChannels\.project\.importZipFromPath,[\s\S]*?return \{ project \}/,
|
||||
);
|
||||
assert.ok(importFromPath);
|
||||
assert.match(importFromPath[0], /importAppTokensFromOpenProjectCache/);
|
||||
});
|
||||
|
||||
void test('zipStore: zipDir uses ensureParentDir (no mkdir of drive root)', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||
assert.match(src, /from '\.\/ensureParentDir'/);
|
||||
const i = src.indexOf('async function zipDir(');
|
||||
assert.ok(i >= 0);
|
||||
const block = src.slice(i, i + 800);
|
||||
assert.match(block, /await ensureParentDir\(outZipPath\)/);
|
||||
assert.doesNotMatch(block, /fs\.mkdir\(path\.dirname\(outZipPath\)/);
|
||||
});
|
||||
|
||||
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\)/);
|
||||
|
||||
+1856
-201
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,96 @@
|
||||
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);
|
||||
});
|
||||
|
||||
void test('dismiss on window close: pause + hide from players', () => {
|
||||
const store = new TimerSessionStore();
|
||||
const t0 = 1_000_000;
|
||||
store.dispatch({ kind: 'setDuration', durationMs: 60_000 }, t0);
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: true });
|
||||
store.dispatch({ kind: 'start' }, t0);
|
||||
store.dispatch({ kind: 'pause' }, t0 + 5_000);
|
||||
store.dispatch({ kind: 'setVisibleToPlayers', visible: false });
|
||||
assert.equal(store.getState().running, false);
|
||||
assert.equal(store.getState().visibleToPlayers, false);
|
||||
assert.equal(store.getState().remainingMs, 55_000);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,90 @@ 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: Escape запрашивает confirm на пульте', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('export function requestCloseMultiWindowConfirm'));
|
||||
assert.ok(src.includes('closeMultiWindowConfirmRequested'));
|
||||
assert.match(src, /requestCloseMultiWindowConfirm[\s\S]*ctrl\.focus\(\)/);
|
||||
});
|
||||
|
||||
void test('createWindows: окно плейлистов — minWidth 640', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('PLAYLISTS_WINDOW_MIN_WIDTH = 640'));
|
||||
assert.match(src, /kind === 'playlists'[\s\S]*?minWidth:\s*PLAYLISTS_WINDOW_MIN_WIDTH/);
|
||||
});
|
||||
|
||||
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: окно инструкции из редактора, не закрывается с сессией', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('openInstructionsWindow'));
|
||||
assert.ok(src.includes("createWindow('instructions'"));
|
||||
assert.ok(src.includes("return 'instructions.html'"));
|
||||
const aux = /function closePlaySessionAuxiliaryWindows\(\): void \{([\s\S]*?)\n\}/.exec(src);
|
||||
assert.ok(aux, 'closePlaySessionAuxiliaryWindows');
|
||||
assert.doesNotMatch(aux[1] ?? '', /instructions/i);
|
||||
});
|
||||
|
||||
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: закрытие пульта и презентации спрашивает confirm', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.match(src, /kind === 'control'[\s\S]*?e\.preventDefault\(\)[\s\S]*?requestCloseMultiWindowConfirm/);
|
||||
assert.match(src, /kind === 'presentation'[\s\S]*?e\.preventDefault\(\)[\s\S]*?requestCloseMultiWindowConfirm/);
|
||||
assert.match(src, /export function closeMultiWindow[\s\S]*closePlaySessionAuxiliaryWindows/);
|
||||
});
|
||||
|
||||
void test('createWindows: production — loadFile для HTML (не только file://)', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.ok(src.includes('loadFile'));
|
||||
@@ -46,3 +130,17 @@ 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'));
|
||||
});
|
||||
|
||||
void test('createWindows: presentation/control не троттлят RAF в фоне (macOS paths)', () => {
|
||||
const src = readCreateWindows();
|
||||
assert.match(src, /kind === 'presentation' \|\| kind === 'control'/);
|
||||
assert.match(src, /backgroundThrottling:\s*false/);
|
||||
assert.match(src, /setBackgroundThrottling\(false\)/);
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
@@ -184,18 +339,86 @@
|
||||
|
||||
.previewHeader {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px 12px;
|
||||
}
|
||||
|
||||
.previewTitle {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 900;
|
||||
flex: 0 1 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.previewActions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.previewTokenTools {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
min-width: 0;
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
|
||||
.npcTokenScale {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.snapToGrid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.snapToGrid input {
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.snapToGridLabel {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.npcTokenScaleLabel {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.npcTokenScaleRange {
|
||||
flex: 1 1 auto;
|
||||
min-width: 72px;
|
||||
width: min(140px, 100%);
|
||||
height: 34px;
|
||||
margin: 0;
|
||||
accent-color: var(--color-accent, #c9a227);
|
||||
}
|
||||
|
||||
.videoHint {
|
||||
@@ -219,6 +442,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 +473,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 +543,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 +554,8 @@
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.branchCardHeader {
|
||||
@@ -276,6 +572,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 +661,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 +765,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);
|
||||
}
|
||||
|
||||
+2727
-478
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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,49 @@
|
||||
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'));
|
||||
assert.ok(app.includes('perfProfile="preview"'));
|
||||
assert.ok(app.includes('OVERLAY_DRAFT_IPC_MS'));
|
||||
});
|
||||
|
||||
void test('ControlApp: path playback не гоняет setState корня', () => {
|
||||
const app = fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
|
||||
const hook = fs.readFileSync(
|
||||
path.join(here, '../shared/tokens/useTokenPathLivePoses.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.doesNotMatch(app, /\bpathLivePoses\b/);
|
||||
assert.doesNotMatch(hook, /\bsetPoses\b/);
|
||||
assert.ok(hook.includes('applyLivePathPose'));
|
||||
assert.ok(hook.includes('requestAnimationFrame'));
|
||||
});
|
||||
@@ -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,153 @@ 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'));
|
||||
assert.ok(css.includes('.previewTokenTools'));
|
||||
assert.match(css, /\.previewTokenTools[\s\S]*?repeat\(auto-fit/);
|
||||
assert.match(css, /\.previewHeader[\s\S]*?flex-wrap:\s*wrap/);
|
||||
assert.match(css, /\.previewHeader[\s\S]*?align-items:\s*center/);
|
||||
});
|
||||
|
||||
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(false)'));
|
||||
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 +201,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 +246,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*;/;
|
||||
@@ -129,3 +316,17 @@ void test('ControlApp: загрузка камп. аудио — useEffect за
|
||||
assert.ok(!/\bproject\b/.test(depList));
|
||||
assert.ok(!/\bcampaignAudioRefs\b/.test(depList));
|
||||
});
|
||||
|
||||
void test('ControlApp: Escape → своя модалка подтверждения завершения показа', () => {
|
||||
const src = readControlApp();
|
||||
assert.ok(src.includes('closeMultiWindowConfirmRequested'));
|
||||
assert.ok(src.includes('stopConfirmOpen'));
|
||||
assert.ok(src.includes('control.stopConfirm.title'));
|
||||
assert.ok(src.includes('data-testid="stop-confirm-modal"'));
|
||||
assert.ok(src.includes('data-testid="control-help-tool"'));
|
||||
assert.match(src, /data-testid="control-help-tool"[\s\S]{0,180}iconOnly|iconOnly[\s\S]{0,180}data-testid="control-help-tool"/);
|
||||
assert.ok(src.includes("section: 'controlPanel'"));
|
||||
assert.equal((src.match(/windows\.closeMultiWindow,/g) ?? []).length, 1);
|
||||
assert.ok(!src.includes('showMessageBox'));
|
||||
assert.ok(!src.includes('window.confirm'));
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,145 @@
|
||||
.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));
|
||||
color: inherit;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.countValue:focus {
|
||||
border-color: rgba(147, 197, 253, 0.75);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.checkRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import {
|
||||
DICE_COUNT_MAX,
|
||||
DICE_COUNT_MIN,
|
||||
DICE_SIDES,
|
||||
clampDiceCount,
|
||||
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);
|
||||
const [countDraft, setCountDraft] = 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]);
|
||||
|
||||
const commitCountDraft = () => {
|
||||
const trimmed = countDraft.trim();
|
||||
if (trimmed === '') {
|
||||
setCount(DICE_COUNT_MIN);
|
||||
setCountDraft(String(DICE_COUNT_MIN));
|
||||
return;
|
||||
}
|
||||
const parsed = Number(trimmed);
|
||||
if (!Number.isFinite(parsed) || parsed < DICE_COUNT_MIN) {
|
||||
setCount(DICE_COUNT_MIN);
|
||||
setCountDraft(String(DICE_COUNT_MIN));
|
||||
return;
|
||||
}
|
||||
const next = clampDiceCount(parsed);
|
||||
setCount(next);
|
||||
setCountDraft(String(next));
|
||||
};
|
||||
|
||||
const setCountClamped = (n: number) => {
|
||||
const next = clampDiceCount(n);
|
||||
setCount(next);
|
||||
setCountDraft(String(next));
|
||||
};
|
||||
|
||||
const showToPlayers = state?.visibleToPlayers ?? true;
|
||||
|
||||
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={() => setCountClamped(count - 1)}
|
||||
ariaLabel={t('dice.dec')}
|
||||
disabled={count <= DICE_COUNT_MIN}
|
||||
>
|
||||
−
|
||||
</Button>
|
||||
<input
|
||||
className={styles.countValue}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={countDraft}
|
||||
onChange={(e) => {
|
||||
const digits = e.target.value.replace(/\D/g, '');
|
||||
if (digits === '') {
|
||||
setCountDraft('');
|
||||
return;
|
||||
}
|
||||
const n = Number(digits);
|
||||
if (!Number.isFinite(n)) {
|
||||
setCountDraft(String(DICE_COUNT_MIN));
|
||||
setCount(DICE_COUNT_MIN);
|
||||
return;
|
||||
}
|
||||
if (n > DICE_COUNT_MAX) {
|
||||
setCountDraft(String(DICE_COUNT_MAX));
|
||||
setCount(DICE_COUNT_MAX);
|
||||
return;
|
||||
}
|
||||
setCountDraft(digits);
|
||||
}}
|
||||
onBlur={() => commitCountDraft()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitCountDraft();
|
||||
(e.currentTarget as HTMLInputElement).blur();
|
||||
}
|
||||
}}
|
||||
aria-label={t('dice.count')}
|
||||
data-testid="dice-count"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setCountClamped(count + 1)}
|
||||
ariaLabel={t('dice.inc')}
|
||||
disabled={count >= DICE_COUNT_MAX}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<label className={styles.checkRow}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showToPlayers}
|
||||
onChange={(e) =>
|
||||
void diceApi.dispatch({ kind: 'setVisibleToPlayers', visible: e.target.checked })
|
||||
}
|
||||
data-testid="dice-show-to-players"
|
||||
/>
|
||||
<span>{t('dice.showAnimationToPlayers')}</span>
|
||||
</label>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={clampDiceCount(Number(countDraft.trim()) || count) < 1}
|
||||
onClick={() => {
|
||||
const next = clampDiceCount(Number(countDraft.trim()));
|
||||
setCount(next);
|
||||
setCountDraft(String(next));
|
||||
if (next < 1) return;
|
||||
void diceApi.dispatch({ kind: 'roll', count: next, 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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user