spoint 0.1.653 → 0.1.655
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/core/Weather.js +4 -202
- package/client/core/WeatherMaterials.js +208 -0
- package/package.json +1 -1
- package/src/sdk/ServerAPI.js +11 -270
- package/src/sdk/ServerAPIRoutes.js +279 -0
package/client/core/Weather.js
CHANGED
|
@@ -35,208 +35,10 @@
|
|
|
35
35
|
import * as THREE from 'three'
|
|
36
36
|
import { InstancedMesh2 } from '@three.ez/instanced-mesh'
|
|
37
37
|
import { createGrassDecal } from '/src/terrain/GrassDecal.js'
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
function makeStreakGeo() {
|
|
43
|
-
const w = 0.012, h = 0.55 // half-width negligible, tall thin quad in local Y (falls along -Y)
|
|
44
|
-
const pos = new Float32Array([
|
|
45
|
-
-w, 0, 0, w, 0, 0, w, -h, 0,
|
|
46
|
-
-w, 0, 0, w, -h, 0, -w, -h, 0,
|
|
47
|
-
])
|
|
48
|
-
const uv = new Float32Array([0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0])
|
|
49
|
-
const g = new THREE.BufferGeometry()
|
|
50
|
-
g.setAttribute('position', new THREE.BufferAttribute(pos, 3))
|
|
51
|
-
g.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
|
|
52
|
-
g.computeBoundingSphere(); g.computeBoundingBox()
|
|
53
|
-
return g
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// Splash ring: a small flat quad billboard, GPU-expanded+faded in the shader from droplet-recycle time
|
|
57
|
-
// (see makeSplashMaterial) rather than real ring geometry -- cheaper than a torus/segmented-ring mesh
|
|
58
|
-
// for a sub-half-second cosmetic pulse.
|
|
59
|
-
function makeSplashGeo() {
|
|
60
|
-
const s = 0.5
|
|
61
|
-
const pos = new Float32Array([-s, 0, -s, s, 0, -s, s, 0, s, -s, 0, -s, s, 0, s, -s, 0, s])
|
|
62
|
-
const uv = new Float32Array([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1])
|
|
63
|
-
const g = new THREE.BufferGeometry()
|
|
64
|
-
g.setAttribute('position', new THREE.BufferAttribute(pos, 3))
|
|
65
|
-
g.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
|
|
66
|
-
g.computeBoundingSphere(); g.computeBoundingBox()
|
|
67
|
-
return g
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Rain streak material: per-instance fall-streak alpha, faces the camera around the vertical axis only
|
|
71
|
-
// (billboarded in Y, matching how real rain is seen -- always "hanging" vertically regardless of view
|
|
72
|
-
// yaw) via a per-instance yaw baked into the instance matrix at spawn (see _respawnDroplet), refreshed
|
|
73
|
-
// only when the camera's OWN yaw changes enough to matter (see update()'s _lastCamYaw gate) rather than
|
|
74
|
-
// every frame -- rain streaks are thin enough that sub-degree billboard error is imperceptible.
|
|
75
|
-
function makeRainMaterial() {
|
|
76
|
-
const material = new THREE.ShaderMaterial({
|
|
77
|
-
transparent: true, depthWrite: false, side: THREE.DoubleSide,
|
|
78
|
-
uniforms: { uColor: { value: new THREE.Color(0.72, 0.78, 0.86) }, uOpacity: { value: 0.35 } },
|
|
79
|
-
vertexShader: `
|
|
80
|
-
varying vec2 vUv;
|
|
81
|
-
varying float vFade;
|
|
82
|
-
void main() {
|
|
83
|
-
vUv = uv;
|
|
84
|
-
// fade the quad edges (uv.x) so the streak reads as a soft line, not a hard-edged rectangle
|
|
85
|
-
vFade = 1.0 - abs(uv.x * 2.0 - 1.0);
|
|
86
|
-
vec4 mvPosition = modelViewMatrix * instanceMatrix * vec4(position, 1.0);
|
|
87
|
-
gl_Position = projectionMatrix * mvPosition;
|
|
88
|
-
}
|
|
89
|
-
`,
|
|
90
|
-
fragmentShader: `
|
|
91
|
-
uniform vec3 uColor; uniform float uOpacity;
|
|
92
|
-
varying vec2 vUv; varying float vFade;
|
|
93
|
-
void main() {
|
|
94
|
-
float streak = smoothstep(0.0, 0.15, vUv.y) * smoothstep(1.0, 0.85, vUv.y);
|
|
95
|
-
float a = uOpacity * vFade * (0.3 + 0.7 * streak);
|
|
96
|
-
if (a < 0.01) discard;
|
|
97
|
-
gl_FragColor = vec4(uColor, a);
|
|
98
|
-
}
|
|
99
|
-
`,
|
|
100
|
-
})
|
|
101
|
-
material.customProgramCacheKey = () => 'weather-rain-streak'
|
|
102
|
-
return material
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Splash material: a soft radial ring that expands + fades over its own per-instance lifetime
|
|
106
|
-
// (uSplashTime holds the SHARED clock value at spawn, per-instance via initUniformsPerInstance --
|
|
107
|
-
// elapsed = uTime - spawnTime, matching Grass.js's windPhase-style per-instance uniform pattern).
|
|
108
|
-
function makeSplashMaterial() {
|
|
109
|
-
const material = new THREE.ShaderMaterial({
|
|
110
|
-
transparent: true, depthWrite: false, side: THREE.DoubleSide,
|
|
111
|
-
uniforms: { uTime: { value: 0 }, uColor: { value: new THREE.Color(0.8, 0.85, 0.92) }, uLifeS: { value: 0.4 } },
|
|
112
|
-
// instancedmesh2-instanceindex-undeclared-identifier-vegetation-shader: initUniformsPerInstance's
|
|
113
|
-
// (see the call below) windPhase/spawnTime-style texel-fetch injection needs the instanceIndex
|
|
114
|
-
// vertex attribute in scope, normally free via THREE's own '#include <batching_pars_vertex>' but
|
|
115
|
-
// absent from this hand-written ShaderMaterial -- same class of real live GL compile failure as
|
|
116
|
-
// Grass.js/SSAO.js/Vegetation.js's addShadowLOD sites (ERROR 0:83 'instanceIndex' : undeclared
|
|
117
|
-
// identifier, caught live via a WebGL2RenderingContext.prototype.compileShader monkeypatch, real
|
|
118
|
-
// booted server + weather splash particles streamed in during real gameplay, PORT=8250).
|
|
119
|
-
// '#include <instanced_pars_vertex>' declares instanceIndex + getInstancedMatrix(); the raw
|
|
120
|
-
// instanceMatrix attribute is a dummy zero-length buffer under InstancedMesh2's always-on
|
|
121
|
-
// USE_INSTANCING_INDIRECT mode (the real per-instance matrix lives in matricesTexture), so the
|
|
122
|
-
// pre-existing raw instanceMatrix read below was also silently wrong -- fixed by locally shadowing
|
|
123
|
-
// it with the real computed matrix.
|
|
124
|
-
vertexShader: `
|
|
125
|
-
uniform float uTime, uLifeS;
|
|
126
|
-
varying vec2 vUv; varying float vAlpha;
|
|
127
|
-
#include <instanced_pars_vertex>
|
|
128
|
-
void main() {
|
|
129
|
-
#ifdef USE_INSTANCING_INDIRECT
|
|
130
|
-
mat4 instanceMatrix = getInstancedMatrix();
|
|
131
|
-
#endif
|
|
132
|
-
vUv = uv;
|
|
133
|
-
float age = clamp((uTime - spawnTime) / uLifeS, 0.0, 1.0);
|
|
134
|
-
vAlpha = (1.0 - age) * step(0.0, spawnTime);
|
|
135
|
-
float scale = mix(0.15, 1.0, age);
|
|
136
|
-
vec3 p = position * scale;
|
|
137
|
-
vec4 mvPosition = modelViewMatrix * instanceMatrix * vec4(p, 1.0);
|
|
138
|
-
gl_Position = projectionMatrix * mvPosition;
|
|
139
|
-
}
|
|
140
|
-
`,
|
|
141
|
-
fragmentShader: `
|
|
142
|
-
uniform vec3 uColor;
|
|
143
|
-
varying vec2 vUv; varying float vAlpha;
|
|
144
|
-
void main() {
|
|
145
|
-
float d = distance(vUv, vec2(0.5));
|
|
146
|
-
float ring = smoothstep(0.5, 0.38, d) - smoothstep(0.38, 0.28, d);
|
|
147
|
-
float a = ring * vAlpha * 0.5;
|
|
148
|
-
if (a < 0.01) discard;
|
|
149
|
-
gl_FragColor = vec4(uColor, a);
|
|
150
|
-
}
|
|
151
|
-
`,
|
|
152
|
-
})
|
|
153
|
-
material.customProgramCacheKey = () => 'weather-splash-ring'
|
|
154
|
-
return material
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Snow flake quad: a small flat square (not a thin streak like rain -- snow falls slowly enough to read
|
|
158
|
-
// as a soft round dot/blob, not a motion-blurred line), billboarded FULLY toward the camera (both yaw
|
|
159
|
-
// AND pitch, unlike rain's yaw-only vertical hang) since a flat square only reads correctly face-on.
|
|
160
|
-
function makeFlakeGeo() {
|
|
161
|
-
const s = 0.05
|
|
162
|
-
const pos = new Float32Array([-s, -s, 0, s, -s, 0, s, s, 0, -s, -s, 0, s, s, 0, -s, s, 0])
|
|
163
|
-
const uv = new Float32Array([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1])
|
|
164
|
-
const g = new THREE.BufferGeometry()
|
|
165
|
-
g.setAttribute('position', new THREE.BufferAttribute(pos, 3))
|
|
166
|
-
g.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
|
|
167
|
-
g.computeBoundingSphere(); g.computeBoundingBox()
|
|
168
|
-
return g
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// Snow flake material: soft radial falloff (round dot, not a hard-edged square), full camera-facing
|
|
172
|
-
// billboard done via the instance quaternion (set once per frame, see update() -- unlike rain's cheap
|
|
173
|
-
// yaw-only refresh, full billboarding is unavoidable for a flat square viewed from any pitch).
|
|
174
|
-
function makeSnowMaterial() {
|
|
175
|
-
const material = new THREE.ShaderMaterial({
|
|
176
|
-
transparent: true, depthWrite: false, side: THREE.DoubleSide,
|
|
177
|
-
uniforms: { uColor: { value: new THREE.Color(0.95, 0.97, 1.0) }, uOpacity: { value: 0.8 } },
|
|
178
|
-
vertexShader: `
|
|
179
|
-
varying vec2 vUv;
|
|
180
|
-
void main() {
|
|
181
|
-
vUv = uv;
|
|
182
|
-
vec4 mvPosition = modelViewMatrix * instanceMatrix * vec4(position, 1.0);
|
|
183
|
-
gl_Position = projectionMatrix * mvPosition;
|
|
184
|
-
}
|
|
185
|
-
`,
|
|
186
|
-
fragmentShader: `
|
|
187
|
-
uniform vec3 uColor; uniform float uOpacity;
|
|
188
|
-
varying vec2 vUv;
|
|
189
|
-
void main() {
|
|
190
|
-
float d = distance(vUv, vec2(0.5));
|
|
191
|
-
float a = uOpacity * smoothstep(0.5, 0.15, d);
|
|
192
|
-
if (a < 0.01) discard;
|
|
193
|
-
gl_FragColor = vec4(uColor, a);
|
|
194
|
-
}
|
|
195
|
-
`,
|
|
196
|
-
})
|
|
197
|
-
material.customProgramCacheKey = () => 'weather-snow-flake'
|
|
198
|
-
return material
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// Far billboard-sheet tier material: identical visual language to the near-tier material it mirrors
|
|
202
|
-
// (rain streak or snow flake) but with a distance-based fade baked in (uFadeNear/uFadeFar, per-instance
|
|
203
|
-
// distance computed in the vertex shader from view-space Z) so the far sheet's own outer edge (where its
|
|
204
|
-
// own box wrap would otherwise produce a visible "wall" of particles popping in/out) fades smoothly
|
|
205
|
-
// instead of hard-cutting -- the one extra bit of shader work this cheap tier needs since it deliberately
|
|
206
|
-
// has no per-instance CPU-side fade bookkeeping (see the tier-shape comment above: far tier is Y-fall +
|
|
207
|
-
// wrap ONLY, no per-particle state beyond position).
|
|
208
|
-
function makeFarSheetMaterial(baseColor, opacity, roundDot) {
|
|
209
|
-
const material = new THREE.ShaderMaterial({
|
|
210
|
-
transparent: true, depthWrite: false, side: THREE.DoubleSide,
|
|
211
|
-
uniforms: {
|
|
212
|
-
uColor: { value: baseColor.clone() }, uOpacity: { value: opacity },
|
|
213
|
-
uFadeNear: { value: 40 }, uFadeFar: { value: 90 },
|
|
214
|
-
},
|
|
215
|
-
vertexShader: `
|
|
216
|
-
varying vec2 vUv; varying float vDist;
|
|
217
|
-
void main() {
|
|
218
|
-
vUv = uv;
|
|
219
|
-
vec4 mvPosition = modelViewMatrix * instanceMatrix * vec4(position, 1.0);
|
|
220
|
-
vDist = -mvPosition.z;
|
|
221
|
-
gl_Position = projectionMatrix * mvPosition;
|
|
222
|
-
}
|
|
223
|
-
`,
|
|
224
|
-
fragmentShader: `
|
|
225
|
-
uniform vec3 uColor; uniform float uOpacity, uFadeNear, uFadeFar;
|
|
226
|
-
varying vec2 vUv; varying float vDist;
|
|
227
|
-
void main() {
|
|
228
|
-
float shape = ${roundDot ? 'smoothstep(0.5, 0.15, distance(vUv, vec2(0.5)))' : '(smoothstep(0.0, 0.15, vUv.y) * smoothstep(1.0, 0.85, vUv.y) * (1.0 - abs(vUv.x * 2.0 - 1.0)))'};
|
|
229
|
-
float fadeIn = smoothstep(uFadeNear, uFadeNear + 8.0, vDist);
|
|
230
|
-
float fadeOut = 1.0 - smoothstep(uFadeFar - 10.0, uFadeFar, vDist);
|
|
231
|
-
float a = uOpacity * shape * fadeIn * fadeOut;
|
|
232
|
-
if (a < 0.01) discard;
|
|
233
|
-
gl_FragColor = vec4(uColor, a);
|
|
234
|
-
}
|
|
235
|
-
`,
|
|
236
|
-
})
|
|
237
|
-
material.customProgramCacheKey = () => `weather-far-sheet-${roundDot ? 'snow' : 'rain'}`
|
|
238
|
-
return material
|
|
239
|
-
}
|
|
38
|
+
import {
|
|
39
|
+
makeStreakGeo, makeSplashGeo, makeRainMaterial, makeSplashMaterial,
|
|
40
|
+
makeFlakeGeo, makeSnowMaterial, makeFarSheetMaterial
|
|
41
|
+
} from './WeatherMaterials.js'
|
|
240
42
|
|
|
241
43
|
const _q = new THREE.Quaternion(), _upY = new THREE.Vector3(0, 1, 0)
|
|
242
44
|
const _camPos = new THREE.Vector3(), _camQuat = new THREE.Quaternion()
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Pure geometry/material factories for Weather.js's rain/snow/splash/far-sheet InstancedMesh2 tiers.
|
|
2
|
+
// No per-instance simulation state -- these build shared geometry buffers and ShaderMaterials once,
|
|
3
|
+
// consumed by createWeather()'s stateful update loop in Weather.js itself.
|
|
4
|
+
|
|
5
|
+
import * as THREE from 'three'
|
|
6
|
+
|
|
7
|
+
// Streak quad: a thin vertical billboard-ish quad (not a full 3D cylinder -- rain streaks read as a
|
|
8
|
+
// 2D motion-blurred line from any practical viewing angle, matching the cheap-shading discipline
|
|
9
|
+
// Grass.js documents for its own Lambert-lite material). Built once, shared across all instances.
|
|
10
|
+
export function makeStreakGeo() {
|
|
11
|
+
const w = 0.012, h = 0.55 // half-width negligible, tall thin quad in local Y (falls along -Y)
|
|
12
|
+
const pos = new Float32Array([
|
|
13
|
+
-w, 0, 0, w, 0, 0, w, -h, 0,
|
|
14
|
+
-w, 0, 0, w, -h, 0, -w, -h, 0,
|
|
15
|
+
])
|
|
16
|
+
const uv = new Float32Array([0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 0, 0])
|
|
17
|
+
const g = new THREE.BufferGeometry()
|
|
18
|
+
g.setAttribute('position', new THREE.BufferAttribute(pos, 3))
|
|
19
|
+
g.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
|
|
20
|
+
g.computeBoundingSphere(); g.computeBoundingBox()
|
|
21
|
+
return g
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Splash ring: a small flat quad billboard, GPU-expanded+faded in the shader from droplet-recycle time
|
|
25
|
+
// (see makeSplashMaterial) rather than real ring geometry -- cheaper than a torus/segmented-ring mesh
|
|
26
|
+
// for a sub-half-second cosmetic pulse.
|
|
27
|
+
export function makeSplashGeo() {
|
|
28
|
+
const s = 0.5
|
|
29
|
+
const pos = new Float32Array([-s, 0, -s, s, 0, -s, s, 0, s, -s, 0, -s, s, 0, s, -s, 0, s])
|
|
30
|
+
const uv = new Float32Array([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1])
|
|
31
|
+
const g = new THREE.BufferGeometry()
|
|
32
|
+
g.setAttribute('position', new THREE.BufferAttribute(pos, 3))
|
|
33
|
+
g.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
|
|
34
|
+
g.computeBoundingSphere(); g.computeBoundingBox()
|
|
35
|
+
return g
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Rain streak material: per-instance fall-streak alpha, faces the camera around the vertical axis only
|
|
39
|
+
// (billboarded in Y, matching how real rain is seen -- always "hanging" vertically regardless of view
|
|
40
|
+
// yaw) via a per-instance yaw baked into the instance matrix at spawn (see Weather.js's
|
|
41
|
+
// _respawnDroplet), refreshed only when the camera's OWN yaw changes enough to matter (see update()'s
|
|
42
|
+
// _lastCamYaw gate) rather than every frame -- rain streaks are thin enough that sub-degree billboard
|
|
43
|
+
// error is imperceptible.
|
|
44
|
+
export function makeRainMaterial() {
|
|
45
|
+
const material = new THREE.ShaderMaterial({
|
|
46
|
+
transparent: true, depthWrite: false, side: THREE.DoubleSide,
|
|
47
|
+
uniforms: { uColor: { value: new THREE.Color(0.72, 0.78, 0.86) }, uOpacity: { value: 0.35 } },
|
|
48
|
+
vertexShader: `
|
|
49
|
+
varying vec2 vUv;
|
|
50
|
+
varying float vFade;
|
|
51
|
+
void main() {
|
|
52
|
+
vUv = uv;
|
|
53
|
+
// fade the quad edges (uv.x) so the streak reads as a soft line, not a hard-edged rectangle
|
|
54
|
+
vFade = 1.0 - abs(uv.x * 2.0 - 1.0);
|
|
55
|
+
vec4 mvPosition = modelViewMatrix * instanceMatrix * vec4(position, 1.0);
|
|
56
|
+
gl_Position = projectionMatrix * mvPosition;
|
|
57
|
+
}
|
|
58
|
+
`,
|
|
59
|
+
fragmentShader: `
|
|
60
|
+
uniform vec3 uColor; uniform float uOpacity;
|
|
61
|
+
varying vec2 vUv; varying float vFade;
|
|
62
|
+
void main() {
|
|
63
|
+
float streak = smoothstep(0.0, 0.15, vUv.y) * smoothstep(1.0, 0.85, vUv.y);
|
|
64
|
+
float a = uOpacity * vFade * (0.3 + 0.7 * streak);
|
|
65
|
+
if (a < 0.01) discard;
|
|
66
|
+
gl_FragColor = vec4(uColor, a);
|
|
67
|
+
}
|
|
68
|
+
`,
|
|
69
|
+
})
|
|
70
|
+
material.customProgramCacheKey = () => 'weather-rain-streak'
|
|
71
|
+
return material
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Splash material: a soft radial ring that expands + fades over its own per-instance lifetime
|
|
75
|
+
// (uSplashTime holds the SHARED clock value at spawn, per-instance via initUniformsPerInstance --
|
|
76
|
+
// elapsed = uTime - spawnTime, matching Grass.js's windPhase-style per-instance uniform pattern).
|
|
77
|
+
export function makeSplashMaterial() {
|
|
78
|
+
const material = new THREE.ShaderMaterial({
|
|
79
|
+
transparent: true, depthWrite: false, side: THREE.DoubleSide,
|
|
80
|
+
uniforms: { uTime: { value: 0 }, uColor: { value: new THREE.Color(0.8, 0.85, 0.92) }, uLifeS: { value: 0.4 } },
|
|
81
|
+
// instancedmesh2-instanceindex-undeclared-identifier-vegetation-shader: initUniformsPerInstance's
|
|
82
|
+
// (see the call below) windPhase/spawnTime-style texel-fetch injection needs the instanceIndex
|
|
83
|
+
// vertex attribute in scope, normally free via THREE's own '#include <batching_pars_vertex>' but
|
|
84
|
+
// absent from this hand-written ShaderMaterial -- same class of real live GL compile failure as
|
|
85
|
+
// Grass.js/SSAO.js/Vegetation.js's addShadowLOD sites (ERROR 0:83 'instanceIndex' : undeclared
|
|
86
|
+
// identifier, caught live via a WebGL2RenderingContext.prototype.compileShader monkeypatch, real
|
|
87
|
+
// booted server + weather splash particles streamed in during real gameplay, PORT=8250).
|
|
88
|
+
// '#include <instanced_pars_vertex>' declares instanceIndex + getInstancedMatrix(); the raw
|
|
89
|
+
// instanceMatrix attribute is a dummy zero-length buffer under InstancedMesh2's always-on
|
|
90
|
+
// USE_INSTANCING_INDIRECT mode (the real per-instance matrix lives in matricesTexture), so the
|
|
91
|
+
// pre-existing raw instanceMatrix read below was also silently wrong -- fixed by locally shadowing
|
|
92
|
+
// it with the real computed matrix.
|
|
93
|
+
vertexShader: `
|
|
94
|
+
uniform float uTime, uLifeS;
|
|
95
|
+
varying vec2 vUv; varying float vAlpha;
|
|
96
|
+
#include <instanced_pars_vertex>
|
|
97
|
+
void main() {
|
|
98
|
+
#ifdef USE_INSTANCING_INDIRECT
|
|
99
|
+
mat4 instanceMatrix = getInstancedMatrix();
|
|
100
|
+
#endif
|
|
101
|
+
vUv = uv;
|
|
102
|
+
float age = clamp((uTime - spawnTime) / uLifeS, 0.0, 1.0);
|
|
103
|
+
vAlpha = (1.0 - age) * step(0.0, spawnTime);
|
|
104
|
+
float scale = mix(0.15, 1.0, age);
|
|
105
|
+
vec3 p = position * scale;
|
|
106
|
+
vec4 mvPosition = modelViewMatrix * instanceMatrix * vec4(p, 1.0);
|
|
107
|
+
gl_Position = projectionMatrix * mvPosition;
|
|
108
|
+
}
|
|
109
|
+
`,
|
|
110
|
+
fragmentShader: `
|
|
111
|
+
uniform vec3 uColor;
|
|
112
|
+
varying vec2 vUv; varying float vAlpha;
|
|
113
|
+
void main() {
|
|
114
|
+
float d = distance(vUv, vec2(0.5));
|
|
115
|
+
float ring = smoothstep(0.5, 0.38, d) - smoothstep(0.38, 0.28, d);
|
|
116
|
+
float a = ring * vAlpha * 0.5;
|
|
117
|
+
if (a < 0.01) discard;
|
|
118
|
+
gl_FragColor = vec4(uColor, a);
|
|
119
|
+
}
|
|
120
|
+
`,
|
|
121
|
+
})
|
|
122
|
+
material.customProgramCacheKey = () => 'weather-splash-ring'
|
|
123
|
+
return material
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Snow flake quad: a small flat square (not a thin streak like rain -- snow falls slowly enough to read
|
|
127
|
+
// as a soft round dot/blob, not a motion-blurred line), billboarded FULLY toward the camera (both yaw
|
|
128
|
+
// AND pitch, unlike rain's yaw-only vertical hang) since a flat square only reads correctly face-on.
|
|
129
|
+
export function makeFlakeGeo() {
|
|
130
|
+
const s = 0.05
|
|
131
|
+
const pos = new Float32Array([-s, -s, 0, s, -s, 0, s, s, 0, -s, -s, 0, s, s, 0, -s, s, 0])
|
|
132
|
+
const uv = new Float32Array([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1])
|
|
133
|
+
const g = new THREE.BufferGeometry()
|
|
134
|
+
g.setAttribute('position', new THREE.BufferAttribute(pos, 3))
|
|
135
|
+
g.setAttribute('uv', new THREE.BufferAttribute(uv, 2))
|
|
136
|
+
g.computeBoundingSphere(); g.computeBoundingBox()
|
|
137
|
+
return g
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Snow flake material: soft radial falloff (round dot, not a hard-edged square), full camera-facing
|
|
141
|
+
// billboard done via the instance quaternion (set once per frame, see Weather.js's update() -- unlike
|
|
142
|
+
// rain's cheap yaw-only refresh, full billboarding is unavoidable for a flat square viewed from any pitch).
|
|
143
|
+
export function makeSnowMaterial() {
|
|
144
|
+
const material = new THREE.ShaderMaterial({
|
|
145
|
+
transparent: true, depthWrite: false, side: THREE.DoubleSide,
|
|
146
|
+
uniforms: { uColor: { value: new THREE.Color(0.95, 0.97, 1.0) }, uOpacity: { value: 0.8 } },
|
|
147
|
+
vertexShader: `
|
|
148
|
+
varying vec2 vUv;
|
|
149
|
+
void main() {
|
|
150
|
+
vUv = uv;
|
|
151
|
+
vec4 mvPosition = modelViewMatrix * instanceMatrix * vec4(position, 1.0);
|
|
152
|
+
gl_Position = projectionMatrix * mvPosition;
|
|
153
|
+
}
|
|
154
|
+
`,
|
|
155
|
+
fragmentShader: `
|
|
156
|
+
uniform vec3 uColor; uniform float uOpacity;
|
|
157
|
+
varying vec2 vUv;
|
|
158
|
+
void main() {
|
|
159
|
+
float d = distance(vUv, vec2(0.5));
|
|
160
|
+
float a = uOpacity * smoothstep(0.5, 0.15, d);
|
|
161
|
+
if (a < 0.01) discard;
|
|
162
|
+
gl_FragColor = vec4(uColor, a);
|
|
163
|
+
}
|
|
164
|
+
`,
|
|
165
|
+
})
|
|
166
|
+
material.customProgramCacheKey = () => 'weather-snow-flake'
|
|
167
|
+
return material
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Far billboard-sheet tier material: identical visual language to the near-tier material it mirrors
|
|
171
|
+
// (rain streak or snow flake) but with a distance-based fade baked in (uFadeNear/uFadeFar, per-instance
|
|
172
|
+
// distance computed in the vertex shader from view-space Z) so the far sheet's own outer edge (where its
|
|
173
|
+
// own box wrap would otherwise produce a visible "wall" of particles popping in/out) fades smoothly
|
|
174
|
+
// instead of hard-cutting -- the one extra bit of shader work this cheap tier needs since it deliberately
|
|
175
|
+
// has no per-instance CPU-side fade bookkeeping (see Weather.js's tier-shape comment: far tier is
|
|
176
|
+
// Y-fall + wrap ONLY, no per-particle state beyond position).
|
|
177
|
+
export function makeFarSheetMaterial(baseColor, opacity, roundDot) {
|
|
178
|
+
const material = new THREE.ShaderMaterial({
|
|
179
|
+
transparent: true, depthWrite: false, side: THREE.DoubleSide,
|
|
180
|
+
uniforms: {
|
|
181
|
+
uColor: { value: baseColor.clone() }, uOpacity: { value: opacity },
|
|
182
|
+
uFadeNear: { value: 40 }, uFadeFar: { value: 90 },
|
|
183
|
+
},
|
|
184
|
+
vertexShader: `
|
|
185
|
+
varying vec2 vUv; varying float vDist;
|
|
186
|
+
void main() {
|
|
187
|
+
vUv = uv;
|
|
188
|
+
vec4 mvPosition = modelViewMatrix * instanceMatrix * vec4(position, 1.0);
|
|
189
|
+
vDist = -mvPosition.z;
|
|
190
|
+
gl_Position = projectionMatrix * mvPosition;
|
|
191
|
+
}
|
|
192
|
+
`,
|
|
193
|
+
fragmentShader: `
|
|
194
|
+
uniform vec3 uColor; uniform float uOpacity, uFadeNear, uFadeFar;
|
|
195
|
+
varying vec2 vUv; varying float vDist;
|
|
196
|
+
void main() {
|
|
197
|
+
float shape = ${roundDot ? 'smoothstep(0.5, 0.15, distance(vUv, vec2(0.5)))' : '(smoothstep(0.0, 0.15, vUv.y) * smoothstep(1.0, 0.85, vUv.y) * (1.0 - abs(vUv.x * 2.0 - 1.0)))'};
|
|
198
|
+
float fadeIn = smoothstep(uFadeNear, uFadeNear + 8.0, vDist);
|
|
199
|
+
float fadeOut = 1.0 - smoothstep(uFadeFar - 10.0, uFadeFar, vDist);
|
|
200
|
+
float a = uOpacity * shape * fadeIn * fadeOut;
|
|
201
|
+
if (a < 0.01) discard;
|
|
202
|
+
gl_FragColor = vec4(uColor, a);
|
|
203
|
+
}
|
|
204
|
+
`,
|
|
205
|
+
})
|
|
206
|
+
material.customProgramCacheKey = () => `weather-far-sheet-${roundDot ? 'snow' : 'rain'}`
|
|
207
|
+
return material
|
|
208
|
+
}
|
package/package.json
CHANGED
package/src/sdk/ServerAPI.js
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
import { createServer as createHttpServer } from 'node:http'
|
|
2
2
|
import { WebSocketServer as WSServer } from 'ws'
|
|
3
|
-
import { MSG } from '../protocol/MessageTypes.js'
|
|
4
3
|
import { SnapshotEncoder } from '../netcode/SnapshotEncoder.js'
|
|
5
4
|
import { createStaticHandler } from './StaticHandler.js'
|
|
6
5
|
import { WebSocketTransport } from '../transport/WebSocketTransport.js'
|
|
7
6
|
import { WebTransportServer } from '../transport/WebTransportServer.js'
|
|
8
7
|
import { createUploadHandler } from './UploadHandler.js'
|
|
9
8
|
import { setupTerrainStreaming } from '../terrain/TerrainPhysics.js'
|
|
10
|
-
import { timingSafeTokenEqual } from './authCompare.js'
|
|
11
|
-
import { renderMetrics } from './Metrics.js'
|
|
12
9
|
import { restoreWorldSnapshot, saveWorldSnapshot } from './WorldPersistence.js'
|
|
13
|
-
import {
|
|
14
|
-
|
|
10
|
+
import {
|
|
11
|
+
handleUploadModel, handleDebugLog, handleClientError, handleDebugServer,
|
|
12
|
+
handleMetrics, handleBenchmark, handleFreddieViz
|
|
13
|
+
} from './ServerAPIRoutes.js'
|
|
15
14
|
|
|
16
15
|
// Top-down color+height minimap bake-if-missing, keyed by seed (real artifact: apps/world/<worldName>.<seed>.minimap.png
|
|
17
16
|
// + a sibling .json header). Reuses scripts/bake-minimap.mjs's bakeMinimap() directly (pure-Node CPU height+climate
|
|
@@ -56,50 +55,6 @@ export async function bakeMinimapIfMissing(worldName, tcfg, opts = {}) {
|
|
|
56
55
|
console.log(`[minimap] baked ${base}.png (${header.N}x${header.N}, ${(png.length / 1024).toFixed(1)}KB, height ${header.minHeight}..${header.maxHeight}m) in ${Date.now() - t0}ms`)
|
|
57
56
|
}
|
|
58
57
|
|
|
59
|
-
// Per-IP token bucket for /debug-log: caps sustained log-line volume from any single origin even after
|
|
60
|
-
// the loopback/EDITOR_TOKEN gate passes, so a single misbehaving/malicious client on an allowed origin
|
|
61
|
-
// can't still spam the server console / consume CPU by hammering the endpoint at wire speed.
|
|
62
|
-
const DEBUG_LOG_BUCKET_CAPACITY = 20 // burst allowance, lines
|
|
63
|
-
const DEBUG_LOG_BUCKET_REFILL_PER_SEC = 5 // steady-state cap, lines/sec
|
|
64
|
-
const _debugLogBuckets = new Map() // ip -> { tokens, lastRefillMs }
|
|
65
|
-
|
|
66
|
-
function debugLogRateLimited(ip) {
|
|
67
|
-
const now = Date.now()
|
|
68
|
-
let b = _debugLogBuckets.get(ip)
|
|
69
|
-
if (!b) { b = { tokens: DEBUG_LOG_BUCKET_CAPACITY, lastRefillMs: now }; _debugLogBuckets.set(ip, b) }
|
|
70
|
-
const elapsedSec = (now - b.lastRefillMs) / 1000
|
|
71
|
-
if (elapsedSec > 0) {
|
|
72
|
-
b.tokens = Math.min(DEBUG_LOG_BUCKET_CAPACITY, b.tokens + elapsedSec * DEBUG_LOG_BUCKET_REFILL_PER_SEC)
|
|
73
|
-
b.lastRefillMs = now
|
|
74
|
-
}
|
|
75
|
-
if (b.tokens < 1) return true // no tokens left -> rate limited
|
|
76
|
-
b.tokens -= 1
|
|
77
|
-
return false
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// Per-IP token bucket for /client-error: same shape as debugLogRateLimited above, but this
|
|
81
|
-
// endpoint is PUBLIC (real deployed players, not loopback-only dev tooling) so the bucket is the
|
|
82
|
-
// only defense against a hostile or buggy client flooding the server with crash reports -- tighter
|
|
83
|
-
// than the debug-log bucket since a real crash storm (e.g. every connected player hitting the same
|
|
84
|
-
// bug at once) should still log a representative sample, not every single occurrence.
|
|
85
|
-
const CLIENT_ERROR_BUCKET_CAPACITY = 5
|
|
86
|
-
const CLIENT_ERROR_BUCKET_REFILL_PER_SEC = 0.2 // 1 report per 5s steady-state per IP
|
|
87
|
-
const _clientErrorBuckets = new Map() // ip -> { tokens, lastRefillMs }
|
|
88
|
-
|
|
89
|
-
function clientErrorRateLimited(ip) {
|
|
90
|
-
const now = Date.now()
|
|
91
|
-
let b = _clientErrorBuckets.get(ip)
|
|
92
|
-
if (!b) { b = { tokens: CLIENT_ERROR_BUCKET_CAPACITY, lastRefillMs: now }; _clientErrorBuckets.set(ip, b) }
|
|
93
|
-
const elapsedSec = (now - b.lastRefillMs) / 1000
|
|
94
|
-
if (elapsedSec > 0) {
|
|
95
|
-
b.tokens = Math.min(CLIENT_ERROR_BUCKET_CAPACITY, b.tokens + elapsedSec * CLIENT_ERROR_BUCKET_REFILL_PER_SEC)
|
|
96
|
-
b.lastRefillMs = now
|
|
97
|
-
}
|
|
98
|
-
if (b.tokens < 1) return true
|
|
99
|
-
b.tokens -= 1
|
|
100
|
-
return false
|
|
101
|
-
}
|
|
102
|
-
|
|
103
58
|
export function createServerAPI(ctx) {
|
|
104
59
|
const { config, port, tickRate, staticDirs, appLoader, appRuntime, physics, physicsIntegration, stageLoader } = ctx
|
|
105
60
|
const { tickSystem, playerManager, networkState, lagCompensator, connections, sessions, inspector, emitter, reloadManager, eventBus, eventLog, storage } = ctx
|
|
@@ -279,227 +234,13 @@ export function createServerAPI(ctx) {
|
|
|
279
234
|
})
|
|
280
235
|
const staticHandler = staticDirs.length > 0 ? createStaticHandler(staticDirs, { getWorldInfo }) : null
|
|
281
236
|
const httpHandler = (req, res) => {
|
|
282
|
-
if (req.method === 'POST' && req.url === '/upload-model') {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
if (req.method === 'POST' && req.url === '/
|
|
289
|
-
// gated: loopback origin is always allowed (local dev console passthrough); a non-loopback
|
|
290
|
-
// origin must present a valid X-Editor-Token when EDITOR_TOKEN is configured, and is refused
|
|
291
|
-
// outright when it isn't (an unset EDITOR_TOKEN must not leave this endpoint open to the world).
|
|
292
|
-
const _remote = req.socket?.remoteAddress || ''
|
|
293
|
-
const _isLoopback = _remote === '127.0.0.1' || _remote === '::1' || _remote === '::ffff:127.0.0.1'
|
|
294
|
-
if (!_isLoopback) {
|
|
295
|
-
const _tok = process.env.EDITOR_TOKEN
|
|
296
|
-
if (!_tok || !timingSafeTokenEqual(req.headers['x-editor-token'], _tok)) { res.writeHead(403); res.end('forbidden'); return }
|
|
297
|
-
}
|
|
298
|
-
// token-bucket rate limit per-IP: caps sustained lines/sec even from an already-authorized origin
|
|
299
|
-
if (debugLogRateLimited(_remote)) { res.writeHead(429); res.end('rate limited'); return }
|
|
300
|
-
// size-capped: unbounded body buffering here let any origin exhaust server memory
|
|
301
|
-
const _DEBUG_LOG_MAX = 256 * 1024
|
|
302
|
-
let _len = 0, _over = false
|
|
303
|
-
const chunks = []
|
|
304
|
-
req.on('data', d => {
|
|
305
|
-
if (_over) return
|
|
306
|
-
_len += d.length
|
|
307
|
-
if (_len > _DEBUG_LOG_MAX) { _over = true; res.writeHead(413); res.end('payload too large'); req.destroy(); return }
|
|
308
|
-
chunks.push(d)
|
|
309
|
-
})
|
|
310
|
-
req.on('end', () => { if (_over) return; try { const d = JSON.parse(Buffer.concat(chunks).toString()); console.log('[browser]', ...d) } catch(_) {}; res.writeHead(200); res.end() })
|
|
311
|
-
return
|
|
312
|
-
}
|
|
313
|
-
if (req.method === 'POST' && req.url === '/client-error') {
|
|
314
|
-
// PUBLIC, opt-in-only-on-the-CLIENT-side endpoint (client/core/ErrorTelemetry.js) --
|
|
315
|
-
// unlike /debug-log and /upload-model above, this is intentionally reachable from any
|
|
316
|
-
// real deployed player, not loopback/EDITOR_TOKEN-gated, since the whole point is to
|
|
317
|
-
// hear from crashes on machines the operator has no console access to. The gate here is
|
|
318
|
-
// purely anti-abuse (rate limit + size cap), not an identity/auth check -- the payload
|
|
319
|
-
// itself carries no PII by construction (see ErrorTelemetry.js's schema comment).
|
|
320
|
-
const _remote = req.socket?.remoteAddress || ''
|
|
321
|
-
if (clientErrorRateLimited(_remote)) { res.writeHead(429); res.end('rate limited'); return }
|
|
322
|
-
const _CLIENT_ERROR_MAX = 16 * 1024 // payload is a small structured JSON object, not a log dump
|
|
323
|
-
let _len = 0, _over = false
|
|
324
|
-
const chunks = []
|
|
325
|
-
req.on('data', d => {
|
|
326
|
-
if (_over) return
|
|
327
|
-
_len += d.length
|
|
328
|
-
if (_len > _CLIENT_ERROR_MAX) { _over = true; res.writeHead(413); res.end('payload too large'); req.destroy(); return }
|
|
329
|
-
chunks.push(d)
|
|
330
|
-
})
|
|
331
|
-
req.on('end', () => {
|
|
332
|
-
if (_over) return
|
|
333
|
-
try {
|
|
334
|
-
const report = JSON.parse(Buffer.concat(chunks).toString())
|
|
335
|
-
// Structured, one-line-per-report console surface (an operator greps/aggregates
|
|
336
|
-
// this today; a real dashboard/store is explicitly out of scope for this first
|
|
337
|
-
// slice -- see the sibling PRD row filed for that). kind/message/stack/url/ua/ts
|
|
338
|
-
// are the ErrorTelemetry.js schema fields; renderControls/deviceTier are attached
|
|
339
|
-
// objects, logged inline so `console.log`'s default object formatting keeps them
|
|
340
|
-
// inspectable rather than flattened into an unreadable string.
|
|
341
|
-
console.error(`[client-error] ${report.kind || 'error'}: ${String(report.message || '').slice(0, 500)}`,
|
|
342
|
-
{ url: report.url, ua: report.ua, stack: String(report.stack || '').slice(0, 2000), renderControls: report.renderControls, deviceTier: report.deviceTier, remote: _remote })
|
|
343
|
-
} catch (_) { /* malformed payload from a hostile/buggy client -- drop silently, still 200 so sendBeacon doesn't retry-storm */ }
|
|
344
|
-
res.writeHead(200); res.end()
|
|
345
|
-
})
|
|
346
|
-
return
|
|
347
|
-
}
|
|
348
|
-
if (req.method === 'GET' && req.url === '/debug/server') {
|
|
349
|
-
// loopback-only: leaks tick/player/entity/session counts + process memory internals
|
|
350
|
-
const remote = req.socket?.remoteAddress || ''
|
|
351
|
-
if (remote !== '127.0.0.1' && remote !== '::1' && remote !== '::ffff:127.0.0.1') { res.writeHead(403); res.end('forbidden'); return }
|
|
352
|
-
const data = JSON.stringify({
|
|
353
|
-
tick: tickSystem.currentTick,
|
|
354
|
-
tickRate: ctx.tickRate,
|
|
355
|
-
players: playerManager.getPlayerCount(),
|
|
356
|
-
entities: appRuntime.entities.size,
|
|
357
|
-
connections: connections.getAllStats(),
|
|
358
|
-
sessions: sessions.getActiveCount(),
|
|
359
|
-
heap: process.memoryUsage()
|
|
360
|
-
})
|
|
361
|
-
res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(data); return
|
|
362
|
-
}
|
|
363
|
-
if (req.method === 'GET' && req.url === '/metrics') {
|
|
364
|
-
// server-scale-prometheus-metrics-endpoint-dashboard: same loopback-only gate as /debug/server
|
|
365
|
-
// immediately above -- this leaks the identical class of operational internals (tick/player/
|
|
366
|
-
// entity counts, process memory), just reformatted for Prometheus scrape instead of a one-shot
|
|
367
|
-
// JSON GET. A Prometheus server itself is expected to run co-located (or reached via an
|
|
368
|
-
// operator-controlled reverse-proxy/tunnel that terminates on loopback), matching how every
|
|
369
|
-
// other loopback-gated route in this file is already meant to be consumed.
|
|
370
|
-
const remote = req.socket?.remoteAddress || ''
|
|
371
|
-
if (remote !== '127.0.0.1' && remote !== '::1' && remote !== '::ffff:127.0.0.1') { res.writeHead(403); res.end('forbidden'); return }
|
|
372
|
-
const body = renderMetrics({
|
|
373
|
-
tick: tickSystem.currentTick,
|
|
374
|
-
tickRate: ctx.tickRate,
|
|
375
|
-
players: playerManager.getPlayerCount(),
|
|
376
|
-
entities: appRuntime.entities.size,
|
|
377
|
-
sessionCount: sessions.getActiveCount(),
|
|
378
|
-
uptimeSec: process.uptime(),
|
|
379
|
-
memoryUsage: () => process.memoryUsage(),
|
|
380
|
-
// TickHandler.js's onTick.getMetrics() -- see ctx.tickHandlerFn (server.js/WorkerEntry.js
|
|
381
|
-
// setTickHandler), a stable alias reload-swappable handlerState.fn is mirrored onto so this
|
|
382
|
-
// route never reaches into reload-internal plumbing directly. Absent (fresh boot before the
|
|
383
|
-
// first tick, or a handler build that predates this alias) degrades to no tickTiming section
|
|
384
|
-
// rather than throwing -- /metrics must stay a safe, always-200 operational surface.
|
|
385
|
-
tickTiming: typeof ctx.tickHandlerFn?.getMetrics === 'function' ? ctx.tickHandlerFn.getMetrics() : null,
|
|
386
|
-
// RoomDirectory (src/sdk/RoomDirectory.js) is a standalone, opt-in multi-room primitive not
|
|
387
|
-
// constructed by every boot path -- its own getStatus() doc comment already names this route
|
|
388
|
-
// as its intended consumer, so a caller that DOES wire one up onto ctx.roomDirectory gets
|
|
389
|
-
// per-room rows for free with zero further ServerAPI.js changes; every other boot path simply
|
|
390
|
-
// omits the rooms section (Array.isArray guard in renderMetrics).
|
|
391
|
-
rooms: typeof ctx.roomDirectory?.getStatus === 'function' ? ctx.roomDirectory.getStatus() : undefined,
|
|
392
|
-
})
|
|
393
|
-
res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' }); res.end(body); return
|
|
394
|
-
}
|
|
395
|
-
if (req.method === 'GET' && req.url === '/benchmark') {
|
|
396
|
-
// Public benchmark endpoint (see PRD rows ugc-platform + ugc-public-benchmark-dashboard):
|
|
397
|
-
// exposes standardized server performance data as JSON with CORS headers so a static HTML
|
|
398
|
-
// dashboard page (client/benchmark.html) can consume it from any origin. Deliberately UN-gated
|
|
399
|
-
// (no loopback/EDITOR_TOKEN check) -- this is a public brag surface, not an operational secret.
|
|
400
|
-
// The data shape is deliberately high-level (tick stats, player counts, memory, build info) and
|
|
401
|
-
// carries zero PII, internal IPs, auth tokens, or player-identifying data.
|
|
402
|
-
try {
|
|
403
|
-
const data = collectBenchmark(ctx)
|
|
404
|
-
const json = JSON.stringify(data)
|
|
405
|
-
res.writeHead(200, {
|
|
406
|
-
'Content-Type': 'application/json',
|
|
407
|
-
'Cache-Control': 'no-cache',
|
|
408
|
-
'Access-Control-Allow-Origin': '*',
|
|
409
|
-
})
|
|
410
|
-
res.end(json)
|
|
411
|
-
} catch (err) {
|
|
412
|
-
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
413
|
-
res.end(JSON.stringify({ error: 'benchmark collection failed', detail: err.message }))
|
|
414
|
-
}
|
|
415
|
-
return
|
|
416
|
-
}
|
|
417
|
-
if (req.method === 'POST' && req.url === '/freddie/viz') {
|
|
418
|
-
// FreddieBridge viz endpoint: accepts FreddieBridge messages (JSON), validates them,
|
|
419
|
-
// and creates/updates/destroys entities in the live world. EDITOR_TOKEN-gated when
|
|
420
|
-
// configured (same discipline as /upload-model above); an unset EDITOR_TOKEN leaves
|
|
421
|
-
// this endpoint open (dev default). Rate-limited by body size for safety.
|
|
422
|
-
const _tok = process.env.EDITOR_TOKEN
|
|
423
|
-
if (_tok && !timingSafeTokenEqual(req.headers['x-editor-token'], _tok)) { res.writeHead(403); res.end('forbidden'); return }
|
|
424
|
-
const _FREDDIE_MAX = 256 * 1024
|
|
425
|
-
let _len = 0, _over = false
|
|
426
|
-
const chunks = []
|
|
427
|
-
req.on('data', d => {
|
|
428
|
-
if (_over) return
|
|
429
|
-
_len += d.length
|
|
430
|
-
if (_len > _FREDDIE_MAX) { _over = true; res.writeHead(413); res.end('payload too large'); req.destroy(); return }
|
|
431
|
-
chunks.push(d)
|
|
432
|
-
})
|
|
433
|
-
req.on('end', () => {
|
|
434
|
-
if (_over) return
|
|
435
|
-
let body
|
|
436
|
-
try { body = JSON.parse(Buffer.concat(chunks).toString()) } catch (_) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'invalid JSON' })); return }
|
|
437
|
-
// Accept a single message or an array of messages
|
|
438
|
-
const messages = Array.isArray(body) ? body : [body]
|
|
439
|
-
const results = []
|
|
440
|
-
for (const msg of messages) {
|
|
441
|
-
const v = validateMessage(msg)
|
|
442
|
-
if (!v.valid) { results.push({ id: msg.id, ok: false, error: 'validation failed', detail: v.errors }); continue }
|
|
443
|
-
try {
|
|
444
|
-
if (msg.kind === KIND_PLACE) {
|
|
445
|
-
const p = msg.payload
|
|
446
|
-
const entityId = p.entityId
|
|
447
|
-
// Remove existing entity with same id if present (idempotent place)
|
|
448
|
-
if (appRuntime.entities.has(entityId)) appRuntime.destroyEntity(entityId)
|
|
449
|
-
const cfg = {
|
|
450
|
-
position: p.position || [0, 0, 0],
|
|
451
|
-
scale: p.scale || [1, 1, 1],
|
|
452
|
-
custom: {
|
|
453
|
-
mesh: p.primitive || 'box',
|
|
454
|
-
color: p.color ?? 0xffffff,
|
|
455
|
-
emissive: p.emissive ?? 0x000000,
|
|
456
|
-
opacity: p.opacity ?? 1,
|
|
457
|
-
label: p.label || null,
|
|
458
|
-
_freddieSource: msg.source,
|
|
459
|
-
_freddieId: entityId,
|
|
460
|
-
},
|
|
461
|
-
config: {},
|
|
462
|
-
}
|
|
463
|
-
if (p.primitive === 'model' && p.model) cfg.model = p.model
|
|
464
|
-
appRuntime.spawnEntity(entityId, cfg)
|
|
465
|
-
results.push({ id: msg.id, ok: true, entityId })
|
|
466
|
-
} else if (msg.kind === KIND_UPDATE) {
|
|
467
|
-
const p = msg.payload
|
|
468
|
-
const e = appRuntime.entities.get(p.entityId)
|
|
469
|
-
if (!e) { results.push({ id: msg.id, ok: false, error: 'entity not found', entityId: p.entityId }); continue }
|
|
470
|
-
if (p.position) e.position = [...p.position]
|
|
471
|
-
if (p.scale) e.scale = [...p.scale]
|
|
472
|
-
if (e.custom) {
|
|
473
|
-
if (p.color !== undefined) e.custom.color = p.color
|
|
474
|
-
if (p.emissive !== undefined) e.custom.emissive = p.emissive
|
|
475
|
-
if (p.opacity !== undefined) e.custom.opacity = p.opacity
|
|
476
|
-
if (p.label !== undefined) e.custom.label = p.label
|
|
477
|
-
}
|
|
478
|
-
results.push({ id: msg.id, ok: true, entityId: p.entityId })
|
|
479
|
-
} else if (msg.kind === KIND_REMOVE) {
|
|
480
|
-
appRuntime.destroyEntity(msg.payload.entityId)
|
|
481
|
-
results.push({ id: msg.id, ok: true, entityId: msg.payload.entityId })
|
|
482
|
-
} else if (msg.kind === KIND_CLEAR) {
|
|
483
|
-
// Remove all entities created by this source
|
|
484
|
-
const source = msg.source
|
|
485
|
-
const toRemove = []
|
|
486
|
-
for (const [id, e] of appRuntime.entities) {
|
|
487
|
-
if (e.custom?._freddieSource === source) toRemove.push(id)
|
|
488
|
-
}
|
|
489
|
-
for (const id of toRemove) appRuntime.destroyEntity(id)
|
|
490
|
-
results.push({ id: msg.id, ok: true, removed: toRemove.length })
|
|
491
|
-
} else {
|
|
492
|
-
results.push({ id: msg.id, ok: false, error: `unhandled kind: ${msg.kind}` })
|
|
493
|
-
}
|
|
494
|
-
} catch (e) {
|
|
495
|
-
results.push({ id: msg.id, ok: false, error: e.message })
|
|
496
|
-
}
|
|
497
|
-
}
|
|
498
|
-
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
499
|
-
res.end(JSON.stringify(Array.isArray(body) ? results : results[0]))
|
|
500
|
-
})
|
|
501
|
-
return
|
|
502
|
-
}
|
|
237
|
+
if (req.method === 'POST' && req.url === '/upload-model') { handleUploadModel(req, res, uploadHandler); return }
|
|
238
|
+
if (req.method === 'POST' && req.url === '/debug-log') { handleDebugLog(req, res); return }
|
|
239
|
+
if (req.method === 'POST' && req.url === '/client-error') { handleClientError(req, res); return }
|
|
240
|
+
if (req.method === 'GET' && req.url === '/debug/server') { handleDebugServer(req, res, ctx); return }
|
|
241
|
+
if (req.method === 'GET' && req.url === '/metrics') { handleMetrics(req, res, ctx); return }
|
|
242
|
+
if (req.method === 'GET' && req.url === '/benchmark') { handleBenchmark(req, res, ctx); return }
|
|
243
|
+
if (req.method === 'POST' && req.url === '/freddie/viz') { handleFreddieViz(req, res, appRuntime); return }
|
|
503
244
|
if (staticHandler) {
|
|
504
245
|
Promise.resolve(staticHandler(req, res)).catch(e => {
|
|
505
246
|
console.error('[static] handler error:', e?.message || e)
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// HTTP route handlers for ServerAPI.js's start(): /upload-model, /debug-log, /client-error,
|
|
2
|
+
// /debug/server, /metrics, /benchmark, /freddie/viz. Split out because start()'s httpHandler
|
|
3
|
+
// closure was the single largest contiguous block in ServerAPI.js -- each handler here is a pure
|
|
4
|
+
// function of (req, res, ctx-derived state), no shared closure with the rest of ServerAPI.js beyond
|
|
5
|
+
// what's passed in explicitly.
|
|
6
|
+
|
|
7
|
+
import { timingSafeTokenEqual } from './authCompare.js'
|
|
8
|
+
import { renderMetrics } from './Metrics.js'
|
|
9
|
+
import { collectBenchmark } from './PublicBenchmark.js'
|
|
10
|
+
import { validateMessage, KIND_PLACE, KIND_UPDATE, KIND_REMOVE, KIND_CLEAR } from './FreddieBridge.js'
|
|
11
|
+
|
|
12
|
+
// Per-IP token bucket for /debug-log: caps sustained log-line volume from any single origin even after
|
|
13
|
+
// the loopback/EDITOR_TOKEN gate passes, so a single misbehaving/malicious client on an allowed origin
|
|
14
|
+
// can't still spam the server console / consume CPU by hammering the endpoint at wire speed.
|
|
15
|
+
const DEBUG_LOG_BUCKET_CAPACITY = 20 // burst allowance, lines
|
|
16
|
+
const DEBUG_LOG_BUCKET_REFILL_PER_SEC = 5 // steady-state cap, lines/sec
|
|
17
|
+
const _debugLogBuckets = new Map() // ip -> { tokens, lastRefillMs }
|
|
18
|
+
|
|
19
|
+
function debugLogRateLimited(ip) {
|
|
20
|
+
const now = Date.now()
|
|
21
|
+
let b = _debugLogBuckets.get(ip)
|
|
22
|
+
if (!b) { b = { tokens: DEBUG_LOG_BUCKET_CAPACITY, lastRefillMs: now }; _debugLogBuckets.set(ip, b) }
|
|
23
|
+
const elapsedSec = (now - b.lastRefillMs) / 1000
|
|
24
|
+
if (elapsedSec > 0) {
|
|
25
|
+
b.tokens = Math.min(DEBUG_LOG_BUCKET_CAPACITY, b.tokens + elapsedSec * DEBUG_LOG_BUCKET_REFILL_PER_SEC)
|
|
26
|
+
b.lastRefillMs = now
|
|
27
|
+
}
|
|
28
|
+
if (b.tokens < 1) return true // no tokens left -> rate limited
|
|
29
|
+
b.tokens -= 1
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Per-IP token bucket for /client-error: same shape as debugLogRateLimited above, but this
|
|
34
|
+
// endpoint is PUBLIC (real deployed players, not loopback-only dev tooling) so the bucket is the
|
|
35
|
+
// only defense against a hostile or buggy client flooding the server with crash reports -- tighter
|
|
36
|
+
// than the debug-log bucket since a real crash storm (e.g. every connected player hitting the same
|
|
37
|
+
// bug at once) should still log a representative sample, not every single occurrence.
|
|
38
|
+
const CLIENT_ERROR_BUCKET_CAPACITY = 5
|
|
39
|
+
const CLIENT_ERROR_BUCKET_REFILL_PER_SEC = 0.2 // 1 report per 5s steady-state per IP
|
|
40
|
+
const _clientErrorBuckets = new Map() // ip -> { tokens, lastRefillMs }
|
|
41
|
+
|
|
42
|
+
function clientErrorRateLimited(ip) {
|
|
43
|
+
const now = Date.now()
|
|
44
|
+
let b = _clientErrorBuckets.get(ip)
|
|
45
|
+
if (!b) { b = { tokens: CLIENT_ERROR_BUCKET_CAPACITY, lastRefillMs: now }; _clientErrorBuckets.set(ip, b) }
|
|
46
|
+
const elapsedSec = (now - b.lastRefillMs) / 1000
|
|
47
|
+
if (elapsedSec > 0) {
|
|
48
|
+
b.tokens = Math.min(CLIENT_ERROR_BUCKET_CAPACITY, b.tokens + elapsedSec * CLIENT_ERROR_BUCKET_REFILL_PER_SEC)
|
|
49
|
+
b.lastRefillMs = now
|
|
50
|
+
}
|
|
51
|
+
if (b.tokens < 1) return true
|
|
52
|
+
b.tokens -= 1
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function handleUploadModel(req, res, uploadHandler) {
|
|
57
|
+
const _tok = process.env.EDITOR_TOKEN
|
|
58
|
+
if (_tok && !timingSafeTokenEqual(req.headers['x-editor-token'], _tok)) { res.writeHead(403); res.end('forbidden'); return }
|
|
59
|
+
uploadHandler(req, res)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function handleDebugLog(req, res) {
|
|
63
|
+
// gated: loopback origin is always allowed (local dev console passthrough); a non-loopback
|
|
64
|
+
// origin must present a valid X-Editor-Token when EDITOR_TOKEN is configured, and is refused
|
|
65
|
+
// outright when it isn't (an unset EDITOR_TOKEN must not leave this endpoint open to the world).
|
|
66
|
+
const _remote = req.socket?.remoteAddress || ''
|
|
67
|
+
const _isLoopback = _remote === '127.0.0.1' || _remote === '::1' || _remote === '::ffff:127.0.0.1'
|
|
68
|
+
if (!_isLoopback) {
|
|
69
|
+
const _tok = process.env.EDITOR_TOKEN
|
|
70
|
+
if (!_tok || !timingSafeTokenEqual(req.headers['x-editor-token'], _tok)) { res.writeHead(403); res.end('forbidden'); return }
|
|
71
|
+
}
|
|
72
|
+
// token-bucket rate limit per-IP: caps sustained lines/sec even from an already-authorized origin
|
|
73
|
+
if (debugLogRateLimited(_remote)) { res.writeHead(429); res.end('rate limited'); return }
|
|
74
|
+
// size-capped: unbounded body buffering here let any origin exhaust server memory
|
|
75
|
+
const _DEBUG_LOG_MAX = 256 * 1024
|
|
76
|
+
let _len = 0, _over = false
|
|
77
|
+
const chunks = []
|
|
78
|
+
req.on('data', d => {
|
|
79
|
+
if (_over) return
|
|
80
|
+
_len += d.length
|
|
81
|
+
if (_len > _DEBUG_LOG_MAX) { _over = true; res.writeHead(413); res.end('payload too large'); req.destroy(); return }
|
|
82
|
+
chunks.push(d)
|
|
83
|
+
})
|
|
84
|
+
req.on('end', () => { if (_over) return; try { const d = JSON.parse(Buffer.concat(chunks).toString()); console.log('[browser]', ...d) } catch(_) {}; res.writeHead(200); res.end() })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function handleClientError(req, res) {
|
|
88
|
+
// PUBLIC, opt-in-only-on-the-CLIENT-side endpoint (client/core/ErrorTelemetry.js) --
|
|
89
|
+
// unlike /debug-log and /upload-model above, this is intentionally reachable from any
|
|
90
|
+
// real deployed player, not loopback/EDITOR_TOKEN-gated, since the whole point is to
|
|
91
|
+
// hear from crashes on machines the operator has no console access to. The gate here is
|
|
92
|
+
// purely anti-abuse (rate limit + size cap), not an identity/auth check -- the payload
|
|
93
|
+
// itself carries no PII by construction (see ErrorTelemetry.js's schema comment).
|
|
94
|
+
const _remote = req.socket?.remoteAddress || ''
|
|
95
|
+
if (clientErrorRateLimited(_remote)) { res.writeHead(429); res.end('rate limited'); return }
|
|
96
|
+
const _CLIENT_ERROR_MAX = 16 * 1024 // payload is a small structured JSON object, not a log dump
|
|
97
|
+
let _len = 0, _over = false
|
|
98
|
+
const chunks = []
|
|
99
|
+
req.on('data', d => {
|
|
100
|
+
if (_over) return
|
|
101
|
+
_len += d.length
|
|
102
|
+
if (_len > _CLIENT_ERROR_MAX) { _over = true; res.writeHead(413); res.end('payload too large'); req.destroy(); return }
|
|
103
|
+
chunks.push(d)
|
|
104
|
+
})
|
|
105
|
+
req.on('end', () => {
|
|
106
|
+
if (_over) return
|
|
107
|
+
try {
|
|
108
|
+
const report = JSON.parse(Buffer.concat(chunks).toString())
|
|
109
|
+
// Structured, one-line-per-report console surface (an operator greps/aggregates
|
|
110
|
+
// this today; a real dashboard/store is explicitly out of scope for this first
|
|
111
|
+
// slice -- see the sibling PRD row filed for that). kind/message/stack/url/ua/ts
|
|
112
|
+
// are the ErrorTelemetry.js schema fields; renderControls/deviceTier are attached
|
|
113
|
+
// objects, logged inline so `console.log`'s default object formatting keeps them
|
|
114
|
+
// inspectable rather than flattened into an unreadable string.
|
|
115
|
+
console.error(`[client-error] ${report.kind || 'error'}: ${String(report.message || '').slice(0, 500)}`,
|
|
116
|
+
{ url: report.url, ua: report.ua, stack: String(report.stack || '').slice(0, 2000), renderControls: report.renderControls, deviceTier: report.deviceTier, remote: _remote })
|
|
117
|
+
} catch (_) { /* malformed payload from a hostile/buggy client -- drop silently, still 200 so sendBeacon doesn't retry-storm */ }
|
|
118
|
+
res.writeHead(200); res.end()
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function handleDebugServer(req, res, ctx) {
|
|
123
|
+
// loopback-only: leaks tick/player/entity/session counts + process memory internals
|
|
124
|
+
const remote = req.socket?.remoteAddress || ''
|
|
125
|
+
if (remote !== '127.0.0.1' && remote !== '::1' && remote !== '::ffff:127.0.0.1') { res.writeHead(403); res.end('forbidden'); return }
|
|
126
|
+
const { tickSystem, playerManager, appRuntime, connections, sessions } = ctx
|
|
127
|
+
const data = JSON.stringify({
|
|
128
|
+
tick: tickSystem.currentTick,
|
|
129
|
+
tickRate: ctx.tickRate,
|
|
130
|
+
players: playerManager.getPlayerCount(),
|
|
131
|
+
entities: appRuntime.entities.size,
|
|
132
|
+
connections: connections.getAllStats(),
|
|
133
|
+
sessions: sessions.getActiveCount(),
|
|
134
|
+
heap: process.memoryUsage()
|
|
135
|
+
})
|
|
136
|
+
res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(data)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function handleMetrics(req, res, ctx) {
|
|
140
|
+
// server-scale-prometheus-metrics-endpoint-dashboard: same loopback-only gate as /debug/server
|
|
141
|
+
// immediately above -- this leaks the identical class of operational internals (tick/player/
|
|
142
|
+
// entity counts, process memory), just reformatted for Prometheus scrape instead of a one-shot
|
|
143
|
+
// JSON GET. A Prometheus server itself is expected to run co-located (or reached via an
|
|
144
|
+
// operator-controlled reverse-proxy/tunnel that terminates on loopback), matching how every
|
|
145
|
+
// other loopback-gated route in this file is already meant to be consumed.
|
|
146
|
+
const remote = req.socket?.remoteAddress || ''
|
|
147
|
+
if (remote !== '127.0.0.1' && remote !== '::1' && remote !== '::ffff:127.0.0.1') { res.writeHead(403); res.end('forbidden'); return }
|
|
148
|
+
const { tickSystem, playerManager, appRuntime, sessions } = ctx
|
|
149
|
+
const body = renderMetrics({
|
|
150
|
+
tick: tickSystem.currentTick,
|
|
151
|
+
tickRate: ctx.tickRate,
|
|
152
|
+
players: playerManager.getPlayerCount(),
|
|
153
|
+
entities: appRuntime.entities.size,
|
|
154
|
+
sessionCount: sessions.getActiveCount(),
|
|
155
|
+
uptimeSec: process.uptime(),
|
|
156
|
+
memoryUsage: () => process.memoryUsage(),
|
|
157
|
+
// TickHandler.js's onTick.getMetrics() -- see ctx.tickHandlerFn (server.js/WorkerEntry.js
|
|
158
|
+
// setTickHandler), a stable alias reload-swappable handlerState.fn is mirrored onto so this
|
|
159
|
+
// route never reaches into reload-internal plumbing directly. Absent (fresh boot before the
|
|
160
|
+
// first tick, or a handler build that predates this alias) degrades to no tickTiming section
|
|
161
|
+
// rather than throwing -- /metrics must stay a safe, always-200 operational surface.
|
|
162
|
+
tickTiming: typeof ctx.tickHandlerFn?.getMetrics === 'function' ? ctx.tickHandlerFn.getMetrics() : null,
|
|
163
|
+
// RoomDirectory (src/sdk/RoomDirectory.js) is a standalone, opt-in multi-room primitive not
|
|
164
|
+
// constructed by every boot path -- its own getStatus() doc comment already names this route
|
|
165
|
+
// as its intended consumer, so a caller that DOES wire one up onto ctx.roomDirectory gets
|
|
166
|
+
// per-room rows for free with zero further ServerAPI.js changes; every other boot path simply
|
|
167
|
+
// omits the rooms section (Array.isArray guard in renderMetrics).
|
|
168
|
+
rooms: typeof ctx.roomDirectory?.getStatus === 'function' ? ctx.roomDirectory.getStatus() : undefined,
|
|
169
|
+
})
|
|
170
|
+
res.writeHead(200, { 'Content-Type': 'text/plain; version=0.0.4; charset=utf-8' }); res.end(body)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function handleBenchmark(req, res, ctx) {
|
|
174
|
+
// Public benchmark endpoint (see PRD rows ugc-platform + ugc-public-benchmark-dashboard):
|
|
175
|
+
// exposes standardized server performance data as JSON with CORS headers so a static HTML
|
|
176
|
+
// dashboard page (client/benchmark.html) can consume it from any origin. Deliberately UN-gated
|
|
177
|
+
// (no loopback/EDITOR_TOKEN check) -- this is a public brag surface, not an operational secret.
|
|
178
|
+
// The data shape is deliberately high-level (tick stats, player counts, memory, build info) and
|
|
179
|
+
// carries zero PII, internal IPs, auth tokens, or player-identifying data.
|
|
180
|
+
try {
|
|
181
|
+
const data = collectBenchmark(ctx)
|
|
182
|
+
const json = JSON.stringify(data)
|
|
183
|
+
res.writeHead(200, {
|
|
184
|
+
'Content-Type': 'application/json',
|
|
185
|
+
'Cache-Control': 'no-cache',
|
|
186
|
+
'Access-Control-Allow-Origin': '*',
|
|
187
|
+
})
|
|
188
|
+
res.end(json)
|
|
189
|
+
} catch (err) {
|
|
190
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
191
|
+
res.end(JSON.stringify({ error: 'benchmark collection failed', detail: err.message }))
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function handleFreddieViz(req, res, appRuntime) {
|
|
196
|
+
// FreddieBridge viz endpoint: accepts FreddieBridge messages (JSON), validates them,
|
|
197
|
+
// and creates/updates/destroys entities in the live world. EDITOR_TOKEN-gated when
|
|
198
|
+
// configured (same discipline as /upload-model above); an unset EDITOR_TOKEN leaves
|
|
199
|
+
// this endpoint open (dev default). Rate-limited by body size for safety.
|
|
200
|
+
const _tok = process.env.EDITOR_TOKEN
|
|
201
|
+
if (_tok && !timingSafeTokenEqual(req.headers['x-editor-token'], _tok)) { res.writeHead(403); res.end('forbidden'); return }
|
|
202
|
+
const _FREDDIE_MAX = 256 * 1024
|
|
203
|
+
let _len = 0, _over = false
|
|
204
|
+
const chunks = []
|
|
205
|
+
req.on('data', d => {
|
|
206
|
+
if (_over) return
|
|
207
|
+
_len += d.length
|
|
208
|
+
if (_len > _FREDDIE_MAX) { _over = true; res.writeHead(413); res.end('payload too large'); req.destroy(); return }
|
|
209
|
+
chunks.push(d)
|
|
210
|
+
})
|
|
211
|
+
req.on('end', () => {
|
|
212
|
+
if (_over) return
|
|
213
|
+
let body
|
|
214
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString()) } catch (_) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'invalid JSON' })); return }
|
|
215
|
+
// Accept a single message or an array of messages
|
|
216
|
+
const messages = Array.isArray(body) ? body : [body]
|
|
217
|
+
const results = []
|
|
218
|
+
for (const msg of messages) {
|
|
219
|
+
const v = validateMessage(msg)
|
|
220
|
+
if (!v.valid) { results.push({ id: msg.id, ok: false, error: 'validation failed', detail: v.errors }); continue }
|
|
221
|
+
try {
|
|
222
|
+
if (msg.kind === KIND_PLACE) {
|
|
223
|
+
const p = msg.payload
|
|
224
|
+
const entityId = p.entityId
|
|
225
|
+
// Remove existing entity with same id if present (idempotent place)
|
|
226
|
+
if (appRuntime.entities.has(entityId)) appRuntime.destroyEntity(entityId)
|
|
227
|
+
const cfg = {
|
|
228
|
+
position: p.position || [0, 0, 0],
|
|
229
|
+
scale: p.scale || [1, 1, 1],
|
|
230
|
+
custom: {
|
|
231
|
+
mesh: p.primitive || 'box',
|
|
232
|
+
color: p.color ?? 0xffffff,
|
|
233
|
+
emissive: p.emissive ?? 0x000000,
|
|
234
|
+
opacity: p.opacity ?? 1,
|
|
235
|
+
label: p.label || null,
|
|
236
|
+
_freddieSource: msg.source,
|
|
237
|
+
_freddieId: entityId,
|
|
238
|
+
},
|
|
239
|
+
config: {},
|
|
240
|
+
}
|
|
241
|
+
if (p.primitive === 'model' && p.model) cfg.model = p.model
|
|
242
|
+
appRuntime.spawnEntity(entityId, cfg)
|
|
243
|
+
results.push({ id: msg.id, ok: true, entityId })
|
|
244
|
+
} else if (msg.kind === KIND_UPDATE) {
|
|
245
|
+
const p = msg.payload
|
|
246
|
+
const e = appRuntime.entities.get(p.entityId)
|
|
247
|
+
if (!e) { results.push({ id: msg.id, ok: false, error: 'entity not found', entityId: p.entityId }); continue }
|
|
248
|
+
if (p.position) e.position = [...p.position]
|
|
249
|
+
if (p.scale) e.scale = [...p.scale]
|
|
250
|
+
if (e.custom) {
|
|
251
|
+
if (p.color !== undefined) e.custom.color = p.color
|
|
252
|
+
if (p.emissive !== undefined) e.custom.emissive = p.emissive
|
|
253
|
+
if (p.opacity !== undefined) e.custom.opacity = p.opacity
|
|
254
|
+
if (p.label !== undefined) e.custom.label = p.label
|
|
255
|
+
}
|
|
256
|
+
results.push({ id: msg.id, ok: true, entityId: p.entityId })
|
|
257
|
+
} else if (msg.kind === KIND_REMOVE) {
|
|
258
|
+
appRuntime.destroyEntity(msg.payload.entityId)
|
|
259
|
+
results.push({ id: msg.id, ok: true, entityId: msg.payload.entityId })
|
|
260
|
+
} else if (msg.kind === KIND_CLEAR) {
|
|
261
|
+
// Remove all entities created by this source
|
|
262
|
+
const source = msg.source
|
|
263
|
+
const toRemove = []
|
|
264
|
+
for (const [id, e] of appRuntime.entities) {
|
|
265
|
+
if (e.custom?._freddieSource === source) toRemove.push(id)
|
|
266
|
+
}
|
|
267
|
+
for (const id of toRemove) appRuntime.destroyEntity(id)
|
|
268
|
+
results.push({ id: msg.id, ok: true, removed: toRemove.length })
|
|
269
|
+
} else {
|
|
270
|
+
results.push({ id: msg.id, ok: false, error: `unhandled kind: ${msg.kind}` })
|
|
271
|
+
}
|
|
272
|
+
} catch (e) {
|
|
273
|
+
results.push({ id: msg.id, ok: false, error: e.message })
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
277
|
+
res.end(JSON.stringify(Array.isArray(body) ? results : results[0]))
|
|
278
|
+
})
|
|
279
|
+
}
|