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.
@@ -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
- // Streak quad: a thin vertical billboard-ish quad (not a full 3D cylinder -- rain streaks read as a
40
- // 2D motion-blurred line from any practical viewing angle, matching the cheap-shading discipline
41
- // Grass.js documents for its own Lambert-lite material). Built once, shared across all instances.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spoint",
3
- "version": "0.1.653",
3
+ "version": "0.1.655",
4
4
  "description": "Physics and netcode SDK for multiplayer game servers",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -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 { collectBenchmark } from './PublicBenchmark.js'
14
- import { validateMessage, KIND_PLACE, KIND_UPDATE, KIND_REMOVE, KIND_CLEAR, KIND_DATASET, KIND_CAMERA } from './FreddieBridge.js'
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
- // EDITOR_TOKEN unset = open (dev default); configured = required via X-Editor-Token header
284
- const _tok = process.env.EDITOR_TOKEN
285
- if (_tok && !timingSafeTokenEqual(req.headers['x-editor-token'], _tok)) { res.writeHead(403); res.end('forbidden'); return }
286
- uploadHandler(req, res); return
287
- }
288
- if (req.method === 'POST' && req.url === '/debug-log') {
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
+ }