@playcraft/cli 0.1.65 → 0.1.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/client.js +4 -2
- package/dist/assets/messages.js +2 -0
- package/dist/audio-source/messages.js +35 -0
- package/dist/commands/assets.js +1 -1
- package/dist/commands/audio-source.js +109 -0
- package/dist/commands/audio.js +11 -4
- package/dist/commands/build.js +4 -2
- package/dist/commands/tools-context.js +46 -0
- package/dist/commands/tools.js +2 -0
- package/dist/model-adaptation/project.js +1 -1
- package/dist/project-skills/builtin-import.js +69 -22
- package/dist/project-skills/commands.js +11 -2
- package/dist/project-skills/lifecycle.js +1 -1
- package/dist/project-skills/local-store.js +90 -31
- package/dist/project-skills/messages.js +16 -0
- package/dist/project-skills/migrate-lock.js +166 -0
- package/dist/project-skills/remote-cache.js +36 -5
- package/dist/utils/agent-api-client.js +18 -1
- package/dist/utils/agent-generation-provenance.js +8 -2
- package/dist/utils/audio-export.js +249 -0
- package/dist/utils/audio-processor.js +22 -99
- package/dist/utils/execution-context-bridge.js +28 -0
- package/dist/workspace-runtime/batch/control.js +13 -2
- package/dist/workspace-runtime/batch/generate.js +43 -2
- package/dist/workspace-runtime/batch/prepare.js +4 -0
- package/dist/workspace-runtime/batch/skill-artifacts.js +103 -0
- package/dist/workspace-runtime/batch/source-capture.js +6 -8
- package/dist/workspace-runtime/codex/app-server.js +26 -2
- package/dist/workspace-runtime/codex/execution-context-broker.js +61 -0
- package/dist/workspace-runtime/codex/execution-context-tools.js +37 -0
- package/dist/workspace-runtime/codex/generation-provenance-broker.js +9 -6
- package/dist/workspace-runtime/codex/level-editor-tools.js +18 -0
- package/dist/workspace-runtime/codex/model-broker.js +15 -2
- package/dist/workspace-runtime/codex/scheduled-task-tools.js +1 -1
- package/dist/workspace-runtime/previews/finalize.js +1 -1
- package/dist/workspace-runtime/previews/output.js +4 -1
- package/dist/workspace-runtime/previews/publish.js +5 -2
- package/dist/workspace-runtime/previews/runtime-preview.js +38 -3
- package/dist/workspace-runtime/serve.js +6 -0
- package/dist/workspace-runtime/server/accept-operation.js +85 -13
- package/dist/workspace-runtime/server/audio-assets.js +211 -0
- package/dist/workspace-runtime/server/audio-export.js +136 -0
- package/dist/workspace-runtime/server/directory-access.js +222 -67
- package/dist/workspace-runtime/server/dispatch.js +252 -29
- package/dist/workspace-runtime/server/http.js +97 -3
- package/dist/workspace-runtime/server/operation-record.js +1 -1
- package/dist/workspace-runtime/workspaces/artifact-files.js +10 -3
- package/dist/workspace-runtime/workspaces/artifacts.js +47 -6
- package/dist/workspace-runtime/workspaces/audio-assets/artifact-package.js +104 -0
- package/dist/workspace-runtime/workspaces/audio-assets/binding-usage.js +71 -0
- package/dist/workspace-runtime/workspaces/audio-assets/build-inputs.js +76 -0
- package/dist/workspace-runtime/workspaces/audio-assets/render-worker.js +117 -0
- package/dist/workspace-runtime/workspaces/audio-assets/renderer.js +128 -0
- package/dist/workspace-runtime/workspaces/audio-assets/representations.js +73 -0
- package/dist/workspace-runtime/workspaces/audio-assets/service.js +327 -0
- package/dist/workspace-runtime/workspaces/audio-assets/storage.js +125 -0
- package/dist/workspace-runtime/workspaces/build-isolation.js +2 -0
- package/dist/workspace-runtime/workspaces/composer-context.js +40 -1
- package/dist/workspace-runtime/workspaces/empty-template.js +1 -1
- package/dist/workspace-runtime/workspaces/execution-context.js +24 -0
- package/dist/workspace-runtime/workspaces/files.js +3 -5
- package/dist/workspace-runtime/workspaces/level-editor-agent-store.js +143 -0
- package/dist/workspace-runtime/workspaces/level-editor-agent.js +410 -0
- package/dist/workspace-runtime/workspaces/level-editor-atomic.js +424 -0
- package/dist/workspace-runtime/workspaces/level-editor-extensions.js +140 -0
- package/dist/workspace-runtime/workspaces/level-editor-format.js +75 -0
- package/dist/workspace-runtime/workspaces/level-editor-history-store.js +250 -0
- package/dist/workspace-runtime/workspaces/level-editor-json5.js +132 -0
- package/dist/workspace-runtime/workspaces/level-editor-requests.js +724 -0
- package/dist/workspace-runtime/workspaces/level-editor.js +454 -0
- package/dist/workspace-runtime/workspaces/parameter-initialization-skill.js +62 -22
- package/dist/workspace-runtime/workspaces/parameter-production.js +1 -1
- package/dist/workspace-runtime/workspaces/remix-reference-skill.js +4 -3
- package/dist/workspace-runtime/workspaces/skill-artifact-loader.js +19 -5
- package/dist/workspace-runtime/workspaces/skills.js +48 -13
- package/package.json +6 -4
- package/project-template/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
- package/project-template/.claude/skills/playcraft-level-editor/SKILL.md +34 -0
- package/project-template/.claude/skills/playcraft-level-editor/references/authoring-contract.json +3475 -0
- package/project-template/.claude/skills/playcraft-level-editor/references/extensions.md +37 -0
- package/project-template/.claude/skills/playcraft-level-editor/references/preview.md +15 -0
- package/project-template/.claude/skills/playcraft-level-editor/references/protocol-v1.md +35 -0
- package/project-template/vite.config.ts +1 -1
- package/project-template-v2/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
- package/project-template-v2/.playcraft/skills.lock.json +9 -2
- package/project-template-v2/vite.config.ts +1 -1
- package/project-templates/empty/AGENTS.md +8 -4
- package/project-templates/phaser/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
- package/project-templates/phaser/vite.config.ts +1 -1
- package/project-templates/playable-template-phaser/vite.config.ts +1 -1
- package/project-templates/threejs/.claude/skills/playcraft-audio-generation/SKILL.md +74 -4
- package/project-templates/threejs/vite.config.ts +1 -1
- package/templates/preview-runtime/v1/README.md +12 -0
- package/templates/preview-runtime/v1/build-preview.mjs +17 -8
- package/templates/preview-runtime/v1/manifest.json +9 -6
- package/templates/preview-runtime/v1/release-guard.mjs +1 -1
- package/templates/preview-runtime/v1/runtime.d.mts +1 -3
- package/templates/preview-runtime/v1/runtime.d.ts +1 -3
- package/templates/preview-runtime/v1/runtime.mjs +1142 -257
- package/templates/preview-runtime/v1/types/host.d.ts +29 -0
- package/templates/preview-runtime/v1/types/index.d.ts +6 -0
- package/templates/preview-runtime/v1/types/level-editor.d.ts +123 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, lstat, open, readdir, realpath, rename, rm } from 'node:fs/promises';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { LevelEditorError, isLevelEditorJson, levelEditorTransactionRequestSchema, workspaceRelativePathSchema, } from '@playcraft/common/cloud-workspace/runtime';
|
|
6
|
+
const digest = (value) => createHash('sha256').update(value).digest('hex');
|
|
7
|
+
const MAX_RECORD_BYTES = 16 * 1024 * 1024;
|
|
8
|
+
const MAX_HISTORY_BYTES = 32 * 1024 * 1024;
|
|
9
|
+
const MAX_HISTORY_RECORDS = 128;
|
|
10
|
+
export async function readHistoryText(file) {
|
|
11
|
+
const stat = await lstat(file);
|
|
12
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > MAX_RECORD_BYTES)
|
|
13
|
+
throw new LevelEditorError('level_editor_history_state_invalid');
|
|
14
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
15
|
+
try {
|
|
16
|
+
const opened = await handle.stat();
|
|
17
|
+
if (opened.ino !== stat.ino || opened.dev !== stat.dev || opened.size !== stat.size)
|
|
18
|
+
throw new LevelEditorError('level_editor_history_state_invalid');
|
|
19
|
+
const bytes = Buffer.alloc(stat.size + 1);
|
|
20
|
+
let size = 0;
|
|
21
|
+
while (size < bytes.length) {
|
|
22
|
+
const read = await handle.read(bytes, size, bytes.length - size, null);
|
|
23
|
+
if (!read.bytesRead)
|
|
24
|
+
break;
|
|
25
|
+
size += read.bytesRead;
|
|
26
|
+
}
|
|
27
|
+
if (size !== stat.size)
|
|
28
|
+
throw new LevelEditorError('level_editor_history_state_invalid');
|
|
29
|
+
const text = bytes.subarray(0, size).toString('utf8');
|
|
30
|
+
if (Buffer.byteLength(text) !== size)
|
|
31
|
+
throw new LevelEditorError('level_editor_history_state_invalid');
|
|
32
|
+
return text;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await handle.close();
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export function assertLevelEditorHistoryScope(context) {
|
|
39
|
+
const { state } = context;
|
|
40
|
+
const operation = 'operation' in context
|
|
41
|
+
? context.operation
|
|
42
|
+
: {
|
|
43
|
+
environment: state.config.environment,
|
|
44
|
+
userId: state.config.userId,
|
|
45
|
+
generation: state.config.generation,
|
|
46
|
+
runtimeEpoch: state.runtimeEpoch,
|
|
47
|
+
workspaceId: context.workspaceId,
|
|
48
|
+
directoryName: context.directoryName,
|
|
49
|
+
};
|
|
50
|
+
if (operation.environment !== state.config.environment ||
|
|
51
|
+
operation.userId !== state.config.userId ||
|
|
52
|
+
operation.generation !== state.config.generation ||
|
|
53
|
+
operation.runtimeEpoch !== state.runtimeEpoch ||
|
|
54
|
+
operation.directoryName === 'root' ||
|
|
55
|
+
(!operation.workspaceId && state.config.workspaceMode === 'multi'))
|
|
56
|
+
throw new LevelEditorError('level_editor_history_scope_invalid');
|
|
57
|
+
}
|
|
58
|
+
export async function historyDirectory(root, context) {
|
|
59
|
+
assertLevelEditorHistoryScope(context);
|
|
60
|
+
const { state } = context;
|
|
61
|
+
const operation = 'operation' in context ? context.operation : context;
|
|
62
|
+
const requested = resolve(state.config.stateDir, 'level-editor-history');
|
|
63
|
+
const project = await realpath(root);
|
|
64
|
+
let ancestor = requested;
|
|
65
|
+
const missing = [];
|
|
66
|
+
for (;;) {
|
|
67
|
+
try {
|
|
68
|
+
ancestor = await realpath(ancestor);
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
if (error.code !== 'ENOENT')
|
|
73
|
+
throw error;
|
|
74
|
+
missing.unshift(basename(ancestor));
|
|
75
|
+
ancestor = dirname(ancestor);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const anticipated = relative(project, join(ancestor, ...missing));
|
|
79
|
+
if (!isAbsolute(anticipated) && anticipated !== '..' && !anticipated.startsWith(`..${sep}`))
|
|
80
|
+
throw new LevelEditorError('level_editor_history_state_not_private');
|
|
81
|
+
// private
|
|
82
|
+
await mkdir(requested, { recursive: true, mode: 0o700 });
|
|
83
|
+
if ((await lstat(requested)).isSymbolicLink())
|
|
84
|
+
throw new LevelEditorError('level_editor_history_state_invalid');
|
|
85
|
+
const parent = await realpath(requested);
|
|
86
|
+
const rel = relative(project, parent);
|
|
87
|
+
if (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`))
|
|
88
|
+
throw new LevelEditorError('level_editor_history_state_not_private');
|
|
89
|
+
const scope = [
|
|
90
|
+
state.config.environment,
|
|
91
|
+
state.config.userId,
|
|
92
|
+
state.config.runtimeId,
|
|
93
|
+
operation.workspaceId ?? 'local',
|
|
94
|
+
operation.directoryName ?? 'basic',
|
|
95
|
+
state.config.generation,
|
|
96
|
+
state.runtimeEpoch,
|
|
97
|
+
];
|
|
98
|
+
const directory = join(parent, digest(JSON.stringify(scope)));
|
|
99
|
+
// private
|
|
100
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
101
|
+
const stat = await lstat(directory);
|
|
102
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
103
|
+
throw new LevelEditorError('level_editor_history_state_invalid');
|
|
104
|
+
return directory;
|
|
105
|
+
}
|
|
106
|
+
/** The digest is a filename only; the exact original request remains part of the checked record. */
|
|
107
|
+
export async function readLevelEditorHistory(root, context, requestId) {
|
|
108
|
+
const file = join(await historyDirectory(root, context), `${digest(requestId)}.json`);
|
|
109
|
+
try {
|
|
110
|
+
const value = JSON.parse(await readHistoryText(file));
|
|
111
|
+
const request = levelEditorTransactionRequestSchema.parse(value.request);
|
|
112
|
+
if (request.requestId !== requestId ||
|
|
113
|
+
value.requestDigest !== digest(JSON.stringify(request)) ||
|
|
114
|
+
(value.phase !== 'prepared' && value.phase !== 'confirmed') ||
|
|
115
|
+
!isLevelEditorJson(value.before) ||
|
|
116
|
+
!isLevelEditorJson(value.after) ||
|
|
117
|
+
(request.version === 1 && value.documentId !== request.documentId) ||
|
|
118
|
+
(request.version === 2 && !value.batch) ||
|
|
119
|
+
!/^[a-f0-9]{64}$/.test(value.contentHash) ||
|
|
120
|
+
!Number.isSafeInteger(value.size) ||
|
|
121
|
+
value.size < 0 ||
|
|
122
|
+
typeof value.path !== 'string' ||
|
|
123
|
+
JSON.stringify(value.command) !==
|
|
124
|
+
JSON.stringify(request.version === 1
|
|
125
|
+
? {
|
|
126
|
+
commandId: request.commandId,
|
|
127
|
+
selection: request.selection,
|
|
128
|
+
input: request.input,
|
|
129
|
+
}
|
|
130
|
+
: request.commands[0]))
|
|
131
|
+
throw new LevelEditorError('level_editor_history_state_invalid');
|
|
132
|
+
if (value.batch) {
|
|
133
|
+
const batch = value.batch;
|
|
134
|
+
if (request.version !== 2 ||
|
|
135
|
+
JSON.stringify(batch.commands) !== JSON.stringify(request.commands) ||
|
|
136
|
+
!Array.isArray(batch.documents) ||
|
|
137
|
+
!batch.documents.length ||
|
|
138
|
+
!Array.isArray(batch.files) ||
|
|
139
|
+
batch.files.length !== batch.documents.length ||
|
|
140
|
+
!batch.baselines ||
|
|
141
|
+
typeof batch.baselines !== 'object' ||
|
|
142
|
+
Array.isArray(batch.baselines) ||
|
|
143
|
+
typeof batch.operationId !== 'string' ||
|
|
144
|
+
!batch.operationId ||
|
|
145
|
+
batch.operationId.length > 256 ||
|
|
146
|
+
JSON.stringify(Object.keys(batch.baselines).sort()) !==
|
|
147
|
+
JSON.stringify(Object.keys(request.expectedDocuments).sort()) ||
|
|
148
|
+
Object.entries(batch.baselines).some(([id, baseline]) => !baseline ||
|
|
149
|
+
!workspaceRelativePathSchema.safeParse(baseline.path).success ||
|
|
150
|
+
baseline.contentHash !== request.expectedDocuments[id]?.contentHash ||
|
|
151
|
+
baseline.revision !== request.expectedDocuments[id]?.revision) ||
|
|
152
|
+
new Set(batch.files.map((file) => file.documentId)).size !== batch.files.length ||
|
|
153
|
+
new Set(batch.documents.map((item) => item.documentId)).size !== batch.documents.length ||
|
|
154
|
+
batch.documents.some((item) => typeof item.documentId !== 'string' ||
|
|
155
|
+
!isLevelEditorJson(item.before) ||
|
|
156
|
+
!isLevelEditorJson(item.after)) ||
|
|
157
|
+
batch.files.some((file) => !workspaceRelativePathSchema.safeParse(file.path).success ||
|
|
158
|
+
typeof file.beforeBytes !== 'string' ||
|
|
159
|
+
typeof file.afterBytes !== 'string' ||
|
|
160
|
+
file.path !== batch.baselines[file.documentId]?.path ||
|
|
161
|
+
file.revision !== batch.baselines[file.documentId].revision + 1 ||
|
|
162
|
+
!Number.isSafeInteger(file.size) ||
|
|
163
|
+
file.size < 0 ||
|
|
164
|
+
!/^[a-f0-9]{64}$/.test(file.contentHash) ||
|
|
165
|
+
Buffer.from(file.beforeBytes, 'base64').toString('base64') !== file.beforeBytes ||
|
|
166
|
+
Buffer.from(file.afterBytes, 'base64').toString('base64') !== file.afterBytes ||
|
|
167
|
+
digest(Buffer.from(file.beforeBytes, 'base64').toString('utf8')) !==
|
|
168
|
+
batch.baselines[file.documentId]?.contentHash ||
|
|
169
|
+
digest(Buffer.from(file.afterBytes, 'base64').toString('utf8')) !== file.contentHash ||
|
|
170
|
+
Buffer.from(file.afterBytes, 'base64').length !== file.size ||
|
|
171
|
+
!batch.documents.some((item) => item.documentId === file.documentId)))
|
|
172
|
+
throw new LevelEditorError('level_editor_history_state_invalid');
|
|
173
|
+
}
|
|
174
|
+
return { ...value, request };
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (error.code === 'ENOENT')
|
|
178
|
+
return undefined;
|
|
179
|
+
if (error instanceof LevelEditorError)
|
|
180
|
+
throw error;
|
|
181
|
+
throw new LevelEditorError('level_editor_history_state_invalid');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
export const levelEditorHistoryRequestDigest = (request) => digest(JSON.stringify(request));
|
|
185
|
+
async function reserveHistoryCapacity(directory, destination, bytes, referenced) {
|
|
186
|
+
const records = [];
|
|
187
|
+
for (const name of await readdir(directory)) {
|
|
188
|
+
if (!/^[a-f0-9]{64}\.json$/.test(name))
|
|
189
|
+
continue;
|
|
190
|
+
const path = join(directory, name), stat = await lstat(path);
|
|
191
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > MAX_RECORD_BYTES)
|
|
192
|
+
throw new LevelEditorError('level_editor_history_state_invalid');
|
|
193
|
+
records.push({ path, size: stat.size, time: stat.mtimeMs });
|
|
194
|
+
}
|
|
195
|
+
let total = records.reduce((sum, item) => sum + (item.path === destination ? 0 : item.size), bytes);
|
|
196
|
+
let count = records.length + (records.some((item) => item.path === destination) ? 0 : 1);
|
|
197
|
+
if (count <= MAX_HISTORY_RECORDS && total <= MAX_HISTORY_BYTES)
|
|
198
|
+
return;
|
|
199
|
+
const candidates = [];
|
|
200
|
+
for (const item of records) {
|
|
201
|
+
if (item.path === destination || item.path === referenced)
|
|
202
|
+
continue;
|
|
203
|
+
const old = JSON.parse(await readHistoryText(item.path));
|
|
204
|
+
if (old.phase === 'confirmed')
|
|
205
|
+
candidates.push({ ...item, inverse: Boolean(old.request?.history) });
|
|
206
|
+
}
|
|
207
|
+
// Preserve original undo sources ahead of metadata for already-observed inverse requests.
|
|
208
|
+
for (const item of candidates.sort((left, right) => Number(right.inverse) - Number(left.inverse) || left.time - right.time)) {
|
|
209
|
+
if (count <= MAX_HISTORY_RECORDS && total <= MAX_HISTORY_BYTES)
|
|
210
|
+
break;
|
|
211
|
+
await rm(item.path);
|
|
212
|
+
total -= item.size;
|
|
213
|
+
count--;
|
|
214
|
+
}
|
|
215
|
+
if (count > MAX_HISTORY_RECORDS || total > MAX_HISTORY_BYTES)
|
|
216
|
+
throw new LevelEditorError('level_editor_history_budget');
|
|
217
|
+
}
|
|
218
|
+
/** Prepared evidence is durable before the engineering file is replaced. */
|
|
219
|
+
export async function writeLevelEditorHistory(root, context, value) {
|
|
220
|
+
const directory = await historyDirectory(root, context);
|
|
221
|
+
const text = JSON.stringify(value);
|
|
222
|
+
if (Buffer.byteLength(text) > MAX_RECORD_BYTES)
|
|
223
|
+
throw new LevelEditorError('level_editor_history_budget');
|
|
224
|
+
const temporary = join(directory, `.${randomUUID()}.tmp`), destination = join(directory, `${digest(value.request.requestId)}.json`);
|
|
225
|
+
await reserveHistoryCapacity(directory, destination, Buffer.byteLength(text), value.request.history
|
|
226
|
+
? join(directory, `${digest(value.request.history.requestId)}.json`)
|
|
227
|
+
: undefined);
|
|
228
|
+
try {
|
|
229
|
+
// private
|
|
230
|
+
const handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
231
|
+
try {
|
|
232
|
+
await handle.writeFile(text);
|
|
233
|
+
await handle.sync();
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
await handle.close();
|
|
237
|
+
}
|
|
238
|
+
await rename(temporary, destination);
|
|
239
|
+
const parent = await open(directory, constants.O_RDONLY);
|
|
240
|
+
try {
|
|
241
|
+
await parent.sync();
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
await parent.close();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
finally {
|
|
248
|
+
await rm(temporary, { force: true });
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import JSON5 from 'json5';
|
|
2
|
+
import { LevelEditorError, levelEditorCanonicalJson } from '@playcraft/common/cloud-workspace/runtime';
|
|
3
|
+
/** Keep untouched author bytes; refuse structural replacement that would discard authored comments. */
|
|
4
|
+
export function editLevelEditorJson5(source, changes, expected) {
|
|
5
|
+
JSON5.parse(source);
|
|
6
|
+
let output = source;
|
|
7
|
+
for (const change of changes) {
|
|
8
|
+
const root = tree(output);
|
|
9
|
+
let span = root;
|
|
10
|
+
let parent;
|
|
11
|
+
for (const key of change.path) {
|
|
12
|
+
parent = span;
|
|
13
|
+
span = span?.children?.get(key);
|
|
14
|
+
}
|
|
15
|
+
if (!span || change.op === 'remove') {
|
|
16
|
+
if (!parent || parent.commented)
|
|
17
|
+
throw new LevelEditorError('level_editor_comment_preservation_required');
|
|
18
|
+
const value = JSON5.parse(output.slice(parent.start, parent.end));
|
|
19
|
+
const key = change.path.at(-1);
|
|
20
|
+
if (change.op === 'remove') {
|
|
21
|
+
if (Array.isArray(value) && typeof key === 'number')
|
|
22
|
+
value.splice(key, 1);
|
|
23
|
+
else
|
|
24
|
+
delete value[key];
|
|
25
|
+
}
|
|
26
|
+
else
|
|
27
|
+
value[key] = change.value;
|
|
28
|
+
output = output.slice(0, parent.start) + JSON.stringify(value) + output.slice(parent.end);
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
if (span.commented)
|
|
32
|
+
throw new LevelEditorError('level_editor_comment_preservation_required');
|
|
33
|
+
output = output.slice(0, span.start) + JSON.stringify(change.value) + output.slice(span.end);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (levelEditorCanonicalJson(JSON5.parse(output)) !== levelEditorCanonicalJson(expected))
|
|
37
|
+
throw new LevelEditorError('level_editor_serialization_mismatch');
|
|
38
|
+
return output;
|
|
39
|
+
}
|
|
40
|
+
function tree(source) {
|
|
41
|
+
let cursor = 0;
|
|
42
|
+
let comments = 0;
|
|
43
|
+
function trivia() {
|
|
44
|
+
for (;;) {
|
|
45
|
+
while (/\s/.test(source[cursor] ?? '') && cursor < source.length)
|
|
46
|
+
cursor++;
|
|
47
|
+
if (source.slice(cursor, cursor + 2) === '//') {
|
|
48
|
+
comments++;
|
|
49
|
+
cursor = source.indexOf('\n', cursor + 2);
|
|
50
|
+
if (cursor < 0)
|
|
51
|
+
cursor = source.length;
|
|
52
|
+
}
|
|
53
|
+
else if (source.slice(cursor, cursor + 2) === '/*') {
|
|
54
|
+
comments++;
|
|
55
|
+
const end = source.indexOf('*/', cursor + 2);
|
|
56
|
+
if (end < 0)
|
|
57
|
+
throw new LevelEditorError('level_editor_invalid_json');
|
|
58
|
+
cursor = end + 2;
|
|
59
|
+
}
|
|
60
|
+
else
|
|
61
|
+
break;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function quoted() {
|
|
65
|
+
const quote = source[cursor++];
|
|
66
|
+
while (cursor < source.length) {
|
|
67
|
+
const char = source[cursor++];
|
|
68
|
+
if (char === '\\')
|
|
69
|
+
cursor++;
|
|
70
|
+
else if (char === quote)
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
throw new LevelEditorError('level_editor_invalid_json');
|
|
74
|
+
}
|
|
75
|
+
function value(depth) {
|
|
76
|
+
if (depth > 64)
|
|
77
|
+
throw new LevelEditorError('level_editor_document_budget');
|
|
78
|
+
trivia();
|
|
79
|
+
const start = cursor, before = comments;
|
|
80
|
+
const first = source[cursor];
|
|
81
|
+
let children;
|
|
82
|
+
if (first === '{' || first === '[') {
|
|
83
|
+
children = new Map();
|
|
84
|
+
cursor++;
|
|
85
|
+
trivia();
|
|
86
|
+
let index = 0;
|
|
87
|
+
const close = first === '{' ? '}' : ']';
|
|
88
|
+
while (source[cursor] !== close) {
|
|
89
|
+
if (cursor >= source.length)
|
|
90
|
+
throw new LevelEditorError('level_editor_invalid_json');
|
|
91
|
+
let key = index++;
|
|
92
|
+
if (first === '{') {
|
|
93
|
+
const keyStart = cursor;
|
|
94
|
+
if (source[cursor] === '"' || source[cursor] === "'")
|
|
95
|
+
quoted();
|
|
96
|
+
else
|
|
97
|
+
while (cursor < source.length && !/[\s:]/.test(source[cursor]))
|
|
98
|
+
cursor++;
|
|
99
|
+
key = Object.keys(JSON5.parse(`{${source.slice(keyStart, cursor)}:null}`))[0];
|
|
100
|
+
trivia();
|
|
101
|
+
if (source[cursor++] !== ':')
|
|
102
|
+
throw new LevelEditorError('level_editor_invalid_json');
|
|
103
|
+
}
|
|
104
|
+
if (children.has(key))
|
|
105
|
+
throw new LevelEditorError('level_editor_duplicate_json_member');
|
|
106
|
+
children.set(key, value(depth + 1));
|
|
107
|
+
trivia();
|
|
108
|
+
if (source[cursor] === ',') {
|
|
109
|
+
cursor++;
|
|
110
|
+
trivia();
|
|
111
|
+
}
|
|
112
|
+
else if (source[cursor] !== close)
|
|
113
|
+
throw new LevelEditorError('level_editor_invalid_json');
|
|
114
|
+
}
|
|
115
|
+
cursor++;
|
|
116
|
+
}
|
|
117
|
+
else if (first === '"' || first === "'")
|
|
118
|
+
quoted();
|
|
119
|
+
else {
|
|
120
|
+
while (cursor < source.length && !/[\s,\]}]/.test(source[cursor]) && source[cursor] !== '/')
|
|
121
|
+
cursor++;
|
|
122
|
+
}
|
|
123
|
+
if (cursor === start)
|
|
124
|
+
throw new LevelEditorError('level_editor_invalid_json');
|
|
125
|
+
return { start, end: cursor, children, commented: comments !== before };
|
|
126
|
+
}
|
|
127
|
+
const root = value(0);
|
|
128
|
+
trivia();
|
|
129
|
+
if (cursor !== source.length)
|
|
130
|
+
throw new LevelEditorError('level_editor_invalid_json');
|
|
131
|
+
return root;
|
|
132
|
+
}
|