feat(npcs): drop binding; select NPCs on storyline export
Remove storyline/scene NPC binding and export chosen NPCs with relations/groups. Harden export modal so a missing npcs payload no longer blacks out the editor. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -562,11 +562,7 @@ export function EditorApp() {
|
||||
const continueImportAfterScenes = useCallback(
|
||||
(selections: StorylineSelection[], sceneResolutions: SceneImportResolution[]) => {
|
||||
if (!importPeek || !state.project) return;
|
||||
const npcConflicts = computeNpcImportConflicts(
|
||||
state.project,
|
||||
importPeek.sourceProject,
|
||||
selections,
|
||||
);
|
||||
const npcConflicts = computeNpcImportConflicts(state.project, importPeek.sourceProject);
|
||||
setPendingImportSelections(selections);
|
||||
setPendingSceneResolutions(sceneResolutions);
|
||||
setImportConflictsOpen(false);
|
||||
@@ -578,7 +574,6 @@ export function EditorApp() {
|
||||
const npcResolutions = buildNpcResolutionsForImport(
|
||||
state.project,
|
||||
importPeek.sourceProject,
|
||||
selections,
|
||||
[],
|
||||
[],
|
||||
);
|
||||
@@ -1459,8 +1454,8 @@ export function EditorApp() {
|
||||
storylineLabels={storylineLabels}
|
||||
loadStorylines={loadProjectStorylines}
|
||||
onClose={() => setExportModalOpen(false)}
|
||||
onExport={async (projectId, selections) => {
|
||||
await actions.exportProject(projectId, selections, storylineLabels);
|
||||
onExport={async (projectId, selections, npcIds) => {
|
||||
await actions.exportProject(projectId, selections, npcIds, storylineLabels);
|
||||
}}
|
||||
/>
|
||||
<ImportSourceModal
|
||||
@@ -1535,7 +1530,6 @@ export function EditorApp() {
|
||||
const npcResolutions = buildNpcResolutionsForImport(
|
||||
state.project,
|
||||
importPeek.sourceProject,
|
||||
pendingImportSelections,
|
||||
importNpcConflicts,
|
||||
userNpcResolutions,
|
||||
);
|
||||
|
||||
@@ -3,9 +3,9 @@ import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
collectSceneIdsForSelections,
|
||||
filterNpcsForStorylineExport,
|
||||
findNpcNameConflicts,
|
||||
findSceneTitleConflicts,
|
||||
listExportedNpcsFromBundle,
|
||||
storylineSelectionKey,
|
||||
type NpcImportResolution,
|
||||
type NpcNameConflict,
|
||||
@@ -22,29 +22,40 @@ import { Button, Select } from '../shared/ui/controls';
|
||||
import styles from './EditorApp.module.css';
|
||||
import { useEditorI18n } from './i18n/EditorI18nContext';
|
||||
|
||||
export type ExportNpcOption = { id: string; name: string };
|
||||
|
||||
type ExportProjectModalProps = {
|
||||
open: boolean;
|
||||
projects: { id: ProjectId; name: string; fileName: string }[];
|
||||
initialProjectId: ProjectId | null;
|
||||
storylineLabels: StorylineLabels;
|
||||
loadStorylines: (projectId: ProjectId) => Promise<StorylineListItem[]>;
|
||||
loadStorylines: (
|
||||
projectId: ProjectId,
|
||||
) => Promise<{ storylines: StorylineListItem[]; npcs: ExportNpcOption[] }>;
|
||||
onClose: () => void;
|
||||
onExport: (projectId: ProjectId, selections: StorylineSelection[]) => Promise<void>;
|
||||
onExport: (
|
||||
projectId: ProjectId,
|
||||
selections: StorylineSelection[],
|
||||
npcIds: string[],
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
export function ExportProjectModal({
|
||||
open,
|
||||
projects,
|
||||
initialProjectId,
|
||||
storylineLabels,
|
||||
storylineLabels: _storylineLabels,
|
||||
loadStorylines,
|
||||
onClose,
|
||||
onExport,
|
||||
}: ExportProjectModalProps) {
|
||||
const { t } = useEditorI18n();
|
||||
const [step, setStep] = useState<'storylines' | 'npcs'>('storylines');
|
||||
const [projectId, setProjectId] = useState<ProjectId | null>(initialProjectId);
|
||||
const [storylines, setStorylines] = useState<StorylineListItem[]>([]);
|
||||
const [npcs, setNpcs] = useState<ExportNpcOption[]>([]);
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
|
||||
const [selectedNpcIds, setSelectedNpcIds] = useState<Set<string>>(new Set());
|
||||
const [loadingStorylines, setLoadingStorylines] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -55,23 +66,38 @@ export function ExportProjectModal({
|
||||
setSaving(false);
|
||||
setError(null);
|
||||
setSelectedKeys(new Set());
|
||||
setSelectedNpcIds(new Set());
|
||||
setStep('storylines');
|
||||
setNpcs([]);
|
||||
}, [initialProjectId, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !projectId) {
|
||||
setStorylines([]);
|
||||
setNpcs([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoadingStorylines(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const list = await loadStorylines(projectId);
|
||||
const res = await loadStorylines(projectId);
|
||||
if (cancelled) return;
|
||||
const list = Array.isArray(res?.storylines) ? res.storylines : [];
|
||||
const npcList = Array.isArray(res?.npcs) ? res.npcs : [];
|
||||
setStorylines(list);
|
||||
setNpcs(npcList);
|
||||
setSelectedKeys(new Set(list.map((item) => storylineSelectionKey(item.selection))));
|
||||
setSelectedNpcIds(new Set(npcList.map((n) => n.id)));
|
||||
setStep('storylines');
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : String(e));
|
||||
if (!cancelled) {
|
||||
setStorylines([]);
|
||||
setNpcs([]);
|
||||
setSelectedKeys(new Set());
|
||||
setSelectedNpcIds(new Set());
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoadingStorylines(false);
|
||||
}
|
||||
@@ -84,15 +110,18 @@ export function ExportProjectModal({
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Escape') {
|
||||
if (step === 'npcs') setStep('storylines');
|
||||
else onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, open]);
|
||||
}, [onClose, open, step]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const canExport =
|
||||
const canContinueStorylines =
|
||||
projectId !== null &&
|
||||
projects.some((p) => p.id === projectId) &&
|
||||
selectedKeys.size > 0 &&
|
||||
@@ -108,10 +137,44 @@ export function ExportProjectModal({
|
||||
});
|
||||
};
|
||||
|
||||
const toggleNpc = (id: string) => {
|
||||
setSelectedNpcIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const selectedSelections = storylines
|
||||
.filter((item) => selectedKeys.has(storylineSelectionKey(item.selection)))
|
||||
.map((item) => item.selection);
|
||||
|
||||
const runExport = (npcIds: string[]) => {
|
||||
if (!projectId || !canContinueStorylines) return;
|
||||
void (async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onExport(projectId, selectedSelections, npcIds);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
const goNextFromStorylines = () => {
|
||||
if (!canContinueStorylines) return;
|
||||
if (npcs.length === 0) {
|
||||
runExport([]);
|
||||
return;
|
||||
}
|
||||
setStep('npcs');
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<>
|
||||
<button
|
||||
@@ -122,7 +185,9 @@ export function ExportProjectModal({
|
||||
/>
|
||||
<div role="dialog" aria-modal="true" className={styles.modalDialog}>
|
||||
<div className={styles.modalHeader}>
|
||||
<div className={styles.modalTitle}>{t('export.title')}</div>
|
||||
<div className={styles.modalTitle}>
|
||||
{step === 'storylines' ? t('export.title') : t('export.npcsTitle')}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('common.close')}
|
||||
@@ -133,81 +198,108 @@ export function ExportProjectModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>{t('export.project')}</div>
|
||||
<Select
|
||||
value={projectId ?? ''}
|
||||
onChange={(next) => setProjectId((next as ProjectId) || null)}
|
||||
disabled={projects.length === 0}
|
||||
ariaLabel={t('export.project')}
|
||||
options={projects.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} (${p.fileName})`,
|
||||
}))}
|
||||
/>
|
||||
{step === 'storylines' ? (
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.fieldLabel}>{t('export.project')}</div>
|
||||
<Select
|
||||
value={projectId ?? ''}
|
||||
onChange={(next) => setProjectId((next as ProjectId) || null)}
|
||||
disabled={projects.length === 0 || saving}
|
||||
ariaLabel={t('export.project')}
|
||||
options={projects.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name} (${p.fileName})`,
|
||||
}))}
|
||||
/>
|
||||
|
||||
<div className={styles.fieldLabel}>{t('storyline.section')}</div>
|
||||
{loadingStorylines ? (
|
||||
<div className={styles.muted}>{t('storyline.loading')}</div>
|
||||
) : storylines.length === 0 ? (
|
||||
<div className={styles.muted}>{t('storyline.empty')}</div>
|
||||
) : (
|
||||
<div className={styles.fieldLabel}>{t('storyline.section')}</div>
|
||||
{loadingStorylines ? (
|
||||
<div className={styles.muted}>{t('storyline.loading')}</div>
|
||||
) : storylines.length === 0 ? (
|
||||
<div className={styles.muted}>{t('storyline.empty')}</div>
|
||||
) : (
|
||||
<div className={styles.storylineChecklist}>
|
||||
{storylines.map((item) => {
|
||||
const key = storylineSelectionKey(item.selection);
|
||||
const checked = selectedKeys.has(key);
|
||||
const disabled = item.disabled === true;
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled || saving}
|
||||
onChange={() => toggleKey(key, disabled)}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
{disabled && item.disabledReason === 'main_exists' ? (
|
||||
<span className={styles.muted}> — {t('storyline.mainExistsHint')}</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.muted}>{t('export.hint')}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.fieldGrid}>
|
||||
<div className={styles.muted}>{t('export.npcsHint')}</div>
|
||||
<div className={styles.storylineChecklist}>
|
||||
{storylines.map((item) => {
|
||||
const key = storylineSelectionKey(item.selection);
|
||||
const checked = selectedKeys.has(key);
|
||||
const disabled = item.disabled === true;
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
className={disabled ? styles.storylineCheckDisabled : styles.storylineCheck}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={() => toggleKey(key, disabled)}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
{disabled && item.disabledReason === 'main_exists' ? (
|
||||
<span className={styles.muted}> — {t('storyline.mainExistsHint')}</span>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{npcs.map((n) => (
|
||||
<label key={n.id} className={styles.storylineCheck}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedNpcIds.has(n.id)}
|
||||
disabled={saving}
|
||||
onChange={() => toggleNpc(n.id)}
|
||||
/>
|
||||
<span>{n.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.muted}>{t('export.hint')}</div>
|
||||
</div>
|
||||
<Button
|
||||
disabled={saving || npcs.length === 0}
|
||||
onClick={() => setSelectedNpcIds(new Set(npcs.map((n) => n.id)))}
|
||||
>
|
||||
{t('export.selectAllNpcs')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? <div className={styles.fieldError}>{error}</div> : null}
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canExport || saving}
|
||||
onClick={() => {
|
||||
if (!projectId || !canExport) return;
|
||||
void (async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onExport(projectId, selectedSelections);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})();
|
||||
}}
|
||||
>
|
||||
{t('export.saveAs')}
|
||||
</Button>
|
||||
{step === 'npcs' ? (
|
||||
<Button onClick={() => setStep('storylines')} disabled={saving}>
|
||||
{t('export.back')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={onClose} disabled={saving} title={saving ? t('export.exporting') : undefined}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{step === 'storylines' ? (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!canContinueStorylines || saving}
|
||||
onClick={goNextFromStorylines}
|
||||
>
|
||||
{npcs.length > 0 ? t('export.next') : t('export.saveAs')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={saving}
|
||||
onClick={() => runExport([...selectedNpcIds])}
|
||||
>
|
||||
{t('export.saveAs')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>,
|
||||
@@ -831,11 +923,10 @@ export function computeImportConflicts(
|
||||
export function buildNpcResolutionsForImport(
|
||||
_targetProject: Project,
|
||||
sourceProject: Project,
|
||||
selections: StorylineSelection[],
|
||||
conflicts: NpcNameConflict[],
|
||||
userResolutions: NpcImportResolution[],
|
||||
): NpcImportResolution[] {
|
||||
const exported = filterNpcsForStorylineExport(sourceProject, selections);
|
||||
const exported = listExportedNpcsFromBundle(sourceProject);
|
||||
const conflictIds = new Set(conflicts.map((c) => c.sourceNpcId));
|
||||
const bySource = new Map(userResolutions.map((r) => [r.sourceNpcId, r]));
|
||||
const out: NpcImportResolution[] = [];
|
||||
@@ -853,9 +944,8 @@ export function buildNpcResolutionsForImport(
|
||||
export function computeNpcImportConflicts(
|
||||
targetProject: Project,
|
||||
sourceProject: Project,
|
||||
selections: StorylineSelection[],
|
||||
): NpcNameConflict[] {
|
||||
const exported = filterNpcsForStorylineExport(sourceProject, selections);
|
||||
const exported = listExportedNpcsFromBundle(sourceProject);
|
||||
return findNpcNameConflicts(
|
||||
targetProject,
|
||||
sourceProject,
|
||||
|
||||
@@ -298,7 +298,13 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'export.title': 'Экспорт проекта',
|
||||
'export.project': 'ПРОЕКТ',
|
||||
'export.hint':
|
||||
'Выберите сюжетные линии для экспорта. В архив попадут только выбранные линии, их сцены и материалы. Далее откроется окно сохранения .ttrpg.zip.',
|
||||
'Выберите сюжетные линии для экспорта. В архив попадут выбранные линии, их сцены и материалы. Если в проекте есть НПС, на следующем шаге можно отметить, кого включить.',
|
||||
'export.npcsTitle': 'Экспорт НПС',
|
||||
'export.npcsHint':
|
||||
'Отметьте НПС для экспорта. Вместе с ними попадут связи между отмеченными и группы этих персонажей.',
|
||||
'export.selectAllNpcs': 'Отметить всех',
|
||||
'export.next': 'Далее',
|
||||
'export.back': 'Назад',
|
||||
'export.exporting': 'Экспорт…',
|
||||
'export.saveAs': 'Сохранить как…',
|
||||
|
||||
@@ -475,12 +481,6 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'Удалить группу «{name}»? НПС станут без группы, вложенные группы будут подняты на уровень выше.',
|
||||
'npcs.addSubgroup': 'Добавить подгруппу',
|
||||
'npcs.group': 'ГРУППА',
|
||||
'npcs.bindingEnable': 'Привязать…',
|
||||
'npcs.bindingKind': 'ТИП ПРИВЯЗКИ',
|
||||
'npcs.bindingStoryline': 'Сюжетная линия',
|
||||
'npcs.bindingScene': 'Сцена',
|
||||
'npcs.bindingMain': 'Основная линия',
|
||||
'npcs.bindingSelect': 'ОБЪЕКТ',
|
||||
'npcs.graphFilterAll': 'Все',
|
||||
'npcs.graphFilterUngrouped': 'Без группы',
|
||||
'npcs.graphFilter': 'Фильтр графа',
|
||||
@@ -866,7 +866,13 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'export.title': 'Export project',
|
||||
'export.project': 'PROJECT',
|
||||
'export.hint':
|
||||
'Select storylines to export. The archive will include only the chosen lines, their scenes, and assets. Then choose where to save the .ttrpg.zip file.',
|
||||
'Select storylines to export. The archive will include the chosen lines, their scenes, and assets. If the project has NPCs, the next step lets you choose which ones to include.',
|
||||
'export.npcsTitle': 'Export NPCs',
|
||||
'export.npcsHint':
|
||||
'Select NPCs to export. Relations between selected NPCs and their groups are included.',
|
||||
'export.selectAllNpcs': 'Select all',
|
||||
'export.next': 'Next',
|
||||
'export.back': 'Back',
|
||||
'export.exporting': 'Exporting…',
|
||||
'export.saveAs': 'Save as…',
|
||||
|
||||
@@ -1044,12 +1050,6 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
||||
'Delete group “{name}”? NPCs will become ungrouped; child groups will move up one level.',
|
||||
'npcs.addSubgroup': 'Add subgroup',
|
||||
'npcs.group': 'GROUP',
|
||||
'npcs.bindingEnable': 'Bind…',
|
||||
'npcs.bindingKind': 'BINDING TYPE',
|
||||
'npcs.bindingStoryline': 'Storyline',
|
||||
'npcs.bindingScene': 'Scene',
|
||||
'npcs.bindingMain': 'Main storyline',
|
||||
'npcs.bindingSelect': 'TARGET',
|
||||
'npcs.graphFilterAll': 'All',
|
||||
'npcs.graphFilterUngrouped': 'Ungrouped',
|
||||
'npcs.graphFilter': 'Graph filter',
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
import { EditorApp } from './EditorApp';
|
||||
import { EditorI18nProvider } from './i18n/EditorI18nContext';
|
||||
|
||||
@@ -12,8 +13,10 @@ if (!rootEl) {
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<EditorI18nProvider>
|
||||
<EditorApp />
|
||||
</EditorI18nProvider>
|
||||
<WindowErrorBoundary title="Редактор">
|
||||
<EditorI18nProvider>
|
||||
<EditorApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
@@ -148,10 +148,14 @@ type Actions = {
|
||||
npcResolutions?: NpcImportResolution[],
|
||||
) => Promise<{ project: Project; report: StorylineImportMergeReport }>;
|
||||
importProjectFromPath: (filePath: string) => Promise<void>;
|
||||
getProjectStorylines: (projectId: ProjectId, labels: StorylineLabels) => Promise<StorylineListItem[]>;
|
||||
getProjectStorylines: (
|
||||
projectId: ProjectId,
|
||||
labels: StorylineLabels,
|
||||
) => Promise<{ storylines: StorylineListItem[]; npcs: { id: string; name: string }[] }>;
|
||||
exportProject: (
|
||||
projectId: ProjectId,
|
||||
storylineSelections: StorylineSelection[],
|
||||
npcIds: string[],
|
||||
labels: StorylineLabels,
|
||||
) => Promise<void>;
|
||||
deleteProject: (projectId: ProjectId) => Promise<void>;
|
||||
@@ -869,18 +873,23 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts)
|
||||
|
||||
const getProjectStorylines = async (projectId: ProjectId, labels: StorylineLabels) => {
|
||||
const res = await api.invoke(ipcChannels.project.getProjectStorylines, { projectId, labels });
|
||||
return res.storylines;
|
||||
return {
|
||||
storylines: Array.isArray(res?.storylines) ? res.storylines : [],
|
||||
npcs: Array.isArray(res?.npcs) ? res.npcs : [],
|
||||
};
|
||||
};
|
||||
|
||||
const exportProject = async (
|
||||
projectId: ProjectId,
|
||||
storylineSelections: StorylineSelection[],
|
||||
npcIds: string[],
|
||||
labels: StorylineLabels,
|
||||
) => {
|
||||
try {
|
||||
const res = await api.invoke(ipcChannels.project.exportZip, {
|
||||
projectId,
|
||||
storylineSelections,
|
||||
npcIds,
|
||||
labels,
|
||||
});
|
||||
if (res.canceled) return;
|
||||
|
||||
Reference in New Issue
Block a user