@ssobig/writer-cli 0.2.2 → 0.3.2

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 (39) hide show
  1. package/README.md +19 -0
  2. package/asset-repository.js +7 -2
  3. package/config.js +2 -1
  4. package/package.json +1 -1
  5. package/templates/mystery-v1/authoring-view-preference.js +22 -5
  6. package/templates/mystery-v1/character-perspective-preview.js +3 -3
  7. package/templates/mystery-v1/codemirror6-runtime.min.js +28 -0
  8. package/templates/mystery-v1/component-asset-operations.js +19 -6
  9. package/templates/mystery-v1/component-catalog-contract.js +26 -35
  10. package/templates/mystery-v1/component-draft-operations.js +19 -8
  11. package/templates/mystery-v1/component-field-contracts.js +127 -51
  12. package/templates/mystery-v1/component-id-policy.js +1 -1
  13. package/templates/mystery-v1/component-manager.js +21 -11
  14. package/templates/mystery-v1/component-navigation-counts.js +9 -9
  15. package/templates/mystery-v1/component-registry.js +11 -5
  16. package/templates/mystery-v1/component-renderers.js +12 -8
  17. package/templates/mystery-v1/component-storage-contract.js +28 -9
  18. package/templates/mystery-v1/markdown-document-model.js +444 -0
  19. package/templates/mystery-v1/markdown-image-editor.js +320 -0
  20. package/templates/mystery-v1/markdown-live-editor.js +1224 -0
  21. package/templates/mystery-v1/page-header.js +2 -1
  22. package/templates/mystery-v1/timeline-model.js +235 -0
  23. package/tools/writer-cli/package-lock.json +2 -2
  24. package/tools/writer-cli/package.json +1 -1
  25. package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +13 -14
  26. package/tools/writer-cli/skills/ssobig-writer-cli/references/assets-checkpoints.md +5 -1
  27. package/tools/writer-cli/skills/ssobig-writer-cli/references/errors.md +4 -1
  28. package/tools/writer-cli/skills/ssobig-writer-cli/references/install-auth.md +3 -3
  29. package/tools/writer-cli/skills/ssobig-writer-cli/references/investigation-board.md +9 -0
  30. package/tools/writer-cli/skills/ssobig-writer-cli/references/layout-spec.md +130 -0
  31. package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +8 -2
  32. package/tools/writer-cli/skills/ssobig-writer-cli/references/read-search.md +12 -2
  33. package/tools/writer-cli/src/agent-service.cjs +3 -1
  34. package/tools/writer-cli/src/command-registry.cjs +24 -20
  35. package/tools/writer-cli/src/commands.cjs +106 -3
  36. package/tools/writer-cli/src/domain.cjs +296 -1
  37. package/tools/writer-cli/src/gateway.cjs +14 -0
  38. package/tools/writer-cli/src/mutations.cjs +167 -1
  39. package/tools/writer-cli/src/project-import.cjs +27 -22
