@khorsheed/dsh-game 0.0.0-stage → 0.1.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 (99) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.en.md +53 -0
  3. package/README.i18n.yaml +6 -0
  4. package/README.md +52 -2
  5. package/cordis.patch.yml +23 -0
  6. package/lib/client.js +30348 -0
  7. package/lib/index.js +4154 -0
  8. package/lib/typert.host.d.ts +3 -0
  9. package/lib/typert.host.js +3769 -0
  10. package/lib/typert.remote-client.d.ts +101 -0
  11. package/lib/typert.remote-client.js +2082 -0
  12. package/lib/types/agent.d.ts +20 -0
  13. package/lib/types/agent.js +42 -0
  14. package/lib/types/checks.d.ts +44 -0
  15. package/lib/types/checks.js +267 -0
  16. package/lib/types/client/DesignView.d.ts +22 -0
  17. package/lib/types/client/DesignView.js +134 -0
  18. package/lib/types/client/ElementsView.d.ts +21 -0
  19. package/lib/types/client/ElementsView.js +187 -0
  20. package/lib/types/client/GameTab.d.ts +14 -0
  21. package/lib/types/client/GameTab.js +256 -0
  22. package/lib/types/client/PlayFrame.d.ts +54 -0
  23. package/lib/types/client/PlayFrame.js +129 -0
  24. package/lib/types/client/PlayView.d.ts +19 -0
  25. package/lib/types/client/PlayView.js +161 -0
  26. package/lib/types/client/Preview3D.d.ts +50 -0
  27. package/lib/types/client/Preview3D.js +176 -0
  28. package/lib/types/client/ProjectSwitcher.d.ts +20 -0
  29. package/lib/types/client/ProjectSwitcher.js +75 -0
  30. package/lib/types/client/Recipe3DEditor.d.ts +19 -0
  31. package/lib/types/client/Recipe3DEditor.js +44 -0
  32. package/lib/types/client/StyleGallery.d.ts +18 -0
  33. package/lib/types/client/StyleGallery.js +27 -0
  34. package/lib/types/client/TurnCard.d.ts +62 -0
  35. package/lib/types/client/TurnCard.js +100 -0
  36. package/lib/types/client/VersionsView.d.ts +17 -0
  37. package/lib/types/client/VersionsView.js +68 -0
  38. package/lib/types/client/chat-node.d.ts +42 -0
  39. package/lib/types/client/chat-node.js +77 -0
  40. package/lib/types/client/contract.d.ts +125 -0
  41. package/lib/types/client/contract.js +6 -0
  42. package/lib/types/client/definition.d.ts +22 -0
  43. package/lib/types/client/definition.js +28 -0
  44. package/lib/types/client/index.d.ts +21 -0
  45. package/lib/types/client/index.js +229 -0
  46. package/lib/types/client/locales.d.ts +376 -0
  47. package/lib/types/client/locales.js +727 -0
  48. package/lib/types/client/looks.d.ts +36 -0
  49. package/lib/types/client/looks.js +105 -0
  50. package/lib/types/client/markdown.d.ts +11 -0
  51. package/lib/types/client/markdown.js +52 -0
  52. package/lib/types/client/preset-visibility.d.ts +94 -0
  53. package/lib/types/client/preset-visibility.js +115 -0
  54. package/lib/types/client/preview-entry.d.ts +24 -0
  55. package/lib/types/client/preview-entry.js +107 -0
  56. package/lib/types/client/refs.d.ts +36 -0
  57. package/lib/types/client/refs.js +44 -0
  58. package/lib/types/client/style-samples.d.ts +5 -0
  59. package/lib/types/client/style-samples.js +196 -0
  60. package/lib/types/fsutil.d.ts +26 -0
  61. package/lib/types/fsutil.js +104 -0
  62. package/lib/types/hooks.d.ts +83 -0
  63. package/lib/types/hooks.js +108 -0
  64. package/lib/types/index.d.ts +30 -0
  65. package/lib/types/index.js +22 -0
  66. package/lib/types/kits/canvas2d.d.ts +6 -0
  67. package/lib/types/kits/canvas2d.js +60 -0
  68. package/lib/types/kits/ids.d.ts +11 -0
  69. package/lib/types/kits/ids.js +6 -0
  70. package/lib/types/kits/index.d.ts +26 -0
  71. package/lib/types/kits/index.js +28 -0
  72. package/lib/types/kits/three3d.d.ts +12 -0
  73. package/lib/types/kits/three3d.js +188 -0
  74. package/lib/types/kits/types.d.ts +52 -0
  75. package/lib/types/kits/types.js +6 -0
  76. package/lib/types/look3d.d.ts +20 -0
  77. package/lib/types/look3d.js +175 -0
  78. package/lib/types/panel-session.d.ts +21 -0
  79. package/lib/types/panel-session.js +16 -0
  80. package/lib/types/project.d.ts +132 -0
  81. package/lib/types/project.js +494 -0
  82. package/lib/types/remote.d.ts +182 -0
  83. package/lib/types/remote.js +256 -0
  84. package/lib/types/skills.d.ts +13 -0
  85. package/lib/types/skills.js +140 -0
  86. package/lib/types/store.d.ts +325 -0
  87. package/lib/types/store.js +1285 -0
  88. package/lib/types/tools.d.ts +12 -0
  89. package/lib/types/tools.js +301 -0
  90. package/lib/types/trash.d.ts +5 -0
  91. package/lib/types/trash.js +64 -0
  92. package/lib/types/types.d.ts +522 -0
  93. package/lib/types/types.js +85 -0
  94. package/lib/types/vfs.d.ts +92 -0
  95. package/lib/types/vfs.js +217 -0
  96. package/locale/en.json +6 -0
  97. package/locale/zh.json +6 -0
  98. package/package.json +132 -3
  99. package/runtime/dsh3d.js +1279 -0
