spoint 0.1.665 → 0.1.667
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/package.json
CHANGED
package/src/apps/AppRuntime.js
CHANGED
|
@@ -4,18 +4,6 @@ import { EventBus } from './EventBus.js'
|
|
|
4
4
|
import { createEcsEntityMap } from './EcsEntityMap.js'
|
|
5
5
|
import { mulQuat, rotVec } from '../math.js'
|
|
6
6
|
import { MSG } from '../protocol/MessageTypes.js'
|
|
7
|
-
let _existsSync = null, _resolve = null, _realpathSync = null, _sep = '/'
|
|
8
|
-
try { if (typeof process !== 'undefined' && process.versions?.node) { const fs = await import('node:fs'); const path = await import('node:path'); _existsSync = fs.existsSync; _resolve = path.resolve; _realpathSync = fs.realpathSync; _sep = path.sep } } catch {}
|
|
9
|
-
|
|
10
|
-
function containedAssetPath(filePath, rootDir) {
|
|
11
|
-
if (!_realpathSync || !rootDir) return null
|
|
12
|
-
let rootReal
|
|
13
|
-
try { rootReal = _realpathSync(rootDir) } catch { return null }
|
|
14
|
-
const prefix = rootReal.endsWith(_sep) ? rootReal : rootReal + _sep
|
|
15
|
-
let real
|
|
16
|
-
try { real = _realpathSync(filePath) } catch { return null }
|
|
17
|
-
return (real === rootReal || real.startsWith(prefix)) ? real : null
|
|
18
|
-
}
|
|
19
7
|
import { SpatialIndex } from '../spatial/Octree.js'
|
|
20
8
|
import { vecOK } from '../shared/vecGuard.js'
|
|
21
9
|
import { weaponNameToCode } from '../shared/WeaponCodes.js'
|
|
@@ -24,58 +12,7 @@ import { mixinTick } from './AppRuntimeTick.js'
|
|
|
24
12
|
import { installCustomVersion } from './CustomVersion.js'
|
|
25
13
|
import { resolveCCD } from './AppPhysics.js'
|
|
26
14
|
|
|
27
|
-
|
|
28
|
-
// several shipped apps (apps/tps-game/index.js's buffs/invuln/respawning/ammo/reloading/lastEmoteAt/
|
|
29
|
-
// fallTimers/killStreaks/powerups/playerStats, all constructed as `new Map()` in setup()) store real
|
|
30
|
-
// Map/Set instances in it. A naive `JSON.parse(JSON.stringify(appState))` -- the discipline
|
|
31
|
-
// snapshotGameState/restoreGameState already used for entity.custom, which genuinely IS meant to be
|
|
32
|
-
// plain-JSON/editor-facing -- silently downgrades any Map to `{}` (JSON.stringify(new Map()) is "{}",
|
|
33
|
-
// there is no Map wire type) and any Set to `{}` too. Both restoreGameState (in-process rollback +
|
|
34
|
-
// hot-reload, see rollback-entity-gamestate-snapshot) and WorldPersistence.js's on-disk restart-survival
|
|
35
|
-
// save/restore round-trip appState through this exact path, so EVERY app whose ctx.state holds a Map
|
|
36
|
-
// silently loses it on the next resimulate/restore -- e.g. `for (const [pid,buff] of ctx.state.buffs)`
|
|
37
|
-
// throwing "ctx.state.buffs is not iterable" the next time update() runs after a restore, since
|
|
38
|
-
// ctx.state.buffs is now a plain object, not a Map. Fixed at the root (not per-app defensive guards,
|
|
39
|
-
// which only patch the ONE field a given app happened to notice broke) via a tagged Map/Set-preserving
|
|
40
|
-
// pair, used ONLY for appState (custom stays plain JSON -- editor/wire-format contract, must not
|
|
41
|
-
// silently start carrying non-JSON-safe tags).
|
|
42
|
-
//
|
|
43
|
-
// IMPORTANT split, found live via the WorldPersistence.js on-disk path: snapshotGameState's OWN output
|
|
44
|
-
// must stay a plain-JSON-safe TAGGED shape (never a live Map/Set instance), not a fully round-tripped-
|
|
45
|
-
// back-to-Map value -- WorldPersistence.buildWorldSnapshot takes snapshotGameState's entities verbatim
|
|
46
|
-
// and hands them straight to storage.set (FSAdapter.set does its own unguarded JSON.stringify(value)
|
|
47
|
-
// with no replacer of its own), so a live Map sitting in that structure gets silently flattened to `{}`
|
|
48
|
-
// a SECOND time on the way to disk, exactly reproducing the original bug one level downstream. Tagging
|
|
49
|
-
// at snapshot time (tagAppState) and reviving only at actual restore time (untagAppState, called from
|
|
50
|
-
// restoreGameState) keeps the snapshot object plain-JSON-safe everywhere in between, correct for both
|
|
51
|
-
// the in-process rollback consumer (never itself re-serializes the snapshot) and the on-disk consumer
|
|
52
|
-
// (storage.set's plain JSON.stringify is now a no-op on an already-plain-JSON tree).
|
|
53
|
-
function tagAppState(state) {
|
|
54
|
-
if (!state) return null
|
|
55
|
-
const replacer = (key, value) => {
|
|
56
|
-
if (value instanceof Map) return { __type: 'Map', entries: [...value.entries()] }
|
|
57
|
-
if (value instanceof Set) return { __type: 'Set', values: [...value.values()] }
|
|
58
|
-
return value
|
|
59
|
-
}
|
|
60
|
-
return JSON.parse(JSON.stringify(state, replacer))
|
|
61
|
-
}
|
|
62
|
-
// Reviving on the way in tolerates EITHER a tagged plain-JSON shape (the normal case: freshly tagged by
|
|
63
|
-
// tagAppState above, or read back off disk after a real storage.get/JSON.parse round trip) OR a
|
|
64
|
-
// snapshot taken BEFORE this fix existed (on-disk, from an old process -- has plain `{}` where a Map
|
|
65
|
-
// used to be, no tag at all) -- reviving that degrades to a plain object, not a fabricated Map, which is
|
|
66
|
-
// the correct honest-degrade behavior for old data rather than a hard requirement on snapshot shape.
|
|
67
|
-
function untagAppState(state) {
|
|
68
|
-
if (!state) return null
|
|
69
|
-
const reviver = (key, value) => {
|
|
70
|
-
if (value && typeof value === 'object' && value.__type === 'Map' && Array.isArray(value.entries)) return new Map(value.entries)
|
|
71
|
-
if (value && typeof value === 'object' && value.__type === 'Set' && Array.isArray(value.values)) return new Set(value.values)
|
|
72
|
-
return value
|
|
73
|
-
}
|
|
74
|
-
// state may already be a plain-JSON tree (tagAppState's own output, or a fresh JSON.parse off disk) --
|
|
75
|
-
// stringify-then-parse-with-reviver is the simplest way to apply the reviver uniformly to every nested
|
|
76
|
-
// level without hand-walking the tree twice for the two different possible input shapes.
|
|
77
|
-
return JSON.parse(JSON.stringify(state), reviver)
|
|
78
|
-
}
|
|
15
|
+
import { containedAssetPath, tagAppState, untagAppState, _existsSync, _resolve } from './AppRuntimeState.js'
|
|
79
16
|
|
|
80
17
|
export class AppRuntime {
|
|
81
18
|
constructor(c = {}) {
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Stateless entity/app-state helpers for AppRuntime.js: sandboxed asset-path containment check and the
|
|
2
|
+
// Map/Set-preserving tag/untag pair for ctx.state (app-owned internal state that may hold live Map/Set
|
|
3
|
+
// instances -- see tagAppState's own comment for why the naive JSON round-trip snapshotGameState/
|
|
4
|
+
// restoreGameState uses for entity.custom silently drops them). Split out as AppRuntime.js's only
|
|
5
|
+
// pure-function block -- everything else in that file is an AppRuntime instance method reading `this.*`.
|
|
6
|
+
// Node fs/path handles are resolved here too (containedAssetPath needs realpathSync/sep); AppRuntime.js
|
|
7
|
+
// imports the SAME resolved handles for its own resolveAssetPath rather than re-resolving them.
|
|
8
|
+
|
|
9
|
+
let _existsSync = null, _resolve = null, _realpathSync = null, _sep = '/'
|
|
10
|
+
try { if (typeof process !== 'undefined' && process.versions?.node) { const fs = await import('node:fs'); const path = await import('node:path'); _existsSync = fs.existsSync; _resolve = path.resolve; _realpathSync = fs.realpathSync; _sep = path.sep } } catch {}
|
|
11
|
+
|
|
12
|
+
function containedAssetPath(filePath, rootDir) {
|
|
13
|
+
if (!_realpathSync || !rootDir) return null
|
|
14
|
+
let rootReal
|
|
15
|
+
try { rootReal = _realpathSync(rootDir) } catch { return null }
|
|
16
|
+
const prefix = rootReal.endsWith(_sep) ? rootReal : rootReal + _sep
|
|
17
|
+
let real
|
|
18
|
+
try { real = _realpathSync(filePath) } catch { return null }
|
|
19
|
+
return (real === rootReal || real.startsWith(prefix)) ? real : null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// tps-game-ctx-state-buffs-not-iterable: ctx.state (entity._appState) is app-owned internal state, and
|
|
23
|
+
// several shipped apps (apps/tps-game/index.js's buffs/invuln/respawning/ammo/reloading/lastEmoteAt/
|
|
24
|
+
// fallTimers/killStreaks/powerups/playerStats, all constructed as `new Map()` in setup()) store real
|
|
25
|
+
// Map/Set instances in it. A naive `JSON.parse(JSON.stringify(appState))` -- the discipline
|
|
26
|
+
// snapshotGameState/restoreGameState already used for entity.custom, which genuinely IS meant to be
|
|
27
|
+
// plain-JSON/editor-facing -- silently downgrades any Map to `{}` (JSON.stringify(new Map()) is "{}",
|
|
28
|
+
// there is no Map wire type) and any Set to `{}` too. Both restoreGameState (in-process rollback +
|
|
29
|
+
// hot-reload, see rollback-entity-gamestate-snapshot) and WorldPersistence.js's on-disk restart-survival
|
|
30
|
+
// save/restore round-trip appState through this exact path, so EVERY app whose ctx.state holds a Map
|
|
31
|
+
// silently loses it on the next resimulate/restore -- e.g. `for (const [pid,buff] of ctx.state.buffs)`
|
|
32
|
+
// throwing "ctx.state.buffs is not iterable" the next time update() runs after a restore, since
|
|
33
|
+
// ctx.state.buffs is now a plain object, not a Map. Fixed at the root (not per-app defensive guards,
|
|
34
|
+
// which only patch the ONE field a given app happened to notice broke) via a tagged Map/Set-preserving
|
|
35
|
+
// pair, used ONLY for appState (custom stays plain JSON -- editor/wire-format contract, must not
|
|
36
|
+
// silently start carrying non-JSON-safe tags).
|
|
37
|
+
//
|
|
38
|
+
// IMPORTANT split, found live via the WorldPersistence.js on-disk path: snapshotGameState's OWN output
|
|
39
|
+
// must stay a plain-JSON-safe TAGGED shape (never a live Map/Set instance), not a fully round-tripped-
|
|
40
|
+
// back-to-Map value -- WorldPersistence.buildWorldSnapshot takes snapshotGameState's entities verbatim
|
|
41
|
+
// and hands them straight to storage.set (FSAdapter.set does its own unguarded JSON.stringify(value)
|
|
42
|
+
// with no replacer of its own), so a live Map sitting in that structure gets silently flattened to `{}`
|
|
43
|
+
// a SECOND time on the way to disk, exactly reproducing the original bug one level downstream. Tagging
|
|
44
|
+
// at snapshot time (tagAppState) and reviving only at actual restore time (untagAppState, called from
|
|
45
|
+
// restoreGameState) keeps the snapshot object plain-JSON-safe everywhere in between, correct for both
|
|
46
|
+
// the in-process rollback consumer (never itself re-serializes the snapshot) and the on-disk consumer
|
|
47
|
+
// (storage.set's plain JSON.stringify is now a no-op on an already-plain-JSON tree).
|
|
48
|
+
function tagAppState(state) {
|
|
49
|
+
if (!state) return null
|
|
50
|
+
const replacer = (key, value) => {
|
|
51
|
+
if (value instanceof Map) return { __type: 'Map', entries: [...value.entries()] }
|
|
52
|
+
if (value instanceof Set) return { __type: 'Set', values: [...value.values()] }
|
|
53
|
+
return value
|
|
54
|
+
}
|
|
55
|
+
return JSON.parse(JSON.stringify(state, replacer))
|
|
56
|
+
}
|
|
57
|
+
// Reviving on the way in tolerates EITHER a tagged plain-JSON shape (the normal case: freshly tagged by
|
|
58
|
+
// tagAppState above, or read back off disk after a real storage.get/JSON.parse round trip) OR a
|
|
59
|
+
// snapshot taken BEFORE this fix existed (on-disk, from an old process -- has plain `{}` where a Map
|
|
60
|
+
// used to be, no tag at all) -- reviving that degrades to a plain object, not a fabricated Map, which is
|
|
61
|
+
// the correct honest-degrade behavior for old data rather than a hard requirement on snapshot shape.
|
|
62
|
+
function untagAppState(state) {
|
|
63
|
+
if (!state) return null
|
|
64
|
+
const reviver = (key, value) => {
|
|
65
|
+
if (value && typeof value === 'object' && value.__type === 'Map' && Array.isArray(value.entries)) return new Map(value.entries)
|
|
66
|
+
if (value && typeof value === 'object' && value.__type === 'Set' && Array.isArray(value.values)) return new Set(value.values)
|
|
67
|
+
return value
|
|
68
|
+
}
|
|
69
|
+
// state may already be a plain-JSON tree (tagAppState's own output, or a fresh JSON.parse off disk) --
|
|
70
|
+
// stringify-then-parse-with-reviver is the simplest way to apply the reviver uniformly to every nested
|
|
71
|
+
// level without hand-walking the tree twice for the two different possible input shapes.
|
|
72
|
+
return JSON.parse(JSON.stringify(state), reviver)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { containedAssetPath, tagAppState, untagAppState, _existsSync, _resolve, _realpathSync, _sep }
|
|
@@ -4,6 +4,7 @@ import { BIOME_PRESETS } from '../terrain/BiomeOverride.js'
|
|
|
4
4
|
import { createGrassDecal } from '../terrain/GrassDecal.js'
|
|
5
5
|
import { createEditOpLog } from './EditOpLog.js'
|
|
6
6
|
import { createAgentEditServer } from './AgentEditServer.js'
|
|
7
|
+
import { TEXT_EXTS, isTextFile, sanitizeFsError, WORLD_CONFIG_KEYS, serializeEntity, serializeWorld, serializeWorldSource } from './EditorHandlersSerialize.js'
|
|
7
8
|
|
|
8
9
|
const isNode = typeof process !== 'undefined' && process.versions?.node
|
|
9
10
|
let _fs = null, _path = null, _bakeMinimapIfMissing = null
|
|
@@ -46,25 +47,8 @@ const statSync = _fs?.statSync, mkdirSync = _fs?.mkdirSync
|
|
|
46
47
|
const realpathSync = _fs?.realpathSync
|
|
47
48
|
const unlinkSync = _fs?.unlinkSync, renameSync = _fs?.renameSync, rmSync = _fs?.rmSync
|
|
48
49
|
|
|
49
|
-
// Extensions read/written as text by the fs-browse panel; anything else is reported binary (size-only, no decode).
|
|
50
|
-
const TEXT_EXTS = new Set(['.js', '.mjs', '.json', '.md', '.txt', '.css', '.html', '.yml', '.yaml', '.svg'])
|
|
51
|
-
function isTextFile(name) {
|
|
52
|
-
const i = name.lastIndexOf('.')
|
|
53
|
-
return i >= 0 && TEXT_EXTS.has(name.slice(i).toLowerCase())
|
|
54
|
-
}
|
|
55
50
|
const resolvePath = _path?.resolve || (() => ''), joinPath = _path?.join || (() => ''), dirnamePath = _path?.dirname || (() => ''), pathSep = _path?.sep || '/'
|
|
56
51
|
|
|
57
|
-
// Node's raw fs error messages embed the server's ABSOLUTE filesystem path (e.g. "ENOENT: ...,
|
|
58
|
-
// mkdir 'C:\dev\spoint\apps\...'" or a null-byte TypeError quoting the full resolved path) -- sending
|
|
59
|
-
// that verbatim to an editor client leaks server directory layout to whatever authored the request.
|
|
60
|
-
// Found live via an adversarial VERIFY-phase sweep (null-byte path, overlong-path ENOENT) against the
|
|
61
|
-
// real server: both errors round-tripped with the absolute apps-root path intact. Strip it down to the
|
|
62
|
-
// operation-relevant leaf (Node error CODE + the client-relative path already known from payload.path)
|
|
63
|
-
// so the client still gets an actionable reason without the server's real directory structure.
|
|
64
|
-
function sanitizeFsError(e, clientRelativePath) {
|
|
65
|
-
const code = e && e.code ? e.code : (e && e.name) || 'ERROR'
|
|
66
|
-
return `${code}: operation failed on '${clientRelativePath}'`
|
|
67
|
-
}
|
|
68
52
|
|
|
69
53
|
// realpath-based containment check: a symlink inside rootDir could otherwise point outside it and let an editor client read/write arbitrary server files
|
|
70
54
|
function containedReal(filePath, rootDir) {
|
|
@@ -117,43 +101,6 @@ function containedRealCreateParent(filePath, rootDir) {
|
|
|
117
101
|
return joinPath(ancestorReal, ...missingSuffix, filePath.slice(dir.length + pathSep.length))
|
|
118
102
|
}
|
|
119
103
|
|
|
120
|
-
const WORLD_CONFIG_KEYS = ['port', 'tickRate', 'entityTickRate', 'gravity', 'relevanceRadius', 'physicsRadius', 'physicsBodyBudget', 'movement', 'player', 'scene', 'camera', 'animation', 'input', 'spawnPoint', 'spawnPoints', 'playerModel', 'trustedApps']
|
|
121
|
-
|
|
122
|
-
function serializeEntity(e) {
|
|
123
|
-
const out = { id: e.id }
|
|
124
|
-
if (e.model) out.model = e.model
|
|
125
|
-
out.position = [e.position[0], e.position[1], e.position[2]]
|
|
126
|
-
const r = e.rotation
|
|
127
|
-
if (r && !(r[0] === 0 && r[1] === 0 && r[2] === 0 && r[3] === 1)) out.rotation = [r[0], r[1], r[2], r[3]]
|
|
128
|
-
const s = e.scale
|
|
129
|
-
if (s && !(s[0] === 1 && s[1] === 1 && s[2] === 1)) out.scale = [s[0], s[1], s[2]]
|
|
130
|
-
if (e._appName) out.app = e._appName
|
|
131
|
-
if (e.bodyType && e.bodyType !== 'static') out.bodyType = e.bodyType
|
|
132
|
-
if (e._config) out.config = e._config
|
|
133
|
-
if (e.custom) out.custom = e.custom
|
|
134
|
-
if (e.parent) out.parent = e.parent
|
|
135
|
-
return out
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function serializeWorld(appRuntime, sourceWorldDef) {
|
|
139
|
-
const def = {}
|
|
140
|
-
const src = sourceWorldDef || {}
|
|
141
|
-
for (const k of WORLD_CONFIG_KEYS) if (src[k] !== undefined) def[k] = src[k]
|
|
142
|
-
const entities = []
|
|
143
|
-
for (const e of appRuntime.entities.values()) {
|
|
144
|
-
// Keep any entity carrying authored state: an app, a model, custom props (incl. a primitive's mesh/editorProp
|
|
145
|
-
// edits), a saved app config, OR a hierarchy parent (a reparented empty anchor). The old filter dropped an
|
|
146
|
-
// entity that had only a _config or only a parent, silently losing that authoring on save.
|
|
147
|
-
if (!e._appName && !e.model && !e.custom && !e._config && !e.parent) continue
|
|
148
|
-
entities.push(serializeEntity(e))
|
|
149
|
-
}
|
|
150
|
-
def.entities = entities
|
|
151
|
-
return def
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function serializeWorldSource(def) {
|
|
155
|
-
return JSON.stringify(def, null, 2)
|
|
156
|
-
}
|
|
157
104
|
|
|
158
105
|
// In-memory prefab registry, best-effort mirrored to data/prefabs.json when a real filesystem is available
|
|
159
106
|
// (Node server), inert (memory-only, cleared on restart) under the Worker/singleplayer runtime -- mirrors
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// Pure entity/world serialization + text-file-extension helpers for EditorHandlers.js's
|
|
2
|
+
// createEditorHandlers: no fs/path dependency, no closure state -- split out as the one genuinely
|
|
3
|
+
// self-contained block in this file (containedReal/containedRealCreateParent/prefab-persistence all
|
|
4
|
+
// depend on the Node-vs-Worker fs handle dance at this file's own top, and stay there).
|
|
5
|
+
|
|
6
|
+
const TEXT_EXTS = new Set(['.js', '.mjs', '.json', '.md', '.txt', '.css', '.html', '.yml', '.yaml', '.svg'])
|
|
7
|
+
function isTextFile(name) {
|
|
8
|
+
const i = name.lastIndexOf('.')
|
|
9
|
+
return i >= 0 && TEXT_EXTS.has(name.slice(i).toLowerCase())
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Node's raw fs error messages embed the server's ABSOLUTE filesystem path (e.g. "ENOENT: ...,
|
|
13
|
+
// mkdir 'C:\dev\spoint\apps\...'" or a null-byte TypeError quoting the full resolved path) -- sending
|
|
14
|
+
// that verbatim to an editor client leaks server directory layout to whatever authored the request.
|
|
15
|
+
// Found live via an adversarial VERIFY-phase sweep (null-byte path, overlong-path ENOENT) against the
|
|
16
|
+
// real server: both errors round-tripped with the absolute apps-root path intact. Strip it down to the
|
|
17
|
+
// operation-relevant leaf (Node error CODE + the client-relative path already known from payload.path)
|
|
18
|
+
// so the client still gets an actionable reason without the server's real directory structure.
|
|
19
|
+
function sanitizeFsError(e, clientRelativePath) {
|
|
20
|
+
const code = e && e.code ? e.code : (e && e.name) || 'ERROR'
|
|
21
|
+
return `${code}: operation failed on '${clientRelativePath}'`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const WORLD_CONFIG_KEYS = ['port', 'tickRate', 'entityTickRate', 'gravity', 'relevanceRadius', 'physicsRadius', 'physicsBodyBudget', 'movement', 'player', 'scene', 'camera', 'animation', 'input', 'spawnPoint', 'spawnPoints', 'playerModel', 'trustedApps']
|
|
25
|
+
|
|
26
|
+
function serializeEntity(e) {
|
|
27
|
+
const out = { id: e.id }
|
|
28
|
+
if (e.model) out.model = e.model
|
|
29
|
+
out.position = [e.position[0], e.position[1], e.position[2]]
|
|
30
|
+
const r = e.rotation
|
|
31
|
+
if (r && !(r[0] === 0 && r[1] === 0 && r[2] === 0 && r[3] === 1)) out.rotation = [r[0], r[1], r[2], r[3]]
|
|
32
|
+
const s = e.scale
|
|
33
|
+
if (s && !(s[0] === 1 && s[1] === 1 && s[2] === 1)) out.scale = [s[0], s[1], s[2]]
|
|
34
|
+
if (e._appName) out.app = e._appName
|
|
35
|
+
if (e.bodyType && e.bodyType !== 'static') out.bodyType = e.bodyType
|
|
36
|
+
if (e._config) out.config = e._config
|
|
37
|
+
if (e.custom) out.custom = e.custom
|
|
38
|
+
if (e.parent) out.parent = e.parent
|
|
39
|
+
return out
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function serializeWorld(appRuntime, sourceWorldDef) {
|
|
43
|
+
const def = {}
|
|
44
|
+
const src = sourceWorldDef || {}
|
|
45
|
+
for (const k of WORLD_CONFIG_KEYS) if (src[k] !== undefined) def[k] = src[k]
|
|
46
|
+
const entities = []
|
|
47
|
+
for (const e of appRuntime.entities.values()) {
|
|
48
|
+
// Keep any entity carrying authored state: an app, a model, custom props (incl. a primitive's mesh/editorProp
|
|
49
|
+
// edits), a saved app config, OR a hierarchy parent (a reparented empty anchor). The old filter dropped an
|
|
50
|
+
// entity that had only a _config or only a parent, silently losing that authoring on save.
|
|
51
|
+
if (!e._appName && !e.model && !e.custom && !e._config && !e.parent) continue
|
|
52
|
+
entities.push(serializeEntity(e))
|
|
53
|
+
}
|
|
54
|
+
def.entities = entities
|
|
55
|
+
return def
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function serializeWorldSource(def) {
|
|
59
|
+
return JSON.stringify(def, null, 2)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { TEXT_EXTS, isTextFile, sanitizeFsError, WORLD_CONFIG_KEYS, serializeEntity, serializeWorld, serializeWorldSource }
|