@@ -1,8 +1,8 @@
1
1
  (function (root, factory) {
2
- const api = factory();
2
+ const api = factory(typeof module === "object" && module.exports ? require("./markdown-document-model.js") : root?.WriterMarkdownDocumentModel);
3
3
  if (typeof module === "object" && module.exports) module.exports = api;
4
4
  if (root) root.WriterWorkbenchComponentAssetOperations = api;
5
- })(typeof globalThis !== "undefined" ? globalThis : this, function () {
5
+ })(typeof globalThis !== "undefined" ? globalThis : this, function (markdownModel) {
6
6
  "use strict";
7
7
 
8
8
  function characterAssetId(characterId) {
@@ -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;
@@ -34,6 +34,18 @@
34
34
  const label = clue.name || clue.title || clue.id || index + 1;
35
35
  result[assetId].push(`단서 · ${label}`);
36
36
  }
37
+ for (const instance of active) {
38
+ if (instance.templateId === "ssobig.assets") continue;
39
+ const visit = (value, path = []) => {
40
+ if (typeof value === "string") {
41
+ const blocks = (markdownModel || globalThis.WriterMarkdownDocumentModel)?.parseDocument(value)?.blocks || [];
42
+ blocks.filter(block => block.type === "image").forEach(block => {
43
+ (result[block.assetId] ||= []).push(`${instance.tabLabel || instance.instanceId} · 본문 (${path.join(" / ")})`);
44
+ });
45
+ } else if (value && typeof value === "object") Object.entries(value).forEach(([key, child]) => visit(child, [...path, key]));
46
+ };
47
+ visit(dataFor(instance) || instance.data);
48
+ }
37
49
  return result;
38
50
  }
39
51
 
@@ -69,6 +81,7 @@
69
81
  options.hydrate?.();
70
82
  return result;
71
83
  } catch (error) {
84
+ options.onError?.(error);
72
85
  if (ownerInstance) options.autosave.recordError(ownerInstance.instanceId, error);
73
86
  options.setStatus?.({ state: "error", message: error?.message || "에셋을 저장하지 못했습니다." });
74
87
  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
  })
@@ -163,7 +170,15 @@
163
170
  component({
164
171
  templateId: "ssobig.timeline",
165
172
  identityPolicy: IDENTITY.UUID,
166
- root: shape({ events: field("array", { item: "event" }) }),
173
+ // 전환기 계약: 사건이 시간을 소유하는 events 항목이 시간을 소유하는 timeLevels/entries를 상호 배타로 허용한다.
174
+ // 두 모양의 배타성과 참조 검증은 component-storage-contract의 관계 검증이 담당한다.
175
+ root: shape({}, {
176
+ events: field("array", { item: "event" }),
177
+ timeLevels: field("array", { item: "timeLevel" }),
178
+ entries: field("array", { item: "entry" }),
179
+ // 작품의 장소 목록. 배열 순서가 표시 순서이며 v2 모양에서만 사용한다.
180
+ places: field("array", { item: "place" })
181
+ }),
167
182
  items: {
168
183
  event: shape({
169
184
  id: field("uuid", { visibility: "hidden" }), time: field("non-empty-string", { role: "display-name" }),
@@ -174,7 +189,44 @@
174
189
  values: "memory", keyFormat: "stable-key", keyRole: "unresolved-character-key"
175
190
  })
176
191
  }),
177
- memory: shape({ included: field("boolean"), text: field("string") })
192
+ memory: shape({ included: field("boolean"), text: field("string") }),
193
+ timeLevel: shape({
194
+ id: field("uuid", { role: "technical-identity", visibility: "hidden" }),
195
+ name: field("non-empty-string", { role: "display-name" }),
196
+ mode: field("enum", { values: ["manual", "clock"] })
197
+ }),
198
+ entry: shape({
199
+ id: field("uuid", { role: "technical-identity", visibility: "hidden" }),
200
+ timeLevelId: field("uuid", { references: "timeLevel.id" }),
201
+ lane: field("object", { item: "entryLane" }),
202
+ when: field("object", { item: "entryWhen" }),
203
+ timeLabel: field("string", { role: "display-name" }),
204
+ text: field("string", { role: "content" }),
205
+ included: field("boolean")
206
+ }, {
207
+ // location은 장소 목록으로 옮기는 동안 유지하는 전환기 fallback 문자열이다.
208
+ location: field("string"),
209
+ // 배열 순서는 항목의 이동 경로 순서다.
210
+ placeIds: field("unique-uuid-array", { references: "place.id" }),
211
+ eventGroupId: field("uuid", { role: "loose-grouping" })
212
+ }),
213
+ place: shape({
214
+ id: field("uuid", { role: "technical-identity", visibility: "hidden" }),
215
+ name: field("non-empty-string", { role: "display-name" })
216
+ }),
217
+ entryLane: shape({
218
+ kind: field("enum", { values: ["truth", "character", "unresolved"] })
219
+ }, {
220
+ characterId: field("stable-key", { references: "ssobig.character key" }),
221
+ sourceKey: field("stable-key", { keyRole: "unresolved-character-key" })
222
+ }),
223
+ entryWhen: shape({
224
+ kind: field("enum", { values: ["unspecified", "clock"] })
225
+ }, {
226
+ dayOffset: field("non-negative-integer"),
227
+ startMinute: field("integer", { minimum: 0, maximum: 1439 }),
228
+ endMinute: field("integer", { minimum: 0, maximum: 1439 })
229
+ })
178
230
  }
