@dzbot/dzstudio-mcp 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.
- package/LICENSE +661 -0
- package/README.md +162 -2
- package/dist/config/config.js +172 -0
- package/dist/config/config.js.map +1 -0
- package/dist/config/sessions.js +67 -0
- package/dist/config/sessions.js.map +1 -0
- package/dist/index.js +16 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/agent.js +1148 -0
- package/dist/mcp/agent.js.map +1 -0
- package/dist/mcp/server.js +263 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/protocol/client.js +134 -0
- package/dist/protocol/client.js.map +1 -0
- package/dist/protocol/session.js +837 -0
- package/dist/protocol/session.js.map +1 -0
- package/dist/protocol/transport.js +66 -0
- package/dist/protocol/transport.js.map +1 -0
- package/dist/scene/chunks.js +30 -0
- package/dist/scene/chunks.js.map +1 -0
- package/dist/scene/edits.js +183 -0
- package/dist/scene/edits.js.map +1 -0
- package/dist/scene/float32.js +167 -0
- package/dist/scene/float32.js.map +1 -0
- package/dist/scene/ids.js +43 -0
- package/dist/scene/ids.js.map +1 -0
- package/dist/scene/index.js +9 -0
- package/dist/scene/index.js.map +1 -0
- package/dist/scene/json.js +294 -0
- package/dist/scene/json.js.map +1 -0
- package/dist/scene/projectload.js +652 -0
- package/dist/scene/projectload.js.map +1 -0
- package/dist/scene/scene.js +506 -0
- package/dist/scene/scene.js.map +1 -0
- package/dist/scene/srcjson.js +297 -0
- package/dist/scene/srcjson.js.map +1 -0
- package/dist/scene/textform.js +257 -0
- package/dist/scene/textform.js.map +1 -0
- package/dist/scene/types.js +35 -0
- package/dist/scene/types.js.map +1 -0
- package/dist/world/data.js +604 -0
- package/dist/world/data.js.map +1 -0
- package/dist/world/format.js +205 -0
- package/dist/world/format.js.map +1 -0
- package/dist/world/source.js +267 -0
- package/dist/world/source.js.map +1 -0
- package/dist/world/tools.js +530 -0
- package/dist/world/tools.js.map +1 -0
- package/package.json +48 -4
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening a hosted project from its files, the way dzStudio does when it loads a project from a server and no
|
|
3
|
+
* session is running yet: the project file (the store's `manifest`), then every group file in the Object Spawner
|
|
4
|
+
* format, all as one `core:load` operation. The result is the same state a dzStudio would upload as snapshot 0.
|
|
5
|
+
*
|
|
6
|
+
* Where the editor's result depends on the running game and not on the files alone — a model path (the game places
|
|
7
|
+
* the model to learn its origin) or loot that is turned on (it reads the loot files and the vanilla baseline) — the
|
|
8
|
+
* project is refused instead of opened differently.
|
|
9
|
+
*/
|
|
10
|
+
import { readFloat32, writeFloat32 } from './float32.js';
|
|
11
|
+
import { PROJECT_ID } from './ids.js';
|
|
12
|
+
import { writeString } from './json.js';
|
|
13
|
+
import { parseSrc, SrcUnsupported } from './srcjson.js';
|
|
14
|
+
export const PROJECT_FILE = 'projekt.dzstudio.json';
|
|
15
|
+
const PROJECT_FILE_VERSION = 1;
|
|
16
|
+
const PALETTE = ['#60A5FA', '#FB923C', '#2DD4BF', '#F472B6', '#A3E635', '#FDE047', '#94A3B8', '#E879F9'];
|
|
17
|
+
const KNOWN_ENTRY_KEYS = ['name', 'pos', 'ypr', 'scale', 'enableCEPersistency', 'customString'];
|
|
18
|
+
/** The project cannot be opened outside the game without ending up in a different state than dzStudio would. */
|
|
19
|
+
export class LoadRefused extends Error {
|
|
20
|
+
}
|
|
21
|
+
// ---------------------------------------------------------------- small rules of the editor
|
|
22
|
+
/** The editor lower-cases only ASCII letters. */
|
|
23
|
+
function lowerAscii(s) {
|
|
24
|
+
return s.replace(/[A-Z]/g, (c) => c.toLowerCase());
|
|
25
|
+
}
|
|
26
|
+
function compareBytes(a, b) {
|
|
27
|
+
return Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'));
|
|
28
|
+
}
|
|
29
|
+
function comparePaths(a, b) {
|
|
30
|
+
const la = lowerAscii(a);
|
|
31
|
+
const lb = lowerAscii(b);
|
|
32
|
+
if (la !== lb)
|
|
33
|
+
return compareBytes(la, lb);
|
|
34
|
+
return compareBytes(a, b);
|
|
35
|
+
}
|
|
36
|
+
function isNeverGroup(rel) {
|
|
37
|
+
const low = lowerAscii(rel);
|
|
38
|
+
const leaf = low.slice(low.lastIndexOf('/') + 1);
|
|
39
|
+
if (low.startsWith('vanilla/'))
|
|
40
|
+
return true;
|
|
41
|
+
return ['projekt.dzstudio.json', 'cfggameplay.json', 'mapgroupproto.xml', 'mapgrouppos.xml', 'cfglimitsdefinition.xml'].includes(leaf);
|
|
42
|
+
}
|
|
43
|
+
function invalidRel(rel) {
|
|
44
|
+
if (rel === '' || rel.includes(':') || rel[0] === '/' || rel[0] === '\\' || rel.includes('..'))
|
|
45
|
+
return true;
|
|
46
|
+
return isNeverGroup(rel);
|
|
47
|
+
}
|
|
48
|
+
/** Whether opening needs this file of the store: the project file and every file that can become a group. */
|
|
49
|
+
export function neededForLoad(name) {
|
|
50
|
+
return name === 'manifest' || name === PROJECT_FILE || (!invalidRel(name) && handlesFile(name));
|
|
51
|
+
}
|
|
52
|
+
function handlesFile(name) {
|
|
53
|
+
const low = lowerAscii(name);
|
|
54
|
+
return low.length > 5 && low.endsWith('.json');
|
|
55
|
+
}
|
|
56
|
+
function leaf(rel) {
|
|
57
|
+
let l = rel.slice(rel.lastIndexOf('/') + 1);
|
|
58
|
+
const dot = l.lastIndexOf('.');
|
|
59
|
+
if (dot > 0)
|
|
60
|
+
l = l.slice(0, dot);
|
|
61
|
+
return l;
|
|
62
|
+
}
|
|
63
|
+
function validColor(c) {
|
|
64
|
+
return /^#[0-9a-fA-F]{6}$/.test(c);
|
|
65
|
+
}
|
|
66
|
+
function nextColor(used) {
|
|
67
|
+
const upper = used.map((u) => u.toUpperCase());
|
|
68
|
+
return PALETTE.find((c) => !upper.includes(c)) ?? PALETTE[used.length % 8];
|
|
69
|
+
}
|
|
70
|
+
function isProjectId(t) {
|
|
71
|
+
return /^[0-9a-f]{16}$/.test(t);
|
|
72
|
+
}
|
|
73
|
+
/** The game's `ToInt`: the leading digits; refused where it would overflow. */
|
|
74
|
+
function toInt(text) {
|
|
75
|
+
const m = /^-?[0-9]+/.exec(text);
|
|
76
|
+
if (!m)
|
|
77
|
+
return 0;
|
|
78
|
+
const n = Number(m[0]);
|
|
79
|
+
if (Math.abs(n) > 2147483647)
|
|
80
|
+
throw new LoadRefused(`the number ${text} in the project file is too large for the editor`);
|
|
81
|
+
return n;
|
|
82
|
+
}
|
|
83
|
+
/** float32 of a number text; undefined where the editor finds no finite float32. */
|
|
84
|
+
function f32(text) {
|
|
85
|
+
try {
|
|
86
|
+
return readFloat32(text);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function vecText(a, b, c) {
|
|
93
|
+
return `${writeFloat32(a)} ${writeFloat32(b)} ${writeFloat32(c)}`;
|
|
94
|
+
}
|
|
95
|
+
function jsonList(items) {
|
|
96
|
+
return `[${items.join(',')}]`;
|
|
97
|
+
}
|
|
98
|
+
/** The editor's `Near`: |a − b| ≤ 0.001, computed in float32. */
|
|
99
|
+
function near(a, b) {
|
|
100
|
+
return Math.fround(Math.abs(Math.fround(a - b))) <= Math.fround(0.001);
|
|
101
|
+
}
|
|
102
|
+
function bool(n) {
|
|
103
|
+
return n?.type === 'bool' ? n.bool : undefined;
|
|
104
|
+
}
|
|
105
|
+
/** Entries of a plugin data block: `[name, key as source text, value as source text]`, the last of duplicates. */
|
|
106
|
+
function pluginEntries(doc, p) {
|
|
107
|
+
const list = [];
|
|
108
|
+
if (p?.type === 'obj') {
|
|
109
|
+
p.keys.forEach((k, i) => {
|
|
110
|
+
if (p.countKey(k) > 1 && p.get(k) !== p.items[i])
|
|
111
|
+
return;
|
|
112
|
+
const v = p.items[i];
|
|
113
|
+
list.push(jsonList([writeString(k), writeString(doc.slice(v.keyStart, v.keyEnd)), writeString(doc.raw(v))]));
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
return jsonList(list);
|
|
117
|
+
}
|
|
118
|
+
function emptyPF() {
|
|
119
|
+
return {
|
|
120
|
+
hasProject: false,
|
|
121
|
+
groups: [],
|
|
122
|
+
lootEnabled: false,
|
|
123
|
+
lootHidden: false,
|
|
124
|
+
lootLocked: false,
|
|
125
|
+
writtenTypes: [],
|
|
126
|
+
writtenOrig: [],
|
|
127
|
+
projectId: '',
|
|
128
|
+
plugins: '[]',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function readObjectEntry(o) {
|
|
132
|
+
if (o.type !== 'obj')
|
|
133
|
+
return undefined;
|
|
134
|
+
const idx = o.get('index');
|
|
135
|
+
const nm = o.get('name');
|
|
136
|
+
const pos = o.get('pos');
|
|
137
|
+
if (idx?.type !== 'num' || idx.str.includes('.') || idx.str.includes('-') || nm?.type !== 'str' || pos?.type !== 'arr')
|
|
138
|
+
return undefined;
|
|
139
|
+
if (pos.items.length !== 3 || pos.items.some((i) => i.type !== 'num'))
|
|
140
|
+
return undefined;
|
|
141
|
+
const [x, y, z] = pos.items.map((i) => f32(i.str));
|
|
142
|
+
if (x === undefined || y === undefined || z === undefined)
|
|
143
|
+
return undefined;
|
|
144
|
+
return {
|
|
145
|
+
index: toInt(idx.str),
|
|
146
|
+
name: nm.str,
|
|
147
|
+
x,
|
|
148
|
+
y,
|
|
149
|
+
z,
|
|
150
|
+
hidden: bool(o.get('hidden')) ?? false,
|
|
151
|
+
locked: bool(o.get('locked')) ?? false,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function readProjectPart(doc, proj, notes) {
|
|
155
|
+
const d = emptyPF();
|
|
156
|
+
d.hasProject = true;
|
|
157
|
+
const groups = proj.get('groups');
|
|
158
|
+
if (groups?.type === 'arr') {
|
|
159
|
+
for (const g of groups.items) {
|
|
160
|
+
if (g.type !== 'obj')
|
|
161
|
+
continue;
|
|
162
|
+
const f = g.get('file');
|
|
163
|
+
const n = g.get('name');
|
|
164
|
+
const c = g.get('color');
|
|
165
|
+
const oc = g.get('objectCount');
|
|
166
|
+
const objs = g.get('objects');
|
|
167
|
+
const pg = {
|
|
168
|
+
file: f?.type === 'str' ? f.str : '',
|
|
169
|
+
name: n?.type === 'str' ? n.str : '',
|
|
170
|
+
color: c?.type === 'str' && validColor(c.str) ? c.str.toUpperCase() : '',
|
|
171
|
+
hidden: bool(g.get('hidden')) ?? false,
|
|
172
|
+
locked: bool(g.get('locked')) ?? false,
|
|
173
|
+
objectCount: -1,
|
|
174
|
+
objects: [],
|
|
175
|
+
plugins: pluginEntries(doc, g.get('plugins')),
|
|
176
|
+
rawEntry: doc.raw(g),
|
|
177
|
+
};
|
|
178
|
+
if (oc?.type === 'num' && !/[.\-e]/.test(oc.str) && oc.str.length < 10)
|
|
179
|
+
pg.objectCount = toInt(oc.str);
|
|
180
|
+
if (objs?.type === 'arr') {
|
|
181
|
+
for (const o of objs.items) {
|
|
182
|
+
const po = readObjectEntry(o);
|
|
183
|
+
if (po)
|
|
184
|
+
pg.objects.push(po);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
d.groups.push(pg);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else
|
|
191
|
+
notes.push('project.groups is missing or not a list');
|
|
192
|
+
const loot = proj.get('loot');
|
|
193
|
+
if (loot?.type === 'obj') {
|
|
194
|
+
d.lootEnabled = bool(loot.get('enabled')) ?? false;
|
|
195
|
+
d.lootHidden = bool(loot.get('hidden')) ?? false;
|
|
196
|
+
d.lootLocked = bool(loot.get('locked')) ?? false;
|
|
197
|
+
const pid = loot.get('projectId');
|
|
198
|
+
if (pid?.type === 'str' && isProjectId(pid.str))
|
|
199
|
+
d.projectId = pid.str;
|
|
200
|
+
const wt = loot.get('writtenTypes');
|
|
201
|
+
if (wt?.type === 'arr') {
|
|
202
|
+
const lows = [];
|
|
203
|
+
for (const t of wt.items) {
|
|
204
|
+
const tn = t.type === 'obj' ? t.get('name') : undefined;
|
|
205
|
+
const to = t.type === 'obj' ? t.get('original') : undefined;
|
|
206
|
+
const okName = tn?.type === 'str' && tn.str !== '';
|
|
207
|
+
const okOrig = !to || to.type === 'null' || to.type === 'str';
|
|
208
|
+
if (!okName || !okOrig)
|
|
209
|
+
continue;
|
|
210
|
+
const lt = lowerAscii(tn.str);
|
|
211
|
+
if (lows.includes(lt))
|
|
212
|
+
continue;
|
|
213
|
+
lows.push(lt);
|
|
214
|
+
d.writtenTypes.push(tn.str);
|
|
215
|
+
d.writtenOrig.push(to?.type === 'str' ? to.str : '');
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
d.plugins = pluginEntries(doc, proj.get('plugins'));
|
|
220
|
+
return d;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* The project file as the editor reads it after loading from a server: the store keeps only format, version and
|
|
224
|
+
* project, the editor puts the project part into a project file of its own and reads that.
|
|
225
|
+
*/
|
|
226
|
+
function readProjectFile(text, notes) {
|
|
227
|
+
if (text === undefined) {
|
|
228
|
+
notes.push('Project file was missing — every group file becomes a group');
|
|
229
|
+
return emptyPF();
|
|
230
|
+
}
|
|
231
|
+
const doc = parseSrc(text);
|
|
232
|
+
const root = doc.root;
|
|
233
|
+
const proj = root?.type === 'obj' ? root.get('project') : undefined;
|
|
234
|
+
if (proj?.type === 'obj') {
|
|
235
|
+
const reparsed = parseSrc(doc.raw(proj));
|
|
236
|
+
return readProjectPart(reparsed, reparsed.root, notes);
|
|
237
|
+
}
|
|
238
|
+
if (root?.type === 'obj') {
|
|
239
|
+
const fmt = root.get('format');
|
|
240
|
+
const ver = root.get('version');
|
|
241
|
+
if (fmt?.type === 'str' && fmt.str === 'dzstudio-project' && ver?.type === 'num' && !/[.e-]/.test(ver.str) && toInt(ver.str) >= 1) {
|
|
242
|
+
if (ver.str.length > 6 || toInt(ver.str) > PROJECT_FILE_VERSION)
|
|
243
|
+
throw new LoadRefused('The project file comes from a newer version of dzStudio');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
notes.push('Project file was missing or unreadable — every group file becomes a group');
|
|
247
|
+
return emptyPF();
|
|
248
|
+
}
|
|
249
|
+
class Unreadable extends Error {
|
|
250
|
+
}
|
|
251
|
+
function intOutOfRange(num) {
|
|
252
|
+
if (/[.eE]/.test(num))
|
|
253
|
+
return false;
|
|
254
|
+
const d = num.startsWith('-') ? num.slice(1) : num;
|
|
255
|
+
if (d.length > 10)
|
|
256
|
+
return true;
|
|
257
|
+
if (d.length < 10)
|
|
258
|
+
return false;
|
|
259
|
+
return d > '2147483647';
|
|
260
|
+
}
|
|
261
|
+
function badInts(n) {
|
|
262
|
+
if (!n)
|
|
263
|
+
return false;
|
|
264
|
+
if (n.type === 'num')
|
|
265
|
+
return intOutOfRange(n.str);
|
|
266
|
+
if (n.type === 'arr')
|
|
267
|
+
return n.items.some((it) => it.type === 'num' && intOutOfRange(it.str));
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
class SpawnerReader {
|
|
271
|
+
classKnown;
|
|
272
|
+
warns = [];
|
|
273
|
+
constructor(classKnown) {
|
|
274
|
+
this.classKnown = classKnown;
|
|
275
|
+
}
|
|
276
|
+
warn(de, en) {
|
|
277
|
+
this.warns.push(`${de}\n${en}`);
|
|
278
|
+
}
|
|
279
|
+
number(n, fallback, field) {
|
|
280
|
+
const f = f32(n.str);
|
|
281
|
+
if (f === undefined) {
|
|
282
|
+
const fb = writeFloat32(fallback);
|
|
283
|
+
this.warn(`${field}: ${n.str} ist keine endliche float32-Zahl — ersetzt durch ${fb}`, `${field}: ${n.str} is not a finite float32 number — replaced by ${fb}`);
|
|
284
|
+
return fallback;
|
|
285
|
+
}
|
|
286
|
+
return f;
|
|
287
|
+
}
|
|
288
|
+
vec3(n, field) {
|
|
289
|
+
if (!n)
|
|
290
|
+
return [0, 0, 0];
|
|
291
|
+
if (n.type === 'null') {
|
|
292
|
+
this.warn(`${field}: null durch den Standardwert ersetzt`, `${field}: null replaced by the default`);
|
|
293
|
+
return [0, 0, 0];
|
|
294
|
+
}
|
|
295
|
+
if (n.type !== 'arr' || n.items.length !== 3 || n.items.some((i) => i.type !== 'num'))
|
|
296
|
+
return undefined;
|
|
297
|
+
return [this.number(n.items[0], 0, field), this.number(n.items[1], 0, field), this.number(n.items[2], 0, field)];
|
|
298
|
+
}
|
|
299
|
+
entry(doc, e, index) {
|
|
300
|
+
const no = index + 1;
|
|
301
|
+
for (const kk of KNOWN_ENTRY_KEYS)
|
|
302
|
+
if (badInts(e.get(kk)))
|
|
303
|
+
throw new Unreadable(`integer outside 32 bits in entry ${no}`);
|
|
304
|
+
let name = '';
|
|
305
|
+
const nn = e.get('name');
|
|
306
|
+
if (nn) {
|
|
307
|
+
if (nn.type === 'str')
|
|
308
|
+
name = nn.str;
|
|
309
|
+
else if (nn.type === 'null')
|
|
310
|
+
this.warn('name: null durch den Standardwert ersetzt', 'name: null replaced by the default');
|
|
311
|
+
else
|
|
312
|
+
throw new Unreadable(`"name" is not text in entry ${no}`);
|
|
313
|
+
}
|
|
314
|
+
const pos = this.vec3(e.get('pos'), 'pos');
|
|
315
|
+
const ypr = pos ? this.vec3(e.get('ypr'), 'ypr') : undefined;
|
|
316
|
+
if (!pos || !ypr)
|
|
317
|
+
throw new Unreadable(`"pos" or "ypr" is not exactly 3 numbers in entry ${no}`);
|
|
318
|
+
let scale = 1;
|
|
319
|
+
const sn = e.get('scale');
|
|
320
|
+
if (sn) {
|
|
321
|
+
if (sn.type === 'num') {
|
|
322
|
+
scale = this.number(sn, 1, 'scale');
|
|
323
|
+
if (scale === 0)
|
|
324
|
+
scale = 1;
|
|
325
|
+
}
|
|
326
|
+
else if (sn.type === 'null')
|
|
327
|
+
this.warn('scale: null durch den Standardwert ersetzt', 'scale: null replaced by the default');
|
|
328
|
+
else
|
|
329
|
+
throw new Unreadable(`"scale" is not a number in entry ${no}`);
|
|
330
|
+
}
|
|
331
|
+
let persist = '0';
|
|
332
|
+
const pn = e.get('enableCEPersistency');
|
|
333
|
+
if (pn) {
|
|
334
|
+
if (pn.type === 'bool') {
|
|
335
|
+
if (pn.bool)
|
|
336
|
+
persist = '1';
|
|
337
|
+
const readAs = pn.bool ? 'true' : 'false';
|
|
338
|
+
this.warn(`enableCEPersistency wird als ${persist} geschrieben (gelesen: ${readAs})`, `enableCEPersistency is written as ${persist} (read: ${readAs})`);
|
|
339
|
+
}
|
|
340
|
+
else if (pn.type === 'num') {
|
|
341
|
+
const pv = f32(pn.str) ?? 0;
|
|
342
|
+
if (pv !== 0)
|
|
343
|
+
persist = '1';
|
|
344
|
+
if (pn.str !== '0' && pn.str !== '1')
|
|
345
|
+
this.warn(`enableCEPersistency wird als ${persist} geschrieben (gelesen: ${pn.str})`, `enableCEPersistency is written as ${persist} (read: ${pn.str})`);
|
|
346
|
+
}
|
|
347
|
+
else if (pn.type === 'null')
|
|
348
|
+
this.warn('enableCEPersistency: null durch den Standardwert ersetzt', 'enableCEPersistency: null replaced by the default');
|
|
349
|
+
else
|
|
350
|
+
throw new Unreadable(`"enableCEPersistency" has the wrong type in entry ${no}`);
|
|
351
|
+
}
|
|
352
|
+
let custom = '';
|
|
353
|
+
const cn = e.get('customString');
|
|
354
|
+
if (cn) {
|
|
355
|
+
if (cn.type === 'str')
|
|
356
|
+
custom = cn.str;
|
|
357
|
+
else if (cn.type === 'null')
|
|
358
|
+
this.warn('customString: null durch den Standardwert ersetzt', 'customString: null replaced by the default');
|
|
359
|
+
else
|
|
360
|
+
throw new Unreadable(`"customString" is not text in entry ${no}`);
|
|
361
|
+
}
|
|
362
|
+
const unknown = [];
|
|
363
|
+
e.keys.forEach((key, k) => {
|
|
364
|
+
if (KNOWN_ENTRY_KEYS.includes(key))
|
|
365
|
+
return;
|
|
366
|
+
if (e.countKey(key) > 1 && e.get(key) !== e.items[k])
|
|
367
|
+
return;
|
|
368
|
+
const uv = e.items[k];
|
|
369
|
+
unknown.push(jsonList([writeString(doc.slice(uv.keyStart, uv.keyEnd)), writeString(doc.raw(uv))]));
|
|
370
|
+
});
|
|
371
|
+
return this.fromValues(name, pos, ypr, scale, persist, custom, unknown);
|
|
372
|
+
}
|
|
373
|
+
fromValues(name, p, y, scale, persist, custom, unknown) {
|
|
374
|
+
let placeholder = false;
|
|
375
|
+
if (name === '')
|
|
376
|
+
placeholder = true;
|
|
377
|
+
else if (name.includes('\\') || name.includes('/'))
|
|
378
|
+
throw new LoadRefused(`It places a model by its path (${name}); where such a model stands is known only to the game. Ask a person to open it in dzStudio.`);
|
|
379
|
+
else
|
|
380
|
+
placeholder = !this.classKnown(name);
|
|
381
|
+
const posText = vecText(...p);
|
|
382
|
+
const yprText = vecText(...y);
|
|
383
|
+
const scaleText = writeFloat32(scale);
|
|
384
|
+
const fields = new Map([
|
|
385
|
+
['name', name],
|
|
386
|
+
['pos', posText],
|
|
387
|
+
['ypr', yprText],
|
|
388
|
+
['scale', scaleText],
|
|
389
|
+
['persist', persist],
|
|
390
|
+
['customString', custom],
|
|
391
|
+
]);
|
|
392
|
+
const f = jsonList([name, posText, yprText, scaleText, persist, custom].map(writeString));
|
|
393
|
+
const d = `{"f":${f},"p":${writeString(posText)},"y":${writeString(yprText)},"ph":${placeholder},` +
|
|
394
|
+
`"u":${jsonList(unknown)},"w":${jsonList(this.warns.map(writeString))}}`;
|
|
395
|
+
return { fields, comp: new Map([['spawner:read', { v: 1, d }]]), fileName: name, fileX: p[0], fileY: p[1], fileZ: p[2] };
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
function readSpawner(text, classKnown) {
|
|
399
|
+
const res = { readable: false, error: '', objects: [], groupComp: new Map() };
|
|
400
|
+
const doc = parseSrc(text);
|
|
401
|
+
const root = doc.root;
|
|
402
|
+
if (!root) {
|
|
403
|
+
res.error = `not valid JSON: ${doc.error}`;
|
|
404
|
+
return res;
|
|
405
|
+
}
|
|
406
|
+
if (root.type !== 'obj') {
|
|
407
|
+
res.error = 'the root is not an object';
|
|
408
|
+
return res;
|
|
409
|
+
}
|
|
410
|
+
const rootUnknown = [];
|
|
411
|
+
root.keys.forEach((rk, r) => {
|
|
412
|
+
if (root.countKey(rk) > 1 && root.get(rk) !== root.items[r])
|
|
413
|
+
return;
|
|
414
|
+
if (rk === 'Objects')
|
|
415
|
+
return;
|
|
416
|
+
const rv = root.items[r];
|
|
417
|
+
rootUnknown.push(jsonList([writeString(doc.slice(rv.keyStart, rv.keyEnd)), writeString(doc.raw(rv))]));
|
|
418
|
+
});
|
|
419
|
+
if (rootUnknown.length)
|
|
420
|
+
res.groupComp.set('spawner:root', { v: 1, d: jsonList(rootUnknown) });
|
|
421
|
+
const objs = root.get('Objects');
|
|
422
|
+
if (!objs || objs.type === 'null') {
|
|
423
|
+
res.readable = true;
|
|
424
|
+
return res;
|
|
425
|
+
}
|
|
426
|
+
if (objs.type !== 'arr') {
|
|
427
|
+
res.error = '"Objects" is not a list';
|
|
428
|
+
return res;
|
|
429
|
+
}
|
|
430
|
+
// A refusal counts only if the file turns out readable: the editor drops the objects of an unreadable file.
|
|
431
|
+
let refused;
|
|
432
|
+
try {
|
|
433
|
+
objs.items.forEach((e, i) => {
|
|
434
|
+
if (e.type !== 'obj')
|
|
435
|
+
throw new Unreadable(`entry ${i + 1} is not an object`);
|
|
436
|
+
try {
|
|
437
|
+
res.objects.push(new SpawnerReader(classKnown).entry(doc, e, i));
|
|
438
|
+
}
|
|
439
|
+
catch (r) {
|
|
440
|
+
if (!(r instanceof LoadRefused))
|
|
441
|
+
throw r;
|
|
442
|
+
refused ??= r;
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
catch (e) {
|
|
447
|
+
if (!(e instanceof Unreadable))
|
|
448
|
+
throw e;
|
|
449
|
+
res.objects = [];
|
|
450
|
+
res.error = e.message;
|
|
451
|
+
return res;
|
|
452
|
+
}
|
|
453
|
+
if (refused)
|
|
454
|
+
throw refused;
|
|
455
|
+
res.readable = true;
|
|
456
|
+
return res;
|
|
457
|
+
}
|
|
458
|
+
/** Hidden and locked objects of the project file, assigned to the objects read from the group file. */
|
|
459
|
+
function match(g, rr) {
|
|
460
|
+
const n = rr.objects.length;
|
|
461
|
+
const got = new Array(n).fill(undefined);
|
|
462
|
+
const same = (o, e) => o.fileName === e.name && near(o.fileX, e.x) && near(o.fileY, e.y) && near(o.fileZ, e.z);
|
|
463
|
+
const left = [];
|
|
464
|
+
for (const e of g.objects.filter((o) => o.hidden || o.locked)) {
|
|
465
|
+
if (g.objectCount === n && e.index >= 0 && e.index < n && !got[e.index] && same(rr.objects[e.index], e))
|
|
466
|
+
got[e.index] = e;
|
|
467
|
+
else
|
|
468
|
+
left.push(e);
|
|
469
|
+
}
|
|
470
|
+
let lost = 0;
|
|
471
|
+
for (const l of left) {
|
|
472
|
+
let found = -1;
|
|
473
|
+
let count = 0;
|
|
474
|
+
for (let k = 0; k < n; k++) {
|
|
475
|
+
if (got[k])
|
|
476
|
+
continue;
|
|
477
|
+
if (same(rr.objects[k], l)) {
|
|
478
|
+
found = k;
|
|
479
|
+
count++;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (count === 1)
|
|
483
|
+
got[found] = l;
|
|
484
|
+
else
|
|
485
|
+
lost++;
|
|
486
|
+
}
|
|
487
|
+
return { got, lost };
|
|
488
|
+
}
|
|
489
|
+
// ---------------------------------------------------------------- the load
|
|
490
|
+
function writtenJson(names, origs) {
|
|
491
|
+
return jsonList(names.map((n, i) => `{"name":${writeString(n)},"original":${origs[i] === '' ? 'null' : writeString(origs[i])}}`));
|
|
492
|
+
}
|
|
493
|
+
function element(id, kind, parent, after, fields, comp) {
|
|
494
|
+
return { id, kind, parent, after, hidden: false, locked: false, fields, comp };
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Builds the `core:load` of a hosted project from its files. Throws LoadRefused where the editor's result would
|
|
498
|
+
* depend on the game, or where this reader could not tell what the editor holds.
|
|
499
|
+
*/
|
|
500
|
+
export function loadProject(input) {
|
|
501
|
+
const notes = [];
|
|
502
|
+
let projectText;
|
|
503
|
+
const groupFiles = [];
|
|
504
|
+
const keys = new Set();
|
|
505
|
+
for (const [name, text] of input.files) {
|
|
506
|
+
if (name === 'manifest' || name === PROJECT_FILE) {
|
|
507
|
+
if (projectText !== undefined)
|
|
508
|
+
throw new LoadRefused('The project has two project files');
|
|
509
|
+
projectText = text;
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
if (invalidRel(name))
|
|
513
|
+
continue;
|
|
514
|
+
if (name.includes('\\'))
|
|
515
|
+
throw new LoadRefused(`The file name ${name} cannot be a path in dzStudio`);
|
|
516
|
+
const key = lowerAscii(name);
|
|
517
|
+
if (keys.has(key))
|
|
518
|
+
throw new LoadRefused(`Two files differ only in case (${name})`);
|
|
519
|
+
keys.add(key);
|
|
520
|
+
if (handlesFile(name))
|
|
521
|
+
groupFiles.push(name);
|
|
522
|
+
}
|
|
523
|
+
let pf;
|
|
524
|
+
try {
|
|
525
|
+
pf = readProjectFile(projectText, notes);
|
|
526
|
+
}
|
|
527
|
+
catch (e) {
|
|
528
|
+
if (e instanceof SrcUnsupported)
|
|
529
|
+
throw new LoadRefused(`The project file holds ${e.message}`);
|
|
530
|
+
throw e;
|
|
531
|
+
}
|
|
532
|
+
if (pf.lootEnabled)
|
|
533
|
+
throw new LoadRefused('Loot is turned on in this project; only dzStudio can open it from its files. Ask a person to open it in dzStudio.');
|
|
534
|
+
const order = [];
|
|
535
|
+
const taken = new Set();
|
|
536
|
+
if (pf.hasProject) {
|
|
537
|
+
for (const g of pf.groups) {
|
|
538
|
+
const rel = g.file.replace(/\\/g, '/');
|
|
539
|
+
if (invalidRel(rel)) {
|
|
540
|
+
notes.push(`Group entry with invalid file "${g.file}" is dropped`);
|
|
541
|
+
continue;
|
|
542
|
+
}
|
|
543
|
+
const key = lowerAscii(rel);
|
|
544
|
+
if (order.some((o) => o.entry && lowerAscii(o.entry.file.replace(/\\/g, '/')) === key)) {
|
|
545
|
+
notes.push(`Two entries name "${g.file}" — the second is dropped`);
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
let at = -1;
|
|
549
|
+
groupFiles.forEach((f, i) => {
|
|
550
|
+
if (lowerAscii(f) === key)
|
|
551
|
+
at = i;
|
|
552
|
+
});
|
|
553
|
+
if (at < 0) {
|
|
554
|
+
notes.push(`Group ${g.name || g.file}: file ${g.file} not found — removed from the project`);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
taken.add(groupFiles[at]);
|
|
558
|
+
order.push({ entry: g, file: groupFiles[at] });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
const rest = groupFiles.filter((f) => !taken.has(f)).sort(comparePaths);
|
|
562
|
+
for (const f of rest)
|
|
563
|
+
order.push({ entry: undefined, file: f });
|
|
564
|
+
const changes = [];
|
|
565
|
+
const projectFields = new Map([
|
|
566
|
+
['lootEnabled', String(pf.lootEnabled)],
|
|
567
|
+
['lootHidden', String(pf.lootHidden)],
|
|
568
|
+
['lootLocked', String(pf.lootLocked)],
|
|
569
|
+
['writtenTypes', writtenJson(pf.writtenTypes, pf.writtenOrig)],
|
|
570
|
+
['lootProjectId', pf.projectId],
|
|
571
|
+
]);
|
|
572
|
+
changes.push({
|
|
573
|
+
k: 'create',
|
|
574
|
+
el: element(PROJECT_ID, 'core:project', '', '', projectFields, new Map([['projects:plugins', { v: 1, d: pf.plugins }]])),
|
|
575
|
+
});
|
|
576
|
+
const classKnown = (name) => {
|
|
577
|
+
if (!input.classKnown)
|
|
578
|
+
throw new LoadRefused('Opening a project from its files needs the class data (to tell known classes); they are not available');
|
|
579
|
+
return input.classKnown(name);
|
|
580
|
+
};
|
|
581
|
+
const colors = [];
|
|
582
|
+
let lastGroup = '';
|
|
583
|
+
let objects = 0;
|
|
584
|
+
for (const { entry, file } of order) {
|
|
585
|
+
let rr;
|
|
586
|
+
try {
|
|
587
|
+
rr = readSpawner(input.files.get(file), classKnown);
|
|
588
|
+
}
|
|
589
|
+
catch (e) {
|
|
590
|
+
if (e instanceof SrcUnsupported)
|
|
591
|
+
throw new LoadRefused(`${file} holds ${e.message}`);
|
|
592
|
+
throw e;
|
|
593
|
+
}
|
|
594
|
+
let gname = leaf(file);
|
|
595
|
+
let color = '';
|
|
596
|
+
if (entry) {
|
|
597
|
+
if (entry.name !== '')
|
|
598
|
+
gname = entry.name;
|
|
599
|
+
color = entry.color;
|
|
600
|
+
}
|
|
601
|
+
if (color === '')
|
|
602
|
+
color = nextColor(colors);
|
|
603
|
+
colors.push(color);
|
|
604
|
+
const gf = new Map([
|
|
605
|
+
['name', gname],
|
|
606
|
+
['color', color],
|
|
607
|
+
['file', file],
|
|
608
|
+
['format', 'spawner'],
|
|
609
|
+
]);
|
|
610
|
+
const gcomp = new Map([['projects:plugins', { v: 1, d: entry ? entry.plugins : '[]' }]]);
|
|
611
|
+
if (!rr.readable) {
|
|
612
|
+
gf.set('unreadable', '1');
|
|
613
|
+
if (entry)
|
|
614
|
+
gcomp.set('projects:entry', { v: 1, d: entry.rawEntry });
|
|
615
|
+
notes.push(`Group ${gname}: file not readable: ${rr.error}`);
|
|
616
|
+
}
|
|
617
|
+
for (const [k, c] of rr.groupComp)
|
|
618
|
+
gcomp.set(k, c);
|
|
619
|
+
const gid = input.ids.next();
|
|
620
|
+
const g = element(gid, 'core:group', PROJECT_ID, lastGroup, gf, gcomp);
|
|
621
|
+
g.hidden = entry?.hidden ?? false;
|
|
622
|
+
g.locked = entry?.locked ?? false;
|
|
623
|
+
changes.push({ k: 'create', el: g });
|
|
624
|
+
lastGroup = gid;
|
|
625
|
+
if (!rr.readable)
|
|
626
|
+
continue;
|
|
627
|
+
const { got, lost } = entry ? match(entry, rr) : { got: [], lost: 0 };
|
|
628
|
+
if (lost)
|
|
629
|
+
notes.push(`Group ${gname}: ${lost} hidden or locked object(s) no longer matched the file and were lifted`);
|
|
630
|
+
let lastObject = '';
|
|
631
|
+
rr.objects.forEach((ro, i) => {
|
|
632
|
+
const id = input.ids.next();
|
|
633
|
+
const o = element(id, 'core:object', gid, lastObject, ro.fields, ro.comp);
|
|
634
|
+
o.hidden = got[i]?.hidden ?? false;
|
|
635
|
+
o.locked = got[i]?.locked ?? false;
|
|
636
|
+
changes.push({ k: 'create', el: o });
|
|
637
|
+
lastObject = id;
|
|
638
|
+
objects++;
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
const op = {
|
|
642
|
+
id: input.ids.next(),
|
|
643
|
+
type: 'core:load',
|
|
644
|
+
tv: 1,
|
|
645
|
+
author: input.author,
|
|
646
|
+
authorName: input.authorName,
|
|
647
|
+
origin: 'system',
|
|
648
|
+
changes,
|
|
649
|
+
};
|
|
650
|
+
return { op, groups: order.length, objects, notes };
|
|
651
|
+
}
|
|
652
|
+
//# sourceMappingURL=projectload.js.map
|