@@ -0,0 +1,132 @@
1
+ import type { KitId, RuntimeKit } from './kits/types.ts';
2
+ import { type ArtStyle, type ChapterStatus, type CheckFailure, type ElementLook, type ElementView, type GameManifest, type GameParam } from './types.ts';
3
+ /** What `game_open create` must carry before a project exists (the light gate, decision D2). */
4
+ export interface CreateRequest {
5
+ /** `2d` (default) or `3d`; the kit the project is pinned to. */
6
+ readonly runtime?: string | undefined;
7
+ readonly title?: string | undefined;
8
+ readonly pitch?: string | undefined;
9
+ readonly elementTypes?: readonly string[] | string | undefined;
10
+ readonly artStyle?: string | undefined;
11
+ readonly pillars?: readonly string[] | string | undefined;
12
+ }
13
+ /** The gate's verdict: a manifest to write, or what is still missing (model-facing Chinese). */
14
+ export type GateVerdict = {
15
+ readonly ok: true;
16
+ readonly manifest: Omit<GameManifest, 'id' | 'createdAt'>;
17
+ } | {
18
+ readonly ok: false;
19
+ readonly missing: readonly string[];
20
+ };
21
+ /** Split a list given either as an array or as one 、/,/newline-separated string. */
22
+ export declare function listOf(value: readonly string[] | string | undefined): string[];
23
+ /**
24
+ * Resolve the art style the user picked: an example id, its Chinese label,
25
+ * or `custom:<description>`. Anything else is not a choice the user made.
26
+ * @param value - what the model passed.
27
+ * @returns the stored spelling, or undefined when it names no style.
28
+ */
29
+ export declare function resolveArtStyle(value: string | undefined, styles?: readonly ArtStyle[]): string | undefined;
30
+ /** The style's human label (`custom:` keeps the user's own words). */
31
+ export declare function artStyleLabel(id: string): string;
32
+ /** The kit a create request asks for: `3d` / `3d-three` → 3D, anything else 2D. */
33
+ export declare function requestedKit(runtime: string | undefined): KitId;
34
+ /** The example styles a kit offers. */
35
+ export declare function stylesFor(kit: KitId): readonly ArtStyle[];
36
+ /**
37
+ * The light gate: a project needs a title, a one-sentence pitch, at least one
38
+ * element type and a chosen art style. The model gets back exactly what is
39
+ * missing, so it asks the user instead of inventing it.
40
+ * @param request - the create arguments.
41
+ * @returns the manifest body, or the missing items.
42
+ */
43
+ export declare function gate(request: CreateRequest): GateVerdict;
44
+ /** A style's palette; custom styles start from the pixel palette (the agent may set its own in game.json). */
45
+ export declare function stylePalette(styleId: string): string[];
46
+ /** Parse `game.json` `params[]`, dropping malformed rows and clamping each value into its range. */
47
+ export declare function parseParams(raw: unknown): GameParam[];
48
+ /** What a usable pixel look needs, worded for the model. */
49
+ export declare const PIXEL_LOOK_HINT = "look \u4E0D\u53EF\u7528\uFF1A\u9700\u8981 palette\uFF08#rrggbb \u6570\u7EC4\uFF09\u548C states\uFF08\u72B6\u6001\u540D \u2192 \u50CF\u7D20\u884C\u6570\u7EC4\uFF09";
50
+ /**
51
+ * Normalize an element's `look`: a palette of `#rrggbb` colors and pixel
52
+ * rows per state, every row padded/cut to the widest row, characters outside
53
+ * the palette turned transparent. Anything unusable is null.
54
+ */
55
+ export declare function normalizeLook(raw: unknown): ElementLook | null;
56
+ /** The look's grid size (first state). */
57
+ export declare function lookSize(look: ElementLook): {
58
+ w: number;
59
+ h: number;
60
+ };
61
+ /**
62
+ * Parse `game.json`. Unknown keys are dropped; a missing optional key takes
63
+ * its default, so a hand-edited manifest still loads.
64
+ * @param text - the file's contents.
65
+ * @returns the manifest, or a Chinese reason.
66
+ */
67
+ export declare function parseManifest(text: string): {
68
+ ok: true;
69
+ manifest: GameManifest;
70
+ } | {
71
+ ok: false;
72
+ error: string;
73
+ };
74
+ /** Serialize a manifest the way the plugin writes it. */
75
+ export declare function serializeManifest(manifest: GameManifest): string;
76
+ /**
77
+ * A chapter's status from its text: empty is `todo`, a `状态:定稿` (or
78
+ * `status: done`) line is `done`, anything else is a `draft`.
79
+ */
80
+ export declare function chapterStatus(text: string | undefined): ChapterStatus;
81
+ /**
82
+ * The one-pager (pitch, element types, style, pillars) lives in `game.json`
83
+ * and the panel pins it; `design/pillars.md` holds only the notes about the
84
+ * pillars (why each matters, how to test it). Projects created before
85
+ * 2026-10-10 got a pillars.md that restated the one-pager: this strips those
86
+ * restated lines so the notes view never shows the card twice.
87
+ * @param text - the chapter's text.
88
+ * @param manifest - the one-pager it may restate.
89
+ * @returns the notes that are not a restatement (trimmed; '' when none).
90
+ */
91
+ export declare function pillarNotes(text: string, manifest: Pick<GameManifest, 'title' | 'pitch' | 'pillars' | 'artStyle' | 'elementTypes'> | null): string;
92
+ /**
93
+ * Validate the element files. A file holds one element object or an array of
94
+ * them; each needs `id`, `name` and a `type` the manifest declares, and ids
95
+ * are unique across files.
96
+ * @param files - project-relative path → contents, only `elements/*.json`.
97
+ * @param elementTypes - the manifest's declared types (empty = accept any).
98
+ * @param looks - how the project's kit normalizes a `look` (default: 2D pixel rows).
99
+ * @returns the elements that parsed, and one failure per problem.
100
+ */
101
+ export declare function validateElements(files: Readonly<Record<string, string>>, elementTypes: readonly string[], looks?: Pick<RuntimeKit, 'normalizeLook' | 'lookHint'>): {
102
+ elements: ElementView[];
103
+ failures: CheckFailure[];
104
+ };
105
+ /** The generated 元素与数值 chapter: one table per element type. */
106
+ export declare function elementsChapter(elements: readonly ElementView[], elementTypes: readonly string[]): string;
107
+ /** The generated 元素设定 chapter: every element's 设定 by type (elements without one are left out). */
108
+ export declare function loreChapter(elements: readonly ElementView[], elementTypes: readonly string[]): string;
109
+ /**
110
+ * Whether a project-relative path belongs in a playable build: everything
111
+ * except the plugin's state, the design doc, the tests and dot-files.
112
+ */
113
+ export declare function isBuildFile(path: string): boolean;
114
+ /** A file's MIME type by extension. */
115
+ export declare function mimeOf(path: string): string;
116
+ /** Whether a file ships to the sandbox as text. */
117
+ export declare function isTextFile(path: string): boolean;
118
+ /**
119
+ * A directory-safe project folder name from a title: ASCII letters and
120
+ * digits kept, other runs collapsed to `-`; a title with none falls back to
121
+ * `game`. The store appends `-2`, `-3` … on collision.
122
+ */
123
+ export declare function slugOf(title: string): string;
124
+ /**
125
+ * Apply a patch to one element inside its file's text (an object, or one
126
+ * entry of an array), keeping every other key. `undefined` values delete.
127
+ * @returns the new file text, or undefined when the element is not there.
128
+ */
129
+ export declare function patchElementText(text: string, elementId: string, patch: Readonly<Record<string, unknown>>): string | undefined;
130
+ /** A look as model-facing text: palette, then each state's rows. */
131
+ export declare function lookText(look: ElementLook): string;
132
+ //# sourceMappingURL=project.d.ts.map
@@ -0,0 +1,494 @@
1
+ /**
2
+ * Pure project logic: the creation gate, manifest parsing, chapter status,
3
+ * element validation and which files make up a playable build. No I/O —
4
+ * the store feeds it file contents, the specs feed it strings.
5
+ * @module @khorsheed/dsh-game
6
+ */
7
+ import { isKitId } from "./kits/ids.js";
8
+ import { ART_STYLES, ART_STYLES_3D, isPixelLook, LOOK_CHARS, MANIFEST_FILE, STATE_DIR, } from "./types.js";
9
+ /** Split a list given either as an array or as one 、/,/newline-separated string. */
10
+ export function listOf(value) {
11
+ const raw = typeof value === 'string' ? value.split(/[、,,;;\n]/) : Array.isArray(value) ? value : [];
12
+ const out = [];
13
+ for (const item of raw) {
14
+ const clean = typeof item === 'string' ? item.trim() : '';
15
+ if (clean !== '' && !out.includes(clean))
16
+ out.push(clean);
17
+ }
18
+ return out;
19
+ }
20
+ /**
21
+ * Resolve the art style the user picked: an example id, its Chinese label,
22
+ * or `custom:<description>`. Anything else is not a choice the user made.
23
+ * @param value - what the model passed.
24
+ * @returns the stored spelling, or undefined when it names no style.
25
+ */
26
+ export function resolveArtStyle(value, styles = ART_STYLES) {
27
+ const wanted = (value ?? '').trim();
28
+ if (wanted === '')
29
+ return undefined;
30
+ const known = styles.find(style => style.id === wanted || style.label === wanted);
31
+ if (known !== undefined)
32
+ return known.id;
33
+ if (/^custom[::]\s*\S/.test(wanted))
34
+ return `custom:${wanted.replace(/^custom[::]\s*/, '')}`;
35
+ return undefined;
36
+ }
37
+ /** The style's human label (`custom:` keeps the user's own words). */
38
+ export function artStyleLabel(id) {
39
+ if (id.startsWith('custom:'))
40
+ return id.slice('custom:'.length);
41
+ return [...ART_STYLES, ...ART_STYLES_3D].find(style => style.id === id)?.label ?? id;
42
+ }
43
+ /** The kit a create request asks for: `3d` / `3d-three` → 3D, anything else 2D. */
44
+ export function requestedKit(runtime) {
45
+ return /^3d/i.test((runtime ?? '').trim()) ? '3d-three' : '2d-canvas';
46
+ }
47
+ /** The example styles a kit offers. */
48
+ export function stylesFor(kit) {
49
+ return kit === '3d-three' ? ART_STYLES_3D : ART_STYLES;
50
+ }
51
+ /**
52
+ * The light gate: a project needs a title, a one-sentence pitch, at least one
53
+ * element type and a chosen art style. The model gets back exactly what is
54
+ * missing, so it asks the user instead of inventing it.
55
+ * @param request - the create arguments.
56
+ * @returns the manifest body, or the missing items.
57
+ */
58
+ export function gate(request) {
59
+ const missing = [];
60
+ const title = (request.title ?? '').trim().slice(0, 60);
61
+ const pitch = (request.pitch ?? '').trim().slice(0, 400);
62
+ const elementTypes = listOf(request.elementTypes).slice(0, 12);
63
+ const kit = requestedKit(request.runtime);
64
+ const styles = stylesFor(kit);
65
+ const artStyle = resolveArtStyle(request.artStyle, styles);
66
+ if (title === '')
67
+ missing.push('title(游戏名)');
68
+ if (pitch === '')
69
+ missing.push('pitch(一句话:玩家做什么、为什么好玩)');
70
+ if (elementTypes.length === 0)
71
+ missing.push('elementTypes(游戏里有哪几类元素,如 角色、敌人、道具)');
72
+ if (artStyle === undefined) {
73
+ missing.push(`artStyle(用户从示例里选的美术风格:${styles.map(s => `${s.id}=${s.label}`).join('、')},或 custom:描述)`);
74
+ }
75
+ if (missing.length > 0)
76
+ return { ok: false, missing };
77
+ return {
78
+ ok: true,
79
+ manifest: {
80
+ schema: 1,
81
+ title,
82
+ pitch,
83
+ elementTypes,
84
+ artStyle: artStyle,
85
+ pillars: listOf(request.pillars).slice(0, 5),
86
+ autoConfirm: true,
87
+ params: [],
88
+ palette: stylePalette(artStyle),
89
+ // 2D projects keep the pre-kit manifest shape (no runtime key).
90
+ ...(kit === '2d-canvas' ? {} : { runtime: { kit, version: 1 } }),
91
+ },
92
+ };
93
+ }
94
+ /** A style's palette; custom styles start from the pixel palette (the agent may set its own in game.json). */
95
+ export function stylePalette(styleId) {
96
+ return [...([...ART_STYLES, ...ART_STYLES_3D].find(style => style.id === styleId) ?? ART_STYLES[0]).palette];
97
+ }
98
+ const HEX = /^#[0-9a-f]{6}$/i;
99
+ /** Parse `game.json` `params[]`, dropping malformed rows and clamping each value into its range. */
100
+ export function parseParams(raw) {
101
+ if (!Array.isArray(raw))
102
+ return [];
103
+ const out = [];
104
+ for (const row of raw) {
105
+ if (row === null || typeof row !== 'object')
106
+ continue;
107
+ const r = row;
108
+ const key = typeof r.key === 'string' ? r.key.trim() : '';
109
+ const min = Number(r.min), max = Number(r.max);
110
+ if (key === '' || !Number.isFinite(min) || !Number.isFinite(max) || max <= min || out.some(p => p.key === key))
111
+ continue;
112
+ const step = Number(r.step) > 0 ? Number(r.step) : (max - min) / 100;
113
+ const value = Number.isFinite(Number(r.value)) ? Math.min(max, Math.max(min, Number(r.value))) : min;
114
+ out.push({
115
+ key, label: typeof r.label === 'string' && r.label.trim() !== '' ? r.label.trim() : key, min, max, step, value,
116
+ ...(typeof r.unit === 'string' && r.unit !== '' ? { unit: r.unit } : {}),
117
+ });
118
+ }
119
+ return out.slice(0, 24);
120
+ }
121
+ /** What a usable pixel look needs, worded for the model. */
122
+ export const PIXEL_LOOK_HINT = 'look 不可用:需要 palette(#rrggbb 数组)和 states(状态名 → 像素行数组)';
123
+ /**
124
+ * Normalize an element's `look`: a palette of `#rrggbb` colors and pixel
125
+ * rows per state, every row padded/cut to the widest row, characters outside
126
+ * the palette turned transparent. Anything unusable is null.
127
+ */
128
+ export function normalizeLook(raw) {
129
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
130
+ return null;
131
+ const r = raw;
132
+ const palette = Array.isArray(r.palette) ? r.palette.filter((c) => typeof c === 'string' && HEX.test(c)).slice(0, LOOK_CHARS.length) : [];
133
+ if (palette.length === 0 || r.states === null || typeof r.states !== 'object')
134
+ return null;
135
+ const states = {};
136
+ for (const [name, rowsRaw] of Object.entries(r.states).slice(0, 8)) {
137
+ if (!Array.isArray(rowsRaw))
138
+ continue;
139
+ const rows = rowsRaw.filter((row) => typeof row === 'string').slice(0, 64);
140
+ if (rows.length === 0)
141
+ continue;
142
+ const width = Math.min(64, Math.max(...rows.map(row => row.length)));
143
+ states[name] = rows.map(row => [...row.padEnd(width, '.').slice(0, width)].map(ch => {
144
+ const index = LOOK_CHARS.indexOf(ch.toLowerCase());
145
+ return index >= 0 && index < palette.length ? ch.toLowerCase() : '.';
146
+ }).join(''));
147
+ }
148
+ return Object.keys(states).length === 0 ? null : { palette, states };
149
+ }
150
+ /** The look's grid size (first state). */
151
+ export function lookSize(look) {
152
+ const rows = Object.values(look.states)[0] ?? [];
153
+ return { w: rows[0]?.length ?? 0, h: rows.length };
154
+ }
155
+ /**
156
+ * Parse `game.json`. Unknown keys are dropped; a missing optional key takes
157
+ * its default, so a hand-edited manifest still loads.
158
+ * @param text - the file's contents.
159
+ * @returns the manifest, or a Chinese reason.
160
+ */
161
+ export function parseManifest(text) {
162
+ let raw;
163
+ try {
164
+ raw = JSON.parse(text);
165
+ }
166
+ catch (error) {
167
+ return { ok: false, error: `${MANIFEST_FILE} 不是合法 JSON:${error.message}` };
168
+ }
169
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
170
+ return { ok: false, error: `${MANIFEST_FILE} 应是一个对象` };
171
+ const r = raw;
172
+ const str = (v) => (typeof v === 'string' ? v : '');
173
+ const id = str(r.id);
174
+ if (id === '')
175
+ return { ok: false, error: `${MANIFEST_FILE} 缺少 id` };
176
+ return {
177
+ ok: true,
178
+ manifest: {
179
+ schema: 1,
180
+ id,
181
+ title: str(r.title) || id,
182
+ pitch: str(r.pitch),
183
+ elementTypes: listOf(Array.isArray(r.elementTypes) ? r.elementTypes.filter((x) => typeof x === 'string') : str(r.elementTypes)),
184
+ artStyle: str(r.artStyle),
185
+ pillars: listOf(Array.isArray(r.pillars) ? r.pillars.filter((x) => typeof x === 'string') : str(r.pillars)),
186
+ autoConfirm: r.autoConfirm !== false,
187
+ params: parseParams(r.params),
188
+ palette: Array.isArray(r.palette) && r.palette.some(c => typeof c === 'string' && HEX.test(c))
189
+ ? r.palette.filter((c) => typeof c === 'string' && HEX.test(c))
190
+ : stylePalette(str(r.artStyle)),
191
+ createdAt: str(r.createdAt),
192
+ ...(runtimeOf(r.runtime) ?? {}),
193
+ ...(style3dOf(r.style3d) ?? {}),
194
+ },
195
+ };
196
+ }
197
+ /** The manifest's `style3d`, keeping valid colors and clamped numbers; undefined when nothing usable. */
198
+ function style3dOf(raw) {
199
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
200
+ return undefined;
201
+ const r = raw;
202
+ const out = {};
203
+ if (typeof r.preset === 'string' && /^[a-z][\w-]{0,31}$/.test(r.preset))
204
+ out.preset = r.preset;
205
+ Object.assign(out, sceneStyleOf(r));
206
+ if (typeof r.outline === 'string' && HEX.test(r.outline))
207
+ out.outline = r.outline.toLowerCase();
208
+ const width = clampNum(r.outlineWidth, 0, 0.2), shades = clampNum(r.shades, 2, 5);
209
+ if (width !== undefined)
210
+ out.outlineWidth = width;
211
+ if (shades !== undefined)
212
+ out.shades = Math.round(shades);
213
+ if (typeof r.shadows === 'boolean')
214
+ out.shadows = r.shadows;
215
+ const rim = clampNum(r.rim, 0, 2);
216
+ if (rim !== undefined)
217
+ out.rim = rim;
218
+ if (typeof r.rimColor === 'string' && HEX.test(r.rimColor))
219
+ out.rimColor = r.rimColor.toLowerCase();
220
+ // The game's own scenes (any names, a bounded number): each keeps only what a scene may override.
221
+ if (r.scenes !== null && typeof r.scenes === 'object' && !Array.isArray(r.scenes)) {
222
+ const scenes = {};
223
+ for (const [name, raw] of Object.entries(r.scenes).slice(0, 32)) {
224
+ if (name.trim() === '' || name.length > 40 || raw === null || typeof raw !== 'object' || Array.isArray(raw))
225
+ continue;
226
+ scenes[name] = sceneStyleOf(raw);
227
+ }
228
+ if (Object.keys(scenes).length > 0)
229
+ out.scenes = scenes;
230
+ }
231
+ return Object.keys(out).length === 0 ? undefined : { style3d: out };
232
+ }
233
+ const clampNum = (v, min, max) => typeof v === 'number' && Number.isFinite(v) ? Math.min(max, Math.max(min, v)) : undefined;
234
+ /** The keys a scene may override (and the game-wide style shares): colors, fog, light. */
235
+ function sceneStyleOf(r) {
236
+ const out = {};
237
+ for (const key of ['bg', 'ground', 'ground2', 'water']) {
238
+ const v = r[key];
239
+ if (typeof v === 'string' && HEX.test(v))
240
+ out[key] = v.toLowerCase();
241
+ }
242
+ const light = clampNum(r.light, 0, 4);
243
+ if (light !== undefined)
244
+ out.light = light;
245
+ if (Array.isArray(r.fog) && r.fog.length === 2) {
246
+ const a = clampNum(r.fog[0], 0, 500), b = clampNum(r.fog[1], 0, 1000);
247
+ if (a !== undefined && b !== undefined && b > a)
248
+ out.fog = [a, b];
249
+ }
250
+ return out;
251
+ }
252
+ /** The manifest's `runtime` when it names a shipped kit with a positive integer version. */
253
+ function runtimeOf(raw) {
254
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
255
+ return undefined;
256
+ const r = raw;
257
+ if (!isKitId(r.kit))
258
+ return undefined;
259
+ const version = typeof r.version === 'number' && Number.isInteger(r.version) && r.version > 0 ? r.version : 1;
260
+ return { runtime: { kit: r.kit, version } };
261
+ }
262
+ /** Serialize a manifest the way the plugin writes it. */
263
+ export function serializeManifest(manifest) {
264
+ return `${JSON.stringify(manifest, null, 2)}\n`;
265
+ }
266
+ /**
267
+ * A chapter's status from its text: empty is `todo`, a `状态:定稿` (or
268
+ * `status: done`) line is `done`, anything else is a `draft`.
269
+ */
270
+ export function chapterStatus(text) {
271
+ if (text === undefined || text.trim() === '')
272
+ return 'todo';
273
+ if (/^\s*(状态[::]\s*定稿|status:\s*done)\s*$/im.test(text))
274
+ return 'done';
275
+ return 'draft';
276
+ }
277
+ /**
278
+ * The one-pager (pitch, element types, style, pillars) lives in `game.json`
279
+ * and the panel pins it; `design/pillars.md` holds only the notes about the
280
+ * pillars (why each matters, how to test it). Projects created before
281
+ * 2026-10-10 got a pillars.md that restated the one-pager: this strips those
282
+ * restated lines so the notes view never shows the card twice.
283
+ * @param text - the chapter's text.
284
+ * @param manifest - the one-pager it may restate.
285
+ * @returns the notes that are not a restatement (trimmed; '' when none).
286
+ */
287
+ export function pillarNotes(text, manifest) {
288
+ if (manifest === null)
289
+ return text.trim();
290
+ const pillars = new Set(manifest.pillars.map(p => p.trim()));
291
+ const kept = text.split('\n').filter(line => {
292
+ const l = line.trim();
293
+ if (/^#\s/.test(l) && /设计支柱|支柱说明/.test(l))
294
+ return false;
295
+ if (l.startsWith('>') && l.replace(/^>\s*/, '') === manifest.pitch.trim())
296
+ return false;
297
+ if (/^(\d+\.|[-*])\s/.test(l) && pillars.has(l.replace(/^(\d+\.|[-*])\s+/, '')))
298
+ return false;
299
+ if (l === `美术风格:${artStyleLabel(manifest.artStyle)}` || l === `元素类型:${manifest.elementTypes.join('、')}`)
300
+ return false;
301
+ if (l.startsWith('(还没定支柱'))
302
+ return false;
303
+ return true;
304
+ });
305
+ return kept.join('\n').replace(/\n{3,}/g, '\n\n').trim();
306
+ }
307
+ /** Element-file scalar values worth tabulating. */
308
+ function scalarStats(r) {
309
+ const out = {};
310
+ for (const [key, value] of Object.entries(r)) {
311
+ if (key === 'id' || key === 'type' || key === 'name' || key === 'look' || key === 'lore')
312
+ continue;
313
+ if (typeof value === 'number' || typeof value === 'boolean' || (typeof value === 'string' && value.length <= 40))
314
+ out[key] = value;
315
+ }
316
+ return out;
317
+ }
318
+ /**
319
+ * Validate the element files. A file holds one element object or an array of
320
+ * them; each needs `id`, `name` and a `type` the manifest declares, and ids
321
+ * are unique across files.
322
+ * @param files - project-relative path → contents, only `elements/*.json`.
323
+ * @param elementTypes - the manifest's declared types (empty = accept any).
324
+ * @param looks - how the project's kit normalizes a `look` (default: 2D pixel rows).
325
+ * @returns the elements that parsed, and one failure per problem.
326
+ */
327
+ export function validateElements(files, elementTypes, looks = { normalizeLook, lookHint: PIXEL_LOOK_HINT }) {
328
+ const elements = [];
329
+ const failures = [];
330
+ const seen = new Map();
331
+ for (const [file, text] of Object.entries(files).sort(([a], [b]) => a.localeCompare(b))) {
332
+ let raw;
333
+ try {
334
+ raw = JSON.parse(text);
335
+ }
336
+ catch (error) {
337
+ failures.push({ where: file, message: `不是合法 JSON:${error.message}` });
338
+ continue;
339
+ }
340
+ const items = Array.isArray(raw) ? raw : [raw];
341
+ items.forEach((item, index) => {
342
+ const at = Array.isArray(raw) ? `${file}[${index}]` : file;
343
+ if (item === null || typeof item !== 'object' || Array.isArray(item)) {
344
+ failures.push({ where: at, message: '元素应是对象' });
345
+ return;
346
+ }
347
+ const r = item;
348
+ const id = typeof r.id === 'string' ? r.id.trim() : '';
349
+ const name = typeof r.name === 'string' ? r.name.trim() : '';
350
+ const type = typeof r.type === 'string' ? r.type.trim() : '';
351
+ if (id === '')
352
+ failures.push({ where: at, message: '缺少 id' });
353
+ if (name === '')
354
+ failures.push({ where: at, message: '缺少 name' });
355
+ if (type === '')
356
+ failures.push({ where: at, message: '缺少 type' });
357
+ else if (elementTypes.length > 0 && !elementTypes.includes(type)) {
358
+ failures.push({ where: at, message: `type「${type}」不在 game.json 的 elementTypes(${elementTypes.join('、')})里` });
359
+ }
360
+ if (id !== '') {
361
+ const earlier = seen.get(id);
362
+ if (earlier !== undefined)
363
+ failures.push({ where: at, message: `id「${id}」和 ${earlier} 重复` });
364
+ else
365
+ seen.set(id, at);
366
+ }
367
+ if (r.look !== undefined && looks.normalizeLook(r.look) === null)
368
+ failures.push({ where: at, message: looks.lookHint });
369
+ if (id !== '' && name !== '' && type !== '') {
370
+ const look = looks.normalizeLook(r.look);
371
+ elements.push({
372
+ file, index: Array.isArray(raw) ? index : -1, id, type, name, stats: scalarStats(r),
373
+ look: look !== null && isPixelLook(look) ? look : null,
374
+ look3d: look !== null && !isPixelLook(look) ? look : null,
375
+ lore: typeof r.lore === 'string' ? r.lore.trim() : '',
376
+ });
377
+ }
378
+ });
379
+ }
380
+ return { elements, failures };
381
+ }
382
+ /** The generated 元素与数值 chapter: one table per element type. */
383
+ export function elementsChapter(elements, elementTypes) {
384
+ if (elements.length === 0)
385
+ return '';
386
+ const types = [...elementTypes, ...elements.map(e => e.type).filter(t => !elementTypes.includes(t))];
387
+ const lines = ['# 元素与数值', '', '(由 elements/ 里的文件自动生成,改数值请改元素文件。)', ''];
388
+ for (const type of types) {
389
+ const rows = elements.filter(e => e.type === type);
390
+ if (rows.length === 0)
391
+ continue;
392
+ const keys = [...new Set(rows.flatMap(e => Object.keys(e.stats)))].slice(0, 8);
393
+ lines.push(`## ${type}`, '', `| 名称 | id | ${keys.join(' | ')} |`.replace(/ \| \|$/, ' |'), `|${' --- |'.repeat(2 + keys.length)}`);
394
+ for (const e of rows)
395
+ lines.push(`| ${e.name} | ${e.id} | ${keys.map(k => String(e.stats[k] ?? '')).join(' | ')} |`.replace(/ \| \|$/, ' |'));
396
+ lines.push('');
397
+ }
398
+ return lines.join('\n');
399
+ }
400
+ /** The generated 元素设定 chapter: every element's 设定 by type (elements without one are left out). */
401
+ export function loreChapter(elements, elementTypes) {
402
+ const written = elements.filter(e => e.lore !== '');
403
+ if (written.length === 0)
404
+ return '';
405
+ const types = [...elementTypes, ...written.map(e => e.type).filter(t => !elementTypes.includes(t))];
406
+ const lines = ['# 元素设定', '', '(由元素文件里的设定自动汇总,在「元素」里打开一个元素就能改。)', ''];
407
+ for (const type of types) {
408
+ const rows = written.filter(e => e.type === type);
409
+ if (rows.length === 0)
410
+ continue;
411
+ lines.push(`## ${type}`, '');
412
+ for (const e of rows)
413
+ lines.push(`### ${e.name}`, '', e.lore, '');
414
+ }
415
+ return lines.join('\n');
416
+ }
417
+ /** Directories never part of a playable build. */
418
+ const NON_BUILD_DIRS = new Set([STATE_DIR, 'design', 'tests', 'node_modules', '.git']);
419
+ /**
420
+ * Whether a project-relative path belongs in a playable build: everything
421
+ * except the plugin's state, the design doc, the tests and dot-files.
422
+ */
423
+ export function isBuildFile(path) {
424
+ const segments = path.split('/');
425
+ if (segments.some(s => s.startsWith('.') && s !== '.'))
426
+ return false;
427
+ return !NON_BUILD_DIRS.has(segments[0] ?? '');
428
+ }
429
+ /** File-extension → MIME type for the sandbox loader. */
430
+ const TYPES = {
431
+ html: 'text/html', htm: 'text/html', js: 'text/javascript', mjs: 'text/javascript', css: 'text/css', json: 'application/json',
432
+ txt: 'text/plain', md: 'text/markdown', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
433
+ gif: 'image/gif', webp: 'image/webp', wav: 'audio/wav', ogg: 'audio/ogg', mp3: 'audio/mpeg', m4a: 'audio/mp4',
434
+ woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', otf: 'font/otf',
435
+ glb: 'model/gltf-binary', gltf: 'model/gltf+json',
436
+ };
437
+ /** Extensions shipped as text (everything else is base64). */
438
+ const TEXT_EXT = new Set(['html', 'htm', 'js', 'mjs', 'css', 'json', 'txt', 'md', 'svg']);
439
+ /** The extension of a path, lower-cased, without the dot. */
440
+ function extOf(path) {
441
+ const name = path.slice(path.lastIndexOf('/') + 1);
442
+ return name.includes('.') ? name.slice(name.lastIndexOf('.') + 1).toLowerCase() : '';
443
+ }
444
+ /** A file's MIME type by extension. */
445
+ export function mimeOf(path) {
446
+ return TYPES[extOf(path)] ?? 'application/octet-stream';
447
+ }
448
+ /** Whether a file ships to the sandbox as text. */
449
+ export function isTextFile(path) {
450
+ return TEXT_EXT.has(extOf(path));
451
+ }
452
+ /**
453
+ * A directory-safe project folder name from a title: ASCII letters and
454
+ * digits kept, other runs collapsed to `-`; a title with none falls back to
455
+ * `game`. The store appends `-2`, `-3` … on collision.
456
+ */
457
+ export function slugOf(title) {
458
+ const slug = title.normalize('NFKD').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40);
459
+ return slug === '' ? 'game' : slug;
460
+ }
461
+ /**
462
+ * Apply a patch to one element inside its file's text (an object, or one
463
+ * entry of an array), keeping every other key. `undefined` values delete.
464
+ * @returns the new file text, or undefined when the element is not there.
465
+ */
466
+ export function patchElementText(text, elementId, patch) {
467
+ let raw;
468
+ try {
469
+ raw = JSON.parse(text);
470
+ }
471
+ catch {
472
+ return undefined;
473
+ }
474
+ const items = Array.isArray(raw) ? raw : [raw];
475
+ const target = items.find(item => item !== null && typeof item === 'object' && item.id === elementId);
476
+ if (target === undefined)
477
+ return undefined;
478
+ for (const [key, value] of Object.entries(patch)) {
479
+ if (key === 'id')
480
+ continue;
481
+ if (value === undefined)
482
+ delete target[key];
483
+ else
484
+ target[key] = value;
485
+ }
486
+ return `${JSON.stringify(raw, null, 2)}\n`;
487
+ }
488
+ /** A look as model-facing text: palette, then each state's rows. */
489
+ export function lookText(look) {
490
+ const lines = [`palette: ${look.palette.map((c, i) => `${LOOK_CHARS[i]}=${c}`).join(' ')}`];
491
+ for (const [state, rows] of Object.entries(look.states))
492
+ lines.push(`${state}:`, ...rows.map(row => ` ${row}`));
493
+ return lines.join('\n');
494
+ }