@ssobig/writer-cli 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/config.js +2 -1
- package/package.json +1 -1
- package/templates/mystery-v1/authoring-view-preference.js +22 -5
- package/templates/mystery-v1/character-perspective-preview.js +3 -3
- package/templates/mystery-v1/codemirror6-runtime.min.js +28 -0
- package/templates/mystery-v1/component-asset-operations.js +5 -4
- package/templates/mystery-v1/component-catalog-contract.js +26 -35
- package/templates/mystery-v1/component-draft-operations.js +19 -8
- package/templates/mystery-v1/component-field-contracts.js +29 -49
- package/templates/mystery-v1/component-id-policy.js +1 -1
- package/templates/mystery-v1/component-manager.js +21 -11
- package/templates/mystery-v1/component-navigation-counts.js +3 -8
- package/templates/mystery-v1/component-registry.js +11 -5
- package/templates/mystery-v1/component-renderers.js +12 -8
- package/templates/mystery-v1/markdown-live-editor.js +350 -0
- package/templates/mystery-v1/page-header.js +2 -1
- package/tools/writer-cli/package-lock.json +2 -2
- package/tools/writer-cli/package.json +1 -1
- package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +1 -1
- package/tools/writer-cli/skills/ssobig-writer-cli/references/errors.md +1 -1
- package/tools/writer-cli/skills/ssobig-writer-cli/references/install-auth.md +1 -1
- package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +1 -1
- package/tools/writer-cli/src/agent-service.cjs +3 -1
- package/tools/writer-cli/src/command-registry.cjs +3 -0
- package/tools/writer-cli/src/commands.cjs +85 -3
- package/tools/writer-cli/src/domain.cjs +250 -1
- package/tools/writer-cli/src/gateway.cjs +14 -0
- package/tools/writer-cli/src/mutations.cjs +167 -1
- package/tools/writer-cli/src/project-import.cjs +12 -21
- package/tools/writer-cli/src/update-remediation.cjs +4 -0
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
|
|
12
12
|
function usages(components, dataFor, clueAssetId) {
|
|
13
13
|
const result = {
|
|
14
|
-
poster: ["
|
|
15
|
-
logo: ["
|
|
16
|
-
"workspace-background": ["
|
|
14
|
+
poster: ["디자인 · 포스터 이미지"],
|
|
15
|
+
logo: ["디자인 · 로고 이미지"],
|
|
16
|
+
"workspace-background": ["디자인 · 워크벤치 배경"]
|
|
17
17
|
};
|
|
18
18
|
const active = Array.isArray(components) ? components : [];
|
|
19
19
|
const characterInstance = active.find(instance => instance.templateId === "ssobig.character");
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
if (deleted.has(characterId) || !Object.hasOwn(characterData?.characters || {}, characterId)) continue;
|
|
24
24
|
const assetId = characterAssetId(characterId);
|
|
25
25
|
if (!result[assetId]) result[assetId] = [];
|
|
26
|
-
result[assetId].push(
|
|
26
|
+
result[assetId].push(`캐릭터별 문서 · ${characterData.names?.[characterId] || characterId}`);
|
|
27
27
|
}
|
|
28
28
|
const clueInstance = active.find(instance => instance.templateId === "ssobig.clues");
|
|
29
29
|
const clueData = dataFor(clueInstance) || clueInstance?.data;
|
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
options.hydrate?.();
|
|
70
70
|
return result;
|
|
71
71
|
} catch (error) {
|
|
72
|
+
options.onError?.(error);
|
|
72
73
|
if (ownerInstance) options.autosave.recordError(ownerInstance.instanceId, error);
|
|
73
74
|
options.setStatus?.({ state: "error", message: error?.message || "에셋을 저장하지 못했습니다." });
|
|
74
75
|
throw error;
|
|
@@ -31,9 +31,9 @@
|
|
|
31
31
|
|
|
32
32
|
const RAW_CATALOG = [
|
|
33
33
|
{
|
|
34
|
-
templateId: "ssobig.basic", defaultInstanceId: "basic", tabLabel: "기본정보", required: true,
|
|
34
|
+
templateId: "ssobig.basic", defaultInstanceId: "basic", tabLabel: "기본정보", displayLabel: "디자인", required: true,
|
|
35
35
|
description: "작품 제목과 소개, 플레이 인원, 화면 테마 등 기본 설정을 관리합니다.",
|
|
36
|
-
guide: guide("작품 전체의 정체성과 화면 분위기를 정합니다.", "작품명, 한 줄 소개, 4인용 설정과 대표 색상을 입력합니다.", "제목·인원·소개와 포스터·로고·배경·색상·폰트를 설정합니다.", "integrated", "작품 제목과 테마가 제작 화면과 캐릭터 관점 미리보기 전체에 적용됩니다.", ["ssobig.assets"], "
|
|
36
|
+
guide: guide("작품 전체의 정체성과 화면 분위기를 정합니다.", "작품명, 한 줄 소개, 4인용 설정과 대표 색상을 입력합니다.", "제목·인원·소개와 포스터·로고·배경·색상·폰트를 설정합니다.", "integrated", "작품 제목과 테마가 제작 화면과 캐릭터 관점 미리보기 전체에 적용됩니다.", ["ssobig.assets"], "디자인 제작 화면 예시"),
|
|
37
37
|
defaultData: null,
|
|
38
38
|
view: view("ssobig.view.basic-workbench", "basic-workbench", "기본정보 작업 화면", "ssobig.basic", [
|
|
39
39
|
binding("primary", "ssobig.basic", "read_write"), binding("assets", "ssobig.assets", "read_write")
|
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
importProfiles: [IMPORT_PROFILE]
|
|
42
42
|
},
|
|
43
43
|
{
|
|
44
|
-
templateId: "ssobig.progress", defaultInstanceId: "progress", tabLabel: "진행", required: true,
|
|
44
|
+
templateId: "ssobig.progress", defaultInstanceId: "progress", tabLabel: "진행", displayLabel: "진행 순서", required: true,
|
|
45
45
|
description: "게임의 단계별 순서와 시간, 플레이어 안내와 진행 행동을 구성합니다.",
|
|
46
|
-
guide: guide("게임이 어떤 순서와 속도로 진행되는지 설계합니다.", "프롤로그, 1차 조사, 토론, 최종 선택 순서와 시간을 작성합니다.", "단계 이름·시간·안내문·진행 행동을 순서대로 편집합니다.", "integrated", "현재 캐릭터가 확인할 진행 단계와 안내가 통합 미리보기에 표시됩니다.", ["ssobig.common", "ssobig.ending"], "진행
|
|
46
|
+
guide: guide("게임이 어떤 순서와 속도로 진행되는지 설계합니다.", "프롤로그, 1차 조사, 토론, 최종 선택 순서와 시간을 작성합니다.", "단계 이름·시간·안내문·진행 행동을 순서대로 편집합니다.", "integrated", "현재 캐릭터가 확인할 진행 단계와 안내가 통합 미리보기에 표시됩니다.", ["ssobig.common", "ssobig.ending"], "진행 순서 제작 화면 예시"),
|
|
47
47
|
defaultData: { steps: [] },
|
|
48
48
|
view: view("ssobig.view.progress-workbench", "progress-workbench", "진행 작업 화면", "ssobig.progress", [
|
|
49
49
|
binding("primary", "ssobig.progress", "read_write")
|
|
@@ -51,28 +51,19 @@
|
|
|
51
51
|
importProfiles: [IMPORT_PROFILE]
|
|
52
52
|
},
|
|
53
53
|
{
|
|
54
|
-
templateId: "ssobig.common", defaultInstanceId: "common", tabLabel: "공통정보", required: true,
|
|
55
|
-
description: "모든 플레이어에게 함께 보여줄 게임
|
|
56
|
-
guide: guide("모든 참가자가 공통으로 읽는 규칙과 배경 정보를 작성합니다.", "프롤로그, 조사 규칙,
|
|
57
|
-
defaultData: {
|
|
54
|
+
templateId: "ssobig.common", defaultInstanceId: "common", tabLabel: "공통정보", displayLabel: "공통 문서", required: true,
|
|
55
|
+
description: "모든 플레이어에게 함께 보여줄 게임 규칙, 안내와 펼쳐보기 문서를 작성합니다.",
|
|
56
|
+
guide: guide("모든 참가자가 공통으로 읽는 규칙과 배경 정보를 작성합니다.", "프롤로그, 조사 규칙, 사건의 전말과 에필로그를 본문과 펼쳐보기로 구성합니다.", "문서의 제목·본문·노출 순서와 문서별 펼쳐보기 항목을 편집합니다.", "integrated", "공통 문서는 펼쳐보기 항목을 포함해 모든 캐릭터 관점의 통합 미리보기에 함께 표시됩니다.", ["ssobig.progress", "ssobig.character", "ssobig.ending"], "공통 문서 제작 화면 예시"),
|
|
57
|
+
defaultData: { documents: [], deletedDocumentIds: [] },
|
|
58
58
|
view: view("ssobig.view.common-workbench", "common-workbench", "공통정보 작업 화면", "ssobig.common", [
|
|
59
59
|
binding("primary", "ssobig.common", "read_write")
|
|
60
60
|
]),
|
|
61
61
|
importProfiles: [IMPORT_PROFILE]
|
|
62
62
|
},
|
|
63
63
|
{
|
|
64
|
-
templateId: "ssobig.
|
|
65
|
-
description: "플레이어가 게임 중간이나 최종 단계에서 고를 수 있는 선택 정보를 작성합니다.",
|
|
66
|
-
guide: guide("플레이어에게 제시할 선택지와 선택에 필요한 설명을 정리합니다.", "중간 조사 방향이나 최종 범인 지목처럼 플레이어가 결정할 항목을 문서로 구성합니다.", "공통정보와 같은 문서 카드에서 제목·본문·노출 순서를 편집합니다.", "authoring", "선택 문서는 제작 화면의 미리보기에서 공통정보와 같은 카드 형식으로 확인합니다.", ["ssobig.progress", "ssobig.ending"], "선택 제작 화면 예시"),
|
|
67
|
-
defaultData: { sections: {}, customSections: [], sectionOrder: [], baseTitles: {}, deletedBaseSections: [] },
|
|
68
|
-
view: view("ssobig.view.choice-workbench", "choice-workbench", "선택 작업 화면", "ssobig.choice", [
|
|
69
|
-
binding("primary", "ssobig.choice", "read_write")
|
|
70
|
-
])
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
templateId: "ssobig.character", defaultInstanceId: "characters", tabLabel: "캐릭터", required: true,
|
|
64
|
+
templateId: "ssobig.character", defaultInstanceId: "characters", tabLabel: "캐릭터", displayLabel: "캐릭터별 문서", required: true,
|
|
74
65
|
description: "캐릭터의 이름과 이미지, 공개 정보와 개인 이야기를 작성합니다.",
|
|
75
|
-
guide: guide("플레이어 캐릭터와 NPC, 각 인물에게 전달할 정보를 설계합니다.", "탐정, 의뢰인 등 인물별 유형과 자유로운 제목·본문 원고를 작성합니다.", "기본정보 Container와 추가 Container에 캐릭터 원고 section을 배치합니다.", "integrated", "플레이어 캐릭터로 지정한 인물의 Container별 원고가 통합 미리보기에 표시됩니다.", ["ssobig.assets", "ssobig.timeline", "ssobig.investigation-board"], "
|
|
66
|
+
guide: guide("플레이어 캐릭터와 NPC, 각 인물에게 전달할 정보를 설계합니다.", "탐정, 의뢰인 등 인물별 유형과 자유로운 제목·본문 원고를 작성합니다.", "기본정보 Container와 추가 Container에 캐릭터 원고 section을 배치합니다.", "integrated", "플레이어 캐릭터로 지정한 인물의 Container별 원고가 통합 미리보기에 표시됩니다.", ["ssobig.assets", "ssobig.timeline", "ssobig.investigation-board"], "캐릭터별 문서 제작 화면 예시"),
|
|
76
67
|
defaultData: { characters: {}, names: {}, order: [], customCharacters: [], deletedColumns: [] },
|
|
77
68
|
view: view("ssobig.view.character-workbench", "character-workbench", "캐릭터 작업 화면", "ssobig.character", [
|
|
78
69
|
binding("primary", "ssobig.character", "read_write"), binding("assets", "ssobig.assets", "read_write")
|
|
@@ -82,8 +73,8 @@
|
|
|
82
73
|
{
|
|
83
74
|
templateId: "ssobig.clues", defaultInstanceId: "clues", tabLabel: "단서",
|
|
84
75
|
description: "플레이어가 조사하고 획득할 단서의 이미지와 내용을 관리합니다.",
|
|
85
|
-
guide: guide("조사 과정에서 발견하는 증거와 정보를 정리합니다.", "발견 장소, 단서
|
|
86
|
-
defaultData: { clues: [], enabledOptionalFields: ["confirmed", "image", "location", "tags", "color"
|
|
76
|
+
guide: guide("조사 과정에서 발견하는 증거와 정보를 정리합니다.", "발견 장소, 단서 이미지와 공개 설명을 등록합니다.", "단서 이름·제목·설명과 선택 필드·이미지를 표 형태로 편집합니다.", "context", "단서 조합·AI 응답·추리 보드가 단서 ID와 현재 내용을 참고합니다.", ["ssobig.assets", "ssobig.clue-combinations", "ssobig.ai-response", "ssobig.investigation-board"], "단서 제작 화면 예시"),
|
|
77
|
+
defaultData: { clues: [], enabledOptionalFields: ["confirmed", "image", "location", "tags", "color"] },
|
|
87
78
|
view: view("ssobig.view.clues-workbench", "clues-workbench", "단서 작업 화면", "ssobig.clues", [
|
|
88
79
|
binding("primary", "ssobig.clues", "read_write"), binding("assets", "ssobig.assets", "read_write")
|
|
89
80
|
]),
|
|
@@ -121,23 +112,13 @@
|
|
|
121
112
|
{
|
|
122
113
|
templateId: "ssobig.ending", defaultInstanceId: "ending", tabLabel: "엔딩",
|
|
123
114
|
description: "플레이어의 선택과 게임 결과에 따라 공개될 엔딩을 설정합니다.",
|
|
124
|
-
guide: guide("엔딩이 공개되는 조건과 결말을 연결합니다.", "범인 지목 결과에 따라 성공·실패 결말이 열리도록 구성합니다.", "조건 코드·판정 설명과 결과 문서를 한 쌍으로 편집합니다.", "context", "
|
|
115
|
+
guide: guide("엔딩이 공개되는 조건과 결말을 연결합니다.", "범인 지목 결과에 따라 성공·실패 결말이 열리도록 구성합니다.", "조건 코드·판정 설명과 결과 문서를 한 쌍으로 편집합니다.", "context", "현재 통합 미리보기에는 독립 화면이 없습니다.", ["ssobig.common"], "엔딩 제작 화면 예시"),
|
|
125
116
|
defaultData: { conditions: [], outcomes: [] },
|
|
126
117
|
view: view("ssobig.view.ending-workbench", "ending-workbench", "엔딩 작업 화면", "ssobig.ending", [
|
|
127
118
|
binding("primary", "ssobig.ending", "read_write")
|
|
128
119
|
]),
|
|
129
120
|
importProfiles: [IMPORT_PROFILE]
|
|
130
121
|
},
|
|
131
|
-
{
|
|
132
|
-
templateId: "ssobig.postgame", defaultInstanceId: "postgame", tabLabel: "게임 후 문서",
|
|
133
|
-
description: "게임이 끝난 뒤 공개할 사건의 전말, 에필로그와 추가 문서를 작성합니다.",
|
|
134
|
-
guide: guide("게임 종료 뒤 참가자에게 공개할 정답과 후일담을 작성합니다.", "사건의 진실, 범행 과정, 캐릭터별 에필로그를 문서로 구성합니다.", "진실·에필로그 문서의 제목·본문과 추가 항목을 편집합니다.", "authoring", "엔딩을 참고하지만 현재 통합 미리보기에는 독립 플레이 화면이 없습니다.", ["ssobig.ending"], "게임 후 문서 제작 화면 예시"),
|
|
135
|
-
defaultData: { truth: [], epilogue: [], intro: { truth: "", epilogue: "" } },
|
|
136
|
-
view: view("ssobig.view.postgame-workbench", "postgame-workbench", "게임 후 문서 작업 화면", "ssobig.postgame", [
|
|
137
|
-
binding("primary", "ssobig.postgame", "read_write"), binding("ending-context", "ssobig.ending", "read", false)
|
|
138
|
-
]),
|
|
139
|
-
importProfiles: [IMPORT_PROFILE]
|
|
140
|
-
},
|
|
141
122
|
{
|
|
142
123
|
templateId: "ssobig.author-notes", defaultInstanceId: "author-notes", tabLabel: "작가노트",
|
|
143
124
|
description: "작품 제작과 게임 운영에 필요한 메모를 기록합니다.",
|
|
@@ -237,7 +218,7 @@
|
|
|
237
218
|
|
|
238
219
|
function normalizeDescriptor(input) {
|
|
239
220
|
assertKeys(input, [
|
|
240
|
-
"templateId", "defaultInstanceId", "tabLabel", "description", "required", "internal", "allowMultiple",
|
|
221
|
+
"templateId", "defaultInstanceId", "tabLabel", "displayLabel", "description", "required", "internal", "allowMultiple",
|
|
241
222
|
"defaultConfig", "defaultData", "view", "importProfiles", "guide"
|
|
242
223
|
], "Component descriptor");
|
|
243
224
|
const templateId = assertTemplateId(input.templateId, "Component templateId");
|
|
@@ -253,6 +234,7 @@
|
|
|
253
234
|
templateId,
|
|
254
235
|
defaultInstanceId: assertInstanceId(input.defaultInstanceId, `${templateId}.defaultInstanceId`),
|
|
255
236
|
tabLabel: String(input.tabLabel || "").trim(),
|
|
237
|
+
displayLabel: String(input.displayLabel || input.tabLabel || "").trim(),
|
|
256
238
|
description: String(input.description || ""),
|
|
257
239
|
required: input.required === true,
|
|
258
240
|
internal: input.internal === true,
|
|
@@ -324,7 +306,15 @@
|
|
|
324
306
|
return Object.freeze({
|
|
325
307
|
list: () => descriptors.slice(),
|
|
326
308
|
get: templateId => byId.get(String(templateId || "")) || null,
|
|
327
|
-
forImportProfile: profile => descriptors.filter(item => item.importProfiles.includes(String(profile || "")))
|
|
309
|
+
forImportProfile: profile => descriptors.filter(item => item.importProfiles.includes(String(profile || ""))),
|
|
310
|
+
displayLabelFor: (input, defaultLabel = "") => {
|
|
311
|
+
const instance = input && typeof input === "object" ? input : null;
|
|
312
|
+
const templateId = String(instance?.templateId || input || "");
|
|
313
|
+
const descriptor = byId.get(templateId) || null;
|
|
314
|
+
const instanceLabel = String(instance?.tab || "").trim();
|
|
315
|
+
if (descriptor?.allowMultiple && instanceLabel) return instanceLabel;
|
|
316
|
+
return String(descriptor?.displayLabel || instanceLabel || defaultLabel || instance?.instanceId || templateId);
|
|
317
|
+
}
|
|
328
318
|
});
|
|
329
319
|
}
|
|
330
320
|
|
|
@@ -335,6 +325,7 @@
|
|
|
335
325
|
createCatalog,
|
|
336
326
|
get: catalog.get,
|
|
337
327
|
list: catalog.list,
|
|
338
|
-
forImportProfile: catalog.forImportProfile
|
|
328
|
+
forImportProfile: catalog.forImportProfile,
|
|
329
|
+
displayLabelFor: catalog.displayLabelFor
|
|
339
330
|
});
|
|
340
331
|
});
|
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
now: options.now || Date.now,
|
|
23
23
|
randomUUID: options.randomUUID
|
|
24
24
|
});
|
|
25
|
+
const characterDefaultColor = () => {
|
|
26
|
+
const value = String(options.characterDefaultColor?.() || "");
|
|
27
|
+
return /^#[0-9a-f]{6}$/i.test(value) ? value : "#497fa8";
|
|
28
|
+
};
|
|
25
29
|
|
|
26
30
|
function markChange(instance, path, value, changeOptions = {}) {
|
|
27
31
|
if (options.saveState?.(instance)?.locked) return false;
|
|
@@ -102,13 +106,21 @@
|
|
|
102
106
|
occupiedContainers.add(containerId);
|
|
103
107
|
return { id: containerId, role: container.role, title: container.title, sectionIds: [] };
|
|
104
108
|
});
|
|
109
|
+
const occupiedSections = new Set(Object.values(data.characters).flatMap(record => (record.customSections || []).map(section => section.id)));
|
|
110
|
+
const publicSectionId = technicalId(instance, "section", occupiedSections);
|
|
111
|
+
occupiedSections.add(publicSectionId);
|
|
112
|
+
const initialSectionId = technicalId(instance, "section", occupiedSections);
|
|
113
|
+
const primaryContainer = containers.find(container => container.role === "primary") || containers[0];
|
|
114
|
+
primaryContainer.sectionIds.push(publicSectionId, initialSectionId);
|
|
105
115
|
data.characters[id] = {
|
|
106
116
|
isPlayable: true,
|
|
107
|
-
tag: "
|
|
108
|
-
color:
|
|
109
|
-
customSections: [
|
|
110
|
-
|
|
111
|
-
|
|
117
|
+
tag: "",
|
|
118
|
+
color: characterDefaultColor(),
|
|
119
|
+
customSections: [
|
|
120
|
+
{ id: publicSectionId, title: "공개 정보", body: "" },
|
|
121
|
+
{ id: initialSectionId, title: "새 항목", body: "" }
|
|
122
|
+
],
|
|
123
|
+
containers
|
|
112
124
|
};
|
|
113
125
|
data.names[id] = "새 캐릭터";
|
|
114
126
|
data.order.push(id);
|
|
@@ -262,9 +274,8 @@
|
|
|
262
274
|
|
|
263
275
|
function commonAdd(instance) {
|
|
264
276
|
const data = options.draft(instance);
|
|
265
|
-
const id = technicalId(instance, "document", new Set(data.
|
|
266
|
-
data.
|
|
267
|
-
data.sectionOrder.push(id);
|
|
277
|
+
const id = technicalId(instance, "document", new Set(data.documents.map(item => item.id)));
|
|
278
|
+
data.documents.push({ id, title: "새 문서", body: "", subdocuments: [] });
|
|
268
279
|
persistAndRender(instance);
|
|
269
280
|
}
|
|
270
281
|
|
|
@@ -36,6 +36,17 @@
|
|
|
36
36
|
title: field("non-empty-string", { role: "display-title" }),
|
|
37
37
|
body: field("string", { role: "content" })
|
|
38
38
|
});
|
|
39
|
+
const COMMON_SUBDOCUMENT = shape({
|
|
40
|
+
id: field("uuid", { role: "technical-identity", visibility: "hidden" }),
|
|
41
|
+
title: field("non-empty-string", { role: "display-title" }),
|
|
42
|
+
body: field("string", { role: "content" })
|
|
43
|
+
}, { displayId: field("string") });
|
|
44
|
+
const COMMON_DOCUMENT = shape({
|
|
45
|
+
id: field("uuid", { role: "technical-identity", visibility: "hidden" }),
|
|
46
|
+
title: field("non-empty-string", { role: "display-title" }),
|
|
47
|
+
body: field("string", { role: "content" }),
|
|
48
|
+
subdocuments: field("array", { item: "subdocument" })
|
|
49
|
+
});
|
|
39
50
|
const CONTRACTS = deepFreeze([
|
|
40
51
|
component({
|
|
41
52
|
templateId: "ssobig.basic",
|
|
@@ -58,19 +69,15 @@
|
|
|
58
69
|
})
|
|
59
70
|
}
|
|
60
71
|
}),
|
|
61
|
-
|
|
62
|
-
templateId,
|
|
72
|
+
component({
|
|
73
|
+
templateId: "ssobig.common",
|
|
74
|
+
identityPolicy: IDENTITY.UUID,
|
|
63
75
|
root: shape({
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
deletedBaseSections: field("unique-string-array")
|
|
76
|
+
documents: field("array", { item: "document" }),
|
|
77
|
+
deletedDocumentIds: field("unique-uuid-array")
|
|
67
78
|
}),
|
|
68
|
-
items: {
|
|
69
|
-
|
|
70
|
-
sections: field("string-map", { keyRole: "base-section-id", valueRole: "body" }),
|
|
71
|
-
baseTitles: field("string-map", { keyRole: "base-section-id", valueRole: "title" })
|
|
72
|
-
}
|
|
73
|
-
})),
|
|
79
|
+
items: { document: COMMON_DOCUMENT, subdocument: COMMON_SUBDOCUMENT }
|
|
80
|
+
}),
|
|
74
81
|
component({
|
|
75
82
|
templateId: "ssobig.character",
|
|
76
83
|
identityPolicy: IDENTITY.STABLE_KEY,
|
|
@@ -87,7 +94,7 @@
|
|
|
87
94
|
customSections: field("array", { item: "section" }),
|
|
88
95
|
containers: field("array", { item: "characterContainer" })
|
|
89
96
|
}, {
|
|
90
|
-
color: field("hex-color"), tag: field("string")
|
|
97
|
+
color: field("hex-color"), tag: field("string")
|
|
91
98
|
}),
|
|
92
99
|
section: SECTION_ITEM,
|
|
93
100
|
characterContainer: shape({
|
|
@@ -105,7 +112,7 @@
|
|
|
105
112
|
root: shape({
|
|
106
113
|
clues: field("array", { item: "clue" }),
|
|
107
114
|
enabledOptionalFields: field("unique-string-array")
|
|
108
|
-
}
|
|
115
|
+
}),
|
|
109
116
|
items: {
|
|
110
117
|
clue: shape({
|
|
111
118
|
id: field("uuid", { role: "technical-identity", visibility: "hidden" }),
|
|
@@ -115,7 +122,7 @@
|
|
|
115
122
|
}, {
|
|
116
123
|
confirmed: field("boolean"), location: field("string"),
|
|
117
124
|
color: field("hex-color-or-empty"), tags: field("unique-non-empty-string-array"), public: field("string"),
|
|
118
|
-
|
|
125
|
+
hint: field("string"), room: field("string"), round: field("integer"),
|
|
119
126
|
kind: field("string"), point: field("string"), spot: field("string"), character: field("string"),
|
|
120
127
|
related: field("string"), sourceId: field("string"), useTargetType: field("string"), onlyPositiveAllowed: field("boolean")
|
|
121
128
|
})
|
|
@@ -192,24 +199,6 @@
|
|
|
192
199
|
section: SECTION_ITEM
|
|
193
200
|
}
|
|
194
201
|
}),
|
|
195
|
-
component({
|
|
196
|
-
templateId: "ssobig.postgame",
|
|
197
|
-
identityPolicy: IDENTITY.UUID,
|
|
198
|
-
root: shape({
|
|
199
|
-
truth: field("array", { item: "document" }), epilogue: field("array", { item: "document" }),
|
|
200
|
-
intro: field("object", { item: "intro" })
|
|
201
|
-
}),
|
|
202
|
-
items: {
|
|
203
|
-
intro: shape({ truth: field("string"), epilogue: field("string") }),
|
|
204
|
-
document: shape({
|
|
205
|
-
id: field("uuid", { visibility: "hidden" }), title: field("non-empty-string", { role: "display-title" }),
|
|
206
|
-
body: field("string")
|
|
207
|
-
}, {
|
|
208
|
-
displayId: field("string"), customSections: field("array", { item: "section" })
|
|
209
|
-
}),
|
|
210
|
-
section: SECTION_ITEM
|
|
211
|
-
}
|
|
212
|
-
}),
|
|
213
202
|
component({
|
|
214
203
|
templateId: "ssobig.author-notes",
|
|
215
204
|
identityPolicy: IDENTITY.UUID,
|
|
@@ -472,21 +461,16 @@
|
|
|
472
461
|
case "ssobig.basic":
|
|
473
462
|
return data.minPlayers !== undefined && data.maxPlayers !== undefined && data.minPlayers > data.maxPlayers
|
|
474
463
|
? "$.minPlayers는 $.maxPlayers보다 클 수 없습니다." : null;
|
|
475
|
-
case "ssobig.common":
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
if (
|
|
481
|
-
if ([...customIds].some(id => baseIds.has(id))) return "$.customSections id는 base section id와 중복될 수 없습니다.";
|
|
482
|
-
if (data.deletedBaseSections.some(id => !baseIds.has(id))) return "$.deletedBaseSections에 존재하지 않는 base section이 있습니다.";
|
|
483
|
-
if (data.sectionOrder.some(id => deleted.has(id) || (!baseIds.has(id) && !customIds.has(id)))) return "$.sectionOrder에 활성 section을 가리키지 않는 값이 있습니다.";
|
|
484
|
-
const activeIds = new Set([...baseIds].filter(id => !deleted.has(id)).concat([...customIds]));
|
|
485
|
-
if (data.sectionOrder.length !== activeIds.size || [...activeIds].some(id => !data.sectionOrder.includes(id))) return "$.sectionOrder는 모든 활성 section을 정확히 한 번 포함해야 합니다.";
|
|
464
|
+
case "ssobig.common": {
|
|
465
|
+
const documentIds = idSet(data.documents);
|
|
466
|
+
if (documentIds.size !== data.documents.length) return "$.documents에 중복 document id가 있습니다.";
|
|
467
|
+
const subdocumentIds = data.documents.flatMap(item => item.subdocuments.map(subdocument => subdocument.id.toLowerCase()));
|
|
468
|
+
if (new Set(subdocumentIds).size !== subdocumentIds.length) return "$.documents[].subdocuments에 중복 subdocument id가 있습니다.";
|
|
469
|
+
if (data.deletedDocumentIds.some(id => !documentIds.has(id.toLowerCase()))) return "$.deletedDocumentIds에 존재하지 않는 document id가 있습니다.";
|
|
486
470
|
return null;
|
|
487
471
|
}
|
|
488
472
|
case "ssobig.character": {
|
|
489
|
-
const allowedOptionalFields = new Set(["image", "color", "tag"
|
|
473
|
+
const allowedOptionalFields = new Set(["image", "color", "tag"]);
|
|
490
474
|
if (Array.isArray(data.enabledOptionalFields) && data.enabledOptionalFields.some(name => !allowedOptionalFields.has(name))) return "$.enabledOptionalFields에 지원하지 않는 캐릭터 선택 속성이 있습니다.";
|
|
491
475
|
const characterIds = new Set(Object.keys(data.characters));
|
|
492
476
|
if ([...characterIds].some(id => typeof data.names[id] !== "string" || !data.names[id].trim())) return "$.names는 모든 character의 표시 이름을 가져야 합니다.";
|
|
@@ -508,7 +492,7 @@
|
|
|
508
492
|
return null;
|
|
509
493
|
}
|
|
510
494
|
case "ssobig.clues": {
|
|
511
|
-
const allowed = new Set(["confirmed", "image", "location", "tags", "color"
|
|
495
|
+
const allowed = new Set(["confirmed", "image", "location", "tags", "color"]);
|
|
512
496
|
return data.enabledOptionalFields.some(name => !allowed.has(name))
|
|
513
497
|
? "$.enabledOptionalFields에 지원하지 않는 단서 선택 속성이 있습니다." : null;
|
|
514
498
|
}
|
|
@@ -574,10 +558,6 @@
|
|
|
574
558
|
if (conditions.size !== outcomes.size || [...conditions].some(id => !outcomes.has(id))) return "$.conditions와 $.outcomes는 같은 id로 정확히 1:1 연결되어야 합니다.";
|
|
575
559
|
return null;
|
|
576
560
|
}
|
|
577
|
-
case "ssobig.postgame": {
|
|
578
|
-
const ids = [...data.truth, ...data.epilogue].map(item => item.id.toLowerCase());
|
|
579
|
-
return new Set(ids).size === ids.length ? null : "$.truth와 $.epilogue에 중복 document id가 있습니다.";
|
|
580
|
-
}
|
|
581
561
|
default: return null;
|
|
582
562
|
}
|
|
583
563
|
}
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
function technicalId(instance, prefix, occupied, options = {}) {
|
|
47
|
-
const uuidContract = ["ssobig.character", "ssobig.common"
|
|
47
|
+
const uuidContract = ["ssobig.character", "ssobig.common"].includes(instance.templateId);
|
|
48
48
|
if (!uuidContract) return uniqueId(prefix, occupied, options.now || Date.now);
|
|
49
49
|
const randomUUID = options.randomUUID;
|
|
50
50
|
if (typeof randomUUID !== "function") throw new Error("UUID 생성 기능을 사용할 수 없습니다.");
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
const FUNCTION_DISPLAY_ORDER = new Map([
|
|
42
42
|
["ssobig.basic", 0], ["ssobig.progress", 1], ["ssobig.common", 2], ["ssobig.character", 3],
|
|
43
43
|
["ssobig.timeline", 4], ["ssobig.clues", 5], ["ssobig.clue-combinations", 6],
|
|
44
|
-
["ssobig.ai-response", 7], ["ssobig.
|
|
44
|
+
["ssobig.ai-response", 7], ["ssobig.ending", 9],
|
|
45
45
|
["ssobig.author-notes", 11], ["ssobig.investigation-board", 12], ["ssobig.documents", 13]
|
|
46
46
|
]);
|
|
47
47
|
const DEFAULT_ASSET_SLOTS = Object.freeze([
|
|
@@ -156,8 +156,16 @@
|
|
|
156
156
|
return `<dl class="component-manager-definition-list">${items.map(([label, value]) => `<div><dt>${escapeHtml(label)}</dt><dd>${escapeHtml(value)}</dd></div>`).join("")}</dl>`;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
function
|
|
159
|
+
function componentDisplayLabel(instance, template, defaultLabel = "기능") {
|
|
160
|
+
const instanceLabel = String(instance?.tab || "").trim();
|
|
161
|
+
if (template?.allowMultiple && instanceLabel) return instanceLabel;
|
|
162
|
+
return String(template?.name || instanceLabel || instance?.instanceId || defaultLabel);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function relatedViewMarkup(viewComponents, dataTemplateByKey) {
|
|
160
166
|
const labels = viewComponents.map(view => {
|
|
167
|
+
const templateLabel = dataTemplateByKey.get(view.rendererId)?.name;
|
|
168
|
+
if (templateLabel) return templateLabel;
|
|
161
169
|
const compactLabel = String(view.label || "").replace(/\s*작업\s*화면\s*$/u, "").trim() || "연결 화면";
|
|
162
170
|
return compactLabel;
|
|
163
171
|
});
|
|
@@ -200,12 +208,13 @@
|
|
|
200
208
|
const required = instance.required || template?.required;
|
|
201
209
|
const selected = selectedId === instance.instanceId;
|
|
202
210
|
const view = primaryView(instance, viewComponents);
|
|
211
|
+
const label = componentDisplayLabel(instance, template, instance.instanceId);
|
|
203
212
|
return `<article class="component-manager-row is-active${required ? " is-required" : ""}${selected ? " is-selected" : ""}" data-component-row="${escapeHtml(instance.instanceId)}" data-component-state="active" data-component-manager-select="${escapeHtml(instance.instanceId)}" data-component-kind="data" role="button" tabindex="0" aria-pressed="${selected}">
|
|
204
213
|
<div class="component-manager-card-body">
|
|
205
214
|
<span class="component-manager-row-title">
|
|
206
|
-
<strong>${escapeHtml(
|
|
215
|
+
<strong>${escapeHtml(label)}</strong>
|
|
207
216
|
<span class="component-manager-card-status">
|
|
208
|
-
${required ? `<span class="component-manager-required-badge">필수</span>` : `<button class="component-manager-toggle is-on" type="button" role="switch" aria-checked="true" aria-label="${escapeHtml(
|
|
217
|
+
${required ? `<span class="component-manager-required-badge">필수</span>` : `<button class="component-manager-toggle is-on" type="button" role="switch" aria-checked="true" aria-label="${escapeHtml(label)} 사용 해제" data-component-action="archive" data-component-template="${escapeHtml(instance.templateId)}" data-component-instance="${escapeHtml(instance.instanceId)}" data-component-label="${escapeHtml(instance.tab || label)}"${busy ? " disabled" : ""}><em>사용 중</em><span aria-hidden="true"></span></button>`}
|
|
209
218
|
</span>
|
|
210
219
|
</span>
|
|
211
220
|
<span class="component-manager-row-template">${escapeHtml(template?.description || "작품에서 사용하는 기능입니다.")}</span>
|
|
@@ -216,7 +225,7 @@
|
|
|
216
225
|
|
|
217
226
|
function inactiveComponentRow(instance, template, busy, selectedId) {
|
|
218
227
|
const isArchived = Boolean(instance);
|
|
219
|
-
const label = instance
|
|
228
|
+
const label = componentDisplayLabel(instance, template);
|
|
220
229
|
const action = isArchived ? "restore" : "add";
|
|
221
230
|
const instanceId = instance?.instanceId || template?.defaultInstanceId || template?.templateId;
|
|
222
231
|
const templateId = instance?.templateId || template?.templateId;
|
|
@@ -234,7 +243,7 @@
|
|
|
234
243
|
</article>`;
|
|
235
244
|
}
|
|
236
245
|
|
|
237
|
-
function componentInspector(entry, viewComponents, busy) {
|
|
246
|
+
function componentInspector(entry, viewComponents, dataTemplateByKey, busy) {
|
|
238
247
|
if (!entry) return `<aside class="component-manager-inspector is-empty" data-component-manager-detail tabindex="-1">
|
|
239
248
|
<div class="component-manager-inspector-empty-guide">
|
|
240
249
|
<strong>세부 설정</strong>
|
|
@@ -246,7 +255,7 @@
|
|
|
246
255
|
const template = entry.template;
|
|
247
256
|
const instanceId = catalogEntryId(entry);
|
|
248
257
|
const templateId = instance?.templateId || template?.templateId || "";
|
|
249
|
-
const label = instance
|
|
258
|
+
const label = componentDisplayLabel(instance, template, instanceId);
|
|
250
259
|
const description = template?.description || (active ? "작품에서 사용하는 기능입니다." : "작품에서 사용할 수 있는 기능입니다.");
|
|
251
260
|
const relatedViews = active ? viewComponents.filter(candidate => candidate.bindings.some(binding => binding.dataInstanceId === instanceId)) : [];
|
|
252
261
|
const view = active ? primaryView(instance, viewComponents) : null;
|
|
@@ -268,7 +277,7 @@
|
|
|
268
277
|
</header>
|
|
269
278
|
<section class="component-manager-inspector-related">
|
|
270
279
|
<strong>사용 위치</strong>
|
|
271
|
-
${relatedViewMarkup(relatedViews)}
|
|
280
|
+
${relatedViewMarkup(relatedViews, dataTemplateByKey)}
|
|
272
281
|
</section>
|
|
273
282
|
<details class="component-manager-data-details">
|
|
274
283
|
<summary>데이터 정보</summary>
|
|
@@ -292,9 +301,10 @@
|
|
|
292
301
|
}
|
|
293
302
|
|
|
294
303
|
function restoreCard(instance, template, busy) {
|
|
304
|
+
const label = componentDisplayLabel(instance, template, instance.instanceId);
|
|
295
305
|
return `<article class="component-add-card is-archived">
|
|
296
|
-
<div><span>보관됨</span><strong>${escapeHtml(
|
|
297
|
-
<button type="button" data-component-action="restore" data-component-template="${escapeHtml(instance.templateId)}" data-component-instance="${escapeHtml(instance.instanceId)}" data-component-label="${escapeHtml(instance.tab)}"${busy ? " disabled" : ""}>복원</button>
|
|
306
|
+
<div><span>보관됨</span><strong>${escapeHtml(label)}</strong><p>기존 내용을 그대로 다시 사용할 수 있습니다.</p></div>
|
|
307
|
+
<button type="button" data-component-action="restore" data-component-template="${escapeHtml(instance.templateId)}" data-component-instance="${escapeHtml(instance.instanceId)}" data-component-label="${escapeHtml(instance.tab || label)}"${busy ? " disabled" : ""}>복원</button>
|
|
298
308
|
</article>`;
|
|
299
309
|
}
|
|
300
310
|
|
|
@@ -382,7 +392,7 @@
|
|
|
382
392
|
<section class="component-manager-catalog" data-ui-scroll-key="component-manager-catalog" aria-label="작품 기능 목록">
|
|
383
393
|
<div class="component-manager-card-grid">${catalogRows}</div>
|
|
384
394
|
</section>
|
|
385
|
-
${componentInspector(selectedEntry, viewComponents, busy)}
|
|
395
|
+
${componentInspector(selectedEntry, viewComponents, dataTemplateByKey, busy)}
|
|
386
396
|
</div>${addDialogMarkup(dataComponents, archivedComponents, dataTemplates, busy)}` : assetInstance ? assetLibraryMarkup(assetInstance, options) : `<div class="component-manager-empty">에셋 구성을 찾을 수 없습니다.</div>`}
|
|
387
397
|
</section>`;
|
|
388
398
|
}
|
|
@@ -10,11 +10,8 @@
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
function commonCount(data) {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
const customIds = new Set((Array.isArray(data?.customSections) ? data.customSections : []).map(item => item?.id).filter(Boolean));
|
|
16
|
-
return (Array.isArray(data?.sectionOrder) ? data.sectionOrder : [])
|
|
17
|
-
.filter(id => !deleted.has(id) && (Object.hasOwn(sections, id) || customIds.has(id))).length;
|
|
13
|
+
const deleted = new Set(Array.isArray(data?.deletedDocumentIds) ? data.deletedDocumentIds : []);
|
|
14
|
+
return (Array.isArray(data?.documents) ? data.documents : []).filter(document => !deleted.has(document?.id)).length;
|
|
18
15
|
}
|
|
19
16
|
|
|
20
17
|
function characterCount(data) {
|
|
@@ -38,8 +35,7 @@
|
|
|
38
35
|
function componentItemCount(instance, data = instance?.data) {
|
|
39
36
|
switch (String(instance?.templateId || "")) {
|
|
40
37
|
case "ssobig.progress": return listLength(data?.steps);
|
|
41
|
-
case "ssobig.common":
|
|
42
|
-
case "ssobig.choice": return commonCount(data);
|
|
38
|
+
case "ssobig.common": return commonCount(data);
|
|
43
39
|
case "ssobig.character": return characterCount(data);
|
|
44
40
|
case "ssobig.clues": return listLength(data?.clues);
|
|
45
41
|
case "ssobig.clue-combinations": return listLength(data?.combinations);
|
|
@@ -47,7 +43,6 @@
|
|
|
47
43
|
case "ssobig.timeline": return listLength(data?.events);
|
|
48
44
|
case "ssobig.investigation-board": return listLength(data?.nodes);
|
|
49
45
|
case "ssobig.ending": return endingCount(data);
|
|
50
|
-
case "ssobig.postgame": return listLength(data?.truth) + listLength(data?.epilogue);
|
|
51
46
|
case "ssobig.author-notes": return listLength(data?.notes);
|
|
52
47
|
case "ssobig.documents": return listLength(data?.blocks);
|
|
53
48
|
default: return 0;
|
|
@@ -48,8 +48,7 @@
|
|
|
48
48
|
{ key: "image", label: "이미지", description: "단서 이미지 등록과 미리보기" },
|
|
49
49
|
{ key: "location", label: "위치", description: "단서를 발견하거나 사용하는 위치" },
|
|
50
50
|
{ key: "tags", label: "태그", description: "단서 분류와 미리보기 필터" },
|
|
51
|
-
{ key: "color", label: "컬러", description: "단서 분류용 대표 색상" }
|
|
52
|
-
{ key: "secret", label: "작가 노트", description: "제작자만 확인하는 단서 메모" }
|
|
51
|
+
{ key: "color", label: "컬러", description: "단서 분류용 대표 색상" }
|
|
53
52
|
]);
|
|
54
53
|
|
|
55
54
|
const BASIC_FIELD_CONFIGURATION = deepFreeze([
|
|
@@ -73,8 +72,7 @@
|
|
|
73
72
|
{ key: "containers", label: "원고 Container", required: true, description: "캐릭터 기본정보와 원고 section의 묶음·순서" },
|
|
74
73
|
{ key: "image", label: "캐릭터 이미지", required: true, description: "캐릭터 이미지 등록과 미리보기" },
|
|
75
74
|
{ key: "color", label: "컬러", required: true, description: "캐릭터 카드의 대표 색상" },
|
|
76
|
-
{ key: "tag", label: "태그", required: true, description: "캐릭터의 역할과 분류 태그" }
|
|
77
|
-
{ key: "authorNote", label: "작가 노트", description: "연기 방향과 설계 의도 메모" }
|
|
75
|
+
{ key: "tag", label: "태그", required: true, description: "캐릭터의 역할과 분류 태그" }
|
|
78
76
|
]);
|
|
79
77
|
|
|
80
78
|
function template(input) {
|
|
@@ -113,7 +111,7 @@
|
|
|
113
111
|
|
|
114
112
|
const INITIAL_TEMPLATE_CATALOG = deepFreeze(catalogContract.list().map(descriptor => template({
|
|
115
113
|
templateId: descriptor.templateId,
|
|
116
|
-
name: descriptor.
|
|
114
|
+
name: descriptor.displayLabel,
|
|
117
115
|
required: descriptor.required,
|
|
118
116
|
internal: descriptor.internal,
|
|
119
117
|
allowMultiple: descriptor.allowMultiple,
|
|
@@ -186,12 +184,20 @@
|
|
|
186
184
|
return selected.allowMultiple || !instances.some(instance => instance?.templateId === selected.templateId);
|
|
187
185
|
}
|
|
188
186
|
|
|
187
|
+
function displayLabelFor(instance, defaultLabel = "") {
|
|
188
|
+
const selected = get(instance?.templateId);
|
|
189
|
+
const instanceLabel = String(instance?.tab || "").trim();
|
|
190
|
+
if (selected?.allowMultiple && instanceLabel) return instanceLabel;
|
|
191
|
+
return String(selected?.name || instanceLabel || defaultLabel || instance?.instanceId || instance?.templateId || "");
|
|
192
|
+
}
|
|
193
|
+
|
|
189
194
|
initialTemplates.forEach(item => register(item));
|
|
190
195
|
return Object.freeze({
|
|
191
196
|
register,
|
|
192
197
|
get,
|
|
193
198
|
has: id => Boolean(get(id)),
|
|
194
199
|
canInstantiate,
|
|
200
|
+
displayLabelFor,
|
|
195
201
|
list: () => [...templates.values()].sort((left, right) => left.templateId.localeCompare(right.templateId))
|
|
196
202
|
});
|
|
197
203
|
}
|