spoint 0.1.652 → 0.1.654
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/RoomOrchestrator.js +9 -120
- package/src/sdk/RoomOrchestratorHttp.js +139 -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
|
@@ -47,8 +47,11 @@
|
|
|
47
47
|
import { fork } from 'node:child_process'
|
|
48
48
|
import { fileURLToPath } from 'node:url'
|
|
49
49
|
import { join, dirname } from 'node:path'
|
|
50
|
-
import {
|
|
51
|
-
|
|
50
|
+
import { readJsonBody, httpJsonRequest, scoreWorkerRooms, startRoomOrchestratorRouter } from './RoomOrchestratorHttp.js'
|
|
51
|
+
|
|
52
|
+
// Re-exported from RoomOrchestratorHttp.js for backward compatibility -- bin/room-orchestrator-boot.js
|
|
53
|
+
// imports readJsonBody from this file's own path.
|
|
54
|
+
export { readJsonBody }
|
|
52
55
|
|
|
53
56
|
const SDK_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
|
|
54
57
|
const WORKER_ENTRY = join(SDK_ROOT, 'src', 'sdk', 'RoomProcessWorker.js')
|
|
@@ -305,17 +308,11 @@ export class RoomOrchestrator {
|
|
|
305
308
|
const perWorkerRooms = await Promise.all(
|
|
306
309
|
readyIdx.map(i => this._send(i, { type: 'GET_STATUS' }).then(r => r.rooms).catch(() => null))
|
|
307
310
|
)
|
|
308
|
-
const PLAYER_WEIGHT = 1.0, ENTITY_WEIGHT = 0.02, TICKMS_WEIGHT = 0.5, DILATION_PENALTY = 50
|
|
309
311
|
let allOverThreshold = readyIdx.length > 0
|
|
310
312
|
for (let k = 0; k < readyIdx.length; k++) {
|
|
311
|
-
const i = readyIdx[k]
|
|
312
313
|
const rooms = perWorkerRooms[k]
|
|
313
314
|
if (!rooms || rooms.length === 0) { allOverThreshold = false; break }
|
|
314
|
-
const score = rooms
|
|
315
|
-
+ (r.players || 0) * PLAYER_WEIGHT
|
|
316
|
-
+ (r.entities || 0) * ENTITY_WEIGHT
|
|
317
|
-
+ (r.avgTickMs || 0) * TICKMS_WEIGHT
|
|
318
|
-
+ (1 - (r.dilationFactor ?? 1)) * DILATION_PENALTY, 0)
|
|
315
|
+
const score = scoreWorkerRooms(rooms)
|
|
319
316
|
if (score < this._elasticScaleUpThreshold) { allOverThreshold = false; break }
|
|
320
317
|
}
|
|
321
318
|
if (allOverThreshold) {
|
|
@@ -490,7 +487,6 @@ export class RoomOrchestrator {
|
|
|
490
487
|
* failed) so a freshly-spawned empty worker is never starved of placement by a transient status gap.
|
|
491
488
|
*/
|
|
492
489
|
async _pickWeightedWorker() {
|
|
493
|
-
const PLAYER_WEIGHT = 1.0, ENTITY_WEIGHT = 0.02, TICKMS_WEIGHT = 0.5, DILATION_PENALTY = 50
|
|
494
490
|
const readyIdx = []
|
|
495
491
|
for (let i = 0; i < this.workers.length; i++) if (this.workers[i]?.ready && !this._retiring.has(i)) readyIdx.push(i)
|
|
496
492
|
if (readyIdx.length === 0) throw new Error('RoomOrchestrator: no ready worker available to host a new room')
|
|
@@ -503,13 +499,7 @@ export class RoomOrchestrator {
|
|
|
503
499
|
const rooms = perWorkerRooms[k]
|
|
504
500
|
// No usable status (fetch failed, or genuinely zero rooms -> zero weight anyway) -- treat as
|
|
505
501
|
// pure room-count load so an empty/unreachable-status worker is never unfairly skipped.
|
|
506
|
-
const score = rooms
|
|
507
|
-
? rooms.reduce((sum, r) => sum
|
|
508
|
-
+ (r.players || 0) * PLAYER_WEIGHT
|
|
509
|
-
+ (r.entities || 0) * ENTITY_WEIGHT
|
|
510
|
-
+ (r.avgTickMs || 0) * TICKMS_WEIGHT
|
|
511
|
-
+ (1 - (r.dilationFactor ?? 1)) * DILATION_PENALTY, 0)
|
|
512
|
-
: this.workers[i].roomIds.size
|
|
502
|
+
const score = rooms ? scoreWorkerRooms(rooms) : this.workers[i].roomIds.size
|
|
513
503
|
if (score < bestScore) { bestScore = score; best = i }
|
|
514
504
|
}
|
|
515
505
|
return best
|
|
@@ -571,81 +561,9 @@ export class RoomOrchestrator {
|
|
|
571
561
|
}
|
|
572
562
|
|
|
573
563
|
/** Starts a minimal HTTP router on `port`: GET /route/:roomId -> {host,port,workerIndex,worldName} JSON (404 if unknown), GET /status -> full fleet status, POST /workers/register -> register an external worker, DELETE /workers/:index -> deregister an external worker. Not a traffic proxy -- see class doc comment. */
|
|
564
|
+
/** Starts a minimal HTTP router on `port`: GET /route/:roomId -> {host,port,workerIndex,worldName} JSON (404 if unknown), GET /status -> full fleet status, POST /workers/register -> register an external worker, DELETE /workers/:index -> deregister an external worker. Not a traffic proxy -- see class doc comment. Delegates to RoomOrchestratorHttp.js's startRoomOrchestratorRouter, which only reaches this instance through its public methods. */
|
|
574
565
|
startRouter(port) {
|
|
575
|
-
|
|
576
|
-
try {
|
|
577
|
-
const url = new URL(req.url, 'http://localhost')
|
|
578
|
-
|
|
579
|
-
// POST /workers/register -- register an external worker (running on a different Machine)
|
|
580
|
-
// Body: { host: "my-machine.fly.dev", portRange?: [19000, 19015] }
|
|
581
|
-
if (req.method === 'POST' && url.pathname === '/workers/register') {
|
|
582
|
-
const body = await readJsonBody(req)
|
|
583
|
-
if (!body || !body.host) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'host field required' })); return }
|
|
584
|
-
try {
|
|
585
|
-
const result = await this.registerWorker({ host: body.host, portRange: body.portRange })
|
|
586
|
-
res.writeHead(201, { 'Content-Type': 'application/json' })
|
|
587
|
-
res.end(JSON.stringify(result))
|
|
588
|
-
} catch (e) {
|
|
589
|
-
res.writeHead(409, { 'Content-Type': 'application/json' })
|
|
590
|
-
res.end(JSON.stringify({ error: e?.message || String(e) }))
|
|
591
|
-
}
|
|
592
|
-
return
|
|
593
|
-
}
|
|
594
|
-
|
|
595
|
-
// DELETE /workers/:index -- deregister an external worker
|
|
596
|
-
if (req.method === 'DELETE') {
|
|
597
|
-
const wm = url.pathname.match(/^\/workers\/(\d+)$/)
|
|
598
|
-
if (wm) {
|
|
599
|
-
const ok = await this.deregisterWorker(parseInt(wm[1], 10))
|
|
600
|
-
res.writeHead(ok ? 200 : 404, { 'Content-Type': 'application/json' })
|
|
601
|
-
res.end(JSON.stringify({ deregistered: ok }))
|
|
602
|
-
return
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
// GET /workers -- list all workers with their hosts
|
|
607
|
-
if (url.pathname === '/workers') {
|
|
608
|
-
const list = []
|
|
609
|
-
for (let i = 0; i < this.workers.length; i++) {
|
|
610
|
-
const w = this.workers[i]
|
|
611
|
-
if (w) list.push({ workerIndex: i, host: w.host, ready: w.ready, isExternal: w.isExternal, roomCount: w.roomIds.size })
|
|
612
|
-
}
|
|
613
|
-
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
614
|
-
res.end(JSON.stringify(list))
|
|
615
|
-
return
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// GET /crash-stats -- crash/restart stats for monitoring
|
|
619
|
-
if (url.pathname === '/crash-stats') {
|
|
620
|
-
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
621
|
-
res.end(JSON.stringify(this.getCrashStats()))
|
|
622
|
-
return
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
if (url.pathname === '/status') {
|
|
626
|
-
const rooms = await this.getStatus()
|
|
627
|
-
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
628
|
-
res.end(JSON.stringify({ workerCount: this.workers.length, rooms }))
|
|
629
|
-
return
|
|
630
|
-
}
|
|
631
|
-
const m = url.pathname.match(/^\/route\/(.+)$/)
|
|
632
|
-
if (m) {
|
|
633
|
-
const loc = this.route(decodeURIComponent(m[1]))
|
|
634
|
-
if (!loc) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'room not found' })); return }
|
|
635
|
-
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
636
|
-
res.end(JSON.stringify(loc))
|
|
637
|
-
return
|
|
638
|
-
}
|
|
639
|
-
res.writeHead(404); res.end('not found')
|
|
640
|
-
} catch (e) {
|
|
641
|
-
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
642
|
-
res.end(JSON.stringify({ error: e?.message || String(e) }))
|
|
643
|
-
}
|
|
644
|
-
})
|
|
645
|
-
return new Promise((resolve, reject) => {
|
|
646
|
-
this.httpServer.once('error', reject)
|
|
647
|
-
this.httpServer.listen(port, () => resolve({ port: this.httpServer.address().port }))
|
|
648
|
-
})
|
|
566
|
+
return startRoomOrchestratorRouter(this, port)
|
|
649
567
|
}
|
|
650
568
|
|
|
651
569
|
/** Stops every worker process (each drains its own rooms via RoomDirectory.stopAll first) and the router HTTP listener. */
|
|
@@ -657,32 +575,3 @@ export class RoomOrchestrator {
|
|
|
657
575
|
}
|
|
658
576
|
}
|
|
659
577
|
|
|
660
|
-
/** Reads a JSON body from an IncomingMessage, returning the parsed object or null. */
|
|
661
|
-
export function readJsonBody(req) {
|
|
662
|
-
return new Promise((resolve) => {
|
|
663
|
-
let buf = ''
|
|
664
|
-
req.on('data', (chunk) => { buf += chunk })
|
|
665
|
-
req.on('end', () => {
|
|
666
|
-
try { resolve(JSON.parse(buf)) } catch (_) { resolve(null) }
|
|
667
|
-
})
|
|
668
|
-
req.on('error', () => resolve(null))
|
|
669
|
-
})
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
function httpJsonRequest(url, method, body) {
|
|
673
|
-
return new Promise((resolve, reject) => {
|
|
674
|
-
const requestFn = url.startsWith('https') ? httpsRequest : httpRequest
|
|
675
|
-
const data = body !== undefined ? JSON.stringify(body) : null
|
|
676
|
-
const headers = data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}
|
|
677
|
-
const req = requestFn(url, { method, headers }, (res) => {
|
|
678
|
-
let buf = ''
|
|
679
|
-
res.on('data', (c) => { buf += c })
|
|
680
|
-
res.on('end', () => {
|
|
681
|
-
try { resolve({ status: res.statusCode, body: buf ? JSON.parse(buf) : null }) } catch (e) { reject(e) }
|
|
682
|
-
})
|
|
683
|
-
})
|
|
684
|
-
req.on('error', reject)
|
|
685
|
-
if (data) req.write(data)
|
|
686
|
-
req.end()
|
|
687
|
-
})
|
|
688
|
-
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Pure HTTP helpers for RoomOrchestrator.js: JSON request-body reading (for the router's own
|
|
2
|
+
// listener) and JSON-over-HTTP(S) request/response (for talking to an EXTERNAL worker's command
|
|
3
|
+
// port). No reference to RoomOrchestrator's own instance state -- split out as the one genuinely
|
|
4
|
+
// stateless piece of that file.
|
|
5
|
+
|
|
6
|
+
import { createServer as createHttpServer, request as httpRequest } from 'node:http'
|
|
7
|
+
import { request as httpsRequest } from 'node:https'
|
|
8
|
+
|
|
9
|
+
/** Reads a JSON body from an IncomingMessage, returning the parsed object or null. */
|
|
10
|
+
export function readJsonBody(req) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
let buf = ''
|
|
13
|
+
req.on('data', (chunk) => { buf += chunk })
|
|
14
|
+
req.on('end', () => {
|
|
15
|
+
try { resolve(JSON.parse(buf)) } catch (_) { resolve(null) }
|
|
16
|
+
})
|
|
17
|
+
req.on('error', () => resolve(null))
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function httpJsonRequest(url, method, body) {
|
|
22
|
+
return new Promise((resolve, reject) => {
|
|
23
|
+
const requestFn = url.startsWith('https') ? httpsRequest : httpRequest
|
|
24
|
+
const data = body !== undefined ? JSON.stringify(body) : null
|
|
25
|
+
const headers = data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {}
|
|
26
|
+
const req = requestFn(url, { method, headers }, (res) => {
|
|
27
|
+
let buf = ''
|
|
28
|
+
res.on('data', (c) => { buf += c })
|
|
29
|
+
res.on('end', () => {
|
|
30
|
+
try { resolve({ status: res.statusCode, body: buf ? JSON.parse(buf) : null }) } catch (e) { reject(e) }
|
|
31
|
+
})
|
|
32
|
+
})
|
|
33
|
+
req.on('error', reject)
|
|
34
|
+
if (data) req.write(data)
|
|
35
|
+
req.end()
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// server-scale-room-orchestrator-load-aware-placement's weight formula (see RoomOrchestrator.js's
|
|
40
|
+
// _pickWeightedWorker header for the full rationale) -- pure given a room-status row, no orchestrator
|
|
41
|
+
// instance state, so it is shared verbatim between _pickWeightedWorker (placement) and _elasticCheck
|
|
42
|
+
// (scale-up trigger) rather than kept as two independently-maintained copies of the same weights.
|
|
43
|
+
export const PLACEMENT_WEIGHTS = { PLAYER_WEIGHT: 1.0, ENTITY_WEIGHT: 0.02, TICKMS_WEIGHT: 0.5, DILATION_PENALTY: 50 }
|
|
44
|
+
|
|
45
|
+
export function scoreRoom(r) {
|
|
46
|
+
const { PLAYER_WEIGHT, ENTITY_WEIGHT, TICKMS_WEIGHT, DILATION_PENALTY } = PLACEMENT_WEIGHTS
|
|
47
|
+
return (r.players || 0) * PLAYER_WEIGHT
|
|
48
|
+
+ (r.entities || 0) * ENTITY_WEIGHT
|
|
49
|
+
+ (r.avgTickMs || 0) * TICKMS_WEIGHT
|
|
50
|
+
+ (1 - (r.dilationFactor ?? 1)) * DILATION_PENALTY
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function scoreWorkerRooms(rooms) {
|
|
54
|
+
return rooms.reduce((sum, r) => sum + scoreRoom(r), 0)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Starts the minimal HTTP router listener on `port` for a RoomOrchestrator instance `orch`: GET
|
|
58
|
+
// /route/:roomId -> {host,port,workerIndex,worldName} JSON (404 if unknown), GET /status -> full
|
|
59
|
+
// fleet status, POST /workers/register -> register an external worker, DELETE /workers/:index ->
|
|
60
|
+
// deregister, GET /workers -> list, GET /crash-stats -> crash/restart stats. Not a traffic proxy --
|
|
61
|
+
// see RoomOrchestrator.js's class doc comment. Only reaches `orch` through its public methods
|
|
62
|
+
// (registerWorker/deregisterWorker/getCrashStats/getStatus/route) plus a read of orch.workers, so
|
|
63
|
+
// this is safely split from the class despite touching orchestrator state.
|
|
64
|
+
export function startRoomOrchestratorRouter(orch, port) {
|
|
65
|
+
orch.httpServer = createHttpServer(async (req, res) => {
|
|
66
|
+
try {
|
|
67
|
+
const url = new URL(req.url, 'http://localhost')
|
|
68
|
+
|
|
69
|
+
// POST /workers/register -- register an external worker (running on a different Machine)
|
|
70
|
+
// Body: { host: "my-machine.fly.dev", portRange?: [19000, 19015] }
|
|
71
|
+
if (req.method === 'POST' && url.pathname === '/workers/register') {
|
|
72
|
+
const body = await readJsonBody(req)
|
|
73
|
+
if (!body || !body.host) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'host field required' })); return }
|
|
74
|
+
try {
|
|
75
|
+
const result = await orch.registerWorker({ host: body.host, portRange: body.portRange })
|
|
76
|
+
res.writeHead(201, { 'Content-Type': 'application/json' })
|
|
77
|
+
res.end(JSON.stringify(result))
|
|
78
|
+
} catch (e) {
|
|
79
|
+
res.writeHead(409, { 'Content-Type': 'application/json' })
|
|
80
|
+
res.end(JSON.stringify({ error: e?.message || String(e) }))
|
|
81
|
+
}
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// DELETE /workers/:index -- deregister an external worker
|
|
86
|
+
if (req.method === 'DELETE') {
|
|
87
|
+
const wm = url.pathname.match(/^\/workers\/(\d+)$/)
|
|
88
|
+
if (wm) {
|
|
89
|
+
const ok = await orch.deregisterWorker(parseInt(wm[1], 10))
|
|
90
|
+
res.writeHead(ok ? 200 : 404, { 'Content-Type': 'application/json' })
|
|
91
|
+
res.end(JSON.stringify({ deregistered: ok }))
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// GET /workers -- list all workers with their hosts
|
|
97
|
+
if (url.pathname === '/workers') {
|
|
98
|
+
const list = []
|
|
99
|
+
for (let i = 0; i < orch.workers.length; i++) {
|
|
100
|
+
const w = orch.workers[i]
|
|
101
|
+
if (w) list.push({ workerIndex: i, host: w.host, ready: w.ready, isExternal: w.isExternal, roomCount: w.roomIds.size })
|
|
102
|
+
}
|
|
103
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
104
|
+
res.end(JSON.stringify(list))
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// GET /crash-stats -- crash/restart stats for monitoring
|
|
109
|
+
if (url.pathname === '/crash-stats') {
|
|
110
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
111
|
+
res.end(JSON.stringify(orch.getCrashStats()))
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (url.pathname === '/status') {
|
|
116
|
+
const rooms = await orch.getStatus()
|
|
117
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
118
|
+
res.end(JSON.stringify({ workerCount: orch.workers.length, rooms }))
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
const m = url.pathname.match(/^\/route\/(.+)$/)
|
|
122
|
+
if (m) {
|
|
123
|
+
const loc = orch.route(decodeURIComponent(m[1]))
|
|
124
|
+
if (!loc) { res.writeHead(404, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'room not found' })); return }
|
|
125
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
126
|
+
res.end(JSON.stringify(loc))
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
res.writeHead(404); res.end('not found')
|
|
130
|
+
} catch (e) {
|
|
131
|
+
res.writeHead(500, { 'Content-Type': 'application/json' })
|
|
132
|
+
res.end(JSON.stringify({ error: e?.message || String(e) }))
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
orch.httpServer.once('error', reject)
|
|
137
|
+
orch.httpServer.listen(port, () => resolve({ port: orch.httpServer.address().port }))
|
|
138
|
+
})
|
|
139
|
+
}
|