@volter/editor-game 0.5.66 → 0.5.68

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.
Files changed (239) hide show
  1. package/contributions/audio-unlock.service.ts +2 -2
  2. package/contributions/autoplay.service.ts +1 -1
  3. package/contributions/bridge.command.ts +3 -3
  4. package/contributions/build-player.document.tsx +95 -0
  5. package/contributions/canvas/component-board.service.ts +16 -0
  6. package/contributions/canvas/design-time-mount.service.ts +45 -0
  7. package/contributions/canvas-story-capture.service.ts +12 -0
  8. package/contributions/edit-mode-audio.service.ts +2 -2
  9. package/contributions/edit-mode-networking.service.ts +1 -1
  10. package/contributions/gameplay.command.ts +4 -4
  11. package/contributions/generation.service.ts +1 -1
  12. package/contributions/godot.style.ts +26 -6
  13. package/contributions/godot.view.ts +6 -0
  14. package/contributions/ingest.service.ts +2 -2
  15. package/contributions/instances.command.ts +1 -1
  16. package/contributions/navmesh.menu.ts +1 -1
  17. package/contributions/network-observer.service.ts +14 -0
  18. package/contributions/play.command.ts +10 -4
  19. package/contributions/react/component-board.service.ts +1 -1
  20. package/contributions/react/design-time-mount.service.ts +1 -1
  21. package/contributions/scene-document.service.ts +3 -3
  22. package/contributions/state-watch.menu.ts +1 -1
  23. package/contributions/state-watch.utility.tsx +1 -1
  24. package/contributions/team-playtest.service.ts +4 -4
  25. package/contributions/three/component-board.service.ts +1 -1
  26. package/contributions/three/component-verbs.command.ts +6 -6
  27. package/contributions/three/three-authoring.service.ts +8 -5
  28. package/contributions/three-story-capture.service.ts +12 -0
  29. package/contributions/unity.style.ts +17 -7
  30. package/contributions/unity.view.ts +6 -0
  31. package/contributions/unreal.style.ts +10 -2
  32. package/contributions/unreal.view.ts +5 -0
  33. package/package.json +21 -10
  34. package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
  35. package/src/asset-budget/asset-budget-model.ts +1 -1
  36. package/src/audio/AudioDebuggerPanel.tsx +1 -1
  37. package/src/bridge/dispatch.ts +14 -14
  38. package/src/bridge/live-frames.ts +1 -1
  39. package/src/bridge/screenshot.ts +3 -3
  40. package/src/build/BuildProfilesPanel.tsx +14 -3
  41. package/src/build/build-session.ts +35 -0
  42. package/src/canvas/canvas-board/CanvasBoardDocument.tsx +749 -0
  43. package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
  44. package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
  45. package/src/canvas/canvas-design-mount.ts +524 -0
  46. package/src/canvas/design-time-canvas-mount.ts +79 -0
  47. package/src/coverage/live-authoring-surface.ts +5 -5
  48. package/src/coverage/live-project-verbs.ts +1 -1
  49. package/src/coverage/native-system-coverage.ts +5 -5
  50. package/src/coverage/root-coverage.ts +1 -1
  51. package/src/coverage/session-coverage.ts +3 -3
  52. package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
  53. package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +7 -7
  54. package/src/edit-mode/edit-mode-audio.ts +4 -4
  55. package/src/edit-mode/edit-mode-networking.ts +2 -2
  56. package/src/game-document/GameCaptureFrameButton.tsx +1 -1
  57. package/src/game-document/GameDocument.tsx +2 -2
  58. package/src/game-document/GamePanel.tsx +4 -4
  59. package/src/game-document/InstanceInspectorPicker.tsx +2 -2
  60. package/src/game-document/crowd-debug.ts +1 -1
  61. package/src/game-document/device-preview.ts +10 -11
  62. package/src/game-document/physics-debug.ts +1 -1
  63. package/src/generation/GenerationActivity.tsx +3 -3
  64. package/src/generation/generation-documents.tsx +5 -5
  65. package/src/generation/generation-jobs.ts +1 -1
  66. package/src/host/adapter-runtime-bindings.ts +96 -5
  67. package/src/host/authoring/babylon-authoring-adapter.ts +6 -6
  68. package/src/host/authoring/gesture-persist.ts +1 -1
  69. package/src/host/authoring/ingest-data-writer.ts +1 -1
  70. package/src/host/authoring/ingest-source-persistence.ts +4 -4
  71. package/src/host/authoring/mounted-authoring.ts +4 -4
  72. package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
  73. package/src/host/authoring/pixi-authoring-adapter.ts +178 -31
  74. package/src/host/authoring/pixi-creatable-kinds.ts +62 -0
  75. package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
  76. package/src/host/authoring/pixi-live-write-target.ts +4 -4
  77. package/src/host/authoring/pixi-source-identity.ts +3 -3
  78. package/src/host/authoring/pixi-source-write-target.ts +1709 -0
  79. package/src/host/authoring/pixi-still-presentation.ts +1 -1
  80. package/src/host/authoring/pixi-structure-history.ts +2 -2
  81. package/src/host/authoring/pixi-transform-channels.ts +16 -14
  82. package/src/host/authoring/source-persistence-backend.ts +3 -3
  83. package/src/host/authoring/struct-write-pipe.ts +1 -1
  84. package/src/host/binding-resolver.ts +8 -9
  85. package/src/host/browser-transpile.ts +1 -1
  86. package/src/host/canvas-entry-runtime.ts +58 -47
  87. package/src/host/canvas-preview-frames.ts +482 -0
  88. package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
  89. package/src/host/components/HeaderTelemetry.tsx +4 -4
  90. package/src/host/components/PixiIsolationSceneContent.tsx +11 -11
  91. package/src/host/components/ThreeIsolationSceneContent.tsx +3 -3
  92. package/src/host/components/frame-debugger-model.ts +2 -2
  93. package/src/host/components/header-telemetry-model.ts +2 -2
  94. package/src/host/components/scene-document.tsx +14 -14
  95. package/src/host/components/utility-view-state.ts +1 -1
  96. package/src/host/components/world-root-stage-binding.tsx +12 -12
  97. package/src/host/components/world-root-stage.ts +70 -49
  98. package/src/host/coverage/system-adapter-coverage.ts +3 -4
  99. package/src/host/design-system-stories/fixtures/editor-runtime.tsx +9 -11
  100. package/src/host/document-preview-three.ts +1 -1
  101. package/src/host/entry-adjudication.ts +6 -6
  102. package/src/host/game-css-scope-transform.ts +4 -0
  103. package/src/host/game-realm-page.ts +1 -1
  104. package/src/host/gameplay-export.ts +25 -14
  105. package/src/host/gameplay-recording.ts +5 -5
  106. package/src/host/gated-globals.ts +2 -2
  107. package/src/host/history/json-history-resource.ts +1 -1
  108. package/src/host/projection/pixi.ts +24 -2
  109. package/src/host/r3f-entry-runtime.ts +65 -34
  110. package/src/host/react-mount-runtime.ts +7 -48
  111. package/src/host/realm-services.ts +1 -1
  112. package/src/host/roots/canvas-root.tsx +373 -0
  113. package/src/host/roots/r3f-root.tsx +473 -0
  114. package/src/host/roots/react-root.ts +9 -43
  115. package/src/host/served-bundle-runtime-modules.ts +3 -19
  116. package/src/host/server-log-bridge.ts +2 -2
  117. package/src/host/stories/mounted-story-viewport-source.ts +1 -1
  118. package/src/host/stories/pixi-story-model.ts +30 -0
  119. package/src/host/stories/story-media-captures.ts +46 -0
  120. package/src/host/stories/story-media-presence.ts +3 -3
  121. package/src/host/stories/story-pixi-preview.ts +408 -0
  122. package/src/host/stories/story-three-preview.ts +806 -0
  123. package/src/host/stories/three-story-captures.ts +35 -0
  124. package/src/host/story-three-preview-runtime.ts +56 -0
  125. package/src/host/use-active-performance-source.ts +2 -2
  126. package/src/host/viewport-pose-memory.ts +1 -1
  127. package/src/host/viewport-root-presentation.ts +6 -5
  128. package/src/ingest/active-ingest.ts +1 -1
  129. package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
  130. package/src/ingest/authoring/ingest-root-adapter.ts +8 -8
  131. package/src/ingest/deferred-ingest-play.ts +6 -6
  132. package/src/ingest/discovery-public-ingest.ts +2 -2
  133. package/src/ingest/ingest-boot-viewport.ts +2 -2
  134. package/src/ingest/ingest-canvas-scene-document.tsx +9 -9
  135. package/src/ingest/ingest-canvas-scene.ts +3 -3
  136. package/src/ingest/ingest-evidence-hook.ts +1 -1
  137. package/src/ingest/ingest-frame-snapshot.ts +1 -1
  138. package/src/ingest/ingest-play-control.ts +1 -1
  139. package/src/ingest/ingest-render-debug.ts +10 -10
  140. package/src/ingest/ingest-siblings.ts +13 -25
  141. package/src/ingest/module-mode.ts +14 -14
  142. package/src/ingest/mount-canvas-ingest-root.ts +21 -21
  143. package/src/ingest/mount-coverage.ts +2 -2
  144. package/src/ingest/mount-dom-ingest-root.ts +11 -11
  145. package/src/ingest/mount-ingest-root.ts +8 -8
  146. package/src/ingest/mount-three-ingest-root.ts +8 -8
  147. package/src/ingest/resolve-canvas.ts +1 -1
  148. package/src/ingest/served-html-boot.ts +1 -1
  149. package/src/ingest/surface-canvas.ts +1 -1
  150. package/src/ingest/unmount-ingest-root.ts +5 -5
  151. package/src/navmesh/navmesh-handler.ts +24 -16
  152. package/src/network/NetworkInspectorPanel.tsx +939 -37
  153. package/src/network/network-inspector-model.ts +20 -2
  154. package/src/play/play-log-events.ts +1 -1
  155. package/src/play/play-mode.ts +76 -133
  156. package/src/play/play-recording.ts +1 -1
  157. package/src/play/react-play-live-authoring.ts +3 -3
  158. package/src/play/run-selection.ts +93 -0
  159. package/src/play-bar/PlayBar.tsx +20 -39
  160. package/src/profiler/FrameDebuggerPanel.tsx +1 -1
  161. package/src/profiler/PerformancePanel.tsx +3 -3
  162. package/src/react/design-time-react-mount.ts +23 -65
  163. package/src/react/dom-authoring-adapter.ts +9 -9
  164. package/src/react/react-inspector-section.tsx +5 -5
  165. package/src/react/react-world-authoring-adapter.ts +11 -11
  166. package/src/react/story-documents/story-documents.tsx +9 -9
  167. package/src/react/ui-board-document.tsx +7 -7
  168. package/src/react/ui-component-board.ts +2 -2
  169. package/src/runtime/adapter/audio-meter.ts +21 -0
  170. package/src/runtime/adapter/first-party-audio-system.ts +230 -0
  171. package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
  172. package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
  173. package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
  174. package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
  175. package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
  176. package/src/runtime/adapter/native-debug-module.ts +498 -0
  177. package/src/runtime/audio/bus-mixer.ts +161 -0
  178. package/src/runtime/audio/pose-guard.ts +80 -0
  179. package/src/runtime/core/frame-pacing.ts +126 -0
  180. package/src/runtime/core/game-loop.ts +225 -0
  181. package/src/runtime/core/game-scoped-slot.ts +28 -0
  182. package/src/runtime/core/seeded-random.ts +162 -0
  183. package/src/runtime/core/sim-clock.ts +391 -0
  184. package/src/runtime/core/system-runner.ts +269 -0
  185. package/src/runtime/core/types.ts +104 -0
  186. package/src/runtime/create-runtime.ts +1128 -0
  187. package/src/runtime/debug-bridge.ts +570 -0
  188. package/src/runtime/debug-registry.ts +899 -0
  189. package/src/runtime/dev/chrome-trace.ts +153 -0
  190. package/src/runtime/dev/instruments.ts +403 -0
  191. package/src/runtime/dev/logger.ts +119 -0
  192. package/src/runtime/dev/performance-profiler.ts +367 -0
  193. package/src/runtime/dev/register-render-vitals.ts +276 -0
  194. package/src/runtime/dev/render-census.ts +354 -0
  195. package/src/runtime/dev/render-debug-adapter.ts +218 -0
  196. package/src/runtime/dev/render-memory.ts +226 -0
  197. package/src/runtime/dev/render-vitals.ts +338 -0
  198. package/src/runtime/dev/static-batch-advisor.ts +188 -0
  199. package/src/runtime/dev/webgl-frame-capture.ts +366 -0
  200. package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
  201. package/src/runtime/dev-build.ts +47 -0
  202. package/src/runtime/game.ts +1636 -0
  203. package/src/runtime/gameplay-rng-trap.ts +135 -0
  204. package/src/runtime/host-context.ts +64 -0
  205. package/src/runtime/input-router.ts +182 -0
  206. package/src/runtime/mount-manifest.ts +480 -0
  207. package/src/runtime/pixi/authoring.ts +706 -0
  208. package/src/runtime/pixi/ingest.ts +116 -0
  209. package/src/runtime/pixi/physics-registry.ts +49 -0
  210. package/src/runtime/pixi/render-pass-bracket.ts +117 -0
  211. package/src/runtime/pixi/scene-capture.ts +179 -0
  212. package/src/runtime/pixi/system-adapters.ts +69 -0
  213. package/src/runtime/playtest.ts +22 -0
  214. package/src/runtime/presentation.ts +141 -0
  215. package/src/runtime/render-control.ts +642 -0
  216. package/src/runtime/render-seed.ts +77 -0
  217. package/src/runtime/run-ticks-settled.ts +73 -0
  218. package/src/runtime/setup/setup-audio.ts +72 -0
  219. package/src/services/audio-pose-guard.ts +2 -2
  220. package/src/services/game-audio.ts +152 -0
  221. package/src/services/game-network.ts +767 -0
  222. package/src/services/game-physics.ts +334 -0
  223. package/src/state-watch/StateWatchPanel.tsx +1 -1
  224. package/src/three/authoring/camera-runtime-inspector-section.tsx +3 -2
  225. package/src/three/authoring/constraint-inspector-section.tsx +6 -5
  226. package/src/three/authoring/model-asset-inspector-section.tsx +7 -6
  227. package/src/three/authoring/oid-source-persistence.ts +7 -7
  228. package/src/three/authoring/r3f-design-session.ts +58 -54
  229. package/src/three/authoring/r3f-source-authoring-adapter.ts +95 -91
  230. package/src/three/authoring/reflection-probe-inspector-section.tsx +3 -2
  231. package/src/three/authoring/three-authoring-adapter.ts +29 -29
  232. package/src/three/component-verbs/extract-menu.ts +7 -6
  233. package/src/three/component-verbs/fork-menu.ts +7 -6
  234. package/src/three/component-verbs/internals-menu.ts +2 -2
  235. package/src/three/story-documents/three-story-documents.tsx +13 -13
  236. package/src/three/three-board/ThreeBoardDocument.tsx +13 -12
  237. package/src/three/three-board/board-scene.ts +6 -6
  238. package/src/three/three-board/three-component-board.ts +2 -2
  239. package/src/services/game-audio-unlock.ts +0 -48