179
231
  }),
180
232
  component({
@@ -192,24 +244,6 @@
192
244
  section: SECTION_ITEM
193
245
  }
194
246
  }),
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
247
  component({
214
248
  templateId: "ssobig.author-notes",
215
249
  identityPolicy: IDENTITY.UUID,
@@ -467,26 +501,53 @@
467
501
  }
468
502
  }
469
503
  function idSet(items) { return new Set(items.map(item => String(item.id || "").toLowerCase())); }
504
+ function timelineShapeError(data) {
505
+ const events = Array.isArray(data.events);
506
+ const levels = Array.isArray(data.timeLevels);
507
+ const entries = Array.isArray(data.entries);
508
+ if (events && (levels || entries)) return "$.events와 $.timeLevels/$.entries는 함께 사용할 수 없습니다.";
509
+ if (!events && !levels && !entries) return "$에는 $.events 또는 $.timeLevels와 $.entries가 있어야 합니다.";
510
+ if (!events && !(levels && entries)) return "$.timeLevels와 $.entries는 함께 있어야 합니다.";
511
+ // 장소 목록은 항목이 시간을 소유하는 v2 모양에서만 사용한다.
512
+ if (events && Object.hasOwn(data, "places")) return "$.places는 $.events 모양에서 사용할 수 없습니다.";
513
+ return null;
514
+ }
515
+ function timelineLaneError(lane, path) {
516
+ if (lane.kind === "character") {
517
+ if (!Object.hasOwn(lane, "characterId")) return `${path}.lane에 character 종류의 필수 값이 없습니다.`;
518
+ return Object.hasOwn(lane, "sourceKey") ? `${path}.lane.sourceKey 필드는 character 종류에서 사용할 수 없습니다.` : null;
519
+ }
520
+ if (lane.kind === "unresolved") {
521
+ if (!Object.hasOwn(lane, "sourceKey")) return `${path}.lane에 unresolved 종류의 필수 값이 없습니다.`;
522
+ return Object.hasOwn(lane, "characterId") ? `${path}.lane.characterId 필드는 unresolved 종류에서 사용할 수 없습니다.` : null;
523
+ }
524
+ const unexpected = ["characterId", "sourceKey"].find(name => Object.hasOwn(lane, name));
525
+ return unexpected ? `${path}.lane.${unexpected} 필드는 truth 종류에서 사용할 수 없습니다.` : null;
526
+ }
527
+ function timelineWhenError(when, level, path) {
528
+ if (when.kind === "clock") {
529
+ if (!Object.hasOwn(when, "startMinute")) return `${path}.when에 clock 종류의 필수 값이 없습니다.`;
530
+ if (Object.hasOwn(when, "endMinute") && when.endMinute < when.startMinute) return `${path}.when.endMinute은 startMinute보다 앞설 수 없습니다.`;
531
+ return level.mode === "clock" ? null : `${path}.when은 clock 모드 시간 레벨에서만 사용할 수 있습니다.`;
532
+ }
533
+ const unexpected = ["dayOffset", "startMinute", "endMinute"].find(name => Object.hasOwn(when, name));
534
+ return unexpected ? `${path}.when.${unexpected} 필드는 unspecified 종류에서 사용할 수 없습니다.` : null;
535
+ }
470
536
  function validateRelations(contract, data) {
471
537
  switch (contract.templateId) {
472
538
  case "ssobig.basic":
473
539
  return data.minPlayers !== undefined && data.maxPlayers !== undefined && data.minPlayers > data.maxPlayers
474
540
  ? "$.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을 정확히 한 번 포함해야 합니다.";
541
+ case "ssobig.common": {
542
+ const documentIds = idSet(data.documents);
543
+ if (documentIds.size !== data.documents.length) return "$.documents에 중복 document id가 있습니다.";
544
+ const subdocumentIds = data.documents.flatMap(item => item.subdocuments.map(subdocument => subdocument.id.toLowerCase()));
545
+ if (new Set(subdocumentIds).size !== subdocumentIds.length) return "$.documents[].subdocuments에 중복 subdocument id가 있습니다.";
546
+ if (data.deletedDocumentIds.some(id => !documentIds.has(id.toLowerCase()))) return "$.deletedDocumentIds에 존재하지 않는 document id가 있습니다.";
486
547
  return null;
487
548
  }
488
549
  case "ssobig.character": {
489
- const allowedOptionalFields = new Set(["image", "color", "tag", "authorNote"]);
550
+ const allowedOptionalFields = new Set(["image", "color", "tag"]);
490
551
  if (Array.isArray(data.enabledOptionalFields) && data.enabledOptionalFields.some(name => !allowedOptionalFields.has(name))) return "$.enabledOptionalFields에 지원하지 않는 캐릭터 선택 속성이 있습니다.";
491
552
  const characterIds = new Set(Object.keys(data.characters));
492
553
  if ([...characterIds].some(id => typeof data.names[id] !== "string" || !data.names[id].trim())) return "$.names는 모든 character의 표시 이름을 가져야 합니다.";
@@ -508,7 +569,7 @@
508
569
  return null;
509
570
  }
510
571
  case "ssobig.clues": {
511
- const allowed = new Set(["confirmed", "image", "location", "tags", "color", "secret"]);
572
+ const allowed = new Set(["confirmed", "image", "location", "tags", "color"]);
512
573
  return data.enabledOptionalFields.some(name => !allowed.has(name))
513
574
  ? "$.enabledOptionalFields에 지원하지 않는 단서 선택 속성이 있습니다." : null;
514
575
  }
@@ -569,15 +630,30 @@
569
630
  }
570
631
  return null;
571
632
  }
