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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.665",
3
+ "version": "0.1.667",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -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
- // tps-game-ctx-state-buffs-not-iterable: ctx.state (entity._appState) is app-owned internal state, and
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 }