@@ -14,8 +14,9 @@
14
14
  * Degradation ladder rendered honestly (W3a contract, first system-adapter
15
15
  * consumer): no adapter → the register-an-adapter notice; an adapter missing
16
16
  * an optional capability → that section says "not provided by this adapter";
17
- * nothing is ever fabricated. Editor code never imports Colyseus here — only
18
- * the `NetworkingAdapter` interface (net-import-ban).
17
+ * nothing is ever fabricated. The panel reads only the `NetworkingAdapter`
18
+ * interface: the game's own, or the editor's observer of its Colyseus rooms
19
+ * (`services/game-network.ts`).
19
20
  *
20
21
  * Mounted as the `network` workspace utility (drawer tab beside
21
22
  * Console/Profiler, `core-utilities.tsx`), available only while a
@@ -24,8 +25,11 @@
24
25
  */
25
26
 
26
27
  import { editorHost } from '@volter/editor-sdk/host';
28
+ import { NETWORK_AUTOSTART_SECTION } from '../services/game-network';
27
29
  import {
28
30
  Button,
31
+ Checkbox,
32
+ TextArea,
29
33
  DisclosureIcon,
30
34
  fontSizeVar,
31
35
  NumberInput,
@@ -36,7 +40,10 @@ import {
36
40
  import type {
37
41
  ConnectionState,
38
42
  NetConditioning,
43
+ NetEntityTraffic,
39
44
  NetMessageEvent,
45
+ NetServerInspection,
46
+ NetTypeTraffic,
40
47
  NetworkingAdapter,
41
48
  } from '@volter/editor-project/adapter';
42
49
  import { memo, useEffect, useReducer, useRef, useState } from 'react';
@@ -100,8 +107,43 @@ function formatLeaf(value: unknown): string {
100
107
  * are NOT mounted: a fresh snapshot every poll re-renders only the visible
101
108
  * rows, never a collapsed subtree.
102
109
  */
103
- function StateTreeNode({ name, value, depth }: { name: string; value: unknown; depth: number }) {
110
+ type StateEdit = (path: readonly (string | number)[], value: unknown) => Promise<void>;
111
+ type StateDelete = (path: readonly (string | number)[]) => Promise<void>;
112
+ type StateType = (path: readonly (string | number)[]) => string | null;
113
+
114
+ /** A field's replicated type beside its name, dim (Godot's Replication panel's column). */
115
+ function TypeTag({ type }: { type: string | null | undefined }) {
116
+ if (!type) return null;
117
+ return (
118
+ <span data-testid="net-tree-type" style={{ color: themeVars.content.dim, marginLeft: 6 }}>
119
+ {type}
120
+ </span>
121
+ );
122
+ }
123
+
124
+ function StateTreeNode({
125
+ name,
126
+ value,
127
+ depth,
128
+ path = [],
129
+ edit,
130
+ remove,
131
+ typeOf,
132
+ }: {
133
+ name: string;
134
+ value: unknown;
135
+ depth: number;
136
+ path?: readonly string[];
137
+ /** The server's state edit (Monitor's), when the adapter offers one: leaves become editable. */
138
+ edit?: StateEdit | undefined;
139
+ /** The server's state delete (Monitor's), when offered: each key gets a remove control. */
140
+ remove?: StateDelete | undefined;
141
+ /** The schema's declared type for a path, when the adapter reads one. */
142
+ typeOf?: StateType | undefined;
143
+ }) {
104
144
  const [expanded, setExpanded] = useState(depth === 0);
145
+ const [draft, setDraft] = useState<string | null>(null);
146
+ const [failed, setFailed] = useState<string | null>(null);
105
147
  const rowStyle: React.CSSProperties = {
106
148
  ...MONO,
107
149
  paddingLeft: 8 + depth * 14,
@@ -110,11 +152,71 @@ function StateTreeNode({ name, value, depth }: { name: string; value: unknown; d
110
152
  };
111
153
 
112
154
  if (!isBranch(value)) {
155
+ const editable = edit && (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean');
156
+ const commit = (next: unknown) => {
157
+ setDraft(null);
158
+ edit?.(path, next).then(
159
+ () => setFailed(null),
160
+ (caught: unknown) => setFailed(caught instanceof Error ? caught.message : String(caught)),
161
+ );
162
+ };
163
+ const parse = (text: string): unknown => {
164
+ if (typeof value === 'number') {
165
+ const number = Number(text);
166
+ return Number.isFinite(number) ? number : value;
167
+ }
168
+ return text;
169
+ };
113
170
  return (
114
- <div style={rowStyle} data-testid="net-tree-leaf">
171
+ <div style={rowStyle} data-testid="net-tree-leaf" data-path={path.join('.')}>
115
172
  <span style={{ color: themeVars.content.primary }}>{name}</span>
116
173
  <span style={{ color: themeVars.content.muted }}>: </span>
117
- <span style={{ color: themeVars.content.primary }}>{formatLeaf(value)}</span>
174
+ {draft !== null ? (
175
+ <TextInput
176
+ data-testid="net-tree-edit"
177
+ autoFocus
178
+ value={draft}
179
+ onChange={(event) => setDraft(event.target.value)}
180
+ onKeyDown={(event) => {
181
+ if (event.key === 'Enter') commit(parse(draft));
182
+ if (event.key === 'Escape') setDraft(null);
183
+ }}
184
+ onBlur={() => setDraft(null)}
185
+ style={{ width: 120 }}
186
+ />
187
+ ) : (
188
+ <span
189
+ data-testid={editable ? 'net-tree-value' : undefined}
190
+ title={editable ? (typeof value === 'boolean' ? 'Click to toggle on the server' : 'Click to edit on the server') : undefined}
191
+ style={{ color: themeVars.content.primary, cursor: editable ? 'text' : undefined }}
192
+ onClick={
193
+ editable
194
+ ? () => (typeof value === 'boolean' ? commit(!value) : setDraft(String(value)))
195
+ : undefined
196
+ }
197
+ >
198
+ {formatLeaf(value)}
199
+ </span>
200
+ )}
201
+ <TypeTag type={path.length > 0 ? typeOf?.(path) : null} />
202
+ {remove && path.length > 0 ? (
203
+ <button
204
+ type="button"
205
+ data-testid="net-tree-delete"
206
+ title="Delete this key on the server"
207
+ aria-label={`Delete ${path.join('.')} on the server`}
208
+ onClick={() =>
209
+ remove(path).then(
210
+ () => setFailed(null),
211
+ (caught: unknown) => setFailed(caught instanceof Error ? caught.message : String(caught)),
212
+ )
213
+ }
214
+ style={{ marginLeft: 8, border: 0, background: 'transparent', color: themeVars.content.muted, cursor: 'pointer' }}
215
+ >
216
+ ×
217
+ </button>
218
+ ) : null}
219
+ {failed ? <span style={{ color: themeVars.semantic.danger }}> {failed}</span> : null}
118
220
  </div>
119
221
  );
120
222
  }
@@ -136,10 +238,39 @@ function StateTreeNode({ name, value, depth }: { name: string; value: unknown; d
136
238
  {' '}
137
239
  {Array.isArray(value) ? `[${entries.length}]` : `{${entries.length}}`}
138
240
  </span>
241
+ <TypeTag type={path.length > 0 ? typeOf?.(path) : null} />
242
+ {failed ? <span style={{ color: themeVars.semantic.danger }}> {failed}</span> : null}
243
+ {remove && path.length > 0 ? (
244
+ <button
245
+ type="button"
246
+ data-testid="net-tree-delete"
247
+ title="Delete this key on the server"
248
+ aria-label={`Delete ${path.join('.')} on the server`}
249
+ onClick={(event) => {
250
+ event.stopPropagation();
251
+ remove(path).then(
252
+ () => setFailed(null),
253
+ (caught: unknown) => setFailed(caught instanceof Error ? caught.message : String(caught)),
254
+ );
255
+ }}
256
+ style={{ marginLeft: 8, border: 0, background: 'transparent', color: themeVars.content.muted, cursor: 'pointer' }}
257
+ >
258
+ ×
259
+ </button>
260
+ ) : null}
139
261
  </div>
140
262
  {expanded &&
141
263
  entries.map(([key, child]) => (
142
- <StateTreeNode key={key} name={key} value={child} depth={depth + 1} />
264
+ <StateTreeNode
265
+ key={key}
266
+ name={key}
267
+ value={child}
268
+ depth={depth + 1}
269
+ path={depth === 0 ? [key] : [...path, key]}
270
+ edit={edit}
271
+ remove={remove}
272
+ typeOf={typeOf}
273
+ />
143
274
  ))}
144
275
  </div>
145
276
  );
@@ -252,6 +383,7 @@ function ConditionerControls({ adapter }: { adapter: NetworkingAdapter }) {
252
383
  const [cond, setCond] = useState<NetConditioning>(
253
384
  () => adapter.getConditioning?.() ?? { latencyMs: 0, jitterMs: 0, packetLoss: 0 },
254
385
  );
386
+ const limits = adapter.getConditioningLimits?.() ?? {};
255
387
  const apply = (next: NetConditioning) => {
256
388
  setCond(next);
257
389
  adapter.setConditioning?.(next);
@@ -281,13 +413,23 @@ function ConditionerControls({ adapter }: { adapter: NetworkingAdapter }) {
281
413
  testId="net-cond-jitter"
282
414
  onChange={(v) => apply({ ...cond, jitterMs: Math.max(0, v) })}
283
415
  />
284
- <NumberInput
285
- label="loss %"
286
- value={cond.packetLoss * 100}
287
- step={5}
288
- testId="net-cond-loss"
289
- onChange={(v) => apply({ ...cond, packetLoss: Math.min(1, Math.max(0, v / 100)) })}
290
- />
416
+ {limits.packetLoss ? (
417
+ <span
418
+ data-testid="net-cond-loss-absent"
419
+ title={limits.packetLoss}
420
+ style={{ fontSize: fontSizeVar.sm, color: themeVars.content.muted }}
421
+ >
422
+ loss: not simulated
423
+ </span>
424
+ ) : (
425
+ <NumberInput
426
+ label="loss %"
427
+ value={cond.packetLoss * 100}
428
+ step={5}
429
+ testId="net-cond-loss"
430
+ onChange={(v) => apply({ ...cond, packetLoss: Math.min(1, Math.max(0, v / 100)) })}
431
+ />
432
+ )}
291
433
  </div>
292
434
  );
293
435
  }
@@ -436,6 +578,9 @@ function NetworkHeader({
436
578
  );
437
579
  }
438
580
 
581
+ const AUTOSTART_SECTION = NETWORK_AUTOSTART_SECTION;
582
+ const DRAFT_SECTION = 'networkMessageDraft';
583
+
439
584
  // --- The panel ------------------------------------------------------------
440
585
 
441
586
  export function NetworkInspectorPanel() {
@@ -445,6 +590,21 @@ export function NetworkInspectorPanel() {
445
590
  const adapterRef = useRef<NetworkingAdapter | null>(null);
446
591
  const [paused, setPaused] = useState(false);
447
592
  const [filter, setFilter] = useState('');
593
+ // Godot's Autostart: whether a game that starts running starts recording (kept per checkout).
594
+ const [autostart, setAutostart] = useState(
595
+ // Off unless chosen, as Godot's is: a run records once Start is pressed.
596
+ () => editorHost().projectLocalState.read<boolean>(AUTOSTART_SECTION) ?? false,
597
+ );
598
+ // The one message draft: sent as this client, or from the server to one client or all.
599
+ // Kept per checkout, as Monitor keeps its Send draft in its browser storage.
600
+ const [draft, setDraftState] = useState<MessageDraft>(
601
+ // Monitor's own first draft: `message_type` and an empty object.
602
+ () => editorHost().projectLocalState.read<MessageDraft>(DRAFT_SECTION) ?? { type: 'message_type', payload: '{}' },
603
+ );
604
+ const setDraft = (next: MessageDraft): void => {
605
+ editorHost().projectLocalState.write(DRAFT_SECTION, next);
606
+ setDraftState(next);
607
+ };
448
608
 
449
609
  useEffect(() => {
450
610
  const syncAdapter = () => {
@@ -550,6 +710,76 @@ export function NetworkInspectorPanel() {
550
710
  </AbsentNote>
551
711
  )}
552
712
 
713
+ {caps.server ? (
714
+ <ServerView
715
+ adapter={adapter}
716
+ ownSession={view.roomInfo?.sessionId ?? null}
717
+ ownRoom={view.roomInfo?.roomId ?? null}
718
+ draft={draft}
719
+ setDraft={setDraft}
720
+ />
721
+ ) : null}
722
+ {caps.recording || caps.traffic ? (
723
+ // Godot's profiler bar, above the tables it governs: Start/Stop, Clear, Autostart.
724
+ <div
725
+ data-testid="net-profiler-bar"
726
+ style={{
727
+ display: 'flex',
728
+ gap: spaceVar[3],
729
+ alignItems: 'center',
730
+ padding: spaceVar[2],
731
+ borderBottom: `1px solid ${themeVars.boundary.default}`,
732
+ }}
733
+ >
734
+ {caps.recording ? (
735
+ <>
736
+ <Button
737
+ type="button"
738
+ variant="ghost"
739
+ data-testid="net-recording"
740
+ aria-pressed={adapter.isRecording?.() === true}
741
+ onClick={() => {
742
+ adapter.setRecording?.(!adapter.isRecording?.());
743
+ force();
744
+ }}
745
+ >
746
+ {adapter.isRecording?.() ? 'Stop' : 'Start'}
747
+ </Button>
748
+ </>
749
+ ) : null}
750
+ <Button
751
+ type="button"
752
+ variant="ghost"
753
+ data-testid="net-log-clear"
754
+ onClick={() => {
755
+ // Godot's Clear resets its tables; the log clears with them.
756
+ logRef.current.clear();
757
+ adapter.clearTraffic?.();
758
+ force();
759
+ }}
760
+ >
761
+ Clear
762
+ </Button>
763
+ {caps.recording ? (
764
+ <label style={{ display: 'flex', gap: spaceVar[1], alignItems: 'center', whiteSpace: 'nowrap' }}>
765
+ <Checkbox
766
+ data-testid="net-autostart"
767
+ checked={autostart}
768
+ onChange={(event) => {
769
+ const on = event.currentTarget.checked;
770
+ editorHost().projectLocalState.write(AUTOSTART_SECTION, on);
771
+ setAutostart(on);
772
+ }}
773
+ />
774
+ Autostart
775
+ </label>
776
+ ) : null}
777
+ </div>
778
+ ) : null}
779
+ {caps.traffic ? <TrafficTable rows={adapter.getTrafficByType?.() ?? []} /> : null}
780
+ {caps.entityTraffic ? <EntityTrafficTable rows={adapter.getTrafficByEntity?.() ?? []} /> : null}
781
+ {caps.send ? <SendControls adapter={adapter} ping={caps.ping} draft={draft} setDraft={setDraft} /> : null}
782
+
553
783
  {/* Body: state tree | message log */}
554
784
  <div style={{ flex: 1, minHeight: 0, display: 'flex' }}>
555
785
  <div
@@ -564,7 +794,14 @@ export function NetworkInspectorPanel() {
564
794
  >
565
795
  {caps.stateTree ? (
566
796
  snapshot != null ? (
567
- <StateTreeNode name="state" value={snapshot} depth={0} />
797
+ <StateTreeNode
798
+ name="state"
799
+ value={snapshot}
800
+ depth={0}
801
+ edit={adapter.editServerState ? (path, next) => adapter.editServerState!(path, next) : undefined}
802
+ remove={adapter.deleteServerState ? (path) => adapter.deleteServerState!(path) : undefined}
803
+ typeOf={adapter.stateFieldType ? (path) => adapter.stateFieldType!(path) : undefined}
804
+ />
568
805
  ) : (
569
806
  <AbsentNote>Not connected — no replicated state to show.</AbsentNote>
570
807
  )
@@ -589,29 +826,20 @@ export function NetworkInspectorPanel() {
589
826
  padding: spaceVar[2],
590
827
  }}
591
828
  >
592
- <Button
593
- type="button"
594
- variant="ghost"
595
- data-testid="net-log-pause"
596
- onClick={() => {
597
- const next = !paused;
598
- logRef.current.paused = next;
599
- setPaused(next);
600
- }}
601
- >
602
- {paused ? 'Resume' : 'Pause'}
603
- </Button>
604
- <Button
605
- type="button"
606
- variant="ghost"
607
- data-testid="net-log-clear"
608
- onClick={() => {
609
- logRef.current.clear();
610
- force();
611
- }}
612
- >
613
- Clear
614
- </Button>
829
+ {caps.recording ? null : (
830
+ <Button
831
+ type="button"
832
+ variant="ghost"
833
+ data-testid="net-log-pause"
834
+ onClick={() => {
835
+ const next = !paused;
836
+ logRef.current.paused = next;
837
+ setPaused(next);
838
+ }}
839
+ >
840
+ {paused ? 'Resume' : 'Pause'}
841
+ </Button>
842
+ )}
615
843
  <TextInput
616
844
  data-testid="net-log-filter"
617
845
  placeholder="Filter by type"
@@ -642,3 +870,677 @@ export function NetworkInspectorPanel() {
642
870
  </div>
643
871
  );
644
872
  }
873
+
874
+ /**
875
+ * Traffic per message type, both directions — Godot's network profiler reads a game's RPCs and
876
+ * its synchronizers as two tables of counts and sizes; a Colyseus room's are its messages and its
877
+ * state sync, which arrive here as rows of one table (`state`, `patch`, and each message type).
878
+ */
879
+ /** Godot's synchronizer table, for a Colyseus room: each replicated entity's incoming syncs (the
880
+ * patches that changed it), the field changes they carried, and the size range of those patches. */
881
+ function EntityTrafficTable({ rows }: { rows: readonly NetEntityTraffic[] }) {
882
+ const cell = { padding: `0 ${spaceVar[3]}`, textAlign: 'right' as const, whiteSpace: 'nowrap' as const };
883
+ return (
884
+ <div
885
+ data-testid="net-entity-traffic"
886
+ style={{ maxHeight: 132, overflow: 'auto', borderBottom: `1px solid ${themeVars.boundary.default}` }}
887
+ >
888
+ {rows.length === 0 ? (
889
+ <AbsentNote>No state patches yet.</AbsentNote>
890
+ ) : (
891
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: fontSizeVar.sm, ...MONO }}>
892
+ <thead>
893
+ <tr style={{ color: themeVars.content.muted }}>
894
+ <th style={{ ...cell, textAlign: 'left' }}>Entity</th>
895
+ <th
896
+ style={cell}
897
+ title="Syncs in - out, as Godot pairs them: the state patches that changed it, and none out, because a Colyseus client sends no state"
898
+ >
899
+ Count
900
+ </th>
901
+ <th style={cell} title="Field changes those patches carried">Changes</th>
902
+ <th
903
+ style={cell}
904
+ title="Size in - out: the size range of the patches that carried it (they carried other entities too), and none out"
905
+ >
906
+ Size
907
+ </th>
908
+ </tr>
909
+ </thead>
910
+ <tbody>
911
+ {rows.map((row) => (
912
+ <tr key={row.path} data-testid="net-entity-traffic-row">
913
+ <td style={{ ...cell, textAlign: 'left' }}>{row.path}</td>
914
+ <td style={cell}>{`${row.syncs} - 0`}</td>
915
+ <td style={cell}>{row.changes}</td>
916
+ <td style={cell}>
917
+ {`${row.patchBytesMin === row.patchBytesMax ? `${row.patchBytesMin} B` : `${row.patchBytesMin}…${row.patchBytesMax} B`} - 0 B`}
918
+ </td>
919
+ </tr>
920
+ ))}
921
+ </tbody>
922
+ </table>
923
+ )}
924
+ </div>
925
+ );
926
+ }
927
+
928
+ type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
929
+
930
+ /** A leaf's text as typed: a JSON literal when it reads as one (5, true, null, {}), else a string. */
931
+ function readLiteral(text: string): Json {
932
+ try {
933
+ return JSON.parse(text) as Json;
934
+ } catch {
935
+ return text;
936
+ }
937
+ }
938
+
939
+ /**
940
+ * Colyseus Monitor's payload editor: the message's JSON as a tree whose values are edited in
941
+ * place, keys and items added and removed; the Text view is the same payload as raw JSON.
942
+ */
943
+ function PayloadEditor({ text, onChange }: { text: string; onChange: (text: string) => void }) {
944
+ const [view, setView] = useState<'tree' | 'text'>('tree');
945
+ let parsed: Json | undefined;
946
+ try {
947
+ parsed = text.trim() === '' ? {} : (JSON.parse(text) as Json);
948
+ } catch {
949
+ parsed = undefined;
950
+ }
951
+ const write = (next: Json) => onChange(JSON.stringify(next));
952
+ return (
953
+ <div data-testid="net-payload-editor" style={{ display: 'flex', flexDirection: 'column', gap: spaceVar[1] }}>
954
+ <div style={{ display: 'flex', gap: spaceVar[1] }}>
955
+ {(['tree', 'text'] as const).map((key) => (
956
+ <Button
957
+ key={key}
958
+ type="button"
959
+ variant="ghost"
960
+ aria-pressed={view === key}
961
+ data-testid={`net-payload-view-${key}`}
962
+ onClick={() => setView(key)}
963
+ >
964
+ {key === 'tree' ? 'Tree' : 'Text'}
965
+ </Button>
966
+ ))}
967
+ </div>
968
+ {view === 'text' || parsed === undefined ? (
969
+ <>
970
+ <TextArea
971
+ data-testid="net-send-dialog-payload"
972
+ rows={6}
973
+ value={text}
974
+ onChange={(event) => onChange(event.target.value)}
975
+ style={MONO}
976
+ />
977
+ {parsed === undefined ? (
978
+ <span style={{ color: themeVars.content.muted }}>Not JSON yet, so the tree cannot show it.</span>
979
+ ) : null}
980
+ </>
981
+ ) : (
982
+ <div style={{ ...MONO, fontSize: fontSizeVar.sm }}>
983
+ <PayloadNode value={parsed} onChange={write} depth={0} />
984
+ </div>
985
+ )}
986
+ </div>
987
+ );
988
+ }
989
+
990
+ function PayloadNode({ value, onChange, depth }: { value: Json; onChange: (next: Json) => void; depth: number }) {
991
+ const [newKey, setNewKey] = useState('');
992
+ const [newValue, setNewValue] = useState('');
993
+ const pad = { paddingLeft: depth * 12 };
994
+ if (value !== null && typeof value === 'object') {
995
+ const isArray = Array.isArray(value);
996
+ const entries: [string, Json][] = isArray ? value.map((item, index) => [String(index), item]) : Object.entries(value);
997
+ const set = (key: string, next: Json) => {
998
+ if (isArray) onChange(value.map((item, index) => (String(index) === key ? next : item)));
999
+ else onChange({ ...value, [key]: next });
1000
+ };
1001
+ const remove = (key: string) => {
1002
+ if (isArray) onChange(value.filter((_, index) => String(index) !== key));
1003
+ else onChange(Object.fromEntries(Object.entries(value).filter(([entry]) => entry !== key)));
1004
+ };
1005
+ return (
1006
+ <div data-testid="net-payload-branch">
1007
+ {entries.map(([key, child]) => (
1008
+ <div key={key} style={pad}>
1009
+ <span style={{ color: themeVars.content.muted }}>{key}: </span>
1010
+ {child !== null && typeof child === 'object' ? (
1011
+ <span style={{ color: themeVars.content.muted }}>{Array.isArray(child) ? '[' : '{'}</span>
1012
+ ) : null}
1013
+ <button
1014
+ type="button"
1015
+ data-testid="net-payload-remove"
1016
+ title={`Remove ${key}`}
1017
+ onClick={() => remove(key)}
1018
+ style={{ background: 'none', border: 0, color: themeVars.content.muted, cursor: 'pointer' }}
1019
+ >
1020
+ ×
1021
+ </button>
1022
+ {child !== null && typeof child === 'object' ? (
1023
+ <PayloadNode value={child} onChange={(next) => set(key, next)} depth={depth + 1} />
1024
+ ) : (
1025
+ <PayloadLeaf value={child} onChange={(next) => set(key, next)} />
1026
+ )}
1027
+ </div>
1028
+ ))}
1029
+ <div style={{ ...pad, display: 'flex', gap: spaceVar[1] }}>
1030
+ {isArray ? null : (
1031
+ <TextInput
1032
+ data-testid="net-payload-new-key"
1033
+ placeholder="key"
1034
+ value={newKey}
1035
+ onChange={(event) => setNewKey(event.target.value)}
1036
+ style={{ width: 90 }}
1037
+ />
1038
+ )}
1039
+ <TextInput
1040
+ data-testid="net-payload-new-value"
1041
+ placeholder="value"
1042
+ value={newValue}
1043
+ onChange={(event) => setNewValue(event.target.value)}
1044
+ style={{ width: 110 }}
1045
+ />
1046
+ <Button
1047
+ type="button"
1048
+ variant="ghost"
1049
+ data-testid="net-payload-add"
1050
+ disabled={!isArray && newKey.trim() === ''}
1051
+ onClick={() => {
1052
+ const next = readLiteral(newValue);
1053
+ if (isArray) onChange([...value, next]);
1054
+ else onChange({ ...value, [newKey.trim()]: next });
1055
+ setNewKey('');
1056
+ setNewValue('');
1057
+ }}
1058
+ >
1059
+ Add
1060
+ </Button>
1061
+ </div>
1062
+ </div>
1063
+ );
1064
+ }
1065
+ return <PayloadLeaf value={value} onChange={onChange} />;
1066
+ }
1067
+
1068
+ type LeafType = 'string' | 'number' | 'boolean' | 'null';
1069
+
1070
+ /** A leaf keeps its type, as Monitor's editor does, until its type picker changes it. */
1071
+ function PayloadLeaf({ value, onChange }: { value: Json; onChange: (next: Json) => void }) {
1072
+ const type: LeafType =
1073
+ value === null ? 'null' : typeof value === 'number' ? 'number' : typeof value === 'boolean' ? 'boolean' : 'string';
1074
+ const shown = type === 'string' ? String(value) : JSON.stringify(value);
1075
+ const [text, setText] = useState(shown);
1076
+ useEffect(() => setText(shown), [shown]);
1077
+ const as = (to: LeafType, raw: string): Json => {
1078
+ if (to === 'null') return null;
1079
+ if (to === 'boolean') return raw.trim() === 'true';
1080
+ if (to === 'number') {
1081
+ const n = Number(raw);
1082
+ return Number.isFinite(n) ? n : 0;
1083
+ }
1084
+ return raw;
1085
+ };
1086
+ return (
1087
+ <span style={{ display: 'inline-flex', gap: 4 }}>
1088
+ {type === 'boolean' ? (
1089
+ <input
1090
+ type="checkbox"
1091
+ data-testid="net-payload-leaf-boolean"
1092
+ checked={value === true}
1093
+ onChange={(event) => onChange(event.currentTarget.checked)}
1094
+ />
1095
+ ) : type === 'null' ? (
1096
+ <span style={{ color: themeVars.content.muted }}>null</span>
1097
+ ) : (
1098
+ <TextInput
1099
+ data-testid="net-payload-leaf"
1100
+ value={text}
1101
+ onChange={(event) => setText(event.target.value)}
1102
+ onBlur={() => onChange(as(type, text))}
1103
+ onKeyDown={(event) => {
1104
+ if (event.key === 'Enter') onChange(as(type, text));
1105
+ }}
1106
+ style={{ width: 120 }}
1107
+ />
1108
+ )}
1109
+ <select
1110
+ data-testid="net-payload-leaf-type"
1111
+ aria-label="Value type"
1112
+ value={type}
1113
+ onChange={(event) => onChange(as(event.currentTarget.value as LeafType, text))}
1114
+ >
1115
+ {(['string', 'number', 'boolean', 'null'] as const).map((option) => (
1116
+ <option key={option} value={option}>
1117
+ {option}
1118
+ </option>
1119
+ ))}
1120
+ </select>
1121
+ </span>
1122
+ );
1123
+ }
1124
+
1125
+ type RoomSortKey = 'name' | 'roomId' | 'clients' | 'locked' | 'elapsedMs';
1126
+ const ROOM_COLUMNS: readonly (readonly [RoomSortKey, string])[] = [
1127
+ ['name', 'Name'],
1128
+ ['roomId', 'Room'],
1129
+ ['clients', 'Clients'],
1130
+ ['locked', 'Locked'],
1131
+ ['elapsedMs', 'Elapsed'],
1132
+ ];
1133
+
1134
+ function sortRooms<Room extends Record<RoomSortKey, unknown>>(
1135
+ rooms: readonly Room[],
1136
+ sort: { key: RoomSortKey; descending: boolean },
1137
+ ): Room[] {
1138
+ const value = (room: Room): number | string => {
1139
+ const field = room[sort.key];
1140
+ return typeof field === 'number' ? field : typeof field === 'boolean' ? Number(field) : String(field);
1141
+ };
1142
+ return [...rooms].sort((a, b) => {
1143
+ const left = value(a);
1144
+ const right = value(b);
1145
+ const order = left < right ? -1 : left > right ? 1 : 0;
1146
+ return sort.descending ? -order : order;
1147
+ });
1148
+ }
1149
+
1150
+ function TrafficTable({ rows }: { rows: readonly NetTypeTraffic[] }) {
1151
+ const cell = { padding: `0 ${spaceVar[3]}`, textAlign: 'right' as const, whiteSpace: 'nowrap' as const };
1152
+ const bytes = (value: number) => (value >= 1024 ? `${(value / 1024).toFixed(1)} KiB` : `${value} B`);
1153
+ return (
1154
+ <div
1155
+ data-testid="net-traffic"
1156
+ style={{ maxHeight: 132, overflow: 'auto', borderBottom: `1px solid ${themeVars.boundary.default}` }}
1157
+ >
1158
+ {rows.length === 0 ? (
1159
+ <AbsentNote>No traffic yet.</AbsentNote>
1160
+ ) : (
1161
+ <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: fontSizeVar.sm, ...MONO }}>
1162
+ <thead>
1163
+ <tr style={{ color: themeVars.content.muted }}>
1164
+ <th style={{ ...cell, textAlign: 'left' }}>Type</th>
1165
+ <th style={cell}>In</th>
1166
+ <th style={cell}>Bytes in</th>
1167
+ <th style={cell}>Out</th>
1168
+ <th style={cell}>Bytes out</th>
1169
+ </tr>
1170
+ </thead>
1171
+ <tbody>
1172
+ {rows.map((row) => (
1173
+ <tr key={row.type} data-testid="net-traffic-row">
1174
+ <td style={{ ...cell, textAlign: 'left' }}>{row.type}</td>
1175
+ <td style={cell}>{row.countIn || '-'}</td>
1176
+ <td style={cell}>{row.countIn ? bytes(row.bytesIn) : '-'}</td>
1177
+ <td style={cell}>{row.countOut || '-'}</td>
1178
+ <td style={cell}>{row.countOut ? bytes(row.bytesOut) : '-'}</td>
1179
+ </tr>
1180
+ ))}
1181
+ </tbody>
1182
+ </table>
1183
+ )}
1184
+ </div>
1185
+ );
1186
+ }
1187
+
1188
+ interface MessageDraft {
1189
+ readonly type: string;
1190
+ readonly payload: string;
1191
+ }
1192
+
1193
+ /** The draft as a message: its type and its parsed JSON payload (none when empty). */
1194
+ function draftMessage(draft: MessageDraft): { type: string; payload: unknown } {
1195
+ if (draft.type.trim() === '') throw new Error('Name the message type first.');
1196
+ return { type: draft.type, payload: draft.payload.trim() === '' ? undefined : (JSON.parse(draft.payload) as unknown) };
1197
+ }
1198
+
1199
+ /**
1200
+ * A message type and a JSON payload (Colyseus Monitor's Send dialog), sent into the room as this
1201
+ * client so a server handler can be exercised without writing game code for it; the Server section
1202
+ * sends the same draft from the server.
1203
+ */
1204
+ function SendControls({
1205
+ adapter,
1206
+ ping,
1207
+ draft,
1208
+ setDraft,
1209
+ }: {
1210
+ adapter: NetworkingAdapter;
1211
+ ping: boolean;
1212
+ draft: MessageDraft;
1213
+ setDraft: (next: MessageDraft) => void;
1214
+ }) {
1215
+ const [rtt, setRtt] = useState<string | null>(null);
1216
+ const measure = () => {
1217
+ setRtt('…');
1218
+ adapter.ping?.().then(
1219
+ (ms) => setRtt(`${ms} ms`),
1220
+ (caught: unknown) => setRtt(caught instanceof Error ? caught.message : String(caught)),
1221
+ );
1222
+ };
1223
+ const [error, setError] = useState<string | null>(null);
1224
+ const send = () => {
1225
+ try {
1226
+ const message = draftMessage(draft);
1227
+ adapter.sendMessage?.(message.type, message.payload);
1228
+ setError(null);
1229
+ } catch (caught) {
1230
+ setError(caught instanceof Error ? caught.message : String(caught));
1231
+ }
1232
+ };
1233
+ return (
1234
+ <div
1235
+ data-testid="net-send"
1236
+ style={{ display: 'flex', gap: spaceVar[2], alignItems: 'center', padding: spaceVar[2] }}
1237
+ >
1238
+ <TextInput
1239
+ data-testid="net-send-type"
1240
+ placeholder="Message type"
1241
+ value={draft.type}
1242
+ onChange={(event) => setDraft({ ...draft, type: event.target.value })}
1243
+ style={{ width: 140 }}
1244
+ />
1245
+ <TextInput
1246
+ data-testid="net-send-payload"
1247
+ placeholder='Payload (JSON), e.g. {"x": 1}'
1248
+ value={draft.payload}
1249
+ onChange={(event) => setDraft({ ...draft, payload: event.target.value })}
1250
+ style={{ flex: 1, minWidth: 80 }}
1251
+ />
1252
+ <Button type="button" variant="ghost" data-testid="net-send-button" disabled={draft.type.trim() === ''} onClick={send}>
1253
+ Send as this client
1254
+ </Button>
1255
+ {ping ? (
1256
+ <>
1257
+ <Button type="button" variant="ghost" data-testid="net-ping" onClick={measure}>
1258
+ Ping
1259
+ </Button>
1260
+ {rtt ? (
1261
+ <span data-testid="net-rtt" style={{ fontSize: fontSizeVar.sm, ...MONO }}>
1262
+ {rtt}
1263
+ </span>
1264
+ ) : null}
1265
+ </>
1266
+ ) : null}
1267
+ {error ? (
1268
+ <span data-testid="net-send-error" style={{ color: themeVars.semantic.danger, fontSize: fontSizeVar.sm }}>
1269
+ {error}
1270
+ </span>
1271
+ ) : null}
1272
+ </div>
1273
+ );
1274
+ }
1275
+
1276
+ /**
1277
+ * The room server's side — Colyseus Monitor's room list and room view, read from Monitor's own API
1278
+ * on the server: every room (Inspect one), the machine's CPU and memory, and the inspected room's
1279
+ * clients. Its acts are Monitor's: Broadcast and Dispose for the room, Send and Disconnect for a
1280
+ * client, the message being the Send row's draft.
1281
+ */
1282
+ function ServerView({
1283
+ adapter,
1284
+ ownSession,
1285
+ ownRoom,
1286
+ draft,
1287
+ setDraft,
1288
+ }: {
1289
+ adapter: NetworkingAdapter;
1290
+ ownSession: string | null;
1291
+ ownRoom: string | null;
1292
+ draft: MessageDraft;
1293
+ setDraft: (draft: MessageDraft) => void;
1294
+ }) {
1295
+ // Monitor's Send dialog: opened from a client's Send or from Broadcast, titled by its target,
1296
+ // editing the one message draft (which Monitor keeps too, in its browser storage).
1297
+ const [sendTarget, setSendTarget] = useState<{ sessionId: string | null } | null>(null);
1298
+ const [sendError, setSendError] = useState<string | null>(null);
1299
+ const [roomTab, setRoomTab] = useState<'clients' | 'state'>('clients');
1300
+ const [inspection, setInspection] = useState<NetServerInspection | null | undefined>(undefined);
1301
+ const [inspected, setInspected] = useState<string | undefined>(undefined);
1302
+ // Monitor's room grid sorts by any column.
1303
+ const [sort, setSort] = useState<{ key: RoomSortKey; descending: boolean }>({ key: 'elapsedMs', descending: true });
1304
+ const [error, setError] = useState<string | null>(null);
1305
+ useEffect(() => {
1306
+ let live = true;
1307
+ const read = () => {
1308
+ void adapter.inspectServer?.(inspected).then(
1309
+ (next) => {
1310
+ if (live) setInspection(next);
1311
+ },
1312
+ (caught: unknown) => {
1313
+ if (live) setError(caught instanceof Error ? caught.message : String(caught));
1314
+ },
1315
+ );
1316
+ };
1317
+ read();
1318
+ // Monitor refreshes its room view every 5 s; this reads the server every 2.
1319
+ const id = setInterval(read, 2000);
1320
+ return () => {
1321
+ live = false;
1322
+ clearInterval(id);
1323
+ };
1324
+ }, [adapter, inspected]);
1325
+ const act = (run: () => Promise<void> | undefined) => {
1326
+ try {
1327
+ run()?.then(
1328
+ () => setError(null),
1329
+ (caught: unknown) => setError(caught instanceof Error ? caught.message : String(caught)),
1330
+ );
1331
+ } catch (caught) {
1332
+ setError(caught instanceof Error ? caught.message : String(caught));
1333
+ }
1334
+ };
1335
+ const seconds = (ms: number) => `${Math.round(ms / 1000)} s`;
1336
+ const cell = { padding: `0 ${spaceVar[3]}`, whiteSpace: 'nowrap' as const };
1337
+ if (inspection === undefined) return null;
1338
+ if (inspection === null) {
1339
+ return (
1340
+ <AbsentNote testId="net-server-absent">
1341
+ The room server serves no Monitor view (its `server` configuration sets VGAI_ROOM_MONITOR=1).
1342
+ </AbsentNote>
1343
+ );
1344
+ }
1345
+ const roomId = inspection.room?.roomId;
1346
+ const actButton = (testId: string, label: string, run: () => Promise<void> | undefined) => (
1347
+ <Button type="button" variant="ghost" data-testid={testId} onClick={() => act(run)}>
1348
+ {label}
1349
+ </Button>
1350
+ );
1351
+ return (
1352
+ <div
1353
+ data-testid="net-server"
1354
+ style={{ padding: `${spaceVar[2]} 0`, borderBottom: `1px solid ${themeVars.boundary.default}`, fontSize: fontSizeVar.sm }}
1355
+ >
1356
+ <div data-testid="net-server-summary" style={{ padding: `0 ${spaceVar[3]}`, color: themeVars.content.muted }}>
1357
+ Server · {inspection.rooms.length} room{inspection.rooms.length === 1 ? '' : 's'} · {inspection.connections}{' '}
1358
+ connection{inspection.connections === 1 ? '' : 's'}
1359
+ {inspection.cpuPercent !== null ? ` · CPU ${inspection.cpuPercent.toFixed(0)}%` : ''}
1360
+ {inspection.memory ? ` · memory ${Math.round(inspection.memory.usedMb)} / ${Math.round(inspection.memory.totalMb)} MB` : ''}
1361
+ {inspection.room ? ` · state ${inspection.room.stateBytes} B` : ''}
1362
+ </div>
1363
+ <table style={{ borderCollapse: 'collapse', ...MONO }}>
1364
+ <thead>
1365
+ <tr style={{ color: themeVars.content.muted }}>
1366
+ {ROOM_COLUMNS.map(([key, label]) => (
1367
+ <th
1368
+ key={key}
1369
+ data-testid={`net-server-sort-${key}`}
1370
+ aria-sort={sort.key === key ? (sort.descending ? 'descending' : 'ascending') : 'none'}
1371
+ style={{ ...cell, textAlign: 'left', cursor: 'pointer', fontWeight: 'normal' }}
1372
+ onClick={() => setSort({ key, descending: sort.key === key ? !sort.descending : false })}
1373
+ >
1374
+ {label}
1375
+ {sort.key === key ? (sort.descending ? ' ▾' : ' ▴') : ''}
1376
+ </th>
1377
+ ))}
1378
+ <th style={cell} />
1379
+ </tr>
1380
+ </thead>
1381
+ <tbody>
1382
+ {sortRooms(inspection.rooms, sort).map((room) => (
1383
+ <tr key={room.roomId} data-testid="net-server-room" data-inspected={room.roomId === roomId || undefined}>
1384
+ <td style={cell}>{room.name}</td>
1385
+ <td style={cell}>{room.roomId}</td>
1386
+ <td style={cell}>
1387
+ {room.clients}
1388
+ {room.maxClients !== null ? ` / ${room.maxClients}` : ''} clients
1389
+ </td>
1390
+ <td style={cell}>{room.locked ? 'locked' : 'open'}</td>
1391
+ <td style={cell}>{seconds(room.elapsedMs)}</td>
1392
+ <td style={cell}>
1393
+ {room.roomId === roomId ? (
1394
+ <Button
1395
+ type="button"
1396
+ variant="ghost"
1397
+ data-testid="net-server-broadcast"
1398
+ onClick={() => {
1399
+ setSendError(null);
1400
+ setSendTarget({ sessionId: null });
1401
+ }}
1402
+ >
1403
+ Broadcast
1404
+ </Button>
1405
+ ) : (
1406
+ <Button type="button" variant="ghost" data-testid="net-server-inspect" onClick={() => setInspected(room.roomId)}>
1407
+ Inspect
1408
+ </Button>
1409
+ )}
1410
+ {/* Monitor offers Dispose on every room of its list. */}
1411
+ {actButton('net-server-dispose', 'Dispose', () => adapter.disposeRoom?.(room.roomId))}
1412
+ </td>
1413
+ </tr>
1414
+ ))}
1415
+ </tbody>
1416
+ </table>
1417
+ {sendTarget ? (
1418
+ // Monitor's dialog, drawn in the panel rather than over the editor: during Play a field
1419
+ // over the editor would hand the game every key typed into it.
1420
+ <div
1421
+ data-testid="net-send-dialog"
1422
+ role="group"
1423
+ aria-labelledby="net-send-dialog-title"
1424
+ style={{ margin: spaceVar[2], padding: spaceVar[3], border: `1px solid ${themeVars.boundary.default}`, borderRadius: 4 }}
1425
+ >
1426
+ <div id="net-send-dialog-title" style={{ color: themeVars.content.primary, marginBottom: spaceVar[2] }}>
1427
+ {sendTarget.sessionId
1428
+ ? `Send message to client (${sendTarget.sessionId})`
1429
+ : 'Broadcast message to all clients'}
1430
+ </div>
1431
+ <div style={{ display: 'flex', flexDirection: 'column', gap: spaceVar[2] }}>
1432
+ <label style={{ display: 'flex', flexDirection: 'column', gap: spaceVar[1] }}>
1433
+ <span style={{ color: themeVars.content.muted }}>Message type</span>
1434
+ <TextInput
1435
+ data-testid="net-send-dialog-type"
1436
+ value={draft.type}
1437
+ onChange={(event) => setDraft({ ...draft, type: event.target.value })}
1438
+ />
1439
+ </label>
1440
+ <label style={{ display: 'flex', flexDirection: 'column', gap: spaceVar[1] }}>
1441
+ <span style={{ color: themeVars.content.muted }}>Message payload (JSON)</span>
1442
+ <PayloadEditor text={draft.payload} onChange={(payload) => setDraft({ ...draft, payload })} />
1443
+ </label>
1444
+ {sendError ? <span data-testid="net-send-dialog-error">{sendError}</span> : null}
1445
+ </div>
1446
+ <div style={{ display: 'flex', gap: spaceVar[2], justifyContent: 'flex-end', marginTop: spaceVar[2] }}>
1447
+ <Button type="button" variant="ghost" onClick={() => setSendTarget(null)}>
1448
+ Cancel
1449
+ </Button>
1450
+ <Button
1451
+ type="button"
1452
+ data-testid="net-send-dialog-send"
1453
+ onClick={() => {
1454
+ const target = sendTarget;
1455
+ void (async () => {
1456
+ try {
1457
+ const message = draftMessage(draft);
1458
+ await (target.sessionId
1459
+ ? adapter.sendToClient?.(target.sessionId, message.type, message.payload, roomId)
1460
+ : adapter.broadcast?.(message.type, message.payload, roomId));
1461
+ setSendError(null);
1462
+ setSendTarget(null);
1463
+ } catch (caught) {
1464
+ setSendError(caught instanceof Error ? caught.message : String(caught));
1465
+ }
1466
+ })();
1467
+ }}
1468
+ >
1469
+ Send
1470
+ </Button>
1471
+ </div>
1472
+ </div>
1473
+ ) : null}
1474
+ {inspection.room ? (
1475
+ // Monitor's room view: a Clients tab and a State tab. The State tab is the SERVER's state
1476
+ // of the inspected room, edited and deleted there; the tree below this section stays this
1477
+ // client's replicated copy of its own room.
1478
+ <div data-testid="net-server-room-view" style={{ padding: `${spaceVar[2]} 0` }}>
1479
+ <div role="tablist" style={{ display: 'flex', gap: spaceVar[2], padding: `0 ${spaceVar[3]}` }}>
1480
+ {(
1481
+ [
1482
+ ['clients', `Clients (${inspection.room.clients.length})`],
1483
+ ['state', 'State'],
1484
+ ] as const
1485
+ ).map(([key, label]) => (
1486
+ <Button
1487
+ key={key}
1488
+ type="button"
1489
+ variant="ghost"
1490
+ role="tab"
1491
+ aria-selected={roomTab === key}
1492
+ data-testid={`net-server-tab-${key}`}
1493
+ onClick={() => setRoomTab(key)}
1494
+ >
1495
+ {label}
1496
+ </Button>
1497
+ ))}
1498
+ </div>
1499
+ {roomTab === 'clients' ? (
1500
+ <table style={{ borderCollapse: 'collapse', ...MONO }}>
1501
+ <tbody>
1502
+ {inspection.room.clients.map((client) => (
1503
+ <tr key={client.sessionId} data-testid="net-server-client">
1504
+ <td style={cell}>client</td>
1505
+ <td style={cell}>
1506
+ {client.sessionId}
1507
+ {client.sessionId === ownSession ? ' (this client)' : ''}
1508
+ </td>
1509
+ <td style={cell}>{seconds(client.elapsedMs)}</td>
1510
+ <td style={cell} colSpan={3}>
1511
+ <Button
1512
+ type="button"
1513
+ variant="ghost"
1514
+ data-testid="net-server-send"
1515
+ onClick={() => {
1516
+ setSendError(null);
1517
+ setSendTarget({ sessionId: client.sessionId });
1518
+ }}
1519
+ >
1520
+ Send
1521
+ </Button>
1522
+ {actButton('net-server-disconnect', 'Disconnect', () => adapter.disconnectClient?.(client.sessionId, roomId))}
1523
+ </td>
1524
+ </tr>
1525
+ ))}
1526
+ </tbody>
1527
+ </table>
1528
+ ) : inspection.room.state !== undefined ? (
1529
+ <div data-testid="net-server-room-state">
1530
+ <StateTreeNode
1531
+ name={`room ${inspection.room.roomId}${inspection.room.roomId === ownRoom ? ' (this client’s room)' : ''}`}
1532
+ value={inspection.room.state}
1533
+ depth={0}
1534
+ edit={adapter.editServerState ? (path, next) => adapter.editServerState!(path, next, roomId) : undefined}
1535
+ remove={adapter.deleteServerState ? (path) => adapter.deleteServerState!(path, roomId) : undefined}
1536
+ />
1537
+ </div>
1538
+ ) : (
1539
+ <AbsentNote>The server sent no state for this room.</AbsentNote>
1540
+ )}
1541
+ </div>
1542
+ ) : null}
1543
+ {error ? <AbsentNote>{error}</AbsentNote> : null}
1544
+ </div>
1545
+ );
1546
+ }