@ssobig/writer-cli 0.2.2 → 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.
Files changed (28) hide show
  1. package/README.md +12 -0
  2. package/config.js +2 -1
  3. package/package.json +1 -1
  4. package/templates/mystery-v1/authoring-view-preference.js +22 -5
  5. package/templates/mystery-v1/character-perspective-preview.js +3 -3
  6. package/templates/mystery-v1/codemirror6-runtime.min.js +28 -0
  7. package/templates/mystery-v1/component-asset-operations.js +5 -4
  8. package/templates/mystery-v1/component-catalog-contract.js +26 -35
  9. package/templates/mystery-v1/component-draft-operations.js +19 -8
  10. package/templates/mystery-v1/component-field-contracts.js +29 -49
  11. package/templates/mystery-v1/component-id-policy.js +1 -1
  12. package/templates/mystery-v1/component-manager.js +21 -11
  13. package/templates/mystery-v1/component-navigation-counts.js +3 -8
  14. package/templates/mystery-v1/component-registry.js +11 -5
  15. package/templates/mystery-v1/component-renderers.js +12 -8
  16. package/templates/mystery-v1/markdown-live-editor.js +350 -0
  17. package/templates/mystery-v1/page-header.js +2 -1
  18. package/tools/writer-cli/package-lock.json +2 -2
  19. package/tools/writer-cli/package.json +1 -1
  20. package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +1 -1
  21. package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +1 -1
  22. package/tools/writer-cli/src/agent-service.cjs +3 -1
  23. package/tools/writer-cli/src/command-registry.cjs +3 -0
  24. package/tools/writer-cli/src/commands.cjs +85 -3
  25. package/tools/writer-cli/src/domain.cjs +250 -1
  26. package/tools/writer-cli/src/gateway.cjs +14 -0
  27. package/tools/writer-cli/src/mutations.cjs +167 -1
  28. package/tools/writer-cli/src/project-import.cjs +12 -21
@@ -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(`캐릭터 · ${characterData.names?.[characterId] || characterId}`);
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"], "진행 Component 제작 화면 예시"),
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("모든 참가자가 공통으로 읽는 규칙과 배경 정보를 작성합니다.", "프롤로그, 조사 규칙, 주의사항과 공통 세계관을 구성합니다.", "기본 문서와 자유 문서의 제목·본문·노출 순서를 편집합니다.", "integrated", "공통 문서는 모든 캐릭터 관점의 통합 미리보기에 함께 표시됩니다.", ["ssobig.progress", "ssobig.character"], "공통정보 제작 화면 예시"),
57
- defaultData: { sections: {}, customSections: [], sectionOrder: [], baseTitles: {}, deletedBaseSections: [] },
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.choice", defaultInstanceId: "choice", tabLabel: "선택",
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("조사 과정에서 발견하는 증거와 정보를 정리합니다.", "발견 장소, 단서 이미지, 공개 설명과 작가 메모를 등록합니다.", "단서 이름·제목·설명과 선택 필드·이미지를 표 형태로 편집합니다.", "context", "단서 조합·AI 응답·추리 보드가 단서 ID와 현재 내용을 참고합니다.", ["ssobig.assets", "ssobig.clue-combinations", "ssobig.ai-response", "ssobig.investigation-board"], "단서 제작 화면 예시"),
86
- defaultData: { clues: [], enabledOptionalFields: ["confirmed", "image", "location", "tags", "color", "secret"], writerNote: "" },
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", "게임 후 문서가 엔딩을 참고하며 현재 통합 미리보기에는 독립 화면이 없습니다.", ["ssobig.postgame"], "엔딩 제작 화면 예시"),
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: "Player",
108
- color: "#497fa8",
109
- customSections: [],
110
- containers,
111
- authorNote: ""
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.sectionOrder));
266
- data.customSections.push({ id, title: instance.templateId === "ssobig.choice" ? "선택" : "새 문서", body: "" });
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
- ...["ssobig.common", "ssobig.choice"].map(templateId => component({
62
- templateId,
72
+ component({
73
+ templateId: "ssobig.common",
74
+ identityPolicy: IDENTITY.UUID,
63
75
  root: shape({
64
- sections: field("string-map"), customSections: field("array", { item: "section" }),
65
- sectionOrder: field("unique-string-array"), baseTitles: field("string-map"),
66
- deletedBaseSections: field("unique-string-array")
76
+ documents: field("array", { item: "document" }),
77
+ deletedDocumentIds: field("unique-uuid-array")
67
78
  }),
68
- items: { section: SECTION_ITEM },
69
- maps: {
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"), authorNote: 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
- }, { writerNote: field("string") }),
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
- secret: field("string"), hint: field("string"), room: field("string"), round: field("integer"),
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
- case "ssobig.choice": {
477
- const customIds = idSet(data.customSections);
478
- const baseIds = new Set(Object.keys(data.sections));
479
- const deleted = new Set(data.deletedBaseSections);
480
- if (Object.keys(data.sections).some(id => typeof data.baseTitles[id] !== "string")) return "$.baseTitles는 모든 base section의 제목을 가져야 합니다.";
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", "authorNote"]);
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", "secret"]);
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", "ssobig.choice"].includes(instance.templateId);
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.choice", 8], ["ssobig.ending", 9], ["ssobig.postgame", 10],
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 relatedViewMarkup(viewComponents) {
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(instance.tab || instance.instanceId)}</strong>
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(instance.tab)} 사용 해제" data-component-action="archive" data-component-template="${escapeHtml(instance.templateId)}" data-component-instance="${escapeHtml(instance.instanceId)}" data-component-label="${escapeHtml(instance.tab)}"${busy ? " disabled" : ""}><em>사용 중</em><span aria-hidden="true"></span></button>`}
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?.tab || template?.name || instance?.instanceId || "기능";
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?.tab || template?.name || instanceId || "기능";
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(instance.tab || template?.name || instance.instanceId)}</strong><p>기존 내용을 그대로 다시 사용할 수 있습니다.</p></div>
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 sections = data?.sections && typeof data.sections === "object" ? data.sections : {};
14
- const deleted = new Set(Array.isArray(data?.deletedBaseSections) ? data.deletedBaseSections : []);
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.tabLabel,
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
  }