633
+ case "ssobig.timeline": {
634
+ const shapeError = timelineShapeError(data);
635
+ if (shapeError || Array.isArray(data.events)) return shapeError;
636
+ // 배열 항목 id 유일성(places 포함)은 array field 검증이 먼저 확인하므로 여기서는 참조와 종류별 규칙만 본다.
637
+ const levels = new Map(data.timeLevels.map(level => [level.id, level]));
638
+ const places = idSet(Array.isArray(data.places) ? data.places : []);
639
+ for (let index = 0; index < data.entries.length; index += 1) {
640
+ const entry = data.entries[index];
641
+ const path = `$.entries.${index}`;
642
+ const level = levels.get(entry.timeLevelId);
643
+ if (!level) return `${path}.timeLevelId가 존재하지 않는 시간 레벨을 가리킵니다.`;
644
+ const invalid = timelineLaneError(entry.lane, path) || timelineWhenError(entry.when, level, path);
645
+ if (invalid) return invalid;
646
+ if (Array.isArray(entry.placeIds) && entry.placeIds.some(id => !places.has(String(id).toLowerCase()))) {
647
+ return `${path}.placeIds가 존재하지 않는 장소를 가리킵니다.`;
648
+ }
649
+ }
650
+ return null;
651
+ }
572
652
  case "ssobig.ending": {
573
653
  const conditions = idSet(data.conditions), outcomes = idSet(data.outcomes);
574
654
  if (conditions.size !== outcomes.size || [...conditions].some(id => !outcomes.has(id))) return "$.conditions와 $.outcomes는 같은 id로 정확히 1:1 연결되어야 합니다.";
575
655
  return null;
576
656
  }
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
657
  default: return null;
582
658
  }
583
659
  }
@@ -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
  }