spoint 0.1.608 → 0.1.609

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/client/app.js CHANGED
@@ -643,7 +643,9 @@ if (deviceInfo.isMobile) { mobileControls = new MobileControls({ joystickRadius:
643
643
  installQualityPresets()
644
644
  QualityPresets.autoApplyPersisted({ renderer, deviceInfo })
645
645
  const cam = createCameraController(camera, scene)
646
- cam.restore(JSON.parse(sessionStorage.getItem('cam') || 'null')); sessionStorage.removeItem('cam')
646
+ let _savedCam = null
647
+ try { _savedCam = JSON.parse(sessionStorage.getItem('cam') || 'null') } catch (e) { console.warn('[boot] discarding malformed sessionStorage.cam:', e?.message || e) }
648
+ cam.restore(_savedCam); sessionStorage.removeItem('cam')
647
649
  let xrSystem = null
648
650
  // Floating-origin: rebases the whole render-space scene graph (camera + every top-level scene
649
651
  // child) toward (0,0,0) whenever the camera drifts REBASE_THRESHOLD_M from the last rebase point, so
@@ -52,7 +52,16 @@ export const TIER_FULL = 0
52
52
  export const TIER_REDUCED = 1
53
53
  export const TIER_DOT = 2
54
54
 
55
- const _dScratch = []
55
+ let _idxScratch = new Int32Array(0)
56
+ let _d2Scratch = new Float64Array(0)
57
+ const _full = new Set(), _reduced = new Set(), _dot = new Set()
58
+ const _order = []
59
+
60
+ function ensureScratchCapacity(n) {
61
+ if (_idxScratch.length >= n) return
62
+ _idxScratch = new Int32Array(n)
63
+ _d2Scratch = new Float64Array(n)
64
+ }
56
65
 
57
66
  /**
58
67
  * Classifies every remote player id in `entries` (array of {id, x, y, z}) into FULL/REDUCED/DOT tiers
@@ -60,26 +69,31 @@ const _dScratch = []
60
69
  * THREE scene access, no side effects -- so it is directly unit-exercisable against real player position
61
70
  * arrays (see the live witness in the task's verification pass). Returns { full: Set<id>, reduced:
62
71
  * Set<id>, dot: Set<id> } plus `order` (the same ids sorted nearest-first, useful for a caller wanting a
63
- * stable "top N" without re-sorting).
72
+ * stable "top N" without re-sorting). The returned Sets/array are module-level scratch, reused every call.
64
73
  */
65
74
  export function classifyPlayerTiers(entries, viewerPos, fullCount = PLAYER_LOD_FULL_COUNT, reducedD2 = PLAYER_LOD_REDUCED_D2) {
66
75
  const vx = viewerPos.x, vy = viewerPos.y, vz = viewerPos.z
67
- _dScratch.length = 0
68
- for (let i = 0; i < entries.length; i++) {
76
+ const n = entries.length
77
+ ensureScratchCapacity(n)
78
+ for (let i = 0; i < n; i++) {
69
79
  const e = entries[i]
70
80
  const dx = e.x - vx, dy = e.y - vy, dz = e.z - vz
71
- _dScratch.push({ id: e.id, d2: dx * dx + dy * dy + dz * dz })
81
+ _idxScratch[i] = i
82
+ _d2Scratch[i] = dx * dx + dy * dy + dz * dz
72
83
  }
73
- _dScratch.sort((a, b) => a.d2 - b.d2)
74
- const full = new Set(), reduced = new Set(), dot = new Set(), order = []
75
- for (let i = 0; i < _dScratch.length; i++) {
76
- const { id, d2 } = _dScratch[i]
77
- order.push(id)
78
- if (i < fullCount) full.add(id)
79
- else if (d2 < reducedD2) reduced.add(id)
80
- else dot.add(id)
84
+ const idxView = _idxScratch.subarray(0, n)
85
+ idxView.sort((a, b) => _d2Scratch[a] - _d2Scratch[b])
86
+ _full.clear(); _reduced.clear(); _dot.clear(); _order.length = 0
87
+ for (let i = 0; i < n; i++) {
88
+ const srcIdx = idxView[i]
89
+ const id = entries[srcIdx].id
90
+ const d2 = _d2Scratch[srcIdx]
91
+ _order.push(id)
92
+ if (i < fullCount) _full.add(id)
93
+ else if (d2 < reducedD2) _reduced.add(id)
94
+ else _dot.add(id)
81
95
  }
82
- return { full, reduced, dot, order }
96
+ return { full: _full, reduced: _reduced, dot: _dot, order: _order }
83
97
  }
84
98
 
85
99
  /** Single-entity tier classification (no sort/allocation) -- for a caller that already knows a player's
@@ -202,6 +216,8 @@ export function createCrowdDotRenderer(scene, opts = {}) {
202
216
  // owns that per-frame loop (tickPlayerAnimators) and is the correct place to apply the verdict, keeping
203
217
  // this module a pure classifier + the crowd-dot renderer, not a second render-loop owner.
204
218
 
219
+ const _dotFallbackScratch = []
220
+
205
221
  export function installPlayerLOD(scene, opts = {}) {
206
222
  const dots = createCrowdDotRenderer(scene, opts)
207
223
  let lastTiers = { full: new Set(), reduced: new Set(), dot: new Set(), order: [] }
@@ -225,10 +241,12 @@ export function installPlayerLOD(scene, opts = {}) {
225
241
  if (serverDots && serverDots.length) {
226
242
  dots.update(serverDots, viewerPos.y, groundHeightFn)
227
243
  } else if (tiers.dot.size > 0) {
228
- const byId = new Map(remoteEntries.map(e => [e.id, e]))
229
- const arr = []
230
- for (const id of tiers.dot) { const e = byId.get(id); if (e) arr.push(e) }
231
- dots.update(arr, viewerPos.y, groundHeightFn)
244
+ _dotFallbackScratch.length = 0
245
+ for (let i = 0; i < remoteEntries.length; i++) {
246
+ const e = remoteEntries[i]
247
+ if (tiers.dot.has(e.id)) _dotFallbackScratch.push(e)
248
+ }
249
+ dots.update(_dotFallbackScratch, viewerPos.y, groundHeightFn)
232
250
  } else {
233
251
  dots.update(null)
234
252
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.608",
3
+ "version": "0.1.609",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -4,8 +4,18 @@ 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
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 } } catch {}
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
+ }
9
19
  import { SpatialIndex } from '../spatial/Octree.js'
10
20
  import { vecOK } from '../shared/vecGuard.js'
11
21
  import { weaponNameToCode } from '../shared/WeaponCodes.js'
@@ -142,8 +152,22 @@ export class AppRuntime {
142
152
  if (!p) return p
143
153
  // non-Node (Worker, no fs): must return an origin-absolute path or fetch() resolves against the worker script's own URL, not the page origin
144
154
  if (!_resolve) { const rel = p.startsWith('./') ? p.slice(1) : p; return rel.startsWith('/') ? rel : '/' + rel }
145
- const local = _resolve(p); if (_existsSync(local)) return local
146
- if (this._sdkRoot) { const sdk=_resolve(this._sdkRoot,p); if (_existsSync(sdk)) { console.debug(`[SDK-DEFAULT] using bundled asset: ${p}`); return sdk } }
155
+ const cwdRoot = _resolve(process.cwd())
156
+ const local = _resolve(p)
157
+ if (_existsSync(local)) {
158
+ const contained = containedAssetPath(local, cwdRoot)
159
+ if (!contained) { console.warn(`[AppRuntime] resolveAssetPath rejected '${p}' -- resolves outside the server root`); return null }
160
+ return contained
161
+ }
162
+ if (this._sdkRoot) {
163
+ const sdk = _resolve(this._sdkRoot, p)
164
+ if (_existsSync(sdk)) {
165
+ const contained = containedAssetPath(sdk, _resolve(this._sdkRoot))
166
+ if (!contained) { console.warn(`[AppRuntime] resolveAssetPath rejected '${p}' -- resolves outside the SDK root`); return null }
167
+ console.debug(`[SDK-DEFAULT] using bundled asset: ${p}`)
168
+ return contained
169
+ }
170
+ }
147
171
  return local
148
172
  }
149
173
 
@@ -69,6 +69,7 @@ export class BaseClient {
69
69
  try {
70
70
  msg = unpack(bytes)
71
71
  } catch (e) { console.error('[client] wire decode failed (corrupt message dropped):', e?.message || e); this.callbacks.onMessageError('decode', e); return }
72
+ if (msg.type === MSG.NOSTR_AUTH_CHALLENGE) { this._handleNostrAuthChallenge(msg.payload || {}); return }
72
73
  try {
73
74
  const result = this._msgHandler.handleMessage(msg.type, msg.payload || {}, this._snapProc)
74
75
  this._handleSessionTokens(msg.type, result)
@@ -77,6 +78,26 @@ export class BaseClient {
77
78
  } catch (e) { console.error('[client] message handler failed (type ' + msg?.type + '):', e?.message || e); this.callbacks.onMessageError('handler', e, msg?.type) }
78
79
  }
79
80
 
81
+ async _handleNostrAuthChallenge(payload) {
82
+ if (payload.error) { console.error('[client] nostr auth failed:', payload.error); this.callbacks.onMessageError('nostrAuth', new Error(payload.error)); return }
83
+ const challenge = payload.challenge
84
+ if (!challenge) return
85
+ try {
86
+ const NostrTools = await import('nostr-tools')
87
+ const storage = typeof localStorage !== 'undefined' ? localStorage : null
88
+ const skHex = storage?.getItem('zn_sk')
89
+ let sk = skHex ? Uint8Array.from(skHex.match(/.{2}/g).map(b => parseInt(b, 16))) : null
90
+ if (!sk) {
91
+ sk = NostrTools.generateSecretKey()
92
+ storage?.setItem('zn_sk', Array.from(sk).map(b => b.toString(16).padStart(2, '0')).join(''))
93
+ storage?.setItem('zn_pk', NostrTools.getPublicKey(sk))
94
+ }
95
+ const pubkey = NostrTools.getPublicKey(sk)
96
+ const event = NostrTools.finalizeEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), tags: [], content: challenge }, sk)
97
+ this.send(MSG.NOSTR_AUTH_RESPONSE, { pubkey, sig: event.sig, id: event.id, created_at: event.created_at, kind: event.kind, tags: event.tags })
98
+ } catch (e) { console.error('[client] nostr auth challenge response failed:', e?.message || e); this.callbacks.onMessageError('nostrAuth', e) }
99
+ }
100
+
80
101
  _handleSessionTokens(type, result) {}
81
102
 
82
103
  _onSnapshot(data, msgType) {
@@ -8,6 +8,7 @@ let _readFileSync = null
8
8
  try { if (typeof process !== 'undefined' && process.versions?.node) { const m = await import('node:fs'); _readFileSync = m.readFileSync } } catch {}
9
9
 
10
10
  function readGLBSync(filepath) {
11
+ if (!filepath) throw new Error('GLBLoader: no filepath given (resolveAssetPath rejected or returned an empty path)')
11
12
  if (!_readFileSync) throw new Error('readFileSync not available — use URL-based async methods in browser')
12
13
  const buf = _readFileSync(filepath)
13
14
  if (buf.toString('ascii', 0, 4) !== 'glTF') throw new Error('Not a GLB file')
@@ -17,6 +18,7 @@ function readGLBSync(filepath) {
17
18
  }
18
19
 
19
20
  async function readGLBAsync(pathOrUrl) {
21
+ if (!pathOrUrl) throw new Error('GLBLoader: no path given (resolveAssetPath rejected or returned an empty path)')
20
22
  if (_readFileSync && !pathOrUrl.startsWith('http') && !pathOrUrl.startsWith('/')) {
21
23
  return readGLBSync(pathOrUrl)
22
24
  }
@@ -243,6 +243,7 @@ export class PhysicsWorld {
243
243
  // In-flight dedupe (_trimeshInflight) additionally prevents two concurrent placements of the same
244
244
  // model+scale from racing two independent cook operations before either populates the cache.
245
245
  async addStaticTrimeshAsync(glbPath, meshIndex = 0, position = [0, 0, 0], scale = [1, 1, 1], rotation = [0, 0, 0, 1]) {
246
+ if (!glbPath) throw new Error('addStaticTrimeshAsync: no glbPath (resolveAssetPath rejected or returned an empty path)')
246
247
  const J = this.Jolt
247
248
  const key = `${glbPath}|${scale[0]},${scale[1]},${scale[2]}`
248
249
  let shape = this._trimeshCache.get(key)
@@ -115,6 +115,7 @@ export function validateMessage(msg) {
115
115
  if (typeof p.entityId !== 'string' || !p.entityId) errors.push('payload.entityId is required')
116
116
  if (!PRIMITIVES.includes(p.primitive)) errors.push(`payload.primitive must be one of: ${PRIMITIVES.join(', ')}`)
117
117
  if (p.primitive === 'model' && typeof p.model !== 'string') errors.push('payload.model is required when primitive is "model"')
118
+ if (p.primitive === 'model' && typeof p.model === 'string' && (!p.model || p.model.split(/[\\/]/).includes('..') || /^[a-zA-Z]:[\\/]/.test(p.model) || p.model.startsWith('/') || p.model.startsWith('\\'))) errors.push('payload.model must be a non-empty relative path with no ".." segments and no drive-absolute or root-absolute prefix')
118
119
  if (p.position && (!Array.isArray(p.position) || p.position.length !== 3)) errors.push('payload.position must be [x,y,z]')
119
120
  if (p.scale && (!Array.isArray(p.scale) || p.scale.length !== 3)) errors.push('payload.scale must be [sx,sy,sz]')
120
121
  }