@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.
- package/contributions/audio-unlock.service.ts +2 -2
- package/contributions/autoplay.service.ts +1 -1
- package/contributions/bridge.command.ts +3 -3
- package/contributions/build-player.document.tsx +95 -0
- package/contributions/canvas/component-board.service.ts +16 -0
- package/contributions/canvas/design-time-mount.service.ts +45 -0
- package/contributions/canvas-story-capture.service.ts +12 -0
- package/contributions/edit-mode-audio.service.ts +2 -2
- package/contributions/edit-mode-networking.service.ts +1 -1
- package/contributions/gameplay.command.ts +4 -4
- package/contributions/generation.service.ts +1 -1
- package/contributions/godot.style.ts +26 -6
- package/contributions/godot.view.ts +6 -0
- package/contributions/ingest.service.ts +2 -2
- package/contributions/instances.command.ts +1 -1
- package/contributions/navmesh.menu.ts +1 -1
- package/contributions/network-observer.service.ts +14 -0
- package/contributions/play.command.ts +10 -4
- package/contributions/react/component-board.service.ts +1 -1
- package/contributions/react/design-time-mount.service.ts +1 -1
- package/contributions/scene-document.service.ts +3 -3
- package/contributions/state-watch.menu.ts +1 -1
- package/contributions/state-watch.utility.tsx +1 -1
- package/contributions/team-playtest.service.ts +4 -4
- package/contributions/three/component-board.service.ts +1 -1
- package/contributions/three/component-verbs.command.ts +6 -6
- package/contributions/three/three-authoring.service.ts +8 -5
- package/contributions/three-story-capture.service.ts +12 -0
- package/contributions/unity.style.ts +17 -7
- package/contributions/unity.view.ts +6 -0
- package/contributions/unreal.style.ts +10 -2
- package/contributions/unreal.view.ts +5 -0
- package/package.json +21 -10
- package/src/asset-budget/AssetBudgetPanel.tsx +1 -1
- package/src/asset-budget/asset-budget-model.ts +1 -1
- package/src/audio/AudioDebuggerPanel.tsx +1 -1
- package/src/bridge/dispatch.ts +14 -14
- package/src/bridge/live-frames.ts +1 -1
- package/src/bridge/screenshot.ts +3 -3
- package/src/build/BuildProfilesPanel.tsx +14 -3
- package/src/build/build-session.ts +35 -0
- package/src/canvas/canvas-board/CanvasBoardDocument.tsx +749 -0
- package/src/canvas/canvas-board/canvas-board-model.ts +407 -0
- package/src/canvas/canvas-board/canvas-component-board.ts +56 -0
- package/src/canvas/canvas-design-mount.ts +524 -0
- package/src/canvas/design-time-canvas-mount.ts +79 -0
- package/src/coverage/live-authoring-surface.ts +5 -5
- package/src/coverage/live-project-verbs.ts +1 -1
- package/src/coverage/native-system-coverage.ts +5 -5
- package/src/coverage/root-coverage.ts +1 -1
- package/src/coverage/session-coverage.ts +3 -3
- package/src/design-system-stories/ApplicationChrome.stories.tsx +5 -5
- package/src/design-system-stories/InspectorNarrowBodies.stories.tsx +7 -7
- package/src/edit-mode/edit-mode-audio.ts +4 -4
- package/src/edit-mode/edit-mode-networking.ts +2 -2
- package/src/game-document/GameCaptureFrameButton.tsx +1 -1
- package/src/game-document/GameDocument.tsx +2 -2
- package/src/game-document/GamePanel.tsx +4 -4
- package/src/game-document/InstanceInspectorPicker.tsx +2 -2
- package/src/game-document/crowd-debug.ts +1 -1
- package/src/game-document/device-preview.ts +10 -11
- package/src/game-document/physics-debug.ts +1 -1
- package/src/generation/GenerationActivity.tsx +3 -3
- package/src/generation/generation-documents.tsx +5 -5
- package/src/generation/generation-jobs.ts +1 -1
- package/src/host/adapter-runtime-bindings.ts +96 -5
- package/src/host/authoring/babylon-authoring-adapter.ts +6 -6
- package/src/host/authoring/gesture-persist.ts +1 -1
- package/src/host/authoring/ingest-data-writer.ts +1 -1
- package/src/host/authoring/ingest-source-persistence.ts +4 -4
- package/src/host/authoring/mounted-authoring.ts +4 -4
- package/src/host/authoring/phaser-live-authoring-adapter.ts +4 -4
- package/src/host/authoring/pixi-authoring-adapter.ts +178 -31
- package/src/host/authoring/pixi-creatable-kinds.ts +62 -0
- package/src/host/authoring/pixi-creation-site-write-target.ts +2 -2
- package/src/host/authoring/pixi-live-write-target.ts +4 -4
- package/src/host/authoring/pixi-source-identity.ts +3 -3
- package/src/host/authoring/pixi-source-write-target.ts +1709 -0
- package/src/host/authoring/pixi-still-presentation.ts +1 -1
- package/src/host/authoring/pixi-structure-history.ts +2 -2
- package/src/host/authoring/pixi-transform-channels.ts +16 -14
- package/src/host/authoring/source-persistence-backend.ts +3 -3
- package/src/host/authoring/struct-write-pipe.ts +1 -1
- package/src/host/binding-resolver.ts +8 -9
- package/src/host/browser-transpile.ts +1 -1
- package/src/host/canvas-entry-runtime.ts +58 -47
- package/src/host/canvas-preview-frames.ts +482 -0
- package/src/host/components/CameraAuthoringOverlay.tsx +1 -1
- package/src/host/components/HeaderTelemetry.tsx +4 -4
- package/src/host/components/PixiIsolationSceneContent.tsx +11 -11
- package/src/host/components/ThreeIsolationSceneContent.tsx +3 -3
- package/src/host/components/frame-debugger-model.ts +2 -2
- package/src/host/components/header-telemetry-model.ts +2 -2
- package/src/host/components/scene-document.tsx +14 -14
- package/src/host/components/utility-view-state.ts +1 -1
- package/src/host/components/world-root-stage-binding.tsx +12 -12
- package/src/host/components/world-root-stage.ts +70 -49
- package/src/host/coverage/system-adapter-coverage.ts +3 -4
- package/src/host/design-system-stories/fixtures/editor-runtime.tsx +9 -11
- package/src/host/document-preview-three.ts +1 -1
- package/src/host/entry-adjudication.ts +6 -6
- package/src/host/game-css-scope-transform.ts +4 -0
- package/src/host/game-realm-page.ts +1 -1
- package/src/host/gameplay-export.ts +25 -14
- package/src/host/gameplay-recording.ts +5 -5
- package/src/host/gated-globals.ts +2 -2
- package/src/host/history/json-history-resource.ts +1 -1
- package/src/host/projection/pixi.ts +24 -2
- package/src/host/r3f-entry-runtime.ts +65 -34
- package/src/host/react-mount-runtime.ts +7 -48
- package/src/host/realm-services.ts +1 -1
- package/src/host/roots/canvas-root.tsx +373 -0
- package/src/host/roots/r3f-root.tsx +473 -0
- package/src/host/roots/react-root.ts +9 -43
- package/src/host/served-bundle-runtime-modules.ts +3 -19
- package/src/host/server-log-bridge.ts +2 -2
- package/src/host/stories/mounted-story-viewport-source.ts +1 -1
- package/src/host/stories/pixi-story-model.ts +30 -0
- package/src/host/stories/story-media-captures.ts +46 -0
- package/src/host/stories/story-media-presence.ts +3 -3
- package/src/host/stories/story-pixi-preview.ts +408 -0
- package/src/host/stories/story-three-preview.ts +806 -0
- package/src/host/stories/three-story-captures.ts +35 -0
- package/src/host/story-three-preview-runtime.ts +56 -0
- package/src/host/use-active-performance-source.ts +2 -2
- package/src/host/viewport-pose-memory.ts +1 -1
- package/src/host/viewport-root-presentation.ts +6 -5
- package/src/ingest/active-ingest.ts +1 -1
- package/src/ingest/authoring/ingest-dom-surface-authoring.ts +4 -4
- package/src/ingest/authoring/ingest-root-adapter.ts +8 -8
- package/src/ingest/deferred-ingest-play.ts +6 -6
- package/src/ingest/discovery-public-ingest.ts +2 -2
- package/src/ingest/ingest-boot-viewport.ts +2 -2
- package/src/ingest/ingest-canvas-scene-document.tsx +9 -9
- package/src/ingest/ingest-canvas-scene.ts +3 -3
- package/src/ingest/ingest-evidence-hook.ts +1 -1
- package/src/ingest/ingest-frame-snapshot.ts +1 -1
- package/src/ingest/ingest-play-control.ts +1 -1
- package/src/ingest/ingest-render-debug.ts +10 -10
- package/src/ingest/ingest-siblings.ts +13 -25
- package/src/ingest/module-mode.ts +14 -14
- package/src/ingest/mount-canvas-ingest-root.ts +21 -21
- package/src/ingest/mount-coverage.ts +2 -2
- package/src/ingest/mount-dom-ingest-root.ts +11 -11
- package/src/ingest/mount-ingest-root.ts +8 -8
- package/src/ingest/mount-three-ingest-root.ts +8 -8
- package/src/ingest/resolve-canvas.ts +1 -1
- package/src/ingest/served-html-boot.ts +1 -1
- package/src/ingest/surface-canvas.ts +1 -1
- package/src/ingest/unmount-ingest-root.ts +5 -5
- package/src/navmesh/navmesh-handler.ts +24 -16
- package/src/network/NetworkInspectorPanel.tsx +939 -37
- package/src/network/network-inspector-model.ts +20 -2
- package/src/play/play-log-events.ts +1 -1
- package/src/play/play-mode.ts +76 -133
- package/src/play/play-recording.ts +1 -1
- package/src/play/react-play-live-authoring.ts +3 -3
- package/src/play/run-selection.ts +93 -0
- package/src/play-bar/PlayBar.tsx +20 -39
- package/src/profiler/FrameDebuggerPanel.tsx +1 -1
- package/src/profiler/PerformancePanel.tsx +3 -3
- package/src/react/design-time-react-mount.ts +23 -65
- package/src/react/dom-authoring-adapter.ts +9 -9
- package/src/react/react-inspector-section.tsx +5 -5
- package/src/react/react-world-authoring-adapter.ts +11 -11
- package/src/react/story-documents/story-documents.tsx +9 -9
- package/src/react/ui-board-document.tsx +7 -7
- package/src/react/ui-component-board.ts +2 -2
- package/src/runtime/adapter/audio-meter.ts +21 -0
- package/src/runtime/adapter/first-party-audio-system.ts +230 -0
- package/src/runtime/adapter/ingest/contract-debug-adapter.ts +114 -0
- package/src/runtime/adapter/ingest/contract-system-adapters.ts +256 -0
- package/src/runtime/adapter/ingest/merge-debug-adapters.ts +197 -0
- package/src/runtime/adapter/ingest/observation-debug-adapter.ts +162 -0
- package/src/runtime/adapter/ingest/upstream-pin.ts +51 -0
- package/src/runtime/adapter/native-debug-module.ts +498 -0
- package/src/runtime/audio/bus-mixer.ts +161 -0
- package/src/runtime/audio/pose-guard.ts +80 -0
- package/src/runtime/core/frame-pacing.ts +126 -0
- package/src/runtime/core/game-loop.ts +225 -0
- package/src/runtime/core/game-scoped-slot.ts +28 -0
- package/src/runtime/core/seeded-random.ts +162 -0
- package/src/runtime/core/sim-clock.ts +391 -0
- package/src/runtime/core/system-runner.ts +269 -0
- package/src/runtime/core/types.ts +104 -0
- package/src/runtime/create-runtime.ts +1128 -0
- package/src/runtime/debug-bridge.ts +570 -0
- package/src/runtime/debug-registry.ts +899 -0
- package/src/runtime/dev/chrome-trace.ts +153 -0
- package/src/runtime/dev/instruments.ts +403 -0
- package/src/runtime/dev/logger.ts +119 -0
- package/src/runtime/dev/performance-profiler.ts +367 -0
- package/src/runtime/dev/register-render-vitals.ts +276 -0
- package/src/runtime/dev/render-census.ts +354 -0
- package/src/runtime/dev/render-debug-adapter.ts +218 -0
- package/src/runtime/dev/render-memory.ts +226 -0
- package/src/runtime/dev/render-vitals.ts +338 -0
- package/src/runtime/dev/static-batch-advisor.ts +188 -0
- package/src/runtime/dev/webgl-frame-capture.ts +366 -0
- package/src/runtime/dev/webgl-gpu-timer.ts +53 -0
- package/src/runtime/dev-build.ts +47 -0
- package/src/runtime/game.ts +1636 -0
- package/src/runtime/gameplay-rng-trap.ts +135 -0
- package/src/runtime/host-context.ts +64 -0
- package/src/runtime/input-router.ts +182 -0
- package/src/runtime/mount-manifest.ts +480 -0
- package/src/runtime/pixi/authoring.ts +706 -0
- package/src/runtime/pixi/ingest.ts +116 -0
- package/src/runtime/pixi/physics-registry.ts +49 -0
- package/src/runtime/pixi/render-pass-bracket.ts +117 -0
- package/src/runtime/pixi/scene-capture.ts +179 -0
- package/src/runtime/pixi/system-adapters.ts +69 -0
- package/src/runtime/playtest.ts +22 -0
- package/src/runtime/presentation.ts +141 -0
- package/src/runtime/render-control.ts +642 -0
- package/src/runtime/render-seed.ts +77 -0
- package/src/runtime/run-ticks-settled.ts +73 -0
- package/src/runtime/setup/setup-audio.ts +72 -0
- package/src/services/audio-pose-guard.ts +2 -2
- package/src/services/game-audio.ts +152 -0
- package/src/services/game-network.ts +767 -0
- package/src/services/game-physics.ts +334 -0
- package/src/state-watch/StateWatchPanel.tsx +1 -1
- package/src/three/authoring/camera-runtime-inspector-section.tsx +3 -2
- package/src/three/authoring/constraint-inspector-section.tsx +6 -5
- package/src/three/authoring/model-asset-inspector-section.tsx +7 -6
- package/src/three/authoring/oid-source-persistence.ts +7 -7
- package/src/three/authoring/r3f-design-session.ts +58 -54
- package/src/three/authoring/r3f-source-authoring-adapter.ts +95 -91
- package/src/three/authoring/reflection-probe-inspector-section.tsx +3 -2
- package/src/three/authoring/three-authoring-adapter.ts +29 -29
- package/src/three/component-verbs/extract-menu.ts +7 -6
- package/src/three/component-verbs/fork-menu.ts +7 -6
- package/src/three/component-verbs/internals-menu.ts +2 -2
- package/src/three/story-documents/three-story-documents.tsx +13 -13
- package/src/three/three-board/ThreeBoardDocument.tsx +13 -12
- package/src/three/three-board/board-scene.ts +6 -6
- package/src/three/three-board/three-component-board.ts +2 -2
- 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.
|
|
18
|
-
* the
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
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
|
+
